feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user