From 1c3f0c4a1544fea94160efdb9aff9f168faa1fe9 Mon Sep 17 00:00:00 2001 From: shunli Date: Thu, 28 May 2026 08:45:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8F=91=E7=A5=A8=E9=AA=8C?= =?UTF-8?q?=E7=9C=9F=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=EF=BC=8805-28-oa-?= =?UTF-8?q?test-coverage-gap-fill=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../check.jsonl | 8 + .../implement.jsonl | 8 + .../implement.md | 36 ++ .../05-28-oa-test-coverage-gap-fill/prd.md | 112 +++++ .../research/coverage-inspection.md | 42 ++ .../05-28-oa-test-coverage-gap-fill/task.json | 26 + .../e2e/pc-finance-admin-visual-wave4.spec.ts | 187 +++++++- .../tests/unit/jobs/verificationJob.test.js | 161 +++++++ .../routes/invoicePool.verification.test.js | 444 ++++++++++++++++++ 9 files changed, 1021 insertions(+), 3 deletions(-) create mode 100644 .trellis/tasks/05-28-oa-test-coverage-gap-fill/check.jsonl create mode 100644 .trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.jsonl create mode 100644 .trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.md create mode 100644 .trellis/tasks/05-28-oa-test-coverage-gap-fill/prd.md create mode 100644 .trellis/tasks/05-28-oa-test-coverage-gap-fill/research/coverage-inspection.md create mode 100644 .trellis/tasks/05-28-oa-test-coverage-gap-fill/task.json create mode 100644 server/tests/unit/jobs/verificationJob.test.js create mode 100644 server/tests/unit/routes/invoicePool.verification.test.js diff --git a/.trellis/tasks/05-28-oa-test-coverage-gap-fill/check.jsonl b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/check.jsonl new file mode 100644 index 00000000..4d856183 --- /dev/null +++ b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/check.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Verify Trellis flow, scope control, approval, and commit rules."} +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Verify backend checks, route/service tests, and Prisma validation."} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify frontend typecheck/build and Playwright expectations for UI test changes."} +{"file": ".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md", "reason": "Verify invoice/OCR boundary and no real invoice evidence leakage."} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Verify no secrets, raw payloads, full accounts, or invoice originals are captured."} +{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "Verify evidence references and task notes stay privacy-safe."} +{"file": ".trellis/tasks/05-28-oa-test-coverage-gap-fill/research/coverage-inspection.md", "reason": "Verify implementation addressed the identified coverage gaps."} +{"file": ".trellis/tasks/archive/2026-05/05-27-oa-test-coverage-matrix/research/test-coverage-matrix.md", "reason": "Verify scope remains aligned to the prior coverage matrix."} diff --git a/.trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.jsonl b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.jsonl new file mode 100644 index 00000000..697ae41e --- /dev/null +++ b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task flow, approval-before-code, scoped commits, and stop conditions."} +{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Backend Fastify/Prisma validation and focused route/service test expectations."} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend test and Playwright expectations if invoice pool UI E2E is extended."} +{"file": ".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md", "reason": "Invoice/OCR boundaries, PDF-only maintained path, and sensitive invoice evidence restrictions."} +{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "No secrets, raw payloads, real invoice originals, or full accounts in tests/reports."} +{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "Evidence/report privacy and repo-relative references."} +{"file": ".trellis/tasks/05-28-oa-test-coverage-gap-fill/research/coverage-inspection.md", "reason": "Current coverage findings and first-batch testing gaps."} +{"file": ".trellis/tasks/archive/2026-05/05-27-oa-test-coverage-matrix/research/test-coverage-matrix.md", "reason": "Source matrix that identified invoice verification as a high test gap."} diff --git a/.trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.md b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.md new file mode 100644 index 00000000..f8ba16ed --- /dev/null +++ b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/implement.md @@ -0,0 +1,36 @@ +# 实施说明 + +## Recommended Batch + +先做“发票验真自动化测试补齐”这一批。理由是: + +- 代码已存在,测试可以低风险补齐,不需要真实 provider。 +- 它直接对应上一轮矩阵里的高缺口。 +- 能提高后续付费 API POC 的安全边界:即使 provider 未配置、失败或被关闭,系统也能给出可解释结果。 + +## Planned Changes After Approval + +1. 后端测试 + - 新增 `server/tests/unit/routes/invoicePool.verification.test.js`,覆盖触发、禁用、pending、冷却和重试保护。 + - 新增或扩展 job/service 测试,覆盖 flag 关闭、非 pending、provider error 的降级写库。 + - 若发现重复/过宽断言,收紧现有 `verification.*.test.js`,不做无关重构。 + +2. 前端测试 + - 扩展 `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts` 或新增 focused spec。 + - 用 route mock 验证“发起辅助核验”“重新核验”和错误文案,不访问真实后端。 + +3. 质量门禁 + - 后端 focused Vitest + `prisma:validate`。 + - 前端 `typecheck`、`build`,以及 focused Playwright。 + - Trellis task validate、governance gate、`git diff --check`。 + +## Boundaries + +- 不新增真实验真 provider。 +- 不读取或上传真实票据。 +- 不部署、不写生产配置、不调用公网接口。 +- 不改付款、企业微信、OCR provider、备份恢复和试运行主任务。 + +## Rollback + +本批主要是测试代码。若测试实现暴露夹具不稳定,优先收窄为后端 route/service focused tests;必要时回退前端 E2E 扩展,只保留后端自动化覆盖。 diff --git a/.trellis/tasks/05-28-oa-test-coverage-gap-fill/prd.md b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/prd.md new file mode 100644 index 00000000..1f075f0c --- /dev/null +++ b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/prd.md @@ -0,0 +1,112 @@ +# 业务流程测试覆盖缺口补齐 + +## Goal + +补齐上一轮业务流程测试覆盖矩阵中“可以自动化、风险低、不会触碰真实生产写入”的缺口。第一批聚焦发票验真,因为现有系统已经有服务骨架、feature flag、票据中心入口和部分基础单测,但触发、重试、限流、禁用降级和页面动作还缺少 focused 覆盖。 + +## Background + +- 真实 7 天试运行任务继续并行推进,本任务不替代真实员工样本、Day 7 Go/No-Go、企业微信真机或公网带凭据证据。 +- 审查矩阵结论显示:登录、报销、审批、付款/AP、采购/合同已有较多自动化覆盖;高缺口集中在发票验真 provider POC、制度签收、用印台账、真实闭环证据和备份恢复。 +- 制度签收、用印台账和备份恢复需要更明确的产品/运维决策,不适合塞进本批测试补齐。 +- 发票验真当前是“辅助核验”定位,不能在本任务中接真实付费 API,也不能把 mock 或占位 provider 说成真实验真通过。 + +## Scope + +### In Scope + +- 后端发票验真相关自动化测试: + - provider 选择、未配置 provider、unsupported provider 的稳定结果。 + - feature flag / env hard off / department allowlist 的启停判断。 + - 触发验真路由创建 pending 记录、写入审计、携带最小安全 request payload。 + - 禁用、已有 pending、冷却时间、重试上限等业务保护返回可解释错误。 + - 验真 job 在 flag 关闭、记录缺失、非 pending、provider 异常时的降级行为。 +- 前端票据中心 focused E2E: + - 有 `invoice.verify` 权限时,详情面板可触发辅助核验并请求 `/api/invoice-pool/:id/verify`。 + - `failed/error` 记录可显示“重新核验”动作并请求 retry API。 + - 后端返回禁用/频控/重试上限时,页面展示业务可理解错误文案。 +- 测试报告和命令记录写入当前任务,不记录真实票据、完整账号、Secret、原始 payload 或完整本地路径。 + +### Out of Scope + +- 接入真实税局、第三方验真或任何付费 API。 +- 写入生产配置、生产数据库、企业微信后台、真实付款、真实 OCR 样票。 +- 开发制度签收、用印台账、备份恢复演练功能。 +- 关闭或归档 `05-26-oa-seven-day-trial-live-ops`。 +- 大范围重构票据中心 UI 或发票验真架构。 + +## Acceptance Criteria + +- [x] 新增或扩展后端 focused tests,覆盖发票验真触发、重试、禁用、pending、冷却、次数上限和 job 降级场景。 +- [x] 新增或扩展前端 focused Playwright 测试,覆盖票据中心辅助核验按钮、重新核验按钮和可解释错误提示。 +- [x] 所有测试使用 mock/stub 或隔离 fixture,不调用真实外部验真、OCR、企业微信或生产 API。 +- [x] 错误测试只断言脱敏业务码和用户文案,不快照完整 raw payload。 +- [x] 任务验证命令通过,或按测试规约记录明确 `SKIP/BLOCKED` 原因。 + +## Test Plan + +### Backend + +- `cd server && npm run prisma:validate` +- `cd server && npx vitest run tests/unit/verification.access.test.js tests/unit/verification.provider.test.js tests/unit/services/verification.test.js` +- 若新增 route/job 测试: + - `cd server && npx vitest run tests/unit/routes/invoicePool.verification.test.js tests/unit/jobs/verificationJob.test.js` +- 如触及服务/路由实现: + - `cd server && node --check src/services/verification/index.js` + - `cd server && node --check src/routes/invoicePool/verification.js` + - `cd server && node --check src/jobs/verificationJob.js` + +### Frontend + +- `cd client && npm run typecheck` +- `cd client && npm run build` +- 若新增或扩展 Playwright: + - `cd client && npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list` + +### Governance + +- `python3 ./.trellis/scripts/task.py validate 05-28-oa-test-coverage-gap-fill` +- `node scripts/qa/check-trellis-governance.mjs` +- `git diff --check` + +## Implementation Notes + +- 优先补测试,不为了测试重写业务代码;只有发现真实 bug 时才做最小修复。 +- 后端 route 测试复用现有 Fastify injection + Prisma stub 模式。 +- 前端 E2E 复用现有 Wave4 mock API 和权限 fixture,避免启动真实后端或真实数据库。 +- 发票验真真实 provider 仍保持后续 POC,当前只证明系统在开关、占位 provider 和失败降级下行为可控。 + +## Execution Results + +- Added backend focused tests: + - `server/tests/unit/routes/invoicePool.verification.test.js` + - `server/tests/unit/jobs/verificationJob.test.js` +- Extended frontend focused E2E: + - `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts` +- Commands and results: + - `cd server && npx vitest run tests/unit/routes/invoicePool.verification.test.js tests/unit/jobs/verificationJob.test.js` -> 2 files / 12 tests passed. + - `cd server && npm run prisma:validate` -> schema valid. + - `cd server && npx vitest run tests/unit/verification.access.test.js tests/unit/verification.provider.test.js tests/unit/services/verification.test.js tests/unit/routes/invoicePool.verification.test.js tests/unit/jobs/verificationJob.test.js` -> 5 files / 24 tests passed. + - `cd client && npx playwright test tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list` -> 3 tests passed. + - `cd client && npm run typecheck` -> passed. + - `cd client && npm run build` -> passed; Vite build completed and PWA files generated. + - `python3 ./.trellis/scripts/task.py validate 05-28-oa-test-coverage-gap-fill` -> passed. + - `node scripts/qa/check-trellis-governance.mjs` -> passed. + - `git diff --check` -> passed. +- Non-blocking command correction: + - `cd server && npx vitest run ... --reporter=list` failed at startup because this Vitest setup does not resolve reporter `list`; reran with default reporter and passed. + +## Testing State Capsule + +```text +[Testing State Capsule] +Goal: 补齐审查矩阵中可自动化的业务流程测试缺口,第一批聚焦发票验真。 +Repo signals: Backend Vitest/Fastify injection/Prisma validate; Frontend Playwright/Vite typecheck/build; Trellis governance. +Unit command(s): `npm run prisma:validate`; focused Vitest 5 files / 24 tests passed. +Integration command(s): focused Playwright Wave4 3 tests passed; client typecheck/build passed. +Env/deps: 不需要生产配置、付费 API、真实票据、真实付款、企业微信写入或 DB reset。 +Assumptions: 发票验真当前为辅助核验和 provider skeleton,本任务只补 mock/stub 自动化。 +Last result: 后端 route/job 测试与前端票据中心动作 E2E 已补齐,Trellis/governance/diff checks passed. +Next action: 提交本任务改动并归档任务。 +Open questions: 无阻塞问题。 +``` diff --git a/.trellis/tasks/05-28-oa-test-coverage-gap-fill/research/coverage-inspection.md b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/research/coverage-inspection.md new file mode 100644 index 00000000..13c7240f --- /dev/null +++ b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/research/coverage-inspection.md @@ -0,0 +1,42 @@ +# 测试覆盖调研摘要 + +## Existing Signals + +- `server/package.json` 提供 `npm test`、`npm run prisma:validate` 和 Vitest。 +- `client/package.json` 提供 `npm run typecheck`、`npm run build` 和 Playwright。 +- 现有发票验真代码集中在: + - `server/src/services/verification/index.js` + - `server/src/services/verification/tax.js` + - `server/src/routes/invoicePool/verification.js` + - `server/src/jobs/verificationJob.js` + - `client/src/pages/invoice-pool/InvoiceDetailPanel.tsx` +- 现有测试已经覆盖: + - provider 归一化基础行为。 + - mock/tax/unsupported provider 的基础返回。 + - feature flag env hard off 和部门 allowlist 基础判断。 + - Wave4 页面 mock 数据能展示核验记录。 + +## Gaps + +- 后端路由缺少 focused coverage: + - POST `/api/invoice-pool/:id/verify` + - GET `/api/invoice-pool/:id/verification` + - POST `/api/invoice-pool/:id/verification/retry` + - disabled、already pending、too frequent、attempts exceeded 等业务保护。 +- job 缺少覆盖: + - 记录不存在、发票不存在、状态非 pending、feature flag 关闭、provider error。 +- 前端缺少动作覆盖: + - 点击“发起辅助核验”是否请求 verify API。 + - 点击“重新核验”是否请求 retry API。 + - API 返回禁用/频控/次数上限时是否展示用户可理解文案。 + +## Recommended First Implementation + +1. 用 Fastify injection + Prisma stub 新增后端路由测试。 +2. 用 Vitest stub 覆盖 `runVerificationForRecord` 降级行为。 +3. 扩展 Wave4 Playwright mock API,覆盖两个按钮动作和错误文案。 + +## Safety Notes + +- 不调用真实 provider,不读取真实票据,不写生产数据库。 +- 只记录相对路径、业务码和脱敏结论。 diff --git a/.trellis/tasks/05-28-oa-test-coverage-gap-fill/task.json b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/task.json new file mode 100644 index 00000000..86b99eac --- /dev/null +++ b/.trellis/tasks/05-28-oa-test-coverage-gap-fill/task.json @@ -0,0 +1,26 @@ +{ + "id": "oa-test-coverage-gap-fill", + "name": "oa-test-coverage-gap-fill", + "title": "业务流程测试覆盖缺口补齐", + "description": "补齐业务流程自动化测试覆盖矩阵中的第一批缺口,聚焦发票验真 trigger/retry/provider/降级和票据中心动作测试。", + "status": "in_progress", + "dev_type": null, + "scope": "server,client,qa", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-05-28", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts b/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts index 9fe7d76e..b65fcb99 100644 --- a/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts +++ b/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts @@ -65,6 +65,21 @@ async function fulfillJson(route: Route, data: T) { }); } +async function fulfillApiError(route: Route, status: number, details: Record) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ + requestId: "mock-pc-wave4", + error: { + code: "VALID_001", + message: "参数校验失败", + details, + }, + }), + }); +} + function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") { return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null }; } @@ -394,7 +409,41 @@ const notificationDeliveries = [ }, ]; -async function mockWave4Apis(page: Page) { +type MockVerificationRecord = { + id: string; + provider: string; + status: string; + resultCode: string | null; + resultMessage: string | null; + errorCode: string | null; + errorMessage: string | null; + durationMs: number | null; + requestedBy: string | null; + requestedAt: string | null; + completedAt: string | null; + retryOfId: string | null; + attempts: number; +}; + +type Wave4MockOptions = { + detailVerification?: MockVerificationRecord | null; + verificationRecords?: MockVerificationRecord[]; + triggerVerificationError?: Record; + retryVerificationError?: Record; + onTriggerVerification?: (route: Route) => void; + onRetryVerification?: (route: Route) => void; +}; + +async function mockWave4Apis(page: Page, options: Wave4MockOptions = {}) { + const defaultVerification = invoiceListItem.verification as MockVerificationRecord; + const initialVerificationRecords = + options.verificationRecords ?? + [options.detailVerification ?? defaultVerification].filter( + (item): item is MockVerificationRecord => Boolean(item), + ); + let currentVerificationRecords = initialVerificationRecords; + let currentDetailVerification = options.detailVerification ?? currentVerificationRecords[0] ?? defaultVerification; + await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); @@ -593,13 +642,20 @@ async function mockWave4Apis(page: Page) { } if (pathName === "/api/invoice-pool") { - await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceListItem] }); + await fulfillJson(route, { + page: 1, + pageSize: 20, + total: 1, + ownerScope: "own", + items: [{ ...invoiceListItem, verification: currentDetailVerification }], + }); return; } if (pathName === `/api/invoice-pool/${invoiceListItem.id}`) { await fulfillJson(route, { ...invoiceListItem, + verification: currentDetailVerification, archivedReason: null, archivedAt: null, ownerEmployeeId: visualMe.id, @@ -616,8 +672,64 @@ async function mockWave4Apis(page: Page) { return; } + if (pathName === `/api/invoice-pool/${invoiceListItem.id}/verify`) { + options.onTriggerVerification?.(route); + if (options.triggerVerificationError) { + await fulfillApiError(route, 409, options.triggerVerificationError); + return; + } + const pendingRecord: MockVerificationRecord = { + ...defaultVerification, + id: "verify-wave4-triggered", + status: "pending", + resultCode: null, + resultMessage: null, + errorCode: null, + errorMessage: null, + completedAt: null, + attempts: 1, + }; + currentDetailVerification = pendingRecord; + currentVerificationRecords = [pendingRecord, ...currentVerificationRecords]; + await fulfillJson(route, { + invoiceId: invoiceListItem.id, + verificationId: pendingRecord.id, + provider: "mock", + triggerMode: "queued", + }); + return; + } + if (pathName === `/api/invoice-pool/${invoiceListItem.id}/verification`) { - await fulfillJson(route, { invoiceId: invoiceListItem.id, items: [invoiceListItem.verification] }); + await fulfillJson(route, { invoiceId: invoiceListItem.id, items: currentVerificationRecords }); + return; + } + + if (pathName === `/api/invoice-pool/${invoiceListItem.id}/verification/retry`) { + options.onRetryVerification?.(route); + if (options.retryVerificationError) { + await fulfillApiError(route, 409, options.retryVerificationError); + return; + } + const retryRecord: MockVerificationRecord = { + ...defaultVerification, + id: "verify-wave4-retry", + status: "pending", + resultCode: null, + resultMessage: null, + errorCode: null, + errorMessage: null, + completedAt: null, + retryOfId: currentDetailVerification?.id ?? null, + attempts: (currentDetailVerification?.attempts ?? 1) + 1, + }; + currentDetailVerification = retryRecord; + currentVerificationRecords = [retryRecord, ...currentVerificationRecords]; + await fulfillJson(route, { + invoiceId: invoiceListItem.id, + verificationId: retryRecord.id, + status: "pending", + }); return; } @@ -796,3 +908,72 @@ test("PC 财务与管理页 Wave4 视觉冒烟", async ({ page }) => { await capture(page, target.screenshot); } }); + +test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) => { + const failedVerification: MockVerificationRecord = { + ...(invoiceListItem.verification as MockVerificationRecord), + id: "verify-wave4-failed", + status: "error", + resultCode: "tax_provider_not_configured", + resultMessage: null, + errorCode: "tax_provider_not_configured", + errorMessage: "tax provider is not configured in current environment", + attempts: 1, + }; + const triggerRequests: string[] = []; + const retryRequests: string[] = []; + + await mockWave4Apis(page, { + detailVerification: failedVerification, + verificationRecords: [failedVerification], + onTriggerVerification: (route) => triggerRequests.push(route.request().method()), + onRetryVerification: (route) => retryRequests.push(route.request().method()), + }); + await page.setViewportSize({ width: 1440, height: 960 }); + await login(page); + + await page.goto("/invoice-pool"); + await expect(page.getByTestId("invoice-detail-panel")).toBeVisible(); + await expect(page.getByTestId("invoice-trigger-verification")).toBeVisible(); + await expect(page.getByTestId("invoice-retry-verification")).toBeVisible(); + await expect(page.getByTestId("invoice-verification-records")).toContainText("当前环境未配置自动核验服务"); + + await page.getByTestId("invoice-retry-verification").click(); + await expect(page.getByText("发票核验已重新提交")).toBeVisible(); + expect(retryRequests).toEqual(["POST"]); + + await page.getByTestId("invoice-trigger-verification").click(); + await expect(page.getByText("发票核验已提交")).toBeVisible(); + expect(triggerRequests).toEqual(["POST"]); +}); + +test("PC 票据中心辅助核验错误提示可理解", async ({ page }) => { + const failedVerification: MockVerificationRecord = { + ...(invoiceListItem.verification as MockVerificationRecord), + id: "verify-wave4-attempts", + status: "error", + resultCode: "mock_transport_error", + resultMessage: null, + errorCode: "mock_transport_error", + errorMessage: "mock provider transport error", + attempts: 3, + }; + + await mockWave4Apis(page, { + detailVerification: failedVerification, + verificationRecords: [failedVerification], + triggerVerificationError: { verification: "too_frequent" }, + retryVerificationError: { verification: "attempts_exceeded" }, + }); + await page.setViewportSize({ width: 1440, height: 960 }); + await login(page); + + await page.goto("/invoice-pool"); + await expect(page.getByTestId("invoice-detail-panel")).toBeVisible(); + + await page.getByTestId("invoice-trigger-verification").click(); + await expect(page.getByRole("main").getByText("VALID_001: 验真触发过于频繁,请稍后重试。")).toBeVisible(); + + await page.getByTestId("invoice-retry-verification").click(); + await expect(page.getByRole("main").getByText("VALID_001: 验真重试次数已达上限,请联系财务人工核验。")).toBeVisible(); +}); diff --git a/server/tests/unit/jobs/verificationJob.test.js b/server/tests/unit/jobs/verificationJob.test.js new file mode 100644 index 00000000..8d178b8b --- /dev/null +++ b/server/tests/unit/jobs/verificationJob.test.js @@ -0,0 +1,161 @@ +const { FEATURE_FLAG_KEYS } = require("../../../src/services/featureFlags"); +const { runVerificationForRecord } = require("../../../src/jobs/verificationJob"); + +const RECORD_ID = "11111111-1111-4111-8111-111111111111"; +const INVOICE_ID = "22222222-2222-4222-8222-222222222222"; +const DEPARTMENT_ID = "dept-finance"; + +function buildFlagRecord({ overrideMode = "force_on", rolloutType = "global", departmentIds = [] } = {}) { + const now = new Date("2026-05-28T00:00:00.000Z"); + return { + id: "flag-invoice-verification", + flag_key: FEATURE_FLAG_KEYS.invoiceVerification, + override_mode: overrideMode, + rollout_type: rolloutType, + department_ids: departmentIds, + note: "unit-test", + version: 1, + created_at: now, + created_by: null, + updated_at: now, + updated_by: null, + }; +} + +function buildInvoice(overrides = {}) { + return { + id: INVOICE_ID, + invoice_code: "0440026", + invoice_number: "00012890", + total_amount: "1060.00", + remarks: "verify-ok", + status: "available", + owner_employee: { department_id: DEPARTMENT_ID }, + linked_reimbursement: null, + ...overrides, + }; +} + +function buildRecord(overrides = {}) { + return { + id: RECORD_ID, + status: "pending", + invoice: buildInvoice(), + ...overrides, + }; +} + +function buildPrisma({ lockRows = [{ id: RECORD_ID }], record = buildRecord(), flagRecord = buildFlagRecord() } = {}) { + const tx = { + $queryRaw: vi.fn(async () => lockRows), + featureFlag: { + findUnique: vi.fn(async () => flagRecord), + }, + invoiceVerificationRecord: { + findUnique: vi.fn(async () => record), + update: vi.fn(async ({ data }) => ({ id: RECORD_ID, ...data })), + }, + }; + + const prisma = { + ...tx, + $transaction: vi.fn(async (callback) => callback(tx)), + }; + + return { prisma, tx }; +} + +describe("runVerificationForRecord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips when verification record cannot be locked", async () => { + const { prisma, tx } = buildPrisma({ lockRows: [] }); + + const result = await runVerificationForRecord(prisma, { invoiceVerificationEnabled: true }, RECORD_ID); + + expect(result).toEqual({ skipped: true, reason: "record_not_found" }); + expect(tx.invoiceVerificationRecord.findUnique).not.toHaveBeenCalled(); + expect(tx.invoiceVerificationRecord.update).not.toHaveBeenCalled(); + }); + + it("marks record as error when linked invoice is missing", async () => { + const { prisma, tx } = buildPrisma({ record: { id: RECORD_ID, status: "pending", invoice: null } }); + + const result = await runVerificationForRecord(prisma, { invoiceVerificationEnabled: true }, RECORD_ID); + + expect(result).toEqual({ skipped: true, reason: "invoice_not_found" }); + expect(tx.invoiceVerificationRecord.update).toHaveBeenCalledWith({ + where: { id: RECORD_ID }, + data: expect.objectContaining({ + status: "error", + error_code: "invoice_not_found", + error_message: "invoice not found", + completed_at: expect.any(Date), + }), + }); + }); + + it("skips records that are no longer pending", async () => { + const { prisma, tx } = buildPrisma({ record: buildRecord({ status: "passed" }) }); + + const result = await runVerificationForRecord(prisma, { invoiceVerificationEnabled: true }, RECORD_ID); + + expect(result).toEqual({ skipped: true, reason: "status_not_pending" }); + expect(tx.invoiceVerificationRecord.update).not.toHaveBeenCalled(); + }); + + it("records feature-flag shutdown as a controlled verification error", async () => { + const { prisma, tx } = buildPrisma(); + + const result = await runVerificationForRecord(prisma, { invoiceVerificationEnabled: false }, RECORD_ID); + + expect(result).toEqual({ + skipped: false, + status: "error", + durationMs: 0, + resultCode: "verification_disabled_by_flag", + }); + expect(tx.invoiceVerificationRecord.update).toHaveBeenCalledWith({ + where: { id: RECORD_ID }, + data: expect.objectContaining({ + status: "error", + error_code: "verification_disabled_by_flag", + error_message: "env_hard_off", + completed_at: expect.any(Date), + }), + }); + }); + + it("persists provider error result without leaking raw invoice data", async () => { + const record = buildRecord({ + invoice: buildInvoice({ remarks: "verify-error" }), + }); + const { prisma, tx } = buildPrisma({ record }); + + const result = await runVerificationForRecord( + prisma, + { invoiceVerificationEnabled: true, verificationProvider: "mock" }, + RECORD_ID, + ); + + expect(result).toMatchObject({ + skipped: false, + status: "error", + resultCode: "mock_transport_error", + }); + expect(tx.invoiceVerificationRecord.update).toHaveBeenCalledWith({ + where: { id: RECORD_ID }, + data: expect.objectContaining({ + status: "error", + result_code: "mock_transport_error", + error_code: "mock_transport_error", + error_message: "mock provider transport error", + result_payload: expect.objectContaining({ provider: "mock" }), + duration_ms: expect.any(Number), + completed_at: expect.any(Date), + }), + }); + }); +}); diff --git a/server/tests/unit/routes/invoicePool.verification.test.js b/server/tests/unit/routes/invoicePool.verification.test.js new file mode 100644 index 00000000..d0fb1cc4 --- /dev/null +++ b/server/tests/unit/routes/invoicePool.verification.test.js @@ -0,0 +1,444 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const { FEATURE_FLAG_KEYS } = require("../../../src/services/featureFlags"); +const { registerInvoiceVerificationRoutes } = require("../../../src/routes/invoicePool/verification"); + +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; +const RECORD_ID = "22222222-2222-4222-8222-222222222222"; +const RETRY_RECORD_ID = "33333333-3333-4333-8333-333333333333"; +const EMPLOYEE_ID = "44444444-4444-4444-8444-444444444444"; +const DEPARTMENT_ID = "dept-finance"; + +function pickSelect(source, select) { + if (!select) { + return { ...source }; + } + + return Object.fromEntries( + Object.entries(select) + .filter(([, enabled]) => Boolean(enabled)) + .map(([key]) => [key, source[key]]), + ); +} + +function buildFlagRecord({ overrideMode = "force_on", rolloutType = "global", departmentIds = [] } = {}) { + const now = new Date("2026-05-28T00:00:00.000Z"); + return { + id: "flag-invoice-verification", + flag_key: FEATURE_FLAG_KEYS.invoiceVerification, + override_mode: overrideMode, + rollout_type: rolloutType, + department_ids: departmentIds, + note: "unit-test", + version: 1, + created_at: now, + created_by: null, + updated_at: now, + updated_by: null, + }; +} + +function buildInvoice(overrides = {}) { + return { + id: INVOICE_ID, + status: "available", + invoice_code: "0440026", + invoice_number: "00012890", + total_amount: "1060.00", + remarks: "route test", + owner_employee_id: EMPLOYEE_ID, + uploaded_by_employee_id: EMPLOYEE_ID, + owner_employee: { + id: EMPLOYEE_ID, + department_id: DEPARTMENT_ID, + }, + linked_reimbursement: null, + ...overrides, + }; +} + +function buildVerificationRecord(overrides = {}) { + return { + id: RECORD_ID, + invoice_id: INVOICE_ID, + provider: "mock", + status: "failed", + result_code: "invoice_mismatch", + result_message: "invoice metadata mismatch", + error_code: null, + error_message: null, + duration_ms: 120, + requested_by: EMPLOYEE_ID, + requested_at: new Date("2026-05-28T08:00:00.000Z"), + completed_at: new Date("2026-05-28T08:00:10.000Z"), + retry_of_id: null, + attempts: 1, + ...overrides, + }; +} + +function buildQueryRawHandler(state) { + return vi.fn(async (strings, ...values) => { + const sql = Array.isArray(strings) ? strings.join("?") : String(strings); + const targetId = values[0] ? String(values[0]) : null; + + if (sql.includes('FROM "invoice_pool"')) { + if (!state.invoice || (targetId && state.invoice.id !== targetId)) { + return []; + } + return [{ id: state.invoice.id }]; + } + + return []; + }); +} + +function buildPrisma(options = {}) { + const state = { + invoice: options.invoice === null ? null : buildInvoice(options.invoice), + latestRecord: options.latestRecord ?? null, + records: options.records ?? [], + flagRecord: options.flagRecord ?? buildFlagRecord(), + }; + + const tx = { + $queryRaw: buildQueryRawHandler(state), + featureFlag: { + findUnique: vi.fn(async () => state.flagRecord), + }, + invoicePool: { + findUnique: vi.fn(async ({ where, select }) => { + if (!state.invoice || where.id !== state.invoice.id) { + return null; + } + return pickSelect(state.invoice, select); + }), + }, + invoiceVerificationRecord: { + findFirst: vi.fn(async () => state.latestRecord), + findMany: vi.fn(async () => state.records), + create: vi.fn(async ({ data }) => { + const created = { + id: data.retry_of_id ? RETRY_RECORD_ID : RECORD_ID, + ...data, + created_at: new Date("2026-05-28T08:05:00.000Z"), + }; + state.latestRecord = created; + state.records = [created, ...state.records]; + return pickSelect(created, { id: true }); + }), + }, + auditLog: { + create: vi.fn(async () => ({ id: "audit-1" })), + }, + }; + + const prisma = { + ...tx, + $transaction: vi.fn(async (callback) => callback(tx)), + }; + + return { prisma, tx, state }; +} + +async function buildApp(options = {}) { + const { prisma, tx, state } = buildPrisma(options); + const app = Fastify(); + const permissionCodes = options.permissionCodes ?? ["invoice.verify", "invoice.manage"]; + + app.decorate("prisma", prisma); + app.decorate("boss", { + send: vi.fn(async () => ({ id: "job-invoice-verify-1" })), + }); + app.decorate("config", { + invoiceVerificationEnabled: options.invoiceVerificationEnabled ?? true, + verificationProvider: options.verificationProvider ?? "mock", + verificationRetryCooldownSeconds: options.verificationRetryCooldownSeconds ?? 60, + verificationMaxAttempts: options.verificationMaxAttempts ?? 3, + jobDriver: "pgboss", + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + request.employee = { + id: EMPLOYEE_ID, + department_id: DEPARTMENT_ID, + roleCodes: ["finance"], + permissionCodes, + }; + }); + app.decorate( + "hasPermission", + (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes("*") || + (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.decorate("requirePermission", (permissionCode) => async (request) => { + await app.authenticate(request); + if (!app.hasPermission(request.employee, permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(registerInvoiceVerificationRoutes, { prefix: "/api" }); + return { app, prisma, tx, state }; +} + +describe("invoicePool verification routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a pending verification request and queues a job", async () => { + const { app, tx } = await buildApp(); + + try { + const response = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/verify`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + invoiceId: INVOICE_ID, + verificationId: RECORD_ID, + provider: "mock", + triggerMode: "queued", + }); + expect(tx.invoiceVerificationRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + invoice_id: INVOICE_ID, + provider: "mock", + status: "pending", + requested_by: EMPLOYEE_ID, + attempts: 1, + request_payload: expect.objectContaining({ + invoiceCode: "0440026", + invoiceNumber: "00012890", + departmentId: DEPARTMENT_ID, + featureFlagReason: "flag_force_on_global", + }), + }), + select: { id: true }, + }); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actor_id: EMPLOYEE_ID, + action: "INVOICE_VERIFICATION_REQUESTED", + target_type: "invoice_pool", + target_id: INVOICE_ID, + }), + }); + expect(app.boss.send).toHaveBeenCalledWith("invoice.verify", { recordId: RECORD_ID }); + } finally { + await app.close(); + } + }); + + it("blocks trigger when invoice verification is disabled by env gate", async () => { + const { app, tx } = await buildApp({ invoiceVerificationEnabled: false }); + + try { + const response = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/verify`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toMatchObject({ + code: "VALID_001", + details: { verification: "disabled", featureFlag: "env_hard_off" }, + }); + expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); + expect(app.boss.send).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); + + it("blocks trigger when a pending verification already exists", async () => { + const { app, tx } = await buildApp({ + latestRecord: buildVerificationRecord({ status: "pending" }), + }); + + try { + const response = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/verify`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ verification: "already_pending" }); + expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); + + it("blocks trigger when retry cooldown has not elapsed", async () => { + vi.setSystemTime(new Date("2026-05-28T08:00:20.000Z")); + const { app, tx } = await buildApp({ + latestRecord: buildVerificationRecord({ + status: "failed", + requested_at: new Date("2026-05-28T08:00:00.000Z"), + }), + verificationRetryCooldownSeconds: 60, + }); + + try { + const response = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/verify`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ verification: "too_frequent" }); + expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await app.close(); + } + }); + + it("lists verification records for authorized finance user", async () => { + const record = buildVerificationRecord(); + const { app, tx } = await buildApp({ records: [record] }); + + try { + const response = await app.inject({ + method: "GET", + url: `/api/invoice-pool/${INVOICE_ID}/verification?limit=1`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toMatchObject({ + invoiceId: INVOICE_ID, + items: [ + { + id: RECORD_ID, + provider: "mock", + status: "failed", + attempts: 1, + resultMessage: "invoice metadata mismatch", + }, + ], + }); + expect(tx.invoiceVerificationRecord.findMany).toHaveBeenCalledWith({ + where: { invoice_id: INVOICE_ID }, + orderBy: { created_at: "desc" }, + take: 1, + }); + } finally { + await app.close(); + } + }); + + it("creates retry record with incremented attempts and queues a job", async () => { + vi.setSystemTime(new Date("2026-05-28T08:05:00.000Z")); + const { app, tx } = await buildApp({ + latestRecord: buildVerificationRecord({ + id: RECORD_ID, + status: "failed", + attempts: 1, + requested_at: new Date("2026-05-28T08:00:00.000Z"), + }), + verificationRetryCooldownSeconds: 60, + }); + + try { + const response = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/verification/retry`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({ + invoiceId: INVOICE_ID, + verificationId: RETRY_RECORD_ID, + status: "pending", + }); + expect(tx.invoiceVerificationRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + invoice_id: INVOICE_ID, + provider: "mock", + status: "pending", + retry_of_id: RECORD_ID, + attempts: 2, + requested_by: EMPLOYEE_ID, + request_payload: expect.objectContaining({ + retryOfId: RECORD_ID, + departmentId: DEPARTMENT_ID, + featureFlagReason: "flag_force_on_global", + }), + }), + select: { id: true }, + }); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actor_id: EMPLOYEE_ID, + action: "INVOICE_VERIFICATION_RETRIED", + target_type: "invoice_pool", + target_id: INVOICE_ID, + }), + }); + expect(app.boss.send).toHaveBeenCalledWith("invoice.verify", { recordId: RETRY_RECORD_ID }); + } finally { + vi.useRealTimers(); + await app.close(); + } + }); + + it("blocks retry after max attempts are exceeded", async () => { + vi.setSystemTime(new Date("2026-05-28T08:05:00.000Z")); + const { app, tx } = await buildApp({ + latestRecord: buildVerificationRecord({ + status: "error", + attempts: 3, + requested_at: new Date("2026-05-28T08:00:00.000Z"), + }), + verificationMaxAttempts: 3, + verificationRetryCooldownSeconds: 60, + }); + + try { + const response = await app.inject({ + method: "POST", + url: `/api/invoice-pool/${INVOICE_ID}/verification/retry`, + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ verification: "attempts_exceeded" }); + expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled(); + expect(app.boss.send).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await app.close(); + } + }); +});