539 行
14 KiB
JavaScript
539 行
14 KiB
JavaScript
const {
|
|
IntegrationDispatchError,
|
|
dispatchOutboxEvent,
|
|
} = require('../../src/services/integrations/dispatcher');
|
|
|
|
describe('dispatchOutboxEvent', () => {
|
|
const originalFetch = global.fetch;
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('handles mock ERP success', async () => {
|
|
const result = await dispatchOutboxEvent({
|
|
config: {
|
|
erpSyncEnabled: true,
|
|
erpSyncProvider: 'mock',
|
|
},
|
|
eventType: 'erp.reimbursement.paid',
|
|
payload: {
|
|
reimbursementId: 'rb-1',
|
|
formNumber: 'BX-001',
|
|
},
|
|
});
|
|
|
|
expect(result.channel).toBe('erp');
|
|
expect(result.code).toBe('mock_synced');
|
|
expect(result.response.externalId).toContain('BX-001');
|
|
});
|
|
|
|
it('records integration binding for reimbursement journal export', async () => {
|
|
const prisma = {
|
|
integrationBinding: {
|
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
|
},
|
|
};
|
|
|
|
const result = await dispatchOutboxEvent({
|
|
prisma,
|
|
config: {
|
|
erpSyncEnabled: true,
|
|
erpSyncProvider: 'mock',
|
|
},
|
|
eventType: 'erp.journal.export',
|
|
payload: {
|
|
journalType: 'reimbursement_paid',
|
|
entityType: 'reimbursement_form',
|
|
entityId: 'rb-1',
|
|
documentNo: 'BX-20260308-0001',
|
|
},
|
|
});
|
|
|
|
expect(result.channel).toBe('erp');
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'reimbursement_journal',
|
|
local_id: 'rb-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records commitment binding for procurement journal export', async () => {
|
|
const prisma = {
|
|
integrationBinding: {
|
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
|
},
|
|
};
|
|
|
|
await dispatchOutboxEvent({
|
|
prisma,
|
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
|
eventType: 'erp.journal.export',
|
|
payload: {
|
|
journalType: 'purchase_request_commitment',
|
|
entityType: 'purchase_request',
|
|
entityId: 'pr-1',
|
|
documentNo: 'PR-001',
|
|
references: { purchaseRequestId: 'pr-1', requestNo: 'PR-001' },
|
|
},
|
|
});
|
|
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'purchase_request_commitment_journal',
|
|
local_id: 'pr-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records accrual binding for procurement journal export', async () => {
|
|
const prisma = {
|
|
integrationBinding: {
|
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
|
},
|
|
};
|
|
|
|
await dispatchOutboxEvent({
|
|
prisma,
|
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
|
eventType: 'erp.journal.export',
|
|
payload: {
|
|
journalType: 'purchase_order_accrual',
|
|
entityType: 'purchase_order_archive',
|
|
entityId: 'archive-1',
|
|
documentNo: 'PO-001',
|
|
references: { purchaseOrderArchiveId: 'archive-1', poNo: 'PO-001' },
|
|
},
|
|
});
|
|
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'purchase_order_accrual_journal',
|
|
local_id: 'archive-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records PO and contract bindings for procurement summary export', async () => {
|
|
const prisma = {
|
|
integrationBinding: {
|
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }),
|
|
},
|
|
};
|
|
|
|
await dispatchOutboxEvent({
|
|
prisma,
|
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
|
eventType: 'erp.procurement.export',
|
|
payload: {
|
|
exportType: 'purchase_order_archive',
|
|
entityType: 'purchase_order_archive',
|
|
entityId: 'archive-1',
|
|
poNo: 'PO-001',
|
|
contractNo: 'CT-001',
|
|
},
|
|
});
|
|
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'purchase_order_po',
|
|
local_id: 'archive-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'purchase_order_contract',
|
|
local_id: 'archive-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records asset binding for asset export', async () => {
|
|
const prisma = {
|
|
integrationBinding: {
|
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-asset-1' }),
|
|
},
|
|
};
|
|
|
|
await dispatchOutboxEvent({
|
|
prisma,
|
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
|
eventType: 'erp.asset.export',
|
|
payload: {
|
|
exportType: 'asset_ledger_summary',
|
|
entityType: 'asset_card',
|
|
entityId: 'asset-1',
|
|
assetNo: 'ZC-001',
|
|
},
|
|
});
|
|
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'asset_erp_card',
|
|
local_id: 'asset-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records summary and settlement bindings for travel reconciliation export', async () => {
|
|
const prisma = {
|
|
integrationBinding: {
|
|
upsert: vi.fn().mockResolvedValue({ id: 'binding-travel-1' }),
|
|
},
|
|
};
|
|
|
|
await dispatchOutboxEvent({
|
|
prisma,
|
|
config: { erpSyncEnabled: true, erpSyncProvider: 'mock' },
|
|
eventType: 'erp.travel.export',
|
|
payload: {
|
|
exportType: 'travel_reconciliation_summary',
|
|
entityType: 'travel_reconciliation_batch',
|
|
entityId: 'batch-1',
|
|
batchNo: 'TRB-20260311-0001',
|
|
},
|
|
});
|
|
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'travel_payable_summary',
|
|
local_id: 'batch-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
where: {
|
|
provider_entity_type_local_id: {
|
|
provider: 'erp',
|
|
entity_type: 'travel_settlement_batch',
|
|
local_id: 'batch-1',
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads IM channel and dispatches mock wecom message', async () => {
|
|
const prisma = {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 'channel-1',
|
|
channel_type: 'wecom',
|
|
name: '采购经理群',
|
|
enabled: true,
|
|
config_json: {
|
|
mode: 'mock',
|
|
routing: {},
|
|
},
|
|
template_json: {
|
|
title: '{{title}}',
|
|
body: '{{body}}',
|
|
footer: '来自海音达工作台',
|
|
deepLinkLabel: '查看详情',
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
|
|
const result = await dispatchOutboxEvent({
|
|
prisma,
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'channel-1',
|
|
channelType: 'wecom',
|
|
notificationType: 'workflow.task_assigned',
|
|
title: '【待办】采购申请待审批',
|
|
body: '采购申请已流转到你,请及时处理。',
|
|
},
|
|
});
|
|
|
|
expect(prisma.notificationChannel.findUnique).toHaveBeenCalledWith({
|
|
where: { id: 'channel-1' },
|
|
select: {
|
|
id: true,
|
|
channel_type: true,
|
|
name: true,
|
|
enabled: true,
|
|
config_json: true,
|
|
template_json: true,
|
|
},
|
|
});
|
|
expect(result.channel).toBe('im');
|
|
expect(result.code).toBe('mock_sent');
|
|
expect(result.previewMessage).toContain('采购申请待审批');
|
|
});
|
|
|
|
it('safely skips missing IM channel', async () => {
|
|
const result = await dispatchOutboxEvent({
|
|
prisma: {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue(null),
|
|
},
|
|
},
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'missing-channel',
|
|
channelType: 'wecom',
|
|
},
|
|
});
|
|
|
|
expect(result.channel).toBe('im');
|
|
expect(result.code).toBe('channel_not_found');
|
|
expect(result.skipped).toBe(true);
|
|
});
|
|
|
|
it('safely skips disabled IM channel', async () => {
|
|
const result = await dispatchOutboxEvent({
|
|
prisma: {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 'channel-2',
|
|
channel_type: 'wecom',
|
|
name: 'disabled',
|
|
enabled: false,
|
|
config_json: { mode: 'mock', routing: {} },
|
|
template_json: null,
|
|
}),
|
|
},
|
|
},
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'channel-2',
|
|
channelType: 'wecom',
|
|
},
|
|
});
|
|
|
|
expect(result.code).toBe('channel_disabled');
|
|
expect(result.skipped).toBe(true);
|
|
});
|
|
|
|
it('maps wecom 4xx as non-retryable IM dispatch error', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: 'bad request' }),
|
|
headers: { get: () => 'application/json' },
|
|
});
|
|
|
|
await expect(
|
|
dispatchOutboxEvent({
|
|
prisma: {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 'channel-3',
|
|
channel_type: 'wecom',
|
|
name: 'wecom-4xx',
|
|
enabled: true,
|
|
config_json: {
|
|
mode: 'wecom_webhook',
|
|
webhookUrl: 'https://example.com/wecom',
|
|
timeoutMs: 500,
|
|
routing: {},
|
|
},
|
|
template_json: null,
|
|
}),
|
|
},
|
|
},
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'channel-3',
|
|
channelType: 'wecom',
|
|
title: 'test',
|
|
body: 'body',
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: 'IntegrationDispatchError',
|
|
code: 'wecom_http_4xx',
|
|
retryable: false,
|
|
});
|
|
});
|
|
|
|
it('maps wecom 5xx as retryable IM dispatch error', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 503,
|
|
json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: 'server error' }),
|
|
headers: { get: () => 'application/json' },
|
|
});
|
|
|
|
await expect(
|
|
dispatchOutboxEvent({
|
|
prisma: {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 'channel-4',
|
|
channel_type: 'wecom',
|
|
name: 'wecom-5xx',
|
|
enabled: true,
|
|
config_json: {
|
|
mode: 'wecom_webhook',
|
|
webhookUrl: 'https://example.com/wecom',
|
|
timeoutMs: 500,
|
|
routing: {},
|
|
},
|
|
template_json: null,
|
|
}),
|
|
},
|
|
},
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'channel-4',
|
|
channelType: 'wecom',
|
|
title: 'test',
|
|
body: 'body',
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: 'IntegrationDispatchError',
|
|
code: 'wecom_http_5xx',
|
|
retryable: true,
|
|
});
|
|
});
|
|
|
|
it('maps wecom timeout as retryable IM dispatch error', async () => {
|
|
global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' });
|
|
|
|
await expect(
|
|
dispatchOutboxEvent({
|
|
prisma: {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 'channel-5',
|
|
channel_type: 'wecom',
|
|
name: 'wecom-timeout',
|
|
enabled: true,
|
|
config_json: {
|
|
mode: 'wecom_webhook',
|
|
webhookUrl: 'https://example.com/wecom',
|
|
timeoutMs: 500,
|
|
routing: {},
|
|
},
|
|
template_json: null,
|
|
}),
|
|
},
|
|
},
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'channel-5',
|
|
channelType: 'wecom',
|
|
title: 'test',
|
|
body: 'body',
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: 'IntegrationDispatchError',
|
|
code: 'wecom_timeout',
|
|
retryable: true,
|
|
});
|
|
});
|
|
|
|
it('maps encrypted secret decrypt failure as non-retryable IM dispatch error', async () => {
|
|
await expect(
|
|
dispatchOutboxEvent({
|
|
prisma: {
|
|
notificationChannel: {
|
|
findUnique: vi.fn().mockResolvedValue({
|
|
id: 'channel-6',
|
|
channel_type: 'wecom',
|
|
name: 'wecom-secret-bad',
|
|
enabled: true,
|
|
config_json: {
|
|
mode: 'wecom_webhook',
|
|
webhookUrl: 'https://example.com/wecom',
|
|
secretEncrypted: 'v1:bad:payload:value',
|
|
timeoutMs: 500,
|
|
routing: {},
|
|
},
|
|
template_json: null,
|
|
}),
|
|
},
|
|
},
|
|
config: {
|
|
imNotifierEnabled: true,
|
|
fieldEncryptionKey: Buffer.alloc(32, 7).toString('base64'),
|
|
},
|
|
eventType: 'notification.im',
|
|
payload: {
|
|
channelId: 'channel-6',
|
|
channelType: 'wecom',
|
|
title: 'test',
|
|
body: 'body',
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: 'IntegrationDispatchError',
|
|
code: 'wecom_secret_decrypt_failed',
|
|
retryable: false,
|
|
});
|
|
});
|
|
|
|
it('throws error for unsupported event', async () => {
|
|
await expect(
|
|
dispatchOutboxEvent({
|
|
config: {},
|
|
eventType: 'unknown.event',
|
|
}),
|
|
).rejects.toThrow('unsupported_outbox_event:unknown.event');
|
|
});
|
|
});
|