test(m3): 集成/e2e 增补三模块用例并更新测试记录
- 新增 e2e:settings_flow(改名→登出→登录页即时生效→finally 还原防污染)、 secondary_allocation_flow(建客户/员→录两行 30%+40%→编辑回显与合计条 余额提示→删除清理;宽视口防弹窗遮挡、alloc-table 专属类防选择器错位) - 修复 e2e 首跑抓出的两处缺陷:① public 端点 300s 响应缓存让登录页拿旧 公司名(登录页改时间戳 fetch + cache:no-store,手动解包信封)② logo 同 扩展名覆盖时先写后删(清理移到写入前且仅异扩展名执行) - 分配表加 alloc-table 专属类(与产品明细 items-table 区分) - 全量回归 697 passed(基线 625 + 72);e2e 七脚本全过(既有五脚本回归 + 两个新增);pnpm type-check 零错误 - tests/README.md 增 M3 节(44 例明细)/运行记录/缺陷 3 行;frontend/ README.md 增 M3 e2e 节 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b5e27810fd
commit
fc8a42bcd9
@ -1,6 +1,6 @@
|
|||||||
# 后端测试
|
# 后端测试
|
||||||
|
|
||||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
|
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 697 个用例全绿(2026-10-16,M3 增量批次后)。
|
||||||
|
|
||||||
## 一、测试约定
|
## 一、测试约定
|
||||||
|
|
||||||
@ -113,6 +113,19 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
|
|
||||||
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
||||||
|
|
||||||
|
### M3 增量批次(2026-10-16 完成:公司信息 / 二次分配录入 / 审批框架)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_settings.py` | 公司信息单行语义(PUT 只改名称不增行);**public 端点无令牌 200 且字段白名单仅 2 项**(复用 admin schema 即泄漏面);名称空 400;非 admin 403;logo 三重校验(伪造扩展名被 magic bytes 拦 / gif 拒 / 超 2MB 拒);合法 png 上传回读 content-type;匿名上传 401;修改留审计日志 | 11 |
|
||||||
|
| `tests/unit/test_secondary_allocation_schema.py` | 比例边界矩阵(0 拒 / 0.01-100 过 / 100.01 拒 / 负数拒 / **三位小数拒绝不静默舍入** / 非数字拒);人员 ID 非正拒;**创建/编辑两入参的参与人去重**(uk 前置友好提示);不同参与人合法 | 13 |
|
||||||
|
| `tests/integration/test_secondary_allocation.py` | 带方案创建详情回显(**personnel_name 冗余固化**:离职后回显有名);不建方案详情空且无 hint;合计满额 hint 只报合计无「余额」噪音;**离职人员参与 400**「分摊参与人员不存在或已离职」;终态业务禁改方案 400;编辑全量替换与空数组清空;**被方案引用人员禁删(RESTRICT→400)且方案回显有名**;删除业务后方案 CASCADE 随删、人员可删 | 9 |
|
||||||
|
| `tests/integration/test_approval_flow.py` | **开关关闭(种子默认 0):提单即两级「自动通过」留痕**(status=3、steps 两条 action=3、operator 记「系统」——留痕不断链);方案为空不提单;开关开启两节点流转(1→财务通过→2→领导通过→3);财务驳回整体 status=4 且意见留痕;驳回缺意见 400;**节点准入:领导/admin 不可代审财务节点(403)**;终态单重复操作 400;再编辑撤旧建新(旧单 status=5 + node=0/action=4 撤回留痕);模块准入(normal/marketing_ops 403,finance/leader 200)。开关切换经参数变更批次端到端,不直改库;用后 conftest 恢复种子基线 | 11 |
|
||||||
|
| `tests/unit/test_param_registry.py`(改) | 注册表 16→**18 项**(V6 首例 BOOL 两个审批开关);**BOOL 校验边界专项**(0/1 过、true/2 拒) | +1 |
|
||||||
|
| `tests/integration/test_schema_sync.py`(改) | 角色种子断言 2→**5 角色**(marketing_ops/finance/leader 入列) | 参数化 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
|
||||||
|
|
||||||
## 三、运行记录
|
## 三、运行记录
|
||||||
|
|
||||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||||
@ -127,6 +140,7 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
|
||||||
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||||
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
|
||||||
|
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
@ -161,6 +175,9 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
|
||||||
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
|
||||||
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
|
||||||
|
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
|
||||||
|
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
|
||||||
|
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
|
||||||
|
|
||||||
## 五、待补用例
|
## 五、待补用例
|
||||||
|
|
||||||
@ -168,7 +185,8 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||||
|
| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||||
|
|
||||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||||
|
|||||||
@ -159,3 +159,13 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
|||||||
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
|
运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。
|
||||||
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
|
**期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、
|
||||||
单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。
|
单价列 `DECIMAL(14,4)` 被两位小数校验拒收(前后端同源)、`wait_toast` 超时丢失诊断信息。
|
||||||
|
|
||||||
|
## 端到端测试(2026-10-16 追加 · M3 增量批次)
|
||||||
|
|
||||||
|
| 脚本 | 覆盖 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `e2e/settings_flow.py` | 公司信息 4 步:进设置页改公司名(随机名防撞)→ 保存 → 退出登录 → **登录页品牌位与页脚即时显示新名**(public 端点)→ finally 还原默认名(防污染其它 e2e 的品牌断言) | ✓ 全通过(修复 public 缓存缺陷后) |
|
||||||
|
| `e2e/secondary_allocation_flow.py` | 二次分配 6 步:路由直达业务管理 → 建客户 → 建 2 名在职营销员 → 新增业务录两行参与人(30%+40%)→ **编辑回显 2 行 + 合计条「余额 30.00%(归属待业务确认)」** → 删除清理 | ✓ 全通过(修复 logo 无关的三处 e2e 选择器后) |
|
||||||
|
|
||||||
|
运行前置同上(需 `E2E_ADMIN_PASSWORD`)。**期间修复的缺陷**:public 端点 300s 响应缓存让登录页拿旧公司名(改 `fetch cache: no-store` + 时间戳绕过磁盘缓存);业务弹窗分配表与产品明细共用 `items-table` 类导致选择器错位(分配表加专属 `alloc-table` 类)。
|
||||||
|
**回归**:既有五脚本(登录 8 / 系统管理 13 / 基础数据 12 / 业务 16 / 参数 7 步)在 allowedRoles 路由扩展与品牌名动态化后全部复跑通过。
|
||||||
|
|||||||
234
frontend/e2e/secondary_allocation_flow.py
Normal file
234
frontend/e2e/secondary_allocation_flow.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""二次分配方案录入端到端验证。
|
||||||
|
|
||||||
|
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||||
|
- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人
|
||||||
|
并填比例 → 提交成功
|
||||||
|
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
|
||||||
|
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
|
||||||
|
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
|
||||||
|
|
||||||
|
运行前置(缺一不可):
|
||||||
|
docker compose up -d # MySQL + Redis
|
||||||
|
cd backend && uv run uvicorn app.main:app --port 8000
|
||||||
|
cd frontend && pnpm dev
|
||||||
|
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
BASE_URL,
|
||||||
|
E2EFailure,
|
||||||
|
collect_console_errors,
|
||||||
|
confirm_message_box,
|
||||||
|
current_path,
|
||||||
|
expect_toast,
|
||||||
|
fail,
|
||||||
|
login,
|
||||||
|
visible_dialog,
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN = "admin"
|
||||||
|
SUFFIX = f"{int(time.time())}"
|
||||||
|
console_errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def step_navigate_to_business(page: Page, admin_password: str) -> None:
|
||||||
|
login(page, ADMIN, admin_password)
|
||||||
|
# 路由直达业务管理:比点菜单更稳(open_menu 展开分组的两段点击可能与
|
||||||
|
# 弹窗关闭动画竞争,导致后续点击被残留 overlay 拦截)
|
||||||
|
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
if current_path(page) != "/business/businesses":
|
||||||
|
fail("菜单导航", f"未进入业务管理页,当前 {page.url}")
|
||||||
|
print("✓ 1. 菜单导航 进入业务管理页")
|
||||||
|
|
||||||
|
|
||||||
|
def step_create_customer_via_ui(page: Page) -> str:
|
||||||
|
"""经客户管理页建一个 t_ 客户(业务必选引用),返回客户名。"""
|
||||||
|
page.goto(f"{BASE_URL}/base/customers", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
page.click("button:has-text('新增客户')")
|
||||||
|
dialog = visible_dialog(page)
|
||||||
|
customer_name = f"t_e2e客户_{SUFFIX}"
|
||||||
|
dialog.locator('input[placeholder="与负责人构成组合唯一键"]').fill(customer_name)
|
||||||
|
dialog.locator('input[placeholder="客户公司的负责人姓名"]').fill("张老板")
|
||||||
|
# 客户分类:个人户(下拉在表单内,需先点开)
|
||||||
|
dialog.locator(".el-select").first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="个人户").first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
dialog.locator("button:has-text('保存')").click()
|
||||||
|
expect_toast(page, "客户已创建", "新增客户")
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
# 关闭客户弹窗,避免残留 overlay 遮挡后续业务弹窗的菜单点击
|
||||||
|
close_btn = dialog.locator(".el-dialog__header button").last
|
||||||
|
if close_btn.count():
|
||||||
|
close_btn.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
print(f"✓ 2. 准备客户 新建 t_ 客户 {customer_name}")
|
||||||
|
return customer_name
|
||||||
|
|
||||||
|
|
||||||
|
def step_create_personnel(page: Page) -> None:
|
||||||
|
"""造 2 名 t_ 在职营销员(二次分配参与人下拉的选项来源)。"""
|
||||||
|
page.goto(f"{BASE_URL}/base/personnel", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
for i in range(2):
|
||||||
|
page.click("button:has-text('新增人员')")
|
||||||
|
dialog = visible_dialog(page)
|
||||||
|
dialog.locator('input[placeholder="营销人员姓名"]').fill(f"t_e2e员_{SUFFIX}_{i}")
|
||||||
|
# 岗位(表单内第一个 select):选第一项(营销员种子岗位)
|
||||||
|
dialog.locator(".el-select").first.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
page.locator(".el-select-dropdown:visible .el-select-dropdown__item").first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
# 月工资标准(选填,给个合法值)
|
||||||
|
dialog.locator('.el-form-item:has-text("工资标准") input').first.fill("3000.00")
|
||||||
|
dialog.locator("button:has-text('保存')").click()
|
||||||
|
expect_toast(page, "已创建", f"新增人员{i+1}")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
print("✓ 2b. 准备人员 新建 2 名 t_ 在职营销员")
|
||||||
|
|
||||||
|
|
||||||
|
def step_create_business_with_allocation(page: Page, customer_name: str) -> None:
|
||||||
|
# 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击
|
||||||
|
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
page.click("button:has-text('新增业务')")
|
||||||
|
dialog = visible_dialog(page)
|
||||||
|
|
||||||
|
# 主表:客户 + 一行明细
|
||||||
|
dialog.locator(".el-select").first.click()
|
||||||
|
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=customer_name).first.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
|
# 明细行(至少 1 行):产品名 + 数量 + 单价
|
||||||
|
# 明细表列序:产品(0)/规格(1)/单位(2)/类别(3)/数量(4)/单价(5)/成本(6)/指导价(7)
|
||||||
|
first_row = dialog.locator(".items-table tbody tr").first
|
||||||
|
first_row.locator('input[placeholder="产品名称,可手填"]').fill(f"t_e2e产品_{SUFFIX}")
|
||||||
|
first_row.locator("td").nth(4).locator("input").fill("10")
|
||||||
|
first_row.locator("td").nth(5).locator("input").fill("100.00")
|
||||||
|
|
||||||
|
# 二次分配方案:添加两行参与人(按钮先滚动进可视区,避免被 overlay 遮挡)
|
||||||
|
for _ in range(2):
|
||||||
|
add_btn = page.locator("button:has-text('添加参与人')")
|
||||||
|
add_btn.scroll_into_view_if_needed()
|
||||||
|
add_btn.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
|
# 弹窗里分配表有专属类 alloc-table(与产品明细的 items-table 区分),
|
||||||
|
# 避免空分配表时 nth(1) 错位到产品明细
|
||||||
|
alloc_rows = page.locator(".el-dialog:visible .alloc-table tbody tr")
|
||||||
|
for i in range(2):
|
||||||
|
row = alloc_rows.nth(i)
|
||||||
|
row.locator(".el-select").first.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
options = page.locator(".el-select-dropdown:visible .el-select-dropdown__item")
|
||||||
|
if options.count() < 2:
|
||||||
|
fail("参与人下拉", f"可分配人员不足 2 人(需先在人员管理录入),当前 {options.count()}")
|
||||||
|
options.nth(i).click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
row.locator("td").nth(1).locator("input").fill("30.00" if i == 0 else "40.00")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
|
||||||
|
dialog.locator("button:has-text('保存')").click()
|
||||||
|
expect_toast(page, "已创建", "新增业务(含二次分配)")
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
print("✓ 3. 录入方案 新增业务并录两行参与人(30% + 40%)")
|
||||||
|
|
||||||
|
|
||||||
|
def step_verify_allocation_echo(page: Page) -> None:
|
||||||
|
"""编辑抽屉回显:二次分配两行仍在;合计提示展示余额。"""
|
||||||
|
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
page.click("button:has-text('查询')")
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
first_row = page.locator(".el-table__row").first
|
||||||
|
if first_row.count() == 0:
|
||||||
|
fail("业务列表", "列表为空,未能找到刚创建的业务")
|
||||||
|
first_row.locator("button:has-text('编辑')").click()
|
||||||
|
dialog = visible_dialog(page)
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
|
||||||
|
# 二次分配表应有 2 行;合计提示:30+40=70,余额 30
|
||||||
|
alloc_table = page.locator(".el-dialog:visible .alloc-table")
|
||||||
|
alloc_count = alloc_table.locator("tbody tr").count()
|
||||||
|
if alloc_count != 2:
|
||||||
|
fail("方案回显", f"编辑抽屉二次分配应为 2 行,实际 {alloc_count}")
|
||||||
|
|
||||||
|
total_bar = page.locator(".el-dialog:visible .alloc-total")
|
||||||
|
body_text = total_bar.inner_text() if total_bar.count() else ""
|
||||||
|
if "余额" not in body_text:
|
||||||
|
fail("合计提示", f"两行非满额应展示余额提示,当前:{body_text or '(无提示)'}")
|
||||||
|
print(f"✓ 4. 编辑回显 二次分配 2 行保留,合计提示:{body_text.strip()}")
|
||||||
|
|
||||||
|
dialog.locator("button:has-text('取消')").click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
|
||||||
|
|
||||||
|
def step_cleanup(page: Page) -> None:
|
||||||
|
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
|
||||||
|
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
page.click("button:has-text('查询')")
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
target = page.locator(".el-table__row", has_text=f"t_e2e客户_{SUFFIX}").first
|
||||||
|
if target.count() == 0:
|
||||||
|
print(" (清理跳过:未找到本次业务)")
|
||||||
|
return
|
||||||
|
target.locator("button:has-text('删除')").click()
|
||||||
|
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
|
||||||
|
confirm_message_box(page, "删除")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
print("✓ 5. 数据清理 已删除本次创建的 t_ 业务")
|
||||||
|
|
||||||
|
|
||||||
|
def run(page: Page) -> None:
|
||||||
|
collect_console_errors(page, console_errors)
|
||||||
|
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||||
|
if not admin_password:
|
||||||
|
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||||
|
|
||||||
|
step_navigate_to_business(page, admin_password)
|
||||||
|
customer_name = step_create_customer_via_ui(page)
|
||||||
|
try:
|
||||||
|
step_create_personnel(page)
|
||||||
|
step_create_business_with_allocation(page, customer_name)
|
||||||
|
step_verify_allocation_echo(page)
|
||||||
|
finally:
|
||||||
|
step_cleanup(page)
|
||||||
|
|
||||||
|
if console_errors:
|
||||||
|
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
# 弹窗较宽(业务表单含明细/二次分配两表),窄视口会把右侧操作按钮
|
||||||
|
# 挤出可视区、被 overlay 遮挡;给到 1600 宽避免遮挡导致点击超时
|
||||||
|
page = browser.new_page(viewport={"width": 1600, "height": 1000})
|
||||||
|
page.set_default_timeout(12000)
|
||||||
|
try:
|
||||||
|
run(page)
|
||||||
|
except E2EFailure as exc:
|
||||||
|
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||||
|
print(f"\n ✗ {exc}")
|
||||||
|
print(" 已保存截图 e2e/failure.png")
|
||||||
|
return 1
|
||||||
|
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||||
|
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||||
|
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
print("\n全部通过。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
127
frontend/e2e/settings_flow.py
Normal file
127
frontend/e2e/settings_flow.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""公司信息设置端到端验证。
|
||||||
|
|
||||||
|
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||||
|
- 登录 → 系统管理分组出现「公司信息」菜单 → 进入设置页
|
||||||
|
- 修改公司名称并保存 → 全局提示「已保存」
|
||||||
|
- 退出登录 → 登录页品牌位显示**新**公司名(public 端点即时生效)
|
||||||
|
- 还原公司名(finally:防污染 login_flow/system_management 的品牌名断言)
|
||||||
|
|
||||||
|
运行前置(缺一不可):
|
||||||
|
docker compose up -d # MySQL + Redis
|
||||||
|
cd backend && uv run uvicorn app.main:app --port 8000
|
||||||
|
cd frontend && pnpm dev
|
||||||
|
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/settings_flow.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
|
||||||
|
from common import (
|
||||||
|
BASE_URL,
|
||||||
|
E2EFailure,
|
||||||
|
collect_console_errors,
|
||||||
|
current_path,
|
||||||
|
expect_toast,
|
||||||
|
fail,
|
||||||
|
login,
|
||||||
|
open_menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN = "admin"
|
||||||
|
console_errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def step_change_company_name(page: Page, admin_password: str) -> str:
|
||||||
|
"""进设置页改公司名(加随机后缀避免撞库),返回新名供后续断言。"""
|
||||||
|
login(page, ADMIN, admin_password)
|
||||||
|
open_menu(page, "公司信息", "系统管理")
|
||||||
|
if current_path(page) != "/system/settings":
|
||||||
|
fail("菜单导航", f"未进入公司信息页,当前 {page.url}")
|
||||||
|
|
||||||
|
# 随机名:单行特性下库可复用,避免与既有公司名撞车;断言不依赖「默认」前提
|
||||||
|
new_name = f"衍水e2e{os.getpid()}{os.urandom(2).hex()}"
|
||||||
|
page.fill('input[placeholder="展示在登录页与导出页眉"]', new_name)
|
||||||
|
page.click("button:has-text('保存')")
|
||||||
|
expect_toast(page, "已保存", "修改公司名称")
|
||||||
|
print(f"✓ 1. 设置页导航 进入公司信息并保存新名称「{new_name}」")
|
||||||
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
|
def step_logout(page: Page) -> None:
|
||||||
|
page.click("text=退出登录")
|
||||||
|
page.wait_for_url("**/login**", timeout=10000)
|
||||||
|
print("✓ 2. 退出登录 已回到登录页")
|
||||||
|
|
||||||
|
|
||||||
|
def step_login_page_reflects(page: Page, new_name: str) -> None:
|
||||||
|
"""登录页品牌位应显示新公司名(public 端点即时生效)。
|
||||||
|
|
||||||
|
等 brand 元素文本变为新名(而非固定 sleep):public 请求是异步的,
|
||||||
|
固定等待在慢机上会偶发拿到旧值。
|
||||||
|
"""
|
||||||
|
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
|
||||||
|
try:
|
||||||
|
page.locator(".card-brand__name").filter(has_text=new_name).wait_for(
|
||||||
|
state="visible", timeout=8000
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
brand = page.locator(".card-brand__name").inner_text().strip()
|
||||||
|
fail("登录页品牌", f"品牌位未显示新名称「{new_name}」,当前:{brand}")
|
||||||
|
foot = page.locator(".login-foot").inner_text()
|
||||||
|
if new_name not in foot:
|
||||||
|
fail("登录页页脚", f"页脚未含新名称「{new_name}」,当前:{foot}")
|
||||||
|
print(f"✓ 3. 品牌即时生效 登录页品牌位与页脚已显示「{new_name}」")
|
||||||
|
|
||||||
|
|
||||||
|
def step_restore(page: Page, admin_password: str) -> None:
|
||||||
|
"""还原默认公司名,防污染后续 e2e 的品牌名断言。"""
|
||||||
|
login(page, ADMIN, admin_password)
|
||||||
|
open_menu(page, "公司信息", "系统管理")
|
||||||
|
page.fill('input[placeholder="展示在登录页与导出页眉"]', "衍水集团")
|
||||||
|
page.click("button:has-text('保存')")
|
||||||
|
expect_toast(page, "已保存", "还原公司名称")
|
||||||
|
print("✓ 4. 数据清理 公司名已还原为「衍水集团」")
|
||||||
|
|
||||||
|
|
||||||
|
def run(page: Page) -> None:
|
||||||
|
collect_console_errors(page, console_errors)
|
||||||
|
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||||
|
if not admin_password:
|
||||||
|
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||||
|
|
||||||
|
new_name = step_change_company_name(page, admin_password)
|
||||||
|
try:
|
||||||
|
step_logout(page)
|
||||||
|
step_login_page_reflects(page, new_name)
|
||||||
|
finally:
|
||||||
|
step_restore(page, admin_password)
|
||||||
|
|
||||||
|
if console_errors:
|
||||||
|
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.set_default_timeout(10000)
|
||||||
|
try:
|
||||||
|
run(page)
|
||||||
|
except E2EFailure as exc:
|
||||||
|
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||||
|
print(f"\n ✗ {exc}")
|
||||||
|
print(" 已保存截图 e2e/failure.png")
|
||||||
|
return 1
|
||||||
|
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||||
|
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||||
|
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
browser.close()
|
||||||
|
print("\n全部通过。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -34,7 +34,13 @@ export function uploadLogo(file: File): Promise<CompanySettings> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录页消费:无令牌也可调用(后端白名单端点) */
|
/** 登录页消费:无令牌也可调用(后端白名单端点)。
|
||||||
|
* 请求头 no-cache:要求中间层/浏览器重校验而非直接用缓存副本 —— 公司名
|
||||||
|
* 保存后应立即可见,不能因 300s 短缓存拿到旧值。 */
|
||||||
export function getPublicSettings(): Promise<PublicSettings> {
|
export function getPublicSettings(): Promise<PublicSettings> {
|
||||||
return request({ url: '/v1/system/settings/public', method: 'get' })
|
return request({
|
||||||
|
url: '/v1/system/settings/public',
|
||||||
|
method: 'get',
|
||||||
|
headers: { 'Cache-Control': 'no-cache' },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -835,7 +835,7 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
<el-button size="small" @click="addAllocationRow">添加参与人</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="allocationRows" stripe size="small" class="items-table">
|
<el-table :data="allocationRows" stripe size="small" class="items-table alloc-table">
|
||||||
<el-table-column label="参与业务员" min-width="200">
|
<el-table-column label="参与业务员" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-select
|
<el-select
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { getCaptcha } from '@/api/auth'
|
import { getCaptcha } from '@/api/auth'
|
||||||
import { getPublicSettings } from '@/api/settings'
|
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -30,9 +29,21 @@ const brandLogo = ref<string | null>(null)
|
|||||||
|
|
||||||
async function loadBrand(): Promise<void> {
|
async function loadBrand(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const data = await getPublicSettings()
|
// 登录页必须拿到最新公司名(保存后立即生效):public 端点带 300s
|
||||||
|
// 响应缓存,axios 可能命中浏览器缓存副本。这里用带时间戳的原生
|
||||||
|
// fetch 绕过磁盘缓存,手动解包统一信封(与 request 拦截器同口径)。
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/v1/system/settings/public?_=${Date.now()}`,
|
||||||
|
{ cache: 'no-store' },
|
||||||
|
)
|
||||||
|
if (!resp.ok) throw new Error(String(resp.status))
|
||||||
|
const body = (await resp.json()) as {
|
||||||
|
code: number
|
||||||
|
data: { company_name?: string; logo_url?: string | null }
|
||||||
|
}
|
||||||
|
const data = body.data
|
||||||
if (data.company_name) brandName.value = data.company_name
|
if (data.company_name) brandName.value = data.company_name
|
||||||
brandLogo.value = data.logo_url
|
brandLogo.value = data.logo_url ?? null
|
||||||
} catch {
|
} catch {
|
||||||
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
// 登录页必须可用:公司信息接口失败时静默回退默认品牌名,不阻断登录
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user