fix: close sms scheduling and billing gaps
This commit is contained in:
@@ -14,6 +14,8 @@ function createPrismaMock() {
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
billingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
status: 'queued',
|
||||
template: { signature: { name: '签名' } },
|
||||
};
|
||||
@@ -23,10 +25,26 @@ function createPrismaMock() {
|
||||
account: 'cmpp-account',
|
||||
srcId: '10690000',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
config: { serviceId: 'SMS' },
|
||||
};
|
||||
return {
|
||||
tenant: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active' }),
|
||||
},
|
||||
smsTemplate: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'approved' },
|
||||
}),
|
||||
},
|
||||
smsBatchTask: {
|
||||
create: jest.fn().mockResolvedValue(task),
|
||||
findUnique: jest.fn().mockResolvedValue(task),
|
||||
@@ -69,6 +87,18 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'uplink-1', ...data })),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
smsBillingRecord: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'bill-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,9 +106,15 @@ function createService(prisma = createPrismaMock()) {
|
||||
const billing = {
|
||||
estimateSmsCost: jest.fn().mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 2,
|
||||
unitPrice: 3,
|
||||
amountCents: 6,
|
||||
}),
|
||||
checkAccount: jest.fn().mockResolvedValue({ canSend: true }),
|
||||
freeze: jest.fn().mockResolvedValue({ id: 'tx-freeze' }),
|
||||
release: jest.fn().mockResolvedValue({ id: 'tx-release' }),
|
||||
charge: jest.fn().mockResolvedValue({ id: 'tx-charge' }),
|
||||
refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }),
|
||||
} as unknown as BillingService;
|
||||
const riskReview = {
|
||||
evaluateTask: jest.fn().mockResolvedValue({
|
||||
@@ -92,7 +128,7 @@ function createService(prisma = createPrismaMock()) {
|
||||
|
||||
describe('SendChainService', () => {
|
||||
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
const { service, prisma, riskReview, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
|
||||
|
||||
await service.createBatchTask({
|
||||
@@ -115,9 +151,105 @@ describe('SendChainService', () => {
|
||||
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3 }),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
const scheduledAt = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
sendMode: 'scheduled',
|
||||
scheduledAt,
|
||||
});
|
||||
|
||||
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ status: 'scheduled', scheduledAt: expect.any(Date) }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({ status: 'scheduled' })],
|
||||
});
|
||||
expect(billing.freeze).not.toHaveBeenCalled();
|
||||
expect(service.enqueueBatchTask).not.toHaveBeenCalled();
|
||||
|
||||
prisma.smsBatchTask.findMany.mockResolvedValue([{ id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1' }]);
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]);
|
||||
|
||||
await expect(service.dispatchDueScheduledTasks(new Date(Date.now() + 120_000))).resolves.toEqual({
|
||||
dispatched: 1,
|
||||
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels scheduled tasks before dispatch', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'scheduled' });
|
||||
|
||||
await service.cancelBatchTask('task-1');
|
||||
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
||||
data: { status: 'canceled', errorMessage: '定时任务已取消' },
|
||||
});
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: { status: 'canceled', canceledAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks sending when enterprise certification is not approved', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' });
|
||||
|
||||
await expect(
|
||||
service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
}),
|
||||
).rejects.toThrow('企业认证未通过,不能发送短信');
|
||||
});
|
||||
|
||||
it('previews imported phone files with duplicate, invalid, blacklist, and variable errors', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000003' }]);
|
||||
|
||||
await expect(
|
||||
service.previewImport({
|
||||
tenantId: 'tenant-1',
|
||||
content: 'phoneNumber,code\n13800000001,1234\n13800000001,1234\nbad,1234\n13800000003,1234\n13900000001,',
|
||||
requiredVariables: ['code'],
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
totalRows: 5,
|
||||
validCount: 1,
|
||||
errorCount: 4,
|
||||
phones: ['13800000001'],
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({ reason: '重复号码' }),
|
||||
expect.objectContaining({ reason: '手机号格式非法' }),
|
||||
expect.objectContaining({ reason: '命中黑名单' }),
|
||||
expect.objectContaining({ reason: '变量列缺失:code' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds queued message jobs for a batch task', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const add = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -155,8 +287,8 @@ describe('SendChainService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('updates submit result status and task progress', async () => {
|
||||
const { service, prisma } = createService();
|
||||
it('updates submit result status, charges billing, and task progress', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
@@ -176,6 +308,32 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
|
||||
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' }));
|
||||
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('releases reservation for rejected submit result and refunds failed receipts', async () => {
|
||||
const { service, billing } = createService();
|
||||
|
||||
await service.handleSubmitResult({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
submitStatus: 'rejected',
|
||||
});
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ remark: expect.stringContaining('提交失败释放冻结') }));
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
});
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
});
|
||||
|
||||
it('records receipts and uplink messages from gateway events', async () => {
|
||||
|
||||
Reference in New Issue
Block a user