PayrollManagementSystem/backend/tests/integration/conftest.py
liuwenxin ca88ee7a0b test(base): 基础数据管理模块集成用例(48 例)与审计盲区修复
- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录
- 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文,
  校验失败(400)同样留痕(成功与失败都记的既定原则)
- conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:22:34 +08:00

224 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""系统管理集成测试共用夹具(需要 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