文件
hyb-oa/server/tests/unit/routes/integrations.bindings.test.js
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

201 行
6.2 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';
function buildBinding(overrides = {}) {
return {
id: '11111111-1111-4111-8111-111111111111',
provider: 'erp',
entity_type: 'purchase_order_po',
local_id: '22222222-2222-4222-8222-222222222222',
external_id: 'ERP-PO-001',
external_code: 'PO-001',
last_synced_at: new Date('2026-04-07T10:00:00.000Z'),
created_at: new Date('2026-04-07T09:00: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([
buildBinding(),
buildBinding({
id: '33333333-3333-4333-8333-333333333333',
entity_type: 'reimbursement_journal',
local_id: '44444444-4444-4444-8444-444444444444',
external_id: 'ERP-BX-001',
external_code: 'BX-001',
}),
]);
const count = options.count ?? vi.fn().mockResolvedValue(2);
const archiveFindMany = options.archiveFindMany ?? vi.fn().mockResolvedValue([
{
id: '22222222-2222-4222-8222-222222222222',
purchase_request_id: '55555555-5555-4555-8555-555555555555',
},
]);
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: [],
}),
},
integrationBinding: {
findMany,
count,
},
purchaseOrderArchive: {
findMany: archiveFindMany,
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
});
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, count, archiveFindMany };
}
describe('integrations binding routes', () => {
it('lists bindings with filters and route paths', async () => {
const { app, findMany, count, archiveFindMany } = await buildApp();
const response = await app.inject({
method: 'GET',
url: '/api/integrations/bindings?page=2&pageSize=10&provider=erp&entity_type=purchase_order_po',
});
expect(response.statusCode).toBe(200);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
provider: 'erp',
entity_type: 'purchase_order_po',
},
orderBy: [{ last_synced_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }],
skip: 10,
take: 10,
}),
);
expect(count).toHaveBeenCalledWith({
where: {
provider: 'erp',
entity_type: 'purchase_order_po',
},
});
expect(archiveFindMany).toHaveBeenCalledWith({
where: {
id: {
in: ['22222222-2222-4222-8222-222222222222'],
},
},
select: {
id: true,
purchase_request_id: true,
},
});
const payload = response.json();
expect(payload.data.items[0]).toMatchObject({
entityType: 'purchase_order_po',
localId: '22222222-2222-4222-8222-222222222222',
routePath: '/purchase-requests?id=55555555-5555-4555-8555-555555555555',
});
expect(payload.data.items[1]).toMatchObject({
entityType: 'reimbursement_journal',
routePath: '/reimbursements/44444444-4444-4444-8444-444444444444',
});
await app.close();
});
it('returns null routePath for unsupported entity types', async () => {
const { app } = await buildApp({
findMany: vi.fn().mockResolvedValue([
buildBinding({
entity_type: 'travel_payable_summary',
local_id: '66666666-6666-4666-8666-666666666666',
}),
]),
count: vi.fn().mockResolvedValue(1),
archiveFindMany: vi.fn().mockResolvedValue([]),
});
const response = await app.inject({
method: 'GET',
url: '/api/integrations/bindings',
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0].routePath).toBe(null);
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/bindings',
});
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe('PERM_001');
await app.close();
});
});
it('explains invalid binding filters without reads', async () => { const { app, findMany, count } = await buildApp(); try { const r = await app.inject({ url: '/api/integrations/bindings?page=0' }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe('integration.bindings_query_invalid'); expect(findMany).not.toHaveBeenCalled(); expect(count).not.toHaveBeenCalled(); } finally { await app.close(); } });