文件
hyb-oa/server/tests/unit/routes/invoicePool.verification.test.js

497 行
16 KiB
JavaScript

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", () => {
it("explains invalid verification limits without reading records", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({ method: "GET", url: `/api/invoice-pool/${INVOICE_ID}/verification?limit=51`, headers: { authorization: "Bearer test" } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details.reasonCode).toBe("invoice_verification.list_query_invalid");
expect(tx.invoiceVerificationRecord.findMany).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it.each([
[null, "invoice_verification.record_missing"],
[buildVerificationRecord({ status: "pending" }), "invoice_verification.already_pending"],
])("explains blocked retry without writing or enqueueing: %s", async (latestRecord, reasonCode) => {
const { app, tx } = await buildApp({ latestRecord });
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.reasonCode).toBe(reasonCode);
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it("explains invalid history identifiers before reading records", async () => {
const { app, tx } = await buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/api/invoice-pool/invalid/verification",
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "invoice_pool.id_invalid" });
expect(tx.invoicePool.findUnique).not.toHaveBeenCalled();
expect(tx.invoiceVerificationRecord.findMany).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
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", reasonCode: "invoice_verification.disabled" },
});
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", reasonCode: "invoice_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", reasonCode: "invoice_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", reasonCode: "invoice_verification.attempts_exceeded" });
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
expect(app.boss.send).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
await app.close();
}
});
});