feat: filter SMS routes by channel sensitive words

This commit is contained in:
hectorzhao
2026-09-10 15:50:56 +08:00
parent 86947827cc
commit 8e4bc5a20e
26 changed files with 1615 additions and 41 deletions
@@ -4,6 +4,7 @@ import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
import { DrainageRejection } from './drainage-authorization';
import { ChannelWordRejection } from './channel-sensitive-routing';
function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -77,6 +78,8 @@ function createPrismaMock() {
},
};
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
@@ -498,6 +501,81 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
}
describe('SendChainService', () => {
it('recovers non-CMPP channel-word finalization without pushing a receipt', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findMany.mockResolvedValue([
{ ...message, channelWordFinalizationPending: true, batchTask: { sourceType: 'client' } },
]);
service['releaseMessageReservation'] = jest.fn();
service['recordCmppFailureReceipt'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.recoverDrainageFailureReceipts();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
expect(service['refreshTaskProgress']).toHaveBeenCalledWith('task-1');
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it.each(['cmpp', 'client', 'http'])(
'fails an all-hit ordinary route without supplier submit (%s)',
async (sourceType) => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...message, batchTask: { id: 'task-1', sourceType } });
service['selectChannelForMessage'] = jest.fn().mockRejectedValue(new ChannelWordRejection());
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
if (sourceType === 'cmpp')
expect(service['recordCmppFailureReceipt']).toHaveBeenCalledWith(
expect.anything(),
'CSW',
'可用通道均命中通道敏感词',
);
else expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ channelWordFinalizationPending: true, drainageReceiptPending: false }),
}),
);
},
);
it('recovers channel-word delivery intent from an existing receipt and keeps pending on failure', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
const message = {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppRegisteredDelivery: true,
cmppSubmitSequenceId: '101',
};
service['queueAndTryDownstreamDelivery'] = jest
.fn()
.mockRejectedValueOnce(Error('persistence failed'))
.mockResolvedValue({ id: 'delivery' });
await expect(service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词')).rejects.toThrow(
'persistence failed',
);
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { channelWordFinalizationPending: false } }),
);
await service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });