签入版本

This commit is contained in:
2026-09-16 12:00:58 +08:00
commit 20a2f846ff
842 changed files with 93035 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""单元测试。"""
+1
View File
@@ -0,0 +1 @@
"""核心层单元测试。"""
+138
View File
@@ -0,0 +1,138 @@
"""BaseService 模板方法测试。"""
import pytest
from core.base_service import BaseService
from tests.fixtures.factories import FakeHandler, FakeRepository
class FakeColumn:
"""伪列对象:模拟 SQLAlchemy 列的 asc/desc。"""
def __init__(self, name):
self.name = name
def asc(self):
return self
def desc(self):
return self
class DummyItem:
"""伪模型:类属性模拟列(支持 .asc()/.desc()),实例属性存值。"""
id = FakeColumn("id")
name = FakeColumn("name")
status = FakeColumn("status")
sort = FakeColumn("sort")
def __init__(self, id=None, name=None, status=1, sort=0):
self.id = id
self.name = name
self.status = status
self.sort = sort
def to_dict(self):
# 与 BaseModel.to_dict() 保持一致:返回 camelCase key
return {"id": self.id, "name": self.name, "status": self.status, "sort": self.sort}
class DummySchema:
"""伪 Pydantic 表单。"""
def __init__(self, data):
for k, v in data.items():
setattr(self, k, v)
self._data = data
@classmethod
def model_validate(cls, data):
return cls(data)
def model_dump(self):
return dict(self._data)
class DummyService(BaseService):
model = DummyItem
repo_cls = FakeRepository
create_schema = DummySchema
update_schema = DummySchema
status_schema = DummySchema
page_like_fields = ("name",)
page_eq_fields = ("status",)
page_order_by = (("sort", "asc"),)
serialize_maps = {"status": {1: "正常", 2: "停用"}}
@pytest.fixture
def service():
repo = FakeRepository([DummyItem(id=1, name="A", status=1), DummyItem(id=2, name="B", status=1)])
DummyService.repo_cls = lambda db=None: repo
return DummyService()
async def test_get_page(service):
handler = FakeHandler(query={"page": 1, "limit": 10})
await service.get_page(handler)
assert handler.result["code"] == 0
assert handler.result["data"]["total"] == 2
assert len(handler.result["data"]["records"]) == 2
assert handler.result["data"]["current"] == 1
assert handler.result["data"]["size"] == 10
# serialize_maps 枚举名(camelCase + Text 后缀)
assert handler.result["data"]["records"][0]["statusText"] == "正常"
async def test_get_detail(service):
assert service.get_detail(1)["name"] == "A"
assert service.get_detail(999) is None
async def test_add(service):
handler = FakeHandler(body={"name": "C", "status": 1, "sort": 3})
await service.add(handler)
assert handler.result["code"] == 0
assert any(item.name == "C" for item in service.repo.items)
async def test_update(service):
handler = FakeHandler(body={"id": 1, "name": "A2", "status": 1, "sort": 5})
await service.update(handler)
assert handler.result["code"] == 0
assert handler.result["msg"] == "更新成功"
async def test_update_requires_id(service):
handler = FakeHandler(body={"name": "A2", "status": 1})
await service.update(handler)
assert handler.result["code"] == 1
assert "记录ID" in handler.result["msg"]
async def test_delete(service):
handler = FakeHandler()
await service.delete(handler, "1,2")
assert handler.result["code"] == 0
assert service.repo._deleted == [1, 2]
async def test_delete_invalid_ids(service):
handler = FakeHandler()
await service.delete(handler, "abc,,0")
assert handler.result["code"] == 1
assert "记录ID" in handler.result["msg"]
async def test_update_status(service):
handler = FakeHandler(body={"id": 1, "status": 2})
await service.update_status(handler)
assert handler.result["code"] == 0
assert handler.result["msg"] == "设置成功"
async def test_update_status_missing_record(service):
handler = FakeHandler(body={"id": 999, "status": 2})
await service.update_status(handler)
assert handler.result["code"] == 1
assert handler.result["msg"] == "记录不存在"
+47
View File
@@ -0,0 +1,47 @@
"""加密工具测试。"""
from common.utils.crypto import (check_password, hash_password, is_bcrypt,
set_password, verify_password, verify_user_password)
def test_hash_and_verify_legacy():
"""无盐 bcrypt(兼容旧数据)。"""
hashed = hash_password("123456")
assert is_bcrypt(hashed)
assert verify_password("123456", hashed)
assert not verify_password("wrong", hashed)
def test_set_and_check_password():
"""盐方案:生成盐 + 双重加盐加密。"""
salt, hashed = set_password("123456")
assert salt
assert is_bcrypt(hashed)
assert check_password("123456", salt, hashed)
assert not check_password("wrong", salt, hashed)
assert not check_password("123456", "", hashed)
def test_salt_differences():
"""同一明文两次 set_password 盐不同,但均可校验通过。"""
salt1, hashed1 = set_password("123456")
salt2, hashed2 = set_password("123456")
assert salt1 != salt2
assert hashed1 != hashed2
assert check_password("123456", salt1, hashed1)
assert check_password("123456", salt2, hashed2)
def test_verify_user_password_prefers_salt():
"""统一校验:优先盐方案。"""
salt, hashed = set_password("123456")
assert verify_user_password("123456", salt, hashed)
assert not verify_user_password("wrong", salt, hashed)
def test_verify_user_password_falls_back_to_bcrypt():
"""统一校验:兼容旧无盐 bcrypt。"""
bcrypt_hash = hash_password("123456")
assert verify_user_password("123456", None, bcrypt_hash)
assert not verify_user_password("wrong", None, bcrypt_hash)
# 非 bcrypt 密文无法通过校验
assert not verify_user_password("123456", None, "not-a-hash")
+43
View File
@@ -0,0 +1,43 @@
"""JWT 加解密测试。"""
import asyncio
from core.jwt import create_token, parse_payload, refresh_token
def test_create_and_parse():
token = create_token({"userId": 1}, timeout=10)
assert isinstance(token, str) and token
result = parse_payload(token)
assert result["code"] == 0
assert result["data"]["userId"] == 1
def test_parse_invalid_token():
result = parse_payload("invalid.token.here")
# 新实现统一约定:code 0-成功 1-失败(旧实现返回 -1)
assert result["code"] == 1
assert result["data"] is None
def test_parse_empty_token():
result = parse_payload("")
assert result["code"] == 1
assert "不能为空" in result["msg"]
def test_refresh_token_rotation():
token = create_token({"userId": 7}, timeout=30)
result = asyncio.run(refresh_token(token, extend_minutes=60))
assert result["code"] == 0
assert result["data"]["expires_in"] == 60 * 60
new_token = result["data"]["access_token"]
parsed = parse_payload(new_token)
assert parsed["code"] == 0
assert parsed["data"]["userId"] == 7
# 轮换后新令牌不等于旧令牌
assert new_token != token
def test_refresh_invalid_token():
result = asyncio.run(refresh_token("invalid.token.here"))
assert result["code"] == 1
+30
View File
@@ -0,0 +1,30 @@
"""Repository 相关纯逻辑与序列化安全测试。"""
from common.utils.helpers import parse_id_list
def test_parse_id_list():
assert parse_id_list("1,2,3") == [1, 2, 3]
assert parse_id_list("1,,3,abc,-2") == [1, 3]
assert parse_id_list("") == []
assert parse_id_list(None) == []
def test_to_dict_excludes_sensitive_fields():
"""BaseModel.to_dict() 不得泄露 password/salt/is_delete。"""
from modules.user.models import User
user = User(username="admin", password="secret-hash", salt="s1", realname="管理员")
data = user.to_dict()
assert "password" not in data
assert "salt" not in data
assert "is_delete" not in data
assert data["username"] == "admin"
assert data["realname"] == "管理员"
def test_to_json_excludes_sensitive_fields():
from modules.user.models import User
user = User(username="admin", password="secret-hash", salt="s1")
data = user.to_json()
assert "password" not in data
assert "salt" not in data
assert "is_delete" not in data
+66
View File
@@ -0,0 +1,66 @@
"""统一响应测试。"""
from core import response as R
class FakeHandler:
"""模拟 Tornado handler 的 finish 方法。"""
def __init__(self):
self.result = None
def finish(self, result):
self.result = result
return self.result
def test_ok_basic():
handler = FakeHandler()
R.ok(handler, data={"a": 1}, msg="成功")
assert handler.result["code"] == 0
assert handler.result["data"] == {"a": 1}
assert handler.result["msg"] == "成功"
assert handler.result["ok"] is True
def test_ok_with_kwargs():
handler = FakeHandler()
R.ok(handler, data=[], count=10)
assert handler.result["count"] == 10
assert handler.result["ok"] is True
def test_failed_default_code():
"""failed 默认码为 1,并含 ok: False。"""
handler = FakeHandler()
R.failed(handler, msg="失败")
assert handler.result["code"] == 1
assert handler.result["data"] is None
assert handler.result["msg"] == "失败"
assert handler.result["ok"] is False
def test_failed_with_code_and_data():
handler = FakeHandler()
R.failed(handler, msg="未认证", code=R.Codes.UNAUTHORIZED, data={"detail": "x"})
assert handler.result["code"] == 401
assert handler.result["data"] == {"detail": "x"}
assert handler.result["ok"] is False
def test_response_generic():
"""通用响应生成器:允许自定义 ok 字段。"""
handler = FakeHandler()
R.response(handler, data=[1], msg="自定义", code=200, success=True, extra=1)
assert handler.result["code"] == 200
assert handler.result["ok"] is True
assert handler.result["extra"] == 1
def test_codes_constants():
assert R.Codes.SUCCESS == 0
assert R.Codes.FAILED == 1
assert R.Codes.UNAUTHORIZED == 401
assert R.Codes.FORBIDDEN == 403
assert R.Codes.NOT_FOUND == 404
assert R.Codes.VALIDATE_ERROR == 422
assert R.Codes.SERVER_ERROR == 500
+1
View File
@@ -0,0 +1 @@
"""业务模块单元测试。"""
+21
View File
@@ -0,0 +1,21 @@
"""认证模块测试。"""
import pytest
from modules.auth.schemas import LoginSchema
def test_login_schema_valid():
form = LoginSchema(username="admin", password="123456", code="123456", key="uuid-1")
assert form.username == "admin"
assert form.key == "uuid-1"
assert form.code == "123456"
def test_login_schema_missing_field():
with pytest.raises(Exception):
LoginSchema(username="admin")
def test_login_schema_extra_ignored():
form = LoginSchema(username="admin", password="123456", code="123456", key="x", extra_field=1)
assert not hasattr(form, "extra_field")
@@ -0,0 +1,66 @@
"""JobLogService 分页时间区间过滤测试。"""
from modules.job_log.service import JobLogService
from tests.fixtures.factories import FakeHandler
class TimeFilterQuery:
"""捕获 filter 条件的伪查询对象。"""
def __init__(self, items=None):
self.items = items or []
self.criteria = []
def filter(self, *criterion):
self.criteria.extend(criterion)
return self
def order_by(self, *cols):
return self
def all(self):
return self.items
class TimeFilterRepo:
"""伪仓储:仅返回捕获查询。"""
def __init__(self):
self.query = TimeFilterQuery()
def filter(self, *criterion):
return self.query
def close(self):
pass
def _service():
service = JobLogService(db=None)
service.repo = TimeFilterRepo() # 注入伪仓储
return service
def _range_names(query):
"""提取 filter 条件中的列名(用于断言区间过滤生效)。"""
return [c.left.name for c in query.criteria if hasattr(c, "left")]
def test_time_range_filter_applied():
service = _service()
handler = FakeHandler(query={
"startTime": "2026-08-01 00:00:00",
"endTime": "2026-08-15 23:59:59",
})
service._build_page_query(handler)
assert _range_names(service.repo.query).count("start_time") == 2
def test_time_range_filter_without_args_no_crash():
service = _service()
handler = FakeHandler(query={})
service._build_page_query(handler)
assert _range_names(service.repo.query) == []
+139
View File
@@ -0,0 +1,139 @@
"""任务调度器测试:执行日志记录、Cron 校验与执行策略防重叠。"""
import types
import pytest
from modules.job import scheduler
from modules.job_log import service as job_log_service
def make_job(**overrides):
"""构造一个伪任务快照(无 DB),与调度器要求的 JobSnapshot 对齐。
默认 URL 用公网 IP 字面量(8.8.8.8):无需 DNS 即能通过 getaddrinfo,
且 is_global=True,符合调度器防 SSRF 的 URL 校验(不再使用 127.0.0.1)。
"""
defaults = dict(
id=1, job_name="测试任务", job_group="系统任务", job_trigger="cron",
cron_expression="*/5 * * * *", url="http://8.8.8.8/api/v1/health",
execute_policy=1, status=1,
)
defaults.update(overrides)
return scheduler._snapshot_job(types.SimpleNamespace(**defaults))
class FakeResponse:
"""支持 with 语法的伪 HTTP 响应。"""
status = 200
def __enter__(self):
return self
def __exit__(self, *exc):
return False
@pytest.fixture(autouse=True)
def _clean_running():
"""每个用例前后清空运行中集合,避免相互污染。"""
scheduler._running.clear()
yield
scheduler._running.clear()
def _capture(monkeypatch):
"""打桩 record_job_log,返回记录列表。"""
records = []
monkeypatch.setattr(job_log_service, "record_job_log", lambda **kw: records.append(kw))
return records
def test_execute_job_success_records_log(monkeypatch):
records = _capture(monkeypatch)
# execute_job 经 _no_redirect_opener.open 发起请求(禁重定向,防 SSRF 302 跳内网)
monkeypatch.setattr(scheduler._no_redirect_opener, "open", lambda url, timeout=None: FakeResponse())
scheduler.execute_job(make_job())
assert len(records) == 1
assert records[0]["status"] == 0
assert records[0]["job_name"] == "测试任务"
assert records[0]["job_message"] == "任务触发成功"
assert records[0]["consume_time"] >= 0
def test_execute_job_failure_records_exception(monkeypatch):
records = _capture(monkeypatch)
def _boom(url, timeout=None):
raise ConnectionError("connect timeout")
monkeypatch.setattr(scheduler._no_redirect_opener, "open", _boom)
scheduler.execute_job(make_job())
assert len(records) == 1
assert records[0]["status"] == 1
assert "connect timeout" in records[0]["exception_info"]
assert records[0]["job_message"] == "任务触发失败"
def test_overlap_policy_skip_while_running(monkeypatch):
"""策略 3-放弃执行:任务执行中时跳过本次触发,不调用任务 URL。"""
records = _capture(monkeypatch)
calls = {"urlopen": 0}
def _urlopen(url, timeout=None):
calls["urlopen"] += 1
return FakeResponse()
monkeypatch.setattr(scheduler._no_redirect_opener, "open", _urlopen)
job = make_job(execute_policy=3)
scheduler._running.add(job.id)
scheduler.execute_job(job)
assert calls["urlopen"] == 0
assert len(records) == 1
assert records[0]["status"] == 0
assert "放弃执行" in records[0]["job_message"]
# 跳过不等于释放运行标记:原执行仍占用,任务应保留在运行集合
assert job.id in scheduler._running
def test_overlap_policy_immediate_runs_in_parallel(monkeypatch):
"""策略 1-立即执行:即使任务执行中仍再次执行(并行)。"""
records = _capture(monkeypatch)
monkeypatch.setattr(scheduler._no_redirect_opener, "open", lambda url, timeout=None: FakeResponse())
job = make_job(execute_policy=1)
scheduler._running.add(job.id)
scheduler.execute_job(job)
assert len(records) == 1
assert records[0]["status"] == 0
assert job.id not in scheduler._running # 执行结束从运行集合移除
def test_validate_cron_expression():
assert scheduler.validate_cron_expression("*/5 * * * *") is None
assert scheduler.validate_cron_expression("") is not None
assert scheduler.validate_cron_expression("not-a-cron") is not None
def test_validate_job_url_blocks_ssrf():
"""防 SSRF:拒绝非 http/https 协议与内网/回环/云元数据地址。"""
assert scheduler.validate_job_url("http://8.8.8.8/ping") is True
assert scheduler.validate_job_url("http://127.0.0.1:8041/x") is False
assert scheduler.validate_job_url("http://192.168.1.1/x") is False
assert scheduler.validate_job_url("http://169.254.169.254/latest/meta-data/") is False
assert scheduler.validate_job_url("file:///etc/passwd") is False
assert scheduler.validate_job_url("ftp://example.com/x") is False
def test_no_redirect_handler_blocks_redirect():
"""禁重定向:公网 URL 302 跳内网的 SSRF 通道被切断。"""
handler = scheduler._NoRedirectHandler()
# redirect_request 返回 None -> urlopen 抛 HTTPError,不会继续请求跳转目标
assert handler.redirect_request(None, None, 302, "", None, "http://169.254.169.254/") is None
+42
View File
@@ -0,0 +1,42 @@
"""用户校验模型测试。"""
import pytest
from modules.user.schemas import UserSchema, UserStatusSchema
def test_user_create_minimal():
form = UserSchema(
realname="测试", gender=1, avatar="default.png",
mobile="13800138000", email="test@example.com",
deptId=0, levelId=0, positionId=0,
address="测试地址", username="tester",
status=1, sort=0,
)
assert form.gender == 1
assert form.status == 1
assert form.city is None
def test_user_create_with_city_and_roles():
form = UserSchema(
realname="测试", gender=1, avatar="default.png",
mobile="13800138000", email="test@example.com",
deptId=0, levelId=0, positionId=0,
address="测试地址", username="tester",
status=1, sort=0,
city=[110000, 110100, 110101],
roles=[1, 2],
)
assert form.city == [110000, 110100, 110101]
assert form.roles == [1, 2]
def test_user_status_invalid():
with pytest.raises(Exception):
UserStatusSchema(id=0, status=1)
def test_user_status_valid():
form = UserStatusSchema(id=1, status=2)
assert form.id == 1
assert form.status == 2