test: add first-version coverage
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { RiskReviewService } from '../risk-review/risk-review.service';
|
||||
import { SendChainService } from './send-chain.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
|
||||
const message = {
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
billingUnits: 1,
|
||||
status: 'queued',
|
||||
template: { signature: { name: '签名' } },
|
||||
};
|
||||
const channel = {
|
||||
id: 'channel-1',
|
||||
code: 'CMPP-A',
|
||||
account: 'cmpp-account',
|
||||
srcId: '10690000',
|
||||
rateLimitPerSecond: 100,
|
||||
status: 'active',
|
||||
config: { serviceId: 'SMS' },
|
||||
};
|
||||
return {
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
findUnique: jest.fn().mockResolvedValue(task),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(task),
|
||||
},
|
||||
smsApiRequest: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'request-1' }),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
|
||||
findUnique: jest.fn().mockResolvedValue(message),
|
||||
findFirst: jest.fn().mockResolvedValue(message),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 1 } }]),
|
||||
},
|
||||
channelRouteRule: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
smsChannel: {
|
||||
findFirst: jest.fn().mockResolvedValue(channel),
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
},
|
||||
cmppSubmitSession: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'session-1' }),
|
||||
},
|
||||
smsSubmitRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'submit-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsReceiptRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsUplinkMessage: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createService(prisma = createPrismaMock()) {
|
||||
const billing = {
|
||||
estimateSmsCost: jest.fn().mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 6,
|
||||
}),
|
||||
} as unknown as BillingService;
|
||||
const riskReview = {
|
||||
evaluateTask: jest.fn().mockResolvedValue({
|
||||
status: 'approved',
|
||||
reason: null,
|
||||
task: { id: 'risk-task-1' },
|
||||
}),
|
||||
} as unknown as RiskReviewService;
|
||||
return { service: new SendChainService(prisma as never, billing, riskReview), prisma, billing, riskReview };
|
||||
}
|
||||
|
||||
describe('SendChainService', () => {
|
||||
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000001', '13800000002'],
|
||||
sourceIp: '127.0.0.1',
|
||||
userAgent: 'jest',
|
||||
});
|
||||
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['13800000001', '13800000002'] }));
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ phoneTotal: 2, status: 'ready', progressTotal: 2, auditStatus: 'approved' }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', billingUnits: 1, amountCents: 3 }),
|
||||
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3 }),
|
||||
]),
|
||||
});
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('adds queued message jobs for a batch task', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const add = jest.fn().mockResolvedValue(undefined);
|
||||
service['getSendQueue'] = jest.fn().mockReturnValue({ add });
|
||||
|
||||
await expect(service.enqueueBatchTask('task-1')).resolves.toEqual({ taskId: 'task-1', enqueued: 1 });
|
||||
expect(add).toHaveBeenCalledWith('send-message', { messageRecordId: 'record-1' }, { jobId: 'record-1', attempts: 3 });
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ where: { id: 'task-1' }, data: { status: 'queued' } });
|
||||
});
|
||||
|
||||
it('routes queued messages to gateway submit commands', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
|
||||
await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual(
|
||||
expect.objectContaining({ submitted: true, messageRecordId: 'record-1', channelId: 'channel-1' }),
|
||||
);
|
||||
|
||||
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1', submitStatus: 'queued' }),
|
||||
});
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect.objectContaining({
|
||||
schemaVersion: 'v1',
|
||||
messageType: 'SubmitCommand',
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
phoneNumber: '13800000001',
|
||||
route: expect.objectContaining({ channelCode: 'CMPP-A', rateLimitPerSecond: 100 }),
|
||||
cmpp: expect.objectContaining({ serviceId: 'SMS', srcId: '10690000' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates submit result status and task progress', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
submitId: 'SUB-1',
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'accepted',
|
||||
submittedAt: '2026-07-01T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { OR: [{ submitId: 'SUB-1' }, { messageRecordId: 'record-1' }] },
|
||||
data: expect.objectContaining({ sequenceId: 7, gatewayMessageId: 'GW-1', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('records receipts and uplink messages from gateway events', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
sequenceId: 7,
|
||||
gatewayMessageId: 'GW-1',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
deliveredAt: '2026-07-01T10:01:00.000Z',
|
||||
});
|
||||
await service.handleUplink({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
sequenceId: 8,
|
||||
phoneNumber: '13800000001',
|
||||
destId: '10690000',
|
||||
content: 'TD',
|
||||
receivedAt: '2026-07-01T10:02:00.000Z',
|
||||
});
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }),
|
||||
});
|
||||
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', content: 'TD' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 72 hour unknown receipts as timeout', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
{ id: 'record-1', batchTaskId: 'task-1' },
|
||||
{ id: 'record-2', batchTaskId: 'task-1' },
|
||||
]);
|
||||
|
||||
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 2 });
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['record-1', 'record-2'] } },
|
||||
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
|
||||
});
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user