文件
hyb-oa/server/tests/unit/routes/integrations.outbox.test.js
T
wangsli 7cc6dc3397 R53 A1-A6:ERP 集成运维可视化与业务同步状态接入
- ErpIntegrationPage Tab 化:主数据同步 / Outbox 队列管理 / ID 映射查看器
- Outbox 分页查询、详情、重试与统计端点
- IntegrationBinding 分页查询、routePath 推导与采购/资产深链
- 报销/采购/资产详情区接入 ERP 同步状态卡片
- 单测覆盖 Outbox 与 Binding 端点
2026-04-07 10:33:58 +08:00

286 行
8.4 KiB
JavaScript

const Fastify = require('fastify');
const integrationsRoutes = require('../../../src/routes/integrations');
const { AppError } = require('../../../src/utils/AppError');
const ACTOR_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const OUTBOX_ID = '11111111-1111-4111-8111-111111111111';
function buildOutbox(overrides = {}) {
return {
id: OUTBOX_ID,
event_type: 'erp.journal.export',
status: 'failed',
payload: {
entityType: 'reimbursement_form',
entityId: '22222222-2222-4222-8222-222222222222',
sourceEventName: 'reimbursement.paid',
journalType: 'reimbursement_paid',
documentNo: 'BX-20260407-001',
token: 'sanitized-value',
},
attempts: 2,
max_attempts: 5,
last_error: 'HTTP 500 | timeout',
next_retry_at: new Date('2026-04-07T10:00:00.000Z'),
created_at: new Date('2026-04-07T09:00:00.000Z'),
processed_at: null,
dead_letter_at: null,
updated_at: new Date('2026-04-07T09:30:00.000Z'),
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: ACTOR_ID,
permissionCodes: ['integration.manage'],
};
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildOutbox()]);
const findUnique = options.findUnique ?? vi.fn().mockResolvedValue(buildOutbox());
const update = options.update ?? vi.fn().mockImplementation(async ({ where, data }) => ({
...buildOutbox({ id: where.id }),
...data,
updated_at: new Date('2026-04-07T10:30:00.000Z'),
}));
const count = options.count ?? vi.fn().mockImplementation(async ({ where } = {}) => {
if (where?.status === 'pending') return 3;
if (where?.status === 'processing') return 1;
if (where?.status === 'succeeded') return 8;
if (where?.status === 'failed') return 2;
if (where?.status === 'dead_letter') return 1;
return 1;
});
const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({});
app.decorate('authenticate', async (request) => {
request.employee = actor;
});
app.decorate('requirePermission', (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes('*') && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403 });
}
});
app.decorate('hasPermission', (employee, permissionCode) => {
const permissionCodes = employee.permissionCodes ?? [];
return permissionCodes.includes('*') || permissionCodes.includes(permissionCode);
});
app.decorate('config', {
erpSyncEnabled: true,
erpSyncProvider: 'mock',
erpMasterDataEndpoint: 'http://example.test/master-data',
erpJournalExportEndpoint: 'http://example.test/journal',
erpSyncEndpoint: 'http://example.test/journal',
erpMasterDataSyncCron: '0 * * * *',
});
app.decorate('prisma', {
featureFlag: {
findUnique: vi.fn().mockResolvedValue({
id: 'flag-erp',
flag_key: 'erp_sync',
override_mode: 'force_on',
rollout_type: 'global',
department_ids: [],
}),
},
integrationOutbox: {
findMany,
findUnique,
count,
update,
},
auditLog: {
create: auditCreate,
},
});
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 ?? null,
},
});
});
await app.register(integrationsRoutes, { prefix: '/api' });
return { app, findMany, findUnique, count, update, auditCreate };
}
describe('integrations outbox routes', () => {
it('lists outbox events with filters and payload summary', async () => {
const { app, findMany, count } = await buildApp();
const response = await app.inject({
method: 'GET',
url: '/api/integrations/outbox?page=2&pageSize=10&status=failed&event_type=erp.journal.export',
});
expect(response.statusCode).toBe(200);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: 'failed',
event_type: 'erp.journal.export',
},
orderBy: [{ created_at: 'desc' }],
skip: 10,
take: 10,
}),
);
expect(count).toHaveBeenCalledWith({
where: {
status: 'failed',
event_type: 'erp.journal.export',
},
});
const payload = response.json();
expect(payload.data.page).toBe(2);
expect(payload.data.items[0]).toMatchObject({
id: OUTBOX_ID,
eventType: 'erp.journal.export',
status: 'failed',
payloadSummary: {
entityType: 'reimbursement_form',
entityId: '22222222-2222-4222-8222-222222222222',
sourceEventName: 'reimbursement.paid',
journalType: 'reimbursement_paid',
exportType: null,
identityCode: 'BX-20260407-001',
},
});
await app.close();
});
it('returns stats with missing buckets defaulted to zero', async () => {
const { app } = await buildApp({
count: vi.fn().mockImplementation(async ({ where } = {}) => {
if (where?.status === 'succeeded') return 5;
return 0;
}),
});
const response = await app.inject({
method: 'GET',
url: '/api/integrations/outbox/stats',
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
pending: 0,
processing: 0,
succeeded: 5,
failed: 0,
deadLetter: 0,
});
await app.close();
});
it('returns outbox detail with full payload', async () => {
const { app, findUnique } = await buildApp();
const response = await app.inject({
method: 'GET',
url: `/api/integrations/outbox/${OUTBOX_ID}`,
});
expect(response.statusCode).toBe(200);
expect(findUnique).toHaveBeenCalledWith({ where: { id: OUTBOX_ID } });
expect(response.json().data.item.payload).toMatchObject({
documentNo: 'BX-20260407-001',
token: 'sanitized-value',
});
await app.close();
});
it('retries failed or dead-letter outbox events by resetting status to pending', async () => {
const { app, update, auditCreate } = await buildApp({
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'dead_letter', dead_letter_at: new Date('2026-04-07T09:40:00.000Z') })),
});
const response = await app.inject({
method: 'POST',
url: `/api/integrations/outbox/${OUTBOX_ID}/retry`,
payload: {},
});
expect(response.statusCode).toBe(200);
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: OUTBOX_ID },
data: expect.objectContaining({
status: 'pending',
attempts: 0,
last_error: null,
next_retry_at: expect.any(Date),
processed_at: null,
dead_letter_at: null,
updated_by: ACTOR_ID,
}),
}),
);
expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: 'ERP_OUTBOX_RETRIED',
target_type: 'integration_outbox',
target_id: OUTBOX_ID,
extra: expect.objectContaining({
previousStatus: 'dead_letter',
nextStatus: 'pending',
eventType: 'erp.journal.export',
outboxId: OUTBOX_ID,
}),
}),
}),
);
await app.close();
});
it('rejects retry for succeeded items with 409', async () => {
const { app, update, auditCreate } = await buildApp({
findUnique: vi.fn().mockResolvedValue(buildOutbox({ status: 'succeeded', last_error: null })),
});
const response = await app.inject({
method: 'POST',
url: `/api/integrations/outbox/${OUTBOX_ID}/retry`,
payload: {},
});
expect(response.statusCode).toBe(409);
expect(update).not.toHaveBeenCalled();
expect(auditCreate).not.toHaveBeenCalled();
await app.close();
});
it('blocks users without integration.manage', async () => {
const { app } = await buildApp({
actor: {
id: ACTOR_ID,
permissionCodes: [],
},
});
const response = await app.inject({
method: 'GET',
url: '/api/integrations/outbox',
});
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe('PERM_001');
await app.close();
});
});