This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""测试包。"""
|
||||
@@ -0,0 +1,14 @@
|
||||
"""pytest 全局配置。"""
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "integration: 需要 MySQL/Redis 服务的集成测试")
|
||||
config.addinivalue_line("markers", "e2e: 端到端测试")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_handler():
|
||||
"""构造一个模拟 Tornado handler,供服务层单测使用。"""
|
||||
from tests.fixtures.factories import FakeHandler
|
||||
return FakeHandler
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
"""测试夹具。"""
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
-- 集成测试基础数据
|
||||
-- 完整结构请使用 document/djangoadmin.tornado.elevue.sql
|
||||
Vendored
+126
@@ -0,0 +1,126 @@
|
||||
"""工厂模式:构造测试用模拟对象。"""
|
||||
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
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"realname": "超级管理员",
|
||||
"nickname": "管理员",
|
||||
"gender": 1,
|
||||
"status": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""集成测试。"""
|
||||
@@ -0,0 +1,28 @@
|
||||
"""集成测试配置: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 服务不可用或认证失败,跳过集成测试")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""数据库连接与 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()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""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
|
||||
@@ -0,0 +1 @@
|
||||
"""单元测试。"""
|
||||
@@ -0,0 +1 @@
|
||||
"""核心层单元测试。"""
|
||||
@@ -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"] == "记录不存在"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""业务模块单元测试。"""
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user