Files
lislgosms/api/src/report-materials/report-materials.service.spec.ts
T

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');
}