补齐发票验真测试覆盖(05-28-oa-test-coverage-gap-fill)
这个提交包含在:
@@ -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."}
|
||||||
@@ -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."}
|
||||||
@@ -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 扩展,只保留后端自动化覆盖。
|
||||||
@@ -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: 无阻塞问题。
|
||||||
|
```
|
||||||
@@ -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,不读取真实票据,不写生产数据库。
|
||||||
|
- 只记录相对路径、业务码和脱敏结论。
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -65,6 +65,21 @@ async function fulfillJson<T>(route: Route, data: T) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fulfillApiError(route: Route, status: number, details: Record<string, unknown>) {
|
||||||
|
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") {
|
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
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<string, unknown>;
|
||||||
|
retryVerificationError?: Record<string, unknown>;
|
||||||
|
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) => {
|
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
@@ -593,13 +642,20 @@ async function mockWave4Apis(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathName === "/api/invoice-pool") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathName === `/api/invoice-pool/${invoiceListItem.id}`) {
|
if (pathName === `/api/invoice-pool/${invoiceListItem.id}`) {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, {
|
||||||
...invoiceListItem,
|
...invoiceListItem,
|
||||||
|
verification: currentDetailVerification,
|
||||||
archivedReason: null,
|
archivedReason: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
ownerEmployeeId: visualMe.id,
|
ownerEmployeeId: visualMe.id,
|
||||||
@@ -616,8 +672,64 @@ async function mockWave4Apis(page: Page) {
|
|||||||
return;
|
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`) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,3 +908,72 @@ test("PC 财务与管理页 Wave4 视觉冒烟", async ({ page }) => {
|
|||||||
await capture(page, target.screenshot);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户