2026-10-10 04:28:04 -04:00
|
|
|
|
"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。
|
|
|
|
|
|
|
|
|
|
|
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
|
|
|
|
|
|
|
|
|
|
|
数据卫生约定:
|
|
|
|
|
|
|
|
|
|
|
|
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
|
|
|
|
|
|
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
|
|
|
|
|
|
失败中断时会残留并影响后续用例的 total 断言)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
- M2 基础数据(人员姓名/产品名/公司名/测试岗位编码)同用 ``t_`` 前缀,
|
|
|
|
|
|
兜底清理按前缀删除;归属留痕对客户/人员 ID 无外键固化、不随主表删除
|
|
|
|
|
|
消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言
|
2026-10-10 04:28:04 -04:00
|
|
|
|
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
|
|
|
|
|
|
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
|
2026-10-13 01:39:09 -04:00
|
|
|
|
- M2 业务数据(业务/发货/回款)单据编号由系统生成(YW/SH/HK 前缀 + 当日
|
|
|
|
|
|
序号),**没有 t_ 前缀可用**,兜底清理按编号前缀全量清(三类单据表目前
|
|
|
|
|
|
只有测试会写);外键均为 RESTRICT,删除顺序不能倒,且必须在客户/人员
|
|
|
|
|
|
清理之前(FK 会挡住删客户/删人员);评级判定表则恢复种子基线 ——
|
|
|
|
|
|
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
|
2026-10-10 04:28:04 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
2026-10-13 01:39:09 -04:00
|
|
|
|
from decimal import Decimal
|
2026-10-10 04:28:04 -04:00
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-10-12 23:22:34 -04:00
|
|
|
|
from sqlalchemy import delete, select, update
|
2026-10-10 04:28:04 -04:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-12 23:22:34 -04:00
|
|
|
|
from app.models import (
|
2026-10-16 04:31:25 -04:00
|
|
|
|
AdjustmentOrder,
|
2026-10-15 06:50:38 -04:00
|
|
|
|
ApprovalOrder,
|
2026-10-13 01:39:09 -04:00
|
|
|
|
Business,
|
|
|
|
|
|
BusinessItem,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
BusinessRole,
|
2026-10-13 01:39:09 -04:00
|
|
|
|
BusinessStatusLog,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
Customer,
|
2026-10-19 23:06:01 -04:00
|
|
|
|
CustomerRating,
|
2026-10-13 01:39:09 -04:00
|
|
|
|
CustomerRatingGrade,
|
2026-10-16 04:31:25 -04:00
|
|
|
|
MonthlySettlement,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
OperationLog,
|
|
|
|
|
|
OwnershipChangeLog,
|
2026-10-15 06:50:38 -04:00
|
|
|
|
ParamChangeBatch,
|
|
|
|
|
|
ParamValue,
|
2026-10-16 04:31:25 -04:00
|
|
|
|
PerformanceLedger,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
Personnel,
|
|
|
|
|
|
PersonnelPlan,
|
|
|
|
|
|
PersonnelRegion,
|
|
|
|
|
|
Product,
|
2026-10-13 01:39:09 -04:00
|
|
|
|
Receipt,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
Role,
|
2026-10-16 04:31:25 -04:00
|
|
|
|
SettlementItem,
|
2026-10-13 01:39:09 -04:00
|
|
|
|
Shipment,
|
|
|
|
|
|
ShipmentItem,
|
2026-10-16 02:23:53 -04:00
|
|
|
|
SystemSetting,
|
2026-10-12 23:22:34 -04:00
|
|
|
|
User,
|
|
|
|
|
|
UserRole,
|
|
|
|
|
|
)
|
2026-10-13 01:39:09 -04:00
|
|
|
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
|
|
|
|
|
|
|
|
|
|
|
# 评级判定表的种子基线(与 db/migration/V5 一致):判定按 sort_order 升序
|
|
|
|
|
|
# 取首个 min_score ≤ 总分的等级,种子铺满 0-100
|
|
|
|
|
|
_RATING_SEEDS = [
|
|
|
|
|
|
("A", Decimal("90"), Decimal("100"), 1, "优秀"),
|
|
|
|
|
|
("B", Decimal("75"), Decimal("89"), 2, "良好"),
|
|
|
|
|
|
("C", Decimal("60"), Decimal("74"), 3, "合格"),
|
|
|
|
|
|
("D", Decimal("0"), Decimal("59"), 4, "待改进"),
|
|
|
|
|
|
]
|
2026-10-10 04:28:04 -04:00
|
|
|
|
|
|
|
|
|
|
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)))
|
2026-10-12 23:22:34 -04:00
|
|
|
|
|
2026-10-16 08:47:29 -04:00
|
|
|
|
# ---- M3-core:核算台账 / 月结 / 调整单(必须先于回款/业务/人员删除——
|
|
|
|
|
|
# ledger.receipt_id/personnel_id/settlement_id 外键 RESTRICT 会挡住后面
|
|
|
|
|
|
# 的单据与人员清理;仅测试写入,全量清)----
|
|
|
|
|
|
session.execute(delete(PerformanceLedger))
|
|
|
|
|
|
session.execute(delete(SettlementItem))
|
|
|
|
|
|
session.execute(delete(MonthlySettlement))
|
|
|
|
|
|
session.execute(delete(AdjustmentOrder).where(AdjustmentOrder.order_no.like("TZ%")))
|
|
|
|
|
|
|
2026-10-13 01:39:09 -04:00
|
|
|
|
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
|
|
|
|
|
|
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
|
|
|
|
|
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
|
|
|
|
|
# 明细 → 发货单 → 业务明细 → 状态留痕 → 业务维度归属留痕(无外键,
|
|
|
|
|
|
# 须显式清)→ 业务主表;且必须在下方客户/人员清理之前,否则 FK 挡住
|
|
|
|
|
|
# 删客户/删人员(businesses.customer_id / .owner_personnel_id RESTRICT)
|
|
|
|
|
|
business_ids = list(
|
|
|
|
|
|
session.execute(select(Business.id).where(Business.business_no.like("YW%"))).scalars()
|
|
|
|
|
|
)
|
|
|
|
|
|
shipment_ids = list(
|
|
|
|
|
|
session.execute(select(Shipment.id).where(Shipment.shipment_no.like("SH%"))).scalars()
|
|
|
|
|
|
)
|
|
|
|
|
|
if business_ids:
|
|
|
|
|
|
session.execute(delete(Receipt).where(Receipt.business_id.in_(business_ids)))
|
|
|
|
|
|
if shipment_ids:
|
|
|
|
|
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id.in_(shipment_ids)))
|
|
|
|
|
|
session.execute(delete(Shipment).where(Shipment.id.in_(shipment_ids)))
|
|
|
|
|
|
if business_ids:
|
|
|
|
|
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id.in_(business_ids)))
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
delete(BusinessStatusLog).where(BusinessStatusLog.business_id.in_(business_ids))
|
|
|
|
|
|
)
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
delete(OwnershipChangeLog).where(
|
|
|
|
|
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
|
|
|
|
|
OwnershipChangeLog.business_id.in_(business_ids),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
session.execute(delete(Business).where(Business.id.in_(business_ids)))
|
|
|
|
|
|
|
|
|
|
|
|
# 评级判定表恢复种子基线:评级用例会删改种子构造重叠/相邻场景,
|
|
|
|
|
|
# 兜底恢复保证每个用例开始时都是纯种子(判定表无下游引用,全删全插安全)
|
|
|
|
|
|
session.execute(delete(CustomerRatingGrade))
|
|
|
|
|
|
session.add_all(
|
|
|
|
|
|
CustomerRatingGrade(grade_name=n, min_score=lo, max_score=hi, sort_order=so, remark=rm)
|
|
|
|
|
|
for n, lo, hi, so, rm in _RATING_SEEDS
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-12 23:22:34 -04:00
|
|
|
|
# ---- 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))
|
|
|
|
|
|
)
|
2026-10-19 23:06:01 -04:00
|
|
|
|
# 评级行 FK RESTRICT:必须先于客户删除(V7 起客户评级每客户一行)
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
delete(CustomerRating).where(CustomerRating.customer_id.in_(customer_ids))
|
|
|
|
|
|
)
|
2026-10-12 23:22:34 -04:00
|
|
|
|
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\\_%")))
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
2026-10-16 02:23:53 -04:00
|
|
|
|
# ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ----
|
2026-10-15 06:50:38 -04:00
|
|
|
|
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
|
|
|
|
|
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
|
|
|
|
|
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
2026-10-19 23:06:01 -04:00
|
|
|
|
# 开关参数恢复种子基线:测试提交的变更批次由 t_ 前缀测试账号创建,
|
|
|
|
|
|
# 按**批次操作人**删除本轮的参数条目与批次,解析后回退到种子值(开关=0)。
|
|
|
|
|
|
# 不用 id 阈值:种子条目的主键在不同环境的漂移库中并不相同(历史库
|
|
|
|
|
|
# 可能补过种子行),按操作人识别测试产物与环境数据分布无关。
|
|
|
|
|
|
test_batch_ids = list(
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(ParamChangeBatch.id).where(ParamChangeBatch.operator_name.like("t\\_%"))
|
|
|
|
|
|
).scalars()
|
2026-10-16 02:23:53 -04:00
|
|
|
|
)
|
2026-10-19 23:06:01 -04:00
|
|
|
|
if test_batch_ids:
|
|
|
|
|
|
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(test_batch_ids)))
|
|
|
|
|
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(test_batch_ids)))
|
2026-10-10 04:28:04 -04:00
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 23:42:49 -04:00
|
|
|
|
# ==================== 系统设置与上传文件的快照还原 ====================
|
|
|
|
|
|
#
|
|
|
|
|
|
# 系统设置是**真实单行数据**,logo/favicon 是真实上传文件,没有 t_ 前缀可供
|
|
|
|
|
|
# 兜底清理。测试(尤其 logo 上传用例)会改公司名、覆盖/删除真实图片 ——
|
|
|
|
|
|
# 必须在用例结束后还原,否则跑一次测试就会破坏开发环境的品牌与 logo。
|
|
|
|
|
|
|
|
|
|
|
|
_ASSET_KINDS = ("logo", "favicon")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _snapshot_company_info() -> dict | None:
|
|
|
|
|
|
"""快照系统设置单行(无行时返回 None)。"""
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
row = session.get(SystemSetting, 1)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return {column.name: getattr(row, column.name) for column in SystemSetting.__table__.columns}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _restore_company_info(snapshot: dict | None) -> None:
|
|
|
|
|
|
if snapshot is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
row = session.get(SystemSetting, 1)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
for key, value in snapshot.items():
|
|
|
|
|
|
if key != "update_time": # 更新时间由数据库 ON UPDATE 维护
|
|
|
|
|
|
setattr(row, key, value)
|
2026-10-10 04:28:04 -04:00
|
|
|
|
session.commit()
|
2026-10-19 23:42:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _snapshot_assets() -> dict[str, dict[str, bytes]]:
|
|
|
|
|
|
"""快照上传目录里的真实图片(logo/favicon)。"""
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
root = get_settings().upload_dir
|
|
|
|
|
|
snapshot: dict[str, dict[str, bytes]] = {}
|
|
|
|
|
|
for kind in _ASSET_KINDS:
|
|
|
|
|
|
directory = root / kind
|
|
|
|
|
|
snapshot[kind] = (
|
|
|
|
|
|
{path.name: path.read_bytes() for path in directory.glob("*") if path.is_file()}
|
|
|
|
|
|
if directory.is_dir()
|
|
|
|
|
|
else {}
|
|
|
|
|
|
)
|
|
|
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _restore_assets(snapshot: dict[str, dict[str, bytes]]) -> None:
|
|
|
|
|
|
"""还原上传目录:删除测试新增文件、回写被测试覆盖的原文件。"""
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
root = get_settings().upload_dir
|
|
|
|
|
|
for kind, files in snapshot.items():
|
|
|
|
|
|
directory = root / kind
|
|
|
|
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
for path in directory.glob("*"):
|
|
|
|
|
|
if path.is_file() and path.name not in files:
|
|
|
|
|
|
path.unlink(missing_ok=True)
|
|
|
|
|
|
for name, content in files.items():
|
|
|
|
|
|
(directory / name).write_bytes(content)
|
2026-10-10 04:28:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
2026-10-19 23:42:49 -04:00
|
|
|
|
def _preserve_company_info():
|
|
|
|
|
|
"""每个用例前快照系统设置与上传图片,结束后还原(见上方说明)。"""
|
|
|
|
|
|
info = _snapshot_company_info()
|
|
|
|
|
|
assets = _snapshot_assets()
|
|
|
|
|
|
yield
|
|
|
|
|
|
# 先还原文件:数据库路径随后回指同一批文件
|
|
|
|
|
|
_restore_assets(assets)
|
|
|
|
|
|
_restore_company_info(info)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _purge_after_each_test(_preserve_company_info):
|
|
|
|
|
|
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。
|
|
|
|
|
|
|
|
|
|
|
|
依赖 ``_preserve_company_info``:保证快照在该夹具之前建立、在该夹具
|
|
|
|
|
|
清理**之后**还原(夹具 teardown 为逆序)。
|
|
|
|
|
|
"""
|
2026-10-10 04:28:04 -04:00
|
|
|
|
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
|