补齐发票验真测试覆盖(05-28-oa-test-coverage-gap-fill)

这个提交包含在:
2026-05-28 08:45:19 +08:00
父节点 ab802cbf2c
当前提交 1c3f0c4a15
共修改 9 个文件,包含 1021 行新增和 3 行删除
@@ -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();
}
});
});