fix: reassemble inbound CMPP long messages
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
@@ -58,7 +59,7 @@ function createPrismaMock() {
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
|
||||
},
|
||||
};
|
||||
return {
|
||||
const prisma = {
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
@@ -160,6 +161,16 @@ function createPrismaMock() {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
cmppInboundLongMessage: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
cmppInboundLongMessageSegment: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
|
||||
findMany: jest.fn().mockImplementation(({ where }) => Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId })))),
|
||||
@@ -301,8 +312,13 @@ function createPrismaMock() {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ dailyLimit: 100000, usedCount: 2 }]),
|
||||
$transaction: jest.fn((operations) => Promise.all(operations)),
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((operations: any) => typeof operations === 'function'
|
||||
? operations(prisma)
|
||||
: Promise.all(operations));
|
||||
return prisma;
|
||||
}
|
||||
|
||||
function createService(prisma = createPrismaMock()) {
|
||||
@@ -850,6 +866,349 @@ describe('SendChainService', () => {
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
const segments: Array<Record<string, any>> = [];
|
||||
const group = {
|
||||
id: 'long-group-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 16,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-1',
|
||||
status: 'collecting',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: new Date(),
|
||||
segments,
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve(
|
||||
segments.length ? { ...group, segments: [...segments] } : null,
|
||||
));
|
||||
prisma.cmppInboundLongMessage.create.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => {
|
||||
const segment = { id: `segment-${data.segmentIndex}`, ...data };
|
||||
segments.push(segment);
|
||||
return Promise.resolve(segment);
|
||||
});
|
||||
prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve(
|
||||
[...segments].sort((a, b) => a.segmentIndex - b.segmentIndex),
|
||||
));
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data);
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
|
||||
const first = await service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '【签名】第一片',
|
||||
sequenceId: 101,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 16, total: 2, index: 1, format: 8 },
|
||||
});
|
||||
expect(first).toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
fragmentPending: true,
|
||||
messageId: 'MSG-LONG-1',
|
||||
receivedSegments: 1,
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
|
||||
const second = await service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 102,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 16, total: 2, index: 2, format: 8 },
|
||||
});
|
||||
expect(second).toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-1',
|
||||
messageRecordId: 'record-1',
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ content: '【签名】第一片第二片正文', phoneTotal: 1 }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
cmppSubmitSequenceId: '101',
|
||||
cmppSubmitGroupMessageId: 'MSG-LONG-1',
|
||||
}),
|
||||
});
|
||||
expect(billing.estimateSmsCost).toHaveBeenCalledWith(expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
phoneCount: 1,
|
||||
}));
|
||||
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'long-group-1' },
|
||||
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts out-of-order and duplicate CMPP long-message fragments but rejects conflicting duplicates', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
const segments: Array<Record<string, any>> = [];
|
||||
const group = {
|
||||
id: 'long-group-2',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key-2',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 17,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-2',
|
||||
status: 'collecting',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: new Date(),
|
||||
segments,
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockImplementation(() => Promise.resolve(
|
||||
segments.length ? { ...group, segments: [...segments] } : null,
|
||||
));
|
||||
prisma.cmppInboundLongMessage.create.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessageSegment.create.mockImplementation(({ data }: { data: any }) => {
|
||||
const segment = { id: `segment-${data.segmentIndex}`, ...data };
|
||||
segments.push(segment);
|
||||
return Promise.resolve(segment);
|
||||
});
|
||||
prisma.cmppInboundLongMessageSegment.findMany.mockImplementation(() => Promise.resolve(
|
||||
[...segments].sort((a, b) => a.segmentIndex - b.segmentIndex),
|
||||
));
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data);
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
|
||||
const secondFragment = {
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 202,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 17, total: 2, index: 2, format: 8 },
|
||||
};
|
||||
await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({
|
||||
fragmentPending: true,
|
||||
receivedSegments: 1,
|
||||
}));
|
||||
await expect(service.submitInboundMessage(secondFragment)).resolves.toEqual(expect.objectContaining({
|
||||
fragmentPending: true,
|
||||
receivedSegments: 1,
|
||||
}));
|
||||
expect(prisma.cmppInboundLongMessageSegment.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
...secondFragment,
|
||||
content: '冲突的第二片',
|
||||
})).rejects.toThrow('fragment 2 conflicts');
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '【签名】第一片',
|
||||
sequenceId: 201,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 17, total: 2, index: 1, format: 8 },
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-2',
|
||||
messageRecordId: 'record-1',
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
cmppSubmitSequenceId: '201',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes a persistently complete CMPP long message after processing is interrupted by a restart', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
|
||||
const staleAt = new Date(Date.now() - 60_000);
|
||||
const group = {
|
||||
id: 'long-group-restart',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key-restart',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 18,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-RESTART',
|
||||
status: 'processing',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: staleAt,
|
||||
updatedAt: staleAt,
|
||||
segments: [
|
||||
{
|
||||
id: 'segment-restart-1',
|
||||
groupId: 'long-group-restart',
|
||||
segmentIndex: 1,
|
||||
sequenceId: '301',
|
||||
content: '【签名】第一片',
|
||||
contentHash: createHash('sha256').update('【签名】第一片').digest('hex'),
|
||||
},
|
||||
{
|
||||
id: 'segment-restart-2',
|
||||
groupId: 'long-group-restart',
|
||||
segmentIndex: 2,
|
||||
sequenceId: '302',
|
||||
content: '第二片正文',
|
||||
contentHash: createHash('sha256').update('第二片正文').digest('hex'),
|
||||
},
|
||||
],
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data, { updatedAt: new Date() });
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 302,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 18, total: 2, index: 2, format: 8 },
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-RESTART',
|
||||
messageRecordId: 'record-1',
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
content: '【签名】第一片第二片正文',
|
||||
cmppSubmitSequenceId: '301',
|
||||
cmppSubmitGroupMessageId: 'MSG-LONG-RESTART',
|
||||
}),
|
||||
});
|
||||
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'long-group-restart' },
|
||||
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers the persisted SMS result after a restart without creating a duplicate main record', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const staleAt = new Date(Date.now() - 60_000);
|
||||
const group = {
|
||||
id: 'long-group-after-record',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupKey: 'group-key-after-record',
|
||||
account: '100001',
|
||||
srcId: null,
|
||||
phoneNumbers: ['13800000001'],
|
||||
concatReference: 19,
|
||||
segmentTotal: 2,
|
||||
msgFmt: 8,
|
||||
messageId: 'MSG-LONG-AFTER-RECORD',
|
||||
status: 'processing',
|
||||
response: null,
|
||||
expiresAt: new Date(Date.now() + 300_000),
|
||||
createdAt: staleAt,
|
||||
updatedAt: staleAt,
|
||||
segments: [
|
||||
{
|
||||
id: 'segment-after-record-1',
|
||||
groupId: 'long-group-after-record',
|
||||
segmentIndex: 1,
|
||||
sequenceId: '401',
|
||||
content: '【签名】第一片',
|
||||
contentHash: createHash('sha256').update('【签名】第一片').digest('hex'),
|
||||
},
|
||||
{
|
||||
id: 'segment-after-record-2',
|
||||
groupId: 'long-group-after-record',
|
||||
segmentIndex: 2,
|
||||
sequenceId: '402',
|
||||
content: '第二片正文',
|
||||
contentHash: createHash('sha256').update('第二片正文').digest('hex'),
|
||||
},
|
||||
],
|
||||
};
|
||||
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue(group);
|
||||
prisma.cmppInboundLongMessage.update.mockImplementation(({ data }: { data: any }) => {
|
||||
Object.assign(group, data, { updatedAt: new Date() });
|
||||
return Promise.resolve({ ...group });
|
||||
});
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
||||
id: 'persisted-record-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
batchTaskId: 'persisted-task-1',
|
||||
messageId: 'MSG-LONG-AFTER-RECORD',
|
||||
phoneNumber: '13800000001',
|
||||
status: 'failed',
|
||||
errorCode: 'SIGNATURE',
|
||||
}]);
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: '第二片正文',
|
||||
sequenceId: 402,
|
||||
remoteIp: '127.0.0.1',
|
||||
longMessage: { reference: 19, total: 2, index: 2, format: 8 },
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
accepted: true,
|
||||
messageId: 'MSG-LONG-AFTER-RECORD',
|
||||
messageRecordId: 'persisted-record-1',
|
||||
taskId: 'persisted-task-1',
|
||||
}));
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.cmppInboundLongMessage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'long-group-after-record' },
|
||||
data: expect.objectContaining({ status: 'completed', response: expect.any(Object) }),
|
||||
});
|
||||
});
|
||||
|
||||
it('expires incomplete or interrupted CMPP long-message groups without creating SMS records', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const now = new Date('2026-07-23T12:00:00.000Z');
|
||||
prisma.cmppInboundLongMessage.updateMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
await expect(service.expireInboundLongMessages(now)).resolves.toEqual({ count: 2 });
|
||||
expect(prisma.cmppInboundLongMessage.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: { in: ['collecting', 'processing'] },
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: {
|
||||
status: 'expired',
|
||||
completedAt: now,
|
||||
},
|
||||
});
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the whole CMPP Submit synchronously while keeping per-destination audit records when the daily limit is exceeded', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
||||
|
||||
Reference in New Issue
Block a user