feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
+91 -2
View File
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
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';
function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -134,7 +135,18 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([
{
id: 'url',
code: 'URL',
name: 'URL',
category: 'url',
pattern: '[a-z]+\\.[a-z]+',
flags: 'giu',
priority: 1,
version: 1,
},
]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
@@ -158,6 +170,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
count: jest.fn().mockResolvedValue(1),
findUnique: jest.fn().mockResolvedValue(message),
findUniqueOrThrow: jest.fn().mockResolvedValue(message),
findFirst: jest.fn().mockResolvedValue(message),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
@@ -233,6 +246,7 @@ function createPrismaMock() {
Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))),
),
},
smsDrainageDecision: { create: jest.fn().mockResolvedValue({ id: 'decision-1' }) },
smsReceiptRecord: {
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }),
@@ -484,6 +498,79 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
}
describe('SendChainService', () => {
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
service['queueAndTryDownstreamDelivery'] = jest.fn().mockResolvedValue({ id: 'delivery' });
service['refreshTaskProgress'] = jest.fn().mockResolvedValue({});
await service['recordCmppFailureReceipt'](
{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitSequenceId: '101',
},
'DRN',
'未报备',
);
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(service['queueAndTryDownstreamDelivery']).toHaveBeenCalledWith(
expect.objectContaining({ queueCmppDelivery: true, receiptDedupeKey: 'receipt:record-1' }),
);
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { drainageReceiptPending: false },
});
});
it('keeps durable recovery pending when drainage receipt intent persistence fails', async () => {
const { service, prisma } = createService();
service['queueAndTryDownstreamDelivery'] = jest.fn().mockRejectedValue(new Error('queue persistence unavailable'));
await expect(
service['recordCmppFailureReceipt'](
{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitSequenceId: '101',
},
'DRN',
'未报备',
),
).rejects.toThrow('queue persistence unavailable');
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { drainageReceiptPending: false } }),
);
});
it('does not push any drainage rejection receipt for a non-CMPP submission', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({
...message,
batchTask: { id: 'task-1', sourceType: 'client' },
});
service['selectChannelForMessage'] = jest
.fn()
.mockRejectedValue(new DrainageRejection('DRAINAGE_NOT_REGISTERED', '未报备'));
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'failed',
errorCode: 'DRAINAGE_NOT_REGISTERED',
drainageReceiptPending: false,
}),
}),
);
});
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
const { service, prisma, riskReview, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
@@ -3051,7 +3138,9 @@ describe('SendChainService', () => {
expect(service['identifyCarrier']).not.toHaveBeenCalled();
expect(service['identifyProvince']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carrier: expect.any(String) }) }),
);
});
it('updates submit result status, charges billing, and task progress', async () => {