fix: enforce carrier-specific channel group routing
This commit is contained in:
@@ -17,7 +17,10 @@ function createPrismaMock() {
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
status: 'queued',
|
||||
template: { signature: { name: '签名' } },
|
||||
submitId: 'SUB-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
channelId: 'channel-1',
|
||||
template: { signature: { id: 'sig-1', name: '签名' } },
|
||||
};
|
||||
const channel = {
|
||||
id: 'channel-1',
|
||||
@@ -42,10 +45,11 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
group: {
|
||||
id: 'group-1',
|
||||
carrier: 'mobile',
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 72,
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', priority: 1, province: null, channel }],
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
|
||||
},
|
||||
};
|
||||
return {
|
||||
@@ -53,7 +57,7 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active' }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3 }),
|
||||
},
|
||||
smsTemplate: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
@@ -104,6 +108,9 @@ function createPrismaMock() {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }),
|
||||
},
|
||||
smsReceiptRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
|
||||
findMany: jest.fn(),
|
||||
@@ -118,6 +125,9 @@ function createPrismaMock() {
|
||||
update: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
accountTransaction: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
@@ -342,13 +352,17 @@ describe('SendChainService', () => {
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
id: 'route-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: { id: 'group-1', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] },
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] },
|
||||
});
|
||||
|
||||
await service.handleSubmitResult({
|
||||
@@ -369,6 +383,127 @@ describe('SendChainService', () => {
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
});
|
||||
|
||||
it('does not let stale failed receipts overwrite a later delivered message', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
channelId: 'channel-new',
|
||||
gatewayMessageId: 'GW-NEW',
|
||||
status: 'delivered',
|
||||
amountCents: 3,
|
||||
billingUnits: 1,
|
||||
unitPrice: 3,
|
||||
});
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-old',
|
||||
gatewayMessageId: 'GW-OLD',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
});
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ channelId: 'channel-old', gatewayMessageId: 'GW-OLD', receiptStatus: 'undelivered' }),
|
||||
});
|
||||
expect(billing.refund).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks submit when signature is not approved on the selected channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
prisma.channelSignatureReportTask.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ submitted: false, status: 'failed', reason: '短信签名未在最终通道报备通过' }),
|
||||
);
|
||||
expect(gatewayAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only selects channel group items allocated to the matched carrier', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
id: 'route-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: {
|
||||
id: 'group-1',
|
||||
carrier: 'mobile',
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 72,
|
||||
items: [
|
||||
{
|
||||
id: 'wrong-item',
|
||||
groupId: 'group-1',
|
||||
channelId: 'channel-unicom',
|
||||
carrier: 'unicom',
|
||||
priority: 1,
|
||||
province: null,
|
||||
channel: {
|
||||
id: 'channel-unicom',
|
||||
code: 'CMPP-U',
|
||||
account: 'u',
|
||||
srcId: '1061',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mobile-item',
|
||||
groupId: 'group-1',
|
||||
channelId: 'channel-all',
|
||||
carrier: 'mobile',
|
||||
priority: 2,
|
||||
province: null,
|
||||
channel: {
|
||||
id: 'channel-all',
|
||||
code: 'CMPP-ALL',
|
||||
account: 'all',
|
||||
srcId: '1062',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ channelId: 'channel-all' }),
|
||||
);
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('records receipts and uplink messages from gateway events', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user