"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。 运行前置:``docker compose -f docker/docker-compose.dev.yml up -d`` 数据卫生约定: - 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有 autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下, 失败中断时会残留并影响后续用例的 total 断言) - M2 基础数据(人员姓名/产品名/公司名/测试岗位编码)同用 ``t_`` 前缀, 兜底清理按前缀删除;归属留痕对客户/人员 ID 无外键固化、不随主表删除 消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言 - 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id`` 会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态 """ import uuid import pytest from sqlalchemy import delete, select, update from app.core.constants import ROLE_ADMIN, ROLE_USER from app.core.db import get_session_factory from app.core.security import create_access_token, hash_password from app.models import ( BusinessRole, Customer, OperationLog, OwnershipChangeLog, Personnel, PersonnelPlan, PersonnelRegion, Product, Role, User, UserRole, ) TEST_PASSWORD = "TestPwd@2026" # 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希 # 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT, # 哈希值不被任何断言消费,复用一个哈希与每次重算等价 _TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD) def _purge_test_data() -> None: """按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。 ``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配, 会把 tn... 这类无关账号一并卷入。 """ factory = get_session_factory() with factory() as session: users = list( session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all() ) ids = [row[0] for row in users] names = [row[1] for row in users] if ids: session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids))) if names: session.execute(delete(OperationLog).where(OperationLog.username.in_(names))) session.execute(delete(User).where(User.id.in_(ids))) role_ids = list( session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars() ) if role_ids: session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids))) session.execute(delete(Role).where(Role.id.in_(role_ids))) # ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ---- # 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清, # 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品 personnel_ids = list( session.execute( select(Personnel.id).where(Personnel.personnel_name.like("t\\_%")) ).scalars() ) customer_ids = list( session.execute( select(Customer.id).where(Customer.company_name.like("t\\_%")) ).scalars() ) if customer_ids: session.execute( delete(OwnershipChangeLog).where(OwnershipChangeLog.customer_id.in_(customer_ids)) ) session.execute(delete(Customer).where(Customer.id.in_(customer_ids))) # 客户被物理删除后其 customer_id 已无从收集,按操作人用户名兜底一条 session.execute( delete(OwnershipChangeLog).where(OwnershipChangeLog.operator_name.like("t\\_%")) ) if personnel_ids: session.execute( update(Personnel) .where(Personnel.manager_id.in_(personnel_ids)) .values(manager_id=None) ) session.execute( delete(PersonnelRegion).where(PersonnelRegion.personnel_id.in_(personnel_ids)) ) session.execute( delete(PersonnelPlan).where(PersonnelPlan.personnel_id.in_(personnel_ids)) ) session.execute(delete(Personnel).where(Personnel.id.in_(personnel_ids))) session.execute(delete(Product).where(Product.product_name.like("t\\_%"))) # 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删 session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%"))) session.commit() @pytest.fixture(autouse=True) def _purge_after_each_test(): """每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。""" yield _purge_test_data() @pytest.fixture def make_user(): """独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。 不复用真实 admin —— 测试会改密码、提 token_version、删除账号, 污染真实账号会让后续手工验证产生困惑。 """ def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict: codes = role_codes if role_codes is not None else [ROLE_USER] name = username or f"t_{uuid.uuid4().hex[:8]}" factory = get_session_factory() with factory() as session: roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars()) assert len(roles) == len(codes), f"角色不存在:{codes}" user = User( username=name, password=_TEST_PASSWORD_HASH, real_name="测试账号", status=1, ) user.user_roles = [UserRole(role_id=role.id) for role in roles] session.add(user) session.commit() return {"id": user.id, "username": name, "password": TEST_PASSWORD} return _factory @pytest.fixture def auth_headers(): """令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。 绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准 (`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis 限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效, 再次调用本工厂可换新令牌。 """ def _factory(user: dict) -> dict[str, str]: factory = get_session_factory() with factory() as session: db_user = session.get(User, user["id"]) assert db_user is not None, "账号已被删除,无法签发令牌" roles = [link.role.role_code for link in db_user.user_roles if link.role] token, _, _ = create_access_token( user_id=db_user.id, token_version=db_user.token_version, roles=roles, ) return {"Authorization": f"Bearer {token}"} return _factory @pytest.fixture def make_role(): """独立测试角色工厂(ORM 直建,用后删除)。 清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。 """ created: list[int] = [] def _factory(**overrides: object) -> dict: code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}")) name = str(overrides.pop("role_name", "测试角色")) factory = get_session_factory() with factory() as session: role = Role( role_code=code, role_name=name, description=overrides.pop("description", None), sort_order=overrides.pop("sort_order", 99), status=overrides.pop("status", 1), **overrides, ) session.add(role) session.commit() created.append(role.id) return {"id": role.id, "role_code": code, "role_name": name} yield _factory factory = get_session_factory() with factory() as session: session.execute(delete(UserRole).where(UserRole.role_id.in_(created))) session.execute(delete(Role).where(Role.id.in_(created))) session.commit() @pytest.fixture def admin(make_user, auth_headers) -> dict: """管理员账号(含请求头)。""" user = make_user([ROLE_ADMIN]) user["headers"] = auth_headers(user) return user @pytest.fixture def normal(make_user, auth_headers) -> dict: """普通用户账号(含请求头)。""" user = make_user([ROLE_USER]) user["headers"] = auth_headers(user) return user