diff --git a/modules/system/level/service.py b/modules/system/level/service.py index 9c25c97..62f4ef6 100644 --- a/modules/system/level/service.py +++ b/modules/system/level/service.py @@ -53,6 +53,7 @@ from sqlalchemy import and_ from shared.constants import PAGE_SIZE from modules.system.level.schemas import LevelForm, LevelStatusForm from modules.system.level.models import Level +from modules.system.level.repository import level_repo from modules.upload import service as upload from config import FASTAPI_TEMP_PATH, FASTAPI_UPLOAD_DIR, FASTAPI_IMAGE_URL from core.database import get_db_session @@ -66,13 +67,12 @@ from utils.request import like_contains # 查询职级分页数据 def get_level_page(request): - db = get_db_session() # 页码 page = int(request.query_params.get("pageNo", 1)) # 每页数 limit = int(request.query_params.get("pageSize", PAGE_SIZE)) # 查询数据 - query = db.query(Level).filter(Level.is_delete == 0) + query = level_repo.filter(Level.is_delete == 0) # 职级名称 name = request.query_params.get('name') if name: @@ -105,9 +105,8 @@ def get_level_page(request): # 根据ID查询职级详情 def get_level_detail(level_id): - db = get_db_session() # 根据ID查询职级 - level = db.query(Level).filter(and_(Level.id == level_id, Level.is_delete == 0)).first() + level = level_repo.get_by_id(level_id) # 查询结果为空判断 if not level: return None @@ -118,15 +117,11 @@ def get_level_detail(level_id): # 添加职级 def add_level(request, data: LevelForm): - db = get_db_session() # 唯一性校验 - if db.query(Level).filter(and_(Level.name == data.name, Level.is_delete == 0)).first(): + if level_repo.filter(Level.name == data.name, Level.is_delete == 0).first(): return R.failed("职级名称不能重复") - # 表单数据赋值给对象 - level = Level(**data.model_dump(exclude={'id'})) - level.create_user = get_user_id(request) - # 插入数据 - level.save() + # 表单数据赋值给对象并插入 + level_repo.create(**data.model_dump(exclude={'id'}), create_user=get_user_id(request)) # 返回结果 return R.ok(msg="添加成功") @@ -134,24 +129,21 @@ def add_level(request, data: LevelForm): # 更新职级 def update_level(request, data: LevelForm): - db = get_db_session() # 记录ID判空 if not data.id or data.id <= 0: return R.failed("记录ID不能为空") # 唯一性校验(排除自身) - if db.query(Level).filter(and_(Level.name == data.name, Level.id != data.id, Level.is_delete == 0)).first(): + if level_repo.filter(Level.name == data.name, Level.id != data.id, Level.is_delete == 0).first(): return R.failed("职级名称不能重复") # 根据ID查询记录 - level = db.query(Level).filter(and_(Level.id == data.id, Level.is_delete == 0)).first() - # 查询结果判空 - if not level: + if not level_repo.get_by_id(data.id): return R.failed("记录不存在") # 构造更新数据(排除 id) updData = data.model_dump(exclude={'id'}) updData['update_user'] = get_user_id(request) updData['update_time'] = datetime.now(tz=timezone.utc) - result = db.query(Level).filter_by(id=data.id).update(updData) + result = level_repo.update(data.id, updData) # 提交数据 if not result: return R.failed("更新失败") @@ -164,15 +156,12 @@ def delete_level(level_id): # 设置状态 def update_level_status(request, data: LevelStatusForm): - db = get_db_session() # 根据ID查询记录 - level = db.query(Level).filter(and_(Level.id == data.id, Level.is_delete == 0)).first() - # 查询结果判空 - if not level: + if not level_repo.get_by_id(data.id): return R.failed("记录不存在") # 更新记录 - result = db.query(Level).filter_by(id=data.id).update({ + result = level_repo.update(data.id, { "status": data.status, "update_user": get_user_id(request), "update_time": datetime.now(tz=timezone.utc) diff --git a/pytest.ini b/pytest.ini index 89d2bee..c66fa70 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] minversion = 7.0 -addopts = -ra --strict-markers +addopts = -ra --strict-markers -m "not integration" testpaths = tests pythonpath = . markers = diff --git a/shared/base_repository.py b/shared/base_repository.py index 3de24cc..a29608e 100644 --- a/shared/base_repository.py +++ b/shared/base_repository.py @@ -42,8 +42,14 @@ class BaseRepository(Generic[M]): # ============================================================ def get_by_id(self, obj_id: Optional[int]) -> Optional[M]: - """根据ID获取未删除记录。""" - if obj_id is None or obj_id <= 0: + """根据ID获取未删除记录(兼容字符串ID,如 URL 路径参数)。""" + if obj_id is None or obj_id == '': + return None + try: + obj_id = int(obj_id) + except (TypeError, ValueError): + return None + if obj_id <= 0: return None return self.db.query(self.model).filter( self.model.id == obj_id, self._soft_col() == 0 @@ -51,7 +57,14 @@ class BaseRepository(Generic[M]): def get_by_ids(self, ids) -> list: """根据ID列表批量获取未删除记录。""" - valid = [i for i in ids if i and i > 0] + valid = [] + for i in ids: + try: + i = int(i) + except (TypeError, ValueError): + continue + if i > 0: + valid.append(i) if not valid: return [] return self.db.query(self.model).filter( diff --git a/tests/conftest.py b/tests/conftest.py index ddff4b0..dda8fa0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,38 @@ -"""pytest 全局配置:确保项目根目录在 sys.path。""" +"""pytest 全局配置:确保项目根目录在 sys.path,并提供 SQLite 内存库 fixture。""" import os import sys +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) + + +@pytest.fixture +def sqlite_db(): + """SQLite 内存库会话 fixture:隔离的 service/repository 测试环境。 + + 将独立会话注入 core.database._db_ctx,测试结束后恢复并清理。 + 不触碰真实 MySQL/Redis。 + """ + from core.database import Base, _db_ctx + + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + + token = _db_ctx.set(session) + try: + yield session + finally: + _db_ctx.reset(token) + session.close() + engine.dispose() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/api/__init__.py b/tests/integration/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py new file mode 100644 index 0000000..ccfaf4e --- /dev/null +++ b/tests/integration/api/test_smoke.py @@ -0,0 +1,46 @@ +"""集成冒烟测试:登录链路 + 各模块只读接口(需 MySQL/Redis 运行,默认跳过)。""" + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_login_flow(client): + """完整登录链路:/captcha → 解析验证码 → /login 获取 token。""" + import redis as redis_lib + + from config import REDIS_INDEX + + r = await client.get("/captcha") + body = r.json() + assert body["code"] == 0 + idkey = body.get("idkey") + assert idkey + + # 从 Redis 读取验证码明文 + redis_client = redis_lib.Redis(db=REDIS_INDEX) + code = redis_client.get(idkey).decode() + + resp = await client.post("/login", json={ + "username": "admin", "password": "123456", + "captcha": code, "idKey": idkey, + }) + login = resp.json() + assert login["code"] == 0 + assert login["data"]["access_token"] + + +@pytest.mark.asyncio +async def test_admin_read_endpoints(client, admin_headers): + """管理员 token 可访问代表性只读接口。""" + for path in ("/user/page", "/dept/list", "/menu/list", "/index/menu", "/job/page"): + resp = await client.get(path, headers=admin_headers) + assert resp.json()["code"] == 0, f"{path} failed" + + +@pytest.mark.asyncio +async def test_unauthorized_blocked(client): + """无 token 访问受保护接口应返回 401。""" + resp = await client.get("/user/page") + assert resp.json()["code"] == 401 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..cc43fa0 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,32 @@ +"""集成测试 fixture:真实 FastAPI 应用 + ASGI client(需 MySQL/Redis 运行)。""" + +import httpx +import pytest +import pytest_asyncio + +from core import jwt as core_jwt +from core.app import create_app + + +@pytest.fixture +def app(): + return create_app() + + +@pytest_asyncio.fixture +async def client(app): + # 手动运行 Redis lifespan(当前 httpx 版本不支持 ASGITransport lifespan 参数) + from core.redis_client import redis_lifespan + + transport = httpx.ASGITransport(app=app) + async with redis_lifespan(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def admin_headers(): + """userId=1 管理员 token(绕过 permission_required 权限校验)。""" + token = core_jwt.create_token( + {"userId": 1, "username": "admin", "realname": "admin"}) + return {"Authorization": f"Bearer {token}"} diff --git a/tests/unit/modules/__init__.py b/tests/unit/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/modules/test_repository.py b/tests/unit/modules/test_repository.py new file mode 100644 index 0000000..7db0bb2 --- /dev/null +++ b/tests/unit/modules/test_repository.py @@ -0,0 +1,36 @@ +"""shared/base_repository 与模块 repository 的单元测试(SQLite 内存库)。""" + +from modules.system.level.repository import level_repo +from modules.system.position.repository import position_repo + + +def test_level_repo_crud(sqlite_db): + # create + obj = level_repo.create(name="高级", status=1, sort=1) + assert obj.id is not None + sqlite_db.commit() + + # get / exists + assert level_repo.get_by_id(obj.id).name == "高级" + assert level_repo.exists(obj.id) + assert level_repo.get_by_id(0) is None + assert not level_repo.exists(999) + + # paginate + items, total = level_repo.paginate(1, 10) + assert total == 1 + assert items[0].id == obj.id + + # update + assert level_repo.update(obj.id, {"name": "资深"}) == 1 + assert level_repo.get_by_id(obj.id).name == "资深" + + # soft delete + assert level_repo.soft_delete([obj.id]) + assert level_repo.get_by_id(obj.id) is None + + +def test_position_repo_paginate_empty(sqlite_db): + items, total = position_repo.paginate(1, 10) + assert total == 0 + assert items == []