chore(P14-8): 完成运营化与验收收口

这个提交包含在:
2026-03-06 23:24:16 +08:00
父节点 b50b084100
当前提交 d8fff0ba21
共修改 28 个文件,包含 2820 行新增和 123 行删除
@@ -0,0 +1,42 @@
const dispatcher = require("../../../../src/services/integrations/dispatcher");
describe("Integration Dispatcher", () => {
it("dispatches erp.reimbursement.paid", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: 1 },
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
});
it("throws IntegrationDispatchError if erp fails", async () => {
await expect(
dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: false },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: 1 },
})
).rejects.toThrow(dispatcher.IntegrationDispatchError);
});
it("dispatches notification.im", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: {},
eventType: "notification.im",
payload: { channelType: "slack" },
});
expect(result.channel).toBe("im");
expect(result.response.channelType).toBe("slack");
});
it("throws error for unsupported event", async () => {
await expect(
dispatcher.dispatchOutboxEvent({
config: {},
eventType: "unknown.event",
})
).rejects.toThrow("unsupported_outbox_event:unknown.event");
});
});
@@ -0,0 +1,40 @@
const erp = require("../../../../src/services/integrations/erp");
describe("ERP Sync Services", () => {
it("builds correct payload map", () => {
const payload = erp.buildErpPayload({
reimbursement: {
id: "r1",
form_number: "F001",
payment_date: "2026-03-01T12:00:00.000Z",
},
});
expect(payload.formNumber).toBe("F001");
expect(payload.paymentDate).toBe("2026-03-01");
});
it("returns mock synced when provider is mock", async () => {
const result = await erp.pushPaidReimbursementToErp(
{ erpSyncEnabled: true, erpSyncProvider: "mock" },
{ formNumber: "F001" }
);
expect(result.ok).toBe(true);
expect(result.code).toBe("mock_synced");
});
it("returns invalid provider if unsupported", async () => {
const result = await erp.pushPaidReimbursementToErp(
{ erpSyncEnabled: true, erpSyncProvider: "invalid_xyz" },
{}
);
expect(result.ok).toBe(false);
expect(result.code).toBe("erp_sync_provider_invalid");
});
it("returns disabled if disabled in config", async () => {
const result = await erp.pushPaidReimbursementToErp({ erpSyncEnabled: false }, {});
expect(result.ok).toBe(false);
expect(result.code).toBe("erp_sync_disabled");
});
});
@@ -0,0 +1,83 @@
const outbox = require("../../../../src/services/integrations/outbox");
describe("Integration Outbox", () => {
it("enqueues new event", async () => {
const mockPrisma = {
integrationOutbox: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: 1, status: "pending" }),
},
};
const result = await outbox.enqueueIntegrationEvent(mockPrisma, { eventType: "test" });
expect(result.id).toBe(1);
expect(result.duplicated).toBe(false);
});
it("detects duplicated event", async () => {
const mockPrisma = {
integrationOutbox: {
findUnique: vi.fn().mockResolvedValue({ id: 2, status: "pending" }),
},
};
const result = await outbox.enqueueIntegrationEvent(mockPrisma, { eventType: "test", dedupeKey: "key" });
expect(result.id).toBe(2);
expect(result.duplicated).toBe(true);
});
it("marks as processing", async () => {
const mockPrisma = {
integrationOutbox: {
update: vi.fn().mockResolvedValue({ id: 1, status: "processing" }),
},
};
const res = await outbox.markOutboxProcessing(mockPrisma, 1, "actor");
expect(res.status).toBe("processing");
});
it("fetches pending events", async () => {
const mockPrisma = {
integrationOutbox: {
findMany: vi.fn().mockResolvedValue([{ id: 1 }]),
},
};
const items = await outbox.fetchPendingOutboxEvents(mockPrisma, { batchSize: 5 });
expect(items.length).toBe(1);
});
it("marks outbox succeeded", async () => {
const mockPrisma = {
integrationOutbox: {
update: vi.fn().mockResolvedValue({ id: 1, status: "succeeded" }),
},
};
const res = await outbox.markOutboxSucceeded(mockPrisma, 1, "actor");
expect(res.status).toBe("succeeded");
});
it("handles failed outbox and schedules retry", async () => {
const mockPrisma = {
integrationOutbox: {
update: vi.fn().mockImplementation(async ({ data }) => {
return { status: data.status, retry: data.next_retry_at };
}),
},
};
const res = await outbox.markOutboxFailed(mockPrisma, {}, { id: 1, attempts: 0 }, { retryable: true }, "actor");
expect(res.status).toBe("failed");
expect(res.retry).toBeInstanceOf(Date);
});
it("moves to dead_letter if max attempts reached", async () => {
const mockPrisma = {
integrationOutbox: {
update: vi.fn().mockImplementation(async ({ data }) => {
return { status: data.status, retry: data.next_retry_at };
}),
},
};
const res = await outbox.markOutboxFailed(mockPrisma, {}, { id: 1, attempts: 5, max_attempts: 5 }, { retryable: true }, "actor");
expect(res.status).toBe("dead_letter");
expect(res.retry).toBeNull();
});
});