补齐发票验真测试覆盖(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") {
|
||||
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) => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户