汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
305 行
11 KiB
JavaScript
305 行
11 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, event_type: "notification.im" }),
|
|
...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('keeps retired ERP history readable but refuses retry and removed endpoints', async () => {
|
|
const { app, update, auditCreate } = await buildApp();
|
|
const read = await app.inject({ method: 'GET', url: `/api/integrations/outbox/${OUTBOX_ID}` });
|
|
expect(read.statusCode).toBe(200);
|
|
const retry = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry`, payload: {} });
|
|
expect(retry.statusCode).toBe(409);
|
|
expect(retry.json().error.details.reasonCode).toBe('integration.erp_retired');
|
|
expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled();
|
|
const removed = await app.inject({ method: 'POST', url: `/api/integrations/erp/sync/${OUTBOX_ID}`, payload: {} });
|
|
expect(removed.statusCode).toBe(404);
|
|
await app.close();
|
|
});
|
|
|
|
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({ event_type: 'notification.im', 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: 'INTEGRATION_OUTBOX_RETRIED',
|
|
target_type: 'integration_outbox',
|
|
target_id: OUTBOX_ID,
|
|
extra: expect.objectContaining({
|
|
previousStatus: 'dead_letter',
|
|
nextStatus: 'pending',
|
|
eventType: 'notification.im',
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('shared outbox operation reasons', () => {
|
|
it.each([['GET', '/integrations/outbox?page=0', 'integration.outbox_query_invalid'], ['GET', '/integrations/outbox/bad', 'integration.outbox_id_invalid'], ['POST', '/integrations/outbox/bad/retry', 'integration.outbox_id_invalid']])('rejects %s %s before reads or mutations', async (method, path, reasonCode) => { const { app, findMany, findUnique, update, auditCreate } = await buildApp(); try { const r = await app.inject({ method, url: `/api${path}` }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe(reasonCode); expect(findMany).not.toHaveBeenCalled(); expect(findUnique).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } });
|
|
it.each(['pending', 'processing', 'succeeded'])('keeps non-retryable notification state %s', async (status) => { const { app, update, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(buildOutbox({ event_type: 'notification.im', status })) }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ outbox: 'integration_outbox_retry_status_invalid', status, reasonCode: 'integration.retry_state_invalid' }); expect(update).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } });
|
|
it.each(['failed', 'dead_letter'])('retains notification %s retry as pending without delivery', async (status) => { const before = buildOutbox({ event_type: 'notification.im', status, payload: { channelId: 'synthetic-channel' } }); const update = vi.fn(async ({ data }) => ({ ...before, ...data })); const { app, auditCreate } = await buildApp({ findUnique: vi.fn().mockResolvedValue(before), update }); try { const r = await app.inject({ method: 'POST', url: `/api/integrations/outbox/${OUTBOX_ID}/retry` }); expect(r.statusCode).toBe(200); expect(update.mock.calls[0][0].data).toMatchObject({ status: 'pending', attempts: 0, last_error: null }); expect(auditCreate).toHaveBeenCalledOnce(); } finally { await app.close(); } });
|
|
});
|