fix: 完善动态错误指引与发票日期校验(09-05-error-explainability-payment-readiness)
这个提交包含在:
@@ -214,6 +214,41 @@ async function buildApp(passwordHash) {
|
||||
}
|
||||
|
||||
describe("auth login route", () => {
|
||||
it.each([
|
||||
["/login", {}, "login_input_invalid"],
|
||||
["/email-login/code", { email: "invalid" }, "email_input_invalid"],
|
||||
["/email-login/verify", {}, "email_code_input_invalid"],
|
||||
["/change-password", {}, "password_change_input_invalid"],
|
||||
["/forgot-password", { email: "invalid" }, "email_input_invalid"],
|
||||
["/reset-password", {}, "password_reset_input_invalid"],
|
||||
["/change-password", { oldPassword: "SyntheticOld1234", newPassword: "aaaaaaaaaaaa" }, "password_policy_failed"],
|
||||
])("explains invalid input at %s without authentication side effects", async (url, payload, reason) => {
|
||||
const state = await buildApp("unused");
|
||||
try {
|
||||
const response = await state.app.inject({ method: "POST", url: `/api/auth${url}`, payload,
|
||||
headers: { "x-device-fingerprint": "synthetic-input-validation" } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.reasonCode).toBe(`auth.${reason}`);
|
||||
expect(state.employeeUpdate).not.toHaveBeenCalled();
|
||||
expect(state.authSessionCreate).not.toHaveBeenCalled();
|
||||
expect(state.emailLoginCodeCreate).not.toHaveBeenCalled();
|
||||
expect(state.passwordResetCodeCreate).not.toHaveBeenCalled();
|
||||
expect(state.auditCreate).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(response.json())).not.toContain("SyntheticOld1234");
|
||||
} finally { await state.app.close(); }
|
||||
});
|
||||
|
||||
it("explains missing device context before looking up an account", async () => {
|
||||
const state = await buildApp("unused");
|
||||
try {
|
||||
const response = await state.app.inject({ method: "POST", url: "/api/auth/login",
|
||||
payload: { identifier: "[email protected]", password: "SyntheticPassword1234" } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.reasonCode).toBe("auth.device_context_missing");
|
||||
expect(state.employeeFindFirst).not.toHaveBeenCalled();
|
||||
expect(state.authSessionCreate).not.toHaveBeenCalled();
|
||||
} finally { await state.app.close(); }
|
||||
});
|
||||
it("keeps signed deeplink usable after legacy exp and redirects normal browsers to target", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app } = await buildApp(passwordHash);
|
||||
@@ -268,7 +303,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path" });
|
||||
expect(response.json().error.details).toEqual({ targetPath: "invalid_target_path", reasonCode: "auth.deeplink_target_invalid" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -293,7 +328,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(absoluteUrlResponse.statusCode).toBe(400);
|
||||
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path" });
|
||||
expect(absoluteUrlResponse.json().error.details).toEqual({ targetPath: "invalid_target_path", reasonCode: "auth.deeplink_target_invalid" });
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -566,7 +601,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
|
||||
expect(emailLoginCodeUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: "email-code-1", used_at: null },
|
||||
data: { attempts_count: { increment: 1 } },
|
||||
@@ -870,7 +905,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
|
||||
expect(employeeUpdate).not.toHaveBeenCalled();
|
||||
expect(emailLoginCodeUpdateMany).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
@@ -966,7 +1001,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
|
||||
expect(employeeUpdate).toHaveBeenCalledWith({
|
||||
where: { id: EMPLOYEE_ID },
|
||||
data: expect.objectContaining({
|
||||
@@ -1008,7 +1043,7 @@ describe("auth login route", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired" });
|
||||
expect(response.json().error.details).toEqual({ code: "invalid_or_expired", reasonCode: "auth.code_invalid_or_expired" });
|
||||
expect(employeeUpdate).not.toHaveBeenCalled();
|
||||
expect(passwordResetCodeUpdateMany).not.toHaveBeenCalled();
|
||||
expect(auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
@@ -106,6 +106,23 @@ function buildApp({
|
||||
}
|
||||
|
||||
describe("GET /api/invoice-pool/bindable", () => {
|
||||
it.each([
|
||||
["GET", "/invoice-pool?page=0", "list_query_invalid"],
|
||||
["GET", "/invoice-pool/bindable?reimbursementId=invalid", "selection_query_invalid"],
|
||||
["GET", "/invoice-pool/status", "status_query_invalid"],
|
||||
["GET", "/invoice-pool/status?ids=invalid", "status_query_invalid"],
|
||||
["GET", "/invoice-pool/invalid", "id_invalid"],
|
||||
["POST", "/invoice-pool/invalid/signed-url", "id_invalid"],
|
||||
])("explains invalid invoice request %s %s before data access", async (method, url, reason) => {
|
||||
const { app, prisma } = await buildApp();
|
||||
try {
|
||||
const response = await app.inject({ method, url: `/api${url}`, headers: { authorization: "Bearer test" } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
|
||||
expect(prisma.invoicePool.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
it("returns bindable invoices for reimbursement submitter and uses reimbursement owner for filtering", async () => {
|
||||
const bindableItems = [
|
||||
{
|
||||
@@ -199,7 +216,7 @@ describe("GET /api/invoice-pool/bindable", () => {
|
||||
});
|
||||
|
||||
it("rejects reimbursements not in draft or rejected status", async () => {
|
||||
const { app } = await buildApp({
|
||||
const { app, prisma } = await buildApp({
|
||||
reimbursement: {
|
||||
id: REIMBURSEMENT_ID,
|
||||
owner_id: OWNER_ID,
|
||||
@@ -215,7 +232,8 @@ describe("GET /api/invoice-pool/bindable", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind" });
|
||||
expect(response.json().error.details).toEqual({ reimbursement: "invalid_status_for_bind", reasonCode: "invoice_pool.reimbursement_not_editable" });
|
||||
expect(prisma.invoicePool.findMany).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ function buildPrisma(invoiceOverrides = {}) {
|
||||
return { prisma, state };
|
||||
}
|
||||
|
||||
async function buildApp(invoiceOverrides = {}) {
|
||||
async function buildApp(invoiceOverrides = {}, options = {}) {
|
||||
const { prisma, state } = buildPrisma(invoiceOverrides);
|
||||
const app = Fastify();
|
||||
|
||||
@@ -94,7 +94,7 @@ async function buildApp(invoiceOverrides = {}) {
|
||||
appOrigin: "http://localhost:5173",
|
||||
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||
invoiceArchiveUnusedDays: 30,
|
||||
ocrProvider: "mock",
|
||||
ocrProvider: options.ocrProvider ?? "mock",
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
@@ -102,7 +102,7 @@ async function buildApp(invoiceOverrides = {}) {
|
||||
}
|
||||
request.employee = {
|
||||
id: OWNER_ID,
|
||||
permissionCodes: ["invoice.upload"],
|
||||
permissionCodes: options.permissionCodes ?? ["invoice.upload"],
|
||||
roleCodes: ["employee"],
|
||||
};
|
||||
});
|
||||
@@ -141,6 +141,53 @@ async function buildApp(invoiceOverrides = {}) {
|
||||
}
|
||||
|
||||
describe("PATCH /api/invoice-pool/:id", () => {
|
||||
it.each(["2024-02-29", "2026-04-30"])("preserves valid calendar date %s", async (invoiceDate) => {
|
||||
const { app, state } = await buildApp();
|
||||
try {
|
||||
const response = await app.inject({ method: "PATCH", url: `/api/invoice-pool/${INVOICE_ID}`, headers: { authorization: "Bearer test" }, payload: { invoiceDate } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(state.invoice.invoice_date.toISOString().slice(0, 10)).toBe(invoiceDate);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["PATCH", "", {}, {}, "edit_empty", 400],
|
||||
["PATCH", "", { amount: 12 }, {}, "edit_input_invalid", 400],
|
||||
["PATCH", "", { invoiceDate: "2026-02-30" }, {}, "date_invalid", 400],
|
||||
["PATCH", "", { invoiceDate: "2026-02-29" }, {}, "date_invalid", 400],
|
||||
["PATCH", "", { invoiceDate: "2026-13-01" }, {}, "date_invalid", 400],
|
||||
["PATCH", "", { invoiceDate: "2026-04-31" }, {}, "date_invalid", 400],
|
||||
["PATCH", "", { amount: "invalid" }, {}, "amount_invalid", 400],
|
||||
["PATCH", "", { taxAmount: "invalid" }, {}, "tax_amount_invalid", 400],
|
||||
["PATCH", "", { totalAmount: "invalid" }, {}, "total_amount_invalid", 400],
|
||||
["PATCH", "", { remarks: "synthetic" }, { status: "bound" }, "not_editable", 409],
|
||||
["PATCH", "", { status: "available" }, { status: "available" }, "status_transition_invalid", 409],
|
||||
["PATCH", "", { status: "available" }, {}, "ocr_fields_missing", 409],
|
||||
["POST", "/archive", {}, { linked_reimbursement_id: "occupied" }, "archive_bound", 409],
|
||||
["POST", "/archive", {}, { status: "pending_ocr" }, "archive_status_invalid", 409],
|
||||
["POST", "/retry-ocr", {}, { status: "available" }, "ocr_retry_status_invalid", 409],
|
||||
["POST", "/retry-ocr", {}, {}, "ocr_unavailable", 409],
|
||||
["POST", "/override-duplicate", {}, {}, "override_input_invalid", 400],
|
||||
])("explains %s %s %s without writes", async (method, suffix, payload, invoiceOverrides, reason, statusCode) => {
|
||||
const { app, prisma, state } = await buildApp(invoiceOverrides, {
|
||||
ocrProvider: "off",
|
||||
permissionCodes: ["invoice.upload", "expense.finance.override_duplicate"],
|
||||
});
|
||||
const before = JSON.stringify(state.invoice);
|
||||
try {
|
||||
const response = await app.inject({ method, url: `/api/invoice-pool/${INVOICE_ID}${suffix}`, headers: { authorization: "Bearer test" }, payload });
|
||||
expect(response.statusCode).toBe(statusCode);
|
||||
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
|
||||
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(state.invoice)).toBe(before);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid invoiceDate and allows marking OCR-failed VAT invoice as available", async () => {
|
||||
const { app, state } = await buildApp();
|
||||
|
||||
|
||||
@@ -130,6 +130,28 @@ async function buildApp({
|
||||
}
|
||||
|
||||
describe("invoicePool upload route", () => {
|
||||
it.each([
|
||||
["", [], 1024, "empty_file"],
|
||||
["%PDF-1.4 synthetic", [["ocr_type", "unsupported"]], 1024, "ocr_type_invalid"],
|
||||
["%PDF-1.4 synthetic", [], 8, "upload_too_large"],
|
||||
])("explains invalid upload without files, records or jobs: %s", async (content, fields, maxUploadSizeBytes, reason) => {
|
||||
const bossSend = vi.fn();
|
||||
const { app, prisma, tempDir } = await buildApp({ ocrProvider: "mock", jobDriver: "pgboss", bossSend, maxUploadSizeBytes });
|
||||
try {
|
||||
const input = buildMultipartRequest({ filename: "synthetic.pdf", contentType: "application/pdf", content, fields });
|
||||
const response = await app.inject({ method: "POST", url: "/api/invoice-pool/upload", headers: { authorization: "Bearer test", ...input.headers }, payload: input.payload });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.reasonCode).toBe(`invoice_pool.${reason}`);
|
||||
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
expect(await fs.readdir(tempDir)).toEqual([]);
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes the file stream before reading trailing multipart fields", async () => {
|
||||
const { app, prisma, tempDir } = await buildApp();
|
||||
|
||||
@@ -261,7 +283,7 @@ describe("invoicePool upload route", () => {
|
||||
expect.objectContaining({
|
||||
code: "VALID_001",
|
||||
message: "请上传 PDF 发票文件",
|
||||
details: { file: "pdf_invoice_required" },
|
||||
details: { file: "pdf_invoice_required", reasonCode: "invoice_pool.pdf_required" },
|
||||
}),
|
||||
);
|
||||
expect(prisma.invoicePool.create).not.toHaveBeenCalled();
|
||||
@@ -302,6 +324,7 @@ describe("invoicePool upload route", () => {
|
||||
message: "PDF 发票不能超过 7 MiB",
|
||||
details: {
|
||||
file: "ocr_pdf_too_large",
|
||||
reasonCode: "invoice_pool.ocr_pdf_too_large",
|
||||
maxBytes: 7 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -204,6 +204,58 @@ async function buildApp(options = {}) {
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -268,7 +320,7 @@ describe("invoicePool verification routes", () => {
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error).toMatchObject({
|
||||
code: "VALID_001",
|
||||
details: { verification: "disabled", featureFlag: "env_hard_off" },
|
||||
details: { verification: "disabled", featureFlag: "env_hard_off", reasonCode: "invoice_verification.disabled" },
|
||||
});
|
||||
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
|
||||
expect(app.boss.send).not.toHaveBeenCalled();
|
||||
@@ -290,7 +342,7 @@ describe("invoicePool verification routes", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual({ verification: "already_pending" });
|
||||
expect(response.json().error.details).toEqual({ verification: "already_pending", reasonCode: "invoice_verification.already_pending" });
|
||||
expect(tx.invoiceVerificationRecord.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
|
||||
@@ -188,4 +188,17 @@ describe('me routes', () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('explains invalid notification settings without changing preferences', async () => {
|
||||
const { prisma, auditEntries } = createPrismaState();
|
||||
const app = await buildApp(prisma);
|
||||
try {
|
||||
const response = await app.inject({ method: 'PATCH', url: '/api/me/notification-preferences',
|
||||
headers: { authorization: 'Bearer test-token' }, payload: { notificationPushEnabled: 'false' } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.reasonCode).toBe('profile.notification_preferences_invalid');
|
||||
expect(prisma.employee.update).not.toHaveBeenCalled();
|
||||
expect(auditEntries).toHaveLength(0);
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,10 +65,45 @@ async function buildApp(permissionCodes) {
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
|
||||
await app.register(paymentAccountsRoutes, { prefix: "/api" });
|
||||
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
|
||||
return { app, tx, auditCreate, notificationCreateMany, paymentAccountCreate };
|
||||
}
|
||||
|
||||
describe("payment account routes", () => {
|
||||
const validBody = { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT };
|
||||
it.each([
|
||||
["account_input_invalid", "/payment-accounts", { ...validBody, accountNumber: "invalid" }, 400],
|
||||
["bank_input_invalid", "/bank-institutions", { code: "!", name: "" }, 400],
|
||||
["reveal_reason_required", `/payment-accounts/${ACCOUNT_ID}/reveal`, { reason: "" }, 400],
|
||||
["id_invalid", "/payment-accounts/invalid/reveal", { reason: "核对资料" }, 400],
|
||||
["legal_entity_unavailable", "/payment-accounts", validBody, 409],
|
||||
["bank_unavailable", "/payment-accounts", validBody, 409],
|
||||
["legal_entity_change_forbidden", `/payment-accounts/${ACCOUNT_ID}/replace`, { ...validBody, legalEntityId: ACTOR_ID }, 400],
|
||||
["bank_code_exists", "/bank-institutions", { code: "CCB", name: "合成银行" }, 409],
|
||||
["bank_in_use", `/bank-institutions/${BANK_ID}/disable`, {}, 409],
|
||||
])("explains %s without successful mutation or sensitive output", async (reason, url, payload, status) => {
|
||||
const testApp = await buildApp(["*"]);
|
||||
try {
|
||||
if (reason === "legal_entity_unavailable") testApp.tx.legalEntity.findFirst.mockResolvedValue(null);
|
||||
if (reason === "bank_unavailable") testApp.tx.bankInstitution.findFirst.mockResolvedValue(null);
|
||||
if (reason === "bank_code_exists") testApp.tx.bankInstitution.create = vi.fn().mockRejectedValue({ code: "P2002" });
|
||||
if (reason === "bank_in_use") {
|
||||
testApp.tx.bankInstitution.findUnique = vi.fn().mockResolvedValue({ id: BANK_ID });
|
||||
testApp.tx.paymentAccount.count = vi.fn().mockResolvedValue(1);
|
||||
}
|
||||
const response = await testApp.app.inject({ method: "POST", url: `/api${url}`, payload });
|
||||
expect(response.statusCode).toBe(status);
|
||||
expect(response.json().error.code).toBe("VALID_001");
|
||||
expect(response.json().error.details.reasonCode).toBe(`payment_account.${reason}`);
|
||||
const { getErrorReason } = require("../../../src/constants/errorReasons");
|
||||
const expected = require("../fixtures/error-reason-messages.json");
|
||||
expect(getErrorReason(`payment_account.${reason}`).defaultMessage).toBe(expected[`payment_account.${reason}`]);
|
||||
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
|
||||
expect(testApp.paymentAccountCreate).not.toHaveBeenCalled();
|
||||
expect(testApp.tx.paymentAccount.update).not.toHaveBeenCalled();
|
||||
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
||||
expect(testApp.notificationCreateMany).not.toHaveBeenCalled();
|
||||
} finally { await testApp.app.close(); }
|
||||
});
|
||||
it("returns only a masked account on normal reads", async () => {
|
||||
const testApp = await buildApp(["payment.account.read"]);
|
||||
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
|
||||
|
||||
@@ -196,6 +196,32 @@ async function buildApp(options = {}) {
|
||||
}
|
||||
|
||||
describe("workflow batch routes", () => {
|
||||
it.each([
|
||||
["POST", "/workflow/tasks/invalid/approve", {}, "workflow.task_id_invalid"],
|
||||
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/reject", { comment: " " }, "workflow.comment_invalid"],
|
||||
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/transfer", {}, "workflow.transfer_input_invalid"],
|
||||
["POST", "/workflow/batch-approve", { taskIds: [] }, "workflow.batch_approve_input_invalid"],
|
||||
["POST", "/workflow/batch-reject", { taskIds: [] }, "workflow.batch_reject_input_invalid"],
|
||||
["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/remind", { comment: "长".repeat(201) }, "workflow.remind_input_invalid"],
|
||||
["GET", "/workflow/tasks/summary?scope=invalid", undefined, "workflow.summary_query_invalid"],
|
||||
["GET", "/workflow/tasks?page=0", undefined, "workflow.task_list_query_invalid"],
|
||||
["GET", "/workflow/approvers?page=0", undefined, "workflow.approver_query_invalid"],
|
||||
])("rejects malformed %s %s before starting a business transaction", async (method, path, payload, reasonCode) => {
|
||||
const { app, prisma, state } = await buildApp({
|
||||
actor: { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["expense.approve", "expense.transfer"] },
|
||||
});
|
||||
const before = JSON.stringify([...state.tasks]);
|
||||
try {
|
||||
const response = await app.inject({ method, url: `/api${path}`, ...(payload === undefined ? {} : { payload }) });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.reasonCode).toBe(reasonCode);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify([...state.tasks])).toBe(before);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects self approval on a single task action", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
reimbursementOwnerId: ACTOR_ID,
|
||||
@@ -212,6 +238,7 @@ describe("workflow batch routes", () => {
|
||||
code: "VALID_001",
|
||||
details: expect.objectContaining({
|
||||
workflow: "self_approval_not_allowed",
|
||||
reasonCode: "workflow.self_approval_forbidden",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -271,6 +271,7 @@ describe("workflow transfer route", () => {
|
||||
code: "VALID_001",
|
||||
details: expect.objectContaining({
|
||||
toApproverId: "target_not_approver",
|
||||
reasonCode: "workflow.transfer_target_ineligible",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -306,6 +307,7 @@ describe("workflow transfer route", () => {
|
||||
expect(response.json().error.details).toEqual(
|
||||
expect.objectContaining({
|
||||
toApproverId: "target_not_approver",
|
||||
reasonCode: "workflow.transfer_target_ineligible",
|
||||
}),
|
||||
);
|
||||
expect(tx.workflowTask.update).not.toHaveBeenCalled();
|
||||
@@ -341,6 +343,7 @@ describe("workflow transfer route", () => {
|
||||
code: "VALID_001",
|
||||
details: expect.objectContaining({
|
||||
workflow: "self_approval_not_allowed",
|
||||
reasonCode: "workflow.self_approval_forbidden",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户