diff --git a/backend/tests/README.md b/backend/tests/README.md index 92fe381..f05895b 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -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)。 +### 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 | 全部 | 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-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 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 | | 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 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 | -| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) | +| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 | +| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 | | 报表统计 | 未开始 | 依赖报表口径确认 | > 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 diff --git a/frontend/README.md b/frontend/README.md index 385d138..8ee1c90 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -159,3 +159,13 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py 运行前置同上;测试数据 `e2e_` 前缀,正常流程内清理(业务含发货/回款时按设计不可删,留档说明)。 **期间修复的缺陷**:后端删除保护两处漏检(客户被业务引用/人员被业务归属引用 → 500)、 单价列 `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 路由扩展与品牌名动态化后全部复跑通过。 diff --git a/frontend/e2e/secondary_allocation_flow.py b/frontend/e2e/secondary_allocation_flow.py new file mode 100644 index 0000000..0276cdc --- /dev/null +++ b/frontend/e2e/secondary_allocation_flow.py @@ -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()) diff --git a/frontend/e2e/settings_flow.py b/frontend/e2e/settings_flow.py new file mode 100644 index 0000000..a269305 --- /dev/null +++ b/frontend/e2e/settings_flow.py @@ -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()) diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index d2ecf2a..64e259b 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -34,7 +34,13 @@ export function uploadLogo(file: File): Promise { }) } -/** 登录页消费:无令牌也可调用(后端白名单端点) */ +/** 登录页消费:无令牌也可调用(后端白名单端点)。 + * 请求头 no-cache:要求中间层/浏览器重校验而非直接用缓存副本 —— 公司名 + * 保存后应立即可见,不能因 300s 短缓存拿到旧值。 */ export function getPublicSettings(): Promise { - return request({ url: '/v1/system/settings/public', method: 'get' }) + return request({ + url: '/v1/system/settings/public', + method: 'get', + headers: { 'Cache-Control': 'no-cache' }, + }) } diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index 2773ee5..417860d 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -835,7 +835,7 @@ onMounted(() => { 添加参与人 - +