补齐发票验真测试覆盖(05-28-oa-test-coverage-gap-fill)
这个提交包含在:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户