291 lines
20 KiB
TypeScript
291 lines
20 KiB
TypeScript
import ExcelJS from 'exceljs';
|
|
import { ReportMaterialsService } from './report-materials.service';
|
|
|
|
describe('ReportMaterialsService', () => {
|
|
it('builds an official XLSX import template with documented signature columns', async () => {
|
|
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) };
|
|
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
|
|
const exported = await service.buildOfficialTemplate('signature', 'operator-1');
|
|
const workbook = new ExcelJS.Workbook();
|
|
await workbook.xlsx.load(exported.content as never);
|
|
expect(exported.fileName).toContain('签名报备资料官方模板');
|
|
expect(workbook.worksheets[0].getRow(1).values).toEqual(expect.arrayContaining(['短信签名', '用途说明']));
|
|
expect(operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'operator-1' }) });
|
|
});
|
|
|
|
it('builds a drainage template without a separate site-name column', async () => {
|
|
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-drainage-template' }) };
|
|
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
|
|
const exported = await service.buildOfficialTemplate('drainage', 'operator-1');
|
|
const workbook = new ExcelJS.Workbook();
|
|
await workbook.xlsx.load(exported.content as never);
|
|
const headers = workbook.worksheets[0].getRow(1).values;
|
|
expect(headers).toEqual(expect.arrayContaining(['短信签名', '引流 URL 或号码', '备注', '主体证明']));
|
|
expect(headers).not.toEqual(expect.arrayContaining(['站点名称']));
|
|
});
|
|
|
|
it('rejects formula cells before storing or importing a workbook', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('签名资料');
|
|
sheet.addRow(['短信签名']);
|
|
sheet.getCell('A2').value = { formula: 'HYPERLINK("https://invalid.example","click")', result: 'click' };
|
|
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
const files = { upload: jest.fn() };
|
|
const service = new ReportMaterialsService({ reportMaterialImportProfile: { findUnique: jest.fn() } } as never, files as never, {} as never);
|
|
|
|
await expect(service.analyzeImport(
|
|
{ originalname: 'unsafe.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer },
|
|
{ tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 },
|
|
)).rejects.toThrow('公式或可执行单元格');
|
|
expect(files.upload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('detects WPS-compatible embedded images and source columns during XLSX analysis', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('签名资料');
|
|
sheet.addRow(['短信签名', '营业执照']);
|
|
sheet.addRow(['测试签名', '']);
|
|
const imageId = workbook.addImage({ base64: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', extension: 'png' });
|
|
sheet.addImage(imageId, { tl: { col: 1, row: 1 }, ext: { width: 80, height: 60 } });
|
|
const buffer = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
const prisma = {
|
|
reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) },
|
|
reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-1', ...data })) },
|
|
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
|
|
};
|
|
const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
|
|
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
|
|
|
const result = await service.analyzeImport({ originalname: '签名资料.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, { tenantId: 'tenant-1', applicationId: 'app-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2, profileId: 'profile-1' });
|
|
|
|
expect(result.imageCount).toBe(1);
|
|
expect(result.columns).toEqual(expect.arrayContaining([expect.objectContaining({ sourceHeader: '营业执照', imageCount: 1 })]));
|
|
expect(result.rows).toEqual([expect.objectContaining({ rowNumber: 2, imageColumns: [2] })]);
|
|
expect(result.suggestedMappings).toEqual([expect.objectContaining({ sourceColumnIndex: 1, targetKind: 'signatureName' })]);
|
|
});
|
|
|
|
it('expands one selected signature to every routed channel and embeds images in each XLSX', async () => {
|
|
const uploadedWorkbooks: Buffer[] = [];
|
|
let batchItemSequence = 0;
|
|
let exportSequence = 0;
|
|
const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }];
|
|
const prisma = {
|
|
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-1' }), update: jest.fn().mockResolvedValue({}) },
|
|
reportMaterialBatch: {
|
|
create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }),
|
|
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })),
|
|
},
|
|
smsSignature: {
|
|
findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', pendingReport: true, materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用', status: 'active' } }),
|
|
update: jest.fn().mockResolvedValue({}),
|
|
},
|
|
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) },
|
|
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) },
|
|
smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) },
|
|
channelReportField: { findMany: jest.fn().mockResolvedValue([
|
|
{ code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
|
|
{ code: 'license', name: '营业执照', exportName: '营业执照图片', required: true, columnWidth: 24, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null },
|
|
]) },
|
|
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `task-${String(data.channelId)}`, ...data })), update: jest.fn() },
|
|
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
|
|
reportExportFile: { create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: `export-${++exportSequence}`, ...data })) },
|
|
reportExportFileItem: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
|
};
|
|
const files = {
|
|
getDownload: jest.fn().mockResolvedValue({ fileObject: { fileName: 'license.png', contentType: 'image/png' }, content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZsmAAAAAASUVORK5CYII=', 'base64') }),
|
|
upload: jest.fn().mockImplementation((_options: unknown, file: { originalname: string; mimetype: string; buffer: Buffer }) => {
|
|
uploadedWorkbooks.push(file.buffer);
|
|
return Promise.resolve({ id: `file-${uploadedWorkbooks.length}`, fileName: file.originalname, contentType: file.mimetype });
|
|
}),
|
|
};
|
|
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
|
|
|
const result = await service.createBatch({ idempotencyKey: 'report-batch:test-1', items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }] });
|
|
|
|
expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 });
|
|
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2);
|
|
expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'signature-1' }, data: { pendingReport: false } });
|
|
expect(uploadedWorkbooks).toHaveLength(2);
|
|
for (const buffer of uploadedWorkbooks) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
await workbook.xlsx.load(buffer as never);
|
|
const sheet = workbook.getWorksheet('签名报备');
|
|
expect(sheet?.getCell('A1').text).toBe('通道签名');
|
|
expect(sheet?.getCell('A2').text).toBe('测试签名');
|
|
expect(sheet?.getImages()).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => {
|
|
const prisma = {
|
|
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-2' }), update: jest.fn().mockResolvedValue({}) },
|
|
reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve(data)) },
|
|
smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', pendingReport: true, materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用', status: 'active' } }), update: jest.fn() },
|
|
smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() },
|
|
channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) },
|
|
reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) },
|
|
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) },
|
|
channelReportField: { findMany: jest.fn().mockResolvedValue([]) },
|
|
channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'task-2', ...data })) },
|
|
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({}) },
|
|
reportExportFile: { create: jest.fn().mockResolvedValue({ id: 'export-2' }) },
|
|
reportExportFileItem: { createMany: jest.fn() },
|
|
};
|
|
const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) };
|
|
const service = new ReportMaterialsService(prisma as never, files as never, {} as never);
|
|
|
|
await expect(service.createBatch({ idempotencyKey: 'report-batch:test-2', items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }] }))
|
|
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) });
|
|
|
|
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
|
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reuses a completed operation for the same idempotency key without generating a second batch', async () => {
|
|
const prisma = {
|
|
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
operationLog: { findFirst: jest.fn().mockResolvedValue({ id: 'operation-existing', detail: { status: 'completed', fingerprint: expect.anything(), result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }) },
|
|
reportMaterialBatch: { create: jest.fn() },
|
|
};
|
|
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
|
const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }];
|
|
const fingerprint = createFingerprint(items);
|
|
prisma.operationLog.findFirst.mockResolvedValueOnce({ id: 'operation-existing', detail: { status: 'completed', fingerprint, result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } });
|
|
|
|
await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({ id: 'batch-existing', replayed: true, operationId: 'operation-existing' });
|
|
expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stages imported signatures for review without changing or approving business data', async () => {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('签名资料');
|
|
sheet.addRow(['短信签名', '用途说明']);
|
|
sheet.addRow(['待审签名', '验证码']);
|
|
const content = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
const stagedRows: Array<Record<string, unknown>> = [];
|
|
const prisma = {
|
|
reportMaterialImportBatch: {
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'import-review-1',
|
|
tenantId: 'tenant-1',
|
|
applicationId: 'app-1',
|
|
fileObjectId: 'source-1',
|
|
fileName: '签名资料.xlsx',
|
|
reportType: 'signature',
|
|
status: 'analyzed',
|
|
sheetName: '签名资料',
|
|
dataStartRow: 2,
|
|
}),
|
|
update: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => Promise.resolve({ id: 'import-review-1', ...data, items: stagedRows })),
|
|
},
|
|
reportMaterialImportItem: {
|
|
createMany: jest.fn().mockImplementation(({ data }: { data: Array<Record<string, unknown>> }) => {
|
|
stagedRows.push(...data);
|
|
return Promise.resolve({ count: data.length });
|
|
}),
|
|
},
|
|
smsSignature: { findFirst: jest.fn().mockResolvedValue(null) },
|
|
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-import-review' }) },
|
|
$transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)),
|
|
};
|
|
const files = { getDownload: jest.fn().mockResolvedValue({ content }) };
|
|
const smsConfig = {
|
|
createSignature: jest.fn(),
|
|
updateSignature: jest.fn(),
|
|
approveSignature: jest.fn(),
|
|
};
|
|
const service = new ReportMaterialsService(prisma as never, files as never, smsConfig as never);
|
|
|
|
const result = await service.commitImport('import-review-1', {
|
|
operatorId: 'operator-1',
|
|
mappings: [
|
|
{ sourceHeader: '短信签名', sourceColumnIndex: 1, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true },
|
|
{ sourceHeader: '用途说明', sourceColumnIndex: 2, targetFieldCode: 'purpose', targetKind: 'purpose', fieldType: 'string' },
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchObject({ status: 'pending_review', successCount: 1, failedCount: 0 });
|
|
expect(stagedRows).toEqual([expect.objectContaining({
|
|
rowNumber: 2,
|
|
reportType: 'signature',
|
|
operation: 'create',
|
|
status: 'pending_review',
|
|
payload: expect.objectContaining({ name: '待审签名', purpose: '验证码' }),
|
|
})]);
|
|
expect(smsConfig.createSignature).not.toHaveBeenCalled();
|
|
expect(smsConfig.updateSignature).not.toHaveBeenCalled();
|
|
expect(smsConfig.approveSignature).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows rejecting selected imported rows without requiring a reason', async () => {
|
|
const prisma = {
|
|
reportMaterialImportBatch: {
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: 'import-review-2',
|
|
items: [{ id: 'item-1', rowNumber: 2, status: 'pending_review' }],
|
|
}),
|
|
update: jest.fn().mockResolvedValue({}),
|
|
},
|
|
reportMaterialImportItem: {
|
|
update: jest.fn().mockResolvedValue({}),
|
|
groupBy: jest.fn().mockResolvedValue([{ status: 'rejected', _count: { _all: 1 } }]),
|
|
},
|
|
};
|
|
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
|
|
|
await expect(service.reviewImportItems('import-review-2', {
|
|
decision: 'reject',
|
|
itemIds: ['item-1'],
|
|
reviewerId: 'reviewer-1',
|
|
})).resolves.toMatchObject({ status: 'rejected', rejectedCount: 1, failedCount: 0 });
|
|
expect(prisma.reportMaterialImportItem.update).toHaveBeenCalledWith({
|
|
where: { id: 'item-1' },
|
|
data: expect.objectContaining({ status: 'rejected', reviewReason: undefined, reviewedById: 'reviewer-1' }),
|
|
});
|
|
});
|
|
|
|
it('calculates generated batch totals and success rate from per-channel report tasks', async () => {
|
|
const prisma = {
|
|
reportMaterialBatch: {
|
|
findMany: jest.fn().mockResolvedValue([{
|
|
id: 'batch-stats-1',
|
|
batchNo: 'RB-STATS-1',
|
|
exportFiles: [
|
|
{ items: [{ task: { id: 'task-1', status: 'approved' } }, { task: { id: 'task-2', status: 'rejected' } }] },
|
|
{ items: [{ task: { id: 'task-3', status: 'approved' } }] },
|
|
],
|
|
items: [],
|
|
}]),
|
|
count: jest.fn().mockResolvedValue(1),
|
|
},
|
|
};
|
|
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
|
|
|
await expect(service.listBatches({ keyword: 'RB-STATS', page: 2, pageSize: 10 })).resolves.toMatchObject({
|
|
items: [{ id: 'batch-stats-1', reportTotal: 3, successCount: 2, successRate: 2 / 3 }],
|
|
total: 1,
|
|
page: 2,
|
|
pageSize: 10,
|
|
});
|
|
expect(prisma.reportMaterialBatch.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 10, take: 10 }));
|
|
});
|
|
|
|
it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => {
|
|
const prisma = { smsSignature: { findUnique: jest.fn() } };
|
|
const service = new ReportMaterialsService(prisma as never, {} as never, {} as never);
|
|
await expect(service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] })).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) });
|
|
expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) {
|
|
const { createHash } = require('node:crypto') as typeof import('node:crypto');
|
|
return createHash('sha256').update(JSON.stringify(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex');
|
|
}
|