From 4379c8d8ccc91f3a3a257814e40aeee0ead2d252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E8=9A=82=E8=9A=81=E4=BA=91?= Date: Wed, 16 Sep 2026 12:04:20 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AD=BE=E5=85=A5=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/fixtures/__init__.py | 1 - tests/fixtures/data.sql | 2 - tests/fixtures/factories.py | 126 ------------------- tests/fixtures/users.json | 10 -- tests/integration/__init__.py | 1 - tests/integration/conftest.py | 28 ----- tests/integration/test_api.py | 34 ----- tests/integration/test_database.py | 64 ---------- tests/integration/test_redis.py | 15 --- tests/reports/.keep | 0 tests/unit/__init__.py | 1 - tests/unit/core/__init__.py | 1 - tests/unit/core/test_base_service.py | 138 -------------------- tests/unit/core/test_crypto.py | 47 ------- tests/unit/core/test_jwt.py | 43 ------- tests/unit/core/test_repository.py | 30 ----- tests/unit/core/test_response.py | 66 ---------- tests/unit/modules/__init__.py | 1 - tests/unit/modules/test_auth.py | 21 ---- tests/unit/modules/test_job_log_service.py | 66 ---------- tests/unit/modules/test_job_scheduler.py | 139 --------------------- tests/unit/modules/test_user_schema.py | 42 ------- 22 files changed, 876 deletions(-) delete mode 100644 tests/fixtures/__init__.py delete mode 100644 tests/fixtures/data.sql delete mode 100644 tests/fixtures/factories.py delete mode 100644 tests/fixtures/users.json delete mode 100644 tests/integration/__init__.py delete mode 100644 tests/integration/conftest.py delete mode 100644 tests/integration/test_api.py delete mode 100644 tests/integration/test_database.py delete mode 100644 tests/integration/test_redis.py delete mode 100644 tests/reports/.keep delete mode 100644 tests/unit/__init__.py delete mode 100644 tests/unit/core/__init__.py delete mode 100644 tests/unit/core/test_base_service.py delete mode 100644 tests/unit/core/test_crypto.py delete mode 100644 tests/unit/core/test_jwt.py delete mode 100644 tests/unit/core/test_repository.py delete mode 100644 tests/unit/core/test_response.py delete mode 100644 tests/unit/modules/__init__.py delete mode 100644 tests/unit/modules/test_auth.py delete mode 100644 tests/unit/modules/test_job_log_service.py delete mode 100644 tests/unit/modules/test_job_scheduler.py delete mode 100644 tests/unit/modules/test_user_schema.py diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py deleted file mode 100644 index ee04e1e..0000000 --- a/tests/fixtures/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""测试夹具。""" diff --git a/tests/fixtures/data.sql b/tests/fixtures/data.sql deleted file mode 100644 index 2edd8ad..0000000 --- a/tests/fixtures/data.sql +++ /dev/null @@ -1,2 +0,0 @@ --- 集成测试基础数据 --- 完整结构请使用 document/djangoadmin.tornado.antdvue.sql diff --git a/tests/fixtures/factories.py b/tests/fixtures/factories.py deleted file mode 100644 index bfbb79d..0000000 --- a/tests/fixtures/factories.py +++ /dev/null @@ -1,126 +0,0 @@ -"""工厂模式:构造测试用模拟对象。""" -import json - -from common.utils.helpers import parse_id_list - - -class DictItem(dict): - """带 to_dict 的伪模型对象。""" - - def to_dict(self): - return dict(self) - - -class FakeHandler: - """模拟 Tornado handler(支持 get_query_argument / request.body / finish)。""" - - def __init__(self, query=None, body=None): - self._query = query or {} - self.request = type("Request", (), { - "body": json.dumps(body).encode("utf-8") if body is not None else b"{}", - "path": "/api/v1/test", - "method": "POST", - })() - self.result = None - - def get_query_argument(self, name, default=None): - return self._query.get(name, default) - - def finish(self, result): - self.result = result - return self.result - - -class FakeQuery: - """内存查询对象:支持 filter/order_by/count/all(不做真实过滤)。""" - - def __init__(self, items): - self.items = list(items) - - def filter(self, *criterion): - return self - - def order_by(self, *cols): - return self - - def all(self): - return self.items - - def count(self): - return len(self.items) - - -class FakeRepository: - """内存版仓储,支持 BaseService 模板方法所需原语。""" - - def __init__(self, items=None): - self.items = items if items is not None else [] - self._deleted = [] - - # ---------- 数据清洗 ---------- - def _clean_model_data(self, data: dict) -> dict: - """模拟 BaseRepository._clean_model_data:透传数据。""" - return data if data else {} - - # ---------- 查询 ---------- - def filter(self, *criterion): - return FakeQuery(self.items) - - def filter_by(self, **kwargs): - return FakeQuery(self.items) - - def get_by_id(self, pk, soft_delete=True): - try: - pk = int(pk) - except (TypeError, ValueError): - return None - for item in self.items: - if getattr(item, "id", None) == pk: - return item - return None - - def get_by_ids(self, ids, soft_delete=True): - valid = [i for i in ids if isinstance(i, int) and i > 0] - return [item for item in self.items if getattr(item, "id", None) in valid] - - def exists(self, obj_id=None, soft_delete=True, **filters): - if obj_id is not None: - return self.get_by_id(obj_id) is not None - return any(self.items) - - def exists_by_field(self, field, value, exclude_id=None, soft_delete=True): - for item in self.items: - if getattr(item, field, None) == value and getattr(item, "id", None) != exclude_id: - return True - return False - - def paginate(self, query, page=1, limit=10): - items = list(query.items) - total = len(items) - start = (page - 1) * limit - return items[start:start + limit], total - - def all(self, soft_delete=True): - return list(self.items) - - # ---------- 写操作 ---------- - def create(self, obj): - self.items.append(obj) - return obj - - def update(self, pk, data): - return True - - def soft_delete(self, ids): - self._deleted.extend(ids) - return len(ids) - - def batch_delete(self, ids_str): - id_list = parse_id_list(str(ids_str)) - if not id_list: - return 0 - self._deleted.extend(id_list) - return len(id_list) - - def close(self): - pass diff --git a/tests/fixtures/users.json b/tests/fixtures/users.json deleted file mode 100644 index d545fcd..0000000 --- a/tests/fixtures/users.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "username": "admin", - "password": "123456", - "realname": "超级管理员", - "nickname": "管理员", - "gender": 1, - "status": 1 - } -] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py deleted file mode 100644 index de02c1a..0000000 --- a/tests/integration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""集成测试。""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py deleted file mode 100644 index 96a7bf4..0000000 --- a/tests/integration/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -"""集成测试配置:MySQL/Redis 不可用或认证失败时自动跳过。""" -import pytest - - -def _services_available() -> bool: - """尝试真实连接 MySQL 与 Redis,任一不可用则跳过集成测试。""" - try: - # 数据库连通性 + 认证(select(1) 跨方言,Oracle 下自动渲染 SELECT 1 FROM DUAL) - from sqlalchemy import select - from extensions.sqlalchemy import engine - with engine.connect() as conn: - conn.execute(select(1)) - # Redis 连通性 - from core.redis import redis - redis.ping() - return True - except Exception: - return False - - -SERVICES_AVAILABLE = _services_available() - - -@pytest.fixture(autouse=True) -def _skip_without_services(request): - """集成测试依赖 MySQL/Redis,服务不可用或认证失败时跳过。""" - if "integration" in request.keywords and not SERVICES_AVAILABLE: - pytest.skip("MySQL/Redis 服务不可用或认证失败,跳过集成测试") diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py deleted file mode 100644 index a437b12..0000000 --- a/tests/integration/test_api.py +++ /dev/null @@ -1,34 +0,0 @@ -"""API 集成测试。""" -import json - -import pytest -from tornado.testing import AsyncHTTPTestCase - -# 使用 src 包路径导入工厂,避免与根目录入口 app.py 同名冲突 -from src.app import create_app - -pytestmark = pytest.mark.integration - - -class ApiSmokeTest(AsyncHTTPTestCase): - def get_app(self): - return create_app() - - def test_health(self): - resp = self.fetch("/api/v1/health") - assert resp.code == 200 - body = json.loads(resp.body) - assert body["code"] == 0 - assert body["data"]["status"] == "ok" - - def test_captcha(self): - resp = self.fetch("/api/v1/captcha") - assert resp.code == 200 - body = json.loads(resp.body) - assert body["code"] == 0 - assert body["data"].startswith("data:image/png;base64,") - - def test_protected_requires_token(self): - resp = self.fetch("/api/v1/user/list") - body = json.loads(resp.body) - assert body["code"] == 401 diff --git a/tests/integration/test_database.py b/tests/integration/test_database.py deleted file mode 100644 index ae05cb1..0000000 --- a/tests/integration/test_database.py +++ /dev/null @@ -1,64 +0,0 @@ -"""数据库连接与 Repository 集成测试。""" -import pytest -from sqlalchemy import select - -from extensions.sqlalchemy import engine - -pytestmark = pytest.mark.integration - - -def test_db_connect(): - with engine.connect() as conn: - result = conn.execute(select(1)) - assert result.scalar() == 1 - - -def test_repo_get_by_id_robust(): - """get_by_id 对非法 ID 容错,对已存在 ID 正常返回。""" - from modules.role.repository import RoleRepository - repo = RoleRepository() - try: - assert repo.get_by_id(None) is None - assert repo.get_by_id("") is None - assert repo.get_by_id("abc") is None - assert repo.get_by_id(0) is None - assert repo.get_by_id(-1) is None - first = repo.filter_by().order_by(RoleRepository.model.id.asc()).first() - assert first is not None - assert repo.get_by_id(first.id) is not None - finally: - repo.close() - - -def test_repo_exists_by_field(): - """exists_by_field 唯一性校验(仅未删除,可排除自身)。""" - from modules.role.models import Role - from modules.role.repository import RoleRepository - repo = RoleRepository() - try: - role = repo.create(Role(name="唯一名XYZ", code="uniq_xyz", status=1, sort=0)) - assert repo.exists_by_field("code", "uniq_xyz") # 自身命中 - assert not repo.exists_by_field("code", "uniq_xyz", exclude_id=role.id) # 排除自身后无重复 - assert not repo.exists_by_field("code", "__不存在的编码__") - repo.db.query(Role).filter(Role.id == role.id).delete() - repo.db.commit() - finally: - repo.close() - - -def test_repo_batch_delete(): - """batch_delete 逗号分隔软删,返回实际删除条数。""" - from modules.role.models import Role - from modules.role.repository import RoleRepository - repo = RoleRepository() - try: - role = repo.create(Role(name="测试删除角色", code="test_del", status=1, sort=0)) - assert repo.batch_delete(f"{role.id}") == 1 - assert repo.get_by_id(role.id) is None # 软删后不可见 - assert repo.get_by_id(role.id, soft_delete=False) is not None # 物理仍在 - assert repo.batch_delete("abc,,0") == 0 - # 清理 - repo.db.query(Role).filter(Role.id == role.id).delete() - repo.db.commit() - finally: - repo.close() diff --git a/tests/integration/test_redis.py b/tests/integration/test_redis.py deleted file mode 100644 index d1393e9..0000000 --- a/tests/integration/test_redis.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Redis 集成测试。""" -import pytest - -from core.redis import redis - -pytestmark = pytest.mark.integration - -TEST_KEY = "test:djangoadmin:ping" - - -def test_redis_set_get(): - redis.set(TEST_KEY, "pong") - assert redis.get(TEST_KEY) == "pong" - redis.delete(TEST_KEY) - assert redis.get(TEST_KEY) is None diff --git a/tests/reports/.keep b/tests/reports/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index 6d39ee9..0000000 --- a/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""单元测试。""" diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py deleted file mode 100644 index 899a9f5..0000000 --- a/tests/unit/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""核心层单元测试。""" diff --git a/tests/unit/core/test_base_service.py b/tests/unit/core/test_base_service.py deleted file mode 100644 index 4602ab1..0000000 --- a/tests/unit/core/test_base_service.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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"] == "记录不存在" diff --git a/tests/unit/core/test_crypto.py b/tests/unit/core/test_crypto.py deleted file mode 100644 index ae930c3..0000000 --- a/tests/unit/core/test_crypto.py +++ /dev/null @@ -1,47 +0,0 @@ -"""加密工具测试。""" -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") diff --git a/tests/unit/core/test_jwt.py b/tests/unit/core/test_jwt.py deleted file mode 100644 index a75148b..0000000 --- a/tests/unit/core/test_jwt.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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 diff --git a/tests/unit/core/test_repository.py b/tests/unit/core/test_repository.py deleted file mode 100644 index e311c9d..0000000 --- a/tests/unit/core/test_repository.py +++ /dev/null @@ -1,30 +0,0 @@ -"""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 diff --git a/tests/unit/core/test_response.py b/tests/unit/core/test_response.py deleted file mode 100644 index 7608ece..0000000 --- a/tests/unit/core/test_response.py +++ /dev/null @@ -1,66 +0,0 @@ -"""统一响应测试。""" -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 diff --git a/tests/unit/modules/__init__.py b/tests/unit/modules/__init__.py deleted file mode 100644 index 1d620c6..0000000 --- a/tests/unit/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""业务模块单元测试。""" diff --git a/tests/unit/modules/test_auth.py b/tests/unit/modules/test_auth.py deleted file mode 100644 index f364cce..0000000 --- a/tests/unit/modules/test_auth.py +++ /dev/null @@ -1,21 +0,0 @@ -"""认证模块测试。""" -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") diff --git a/tests/unit/modules/test_job_log_service.py b/tests/unit/modules/test_job_log_service.py deleted file mode 100644 index 86a1263..0000000 --- a/tests/unit/modules/test_job_log_service.py +++ /dev/null @@ -1,66 +0,0 @@ -"""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) == [] diff --git a/tests/unit/modules/test_job_scheduler.py b/tests/unit/modules/test_job_scheduler.py deleted file mode 100644 index 8e8b46c..0000000 --- a/tests/unit/modules/test_job_scheduler.py +++ /dev/null @@ -1,139 +0,0 @@ -"""任务调度器测试:执行日志记录、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 diff --git a/tests/unit/modules/test_user_schema.py b/tests/unit/modules/test_user_schema.py deleted file mode 100644 index 16206c9..0000000 --- a/tests/unit/modules/test_user_schema.py +++ /dev/null @@ -1,42 +0,0 @@ -"""用户校验模型测试。""" -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