test: add first-version coverage

This commit is contained in:
hectorzhao
2026-07-01 14:46:35 +08:00
parent 924457a48e
commit 91d1e38a09
13 changed files with 5484 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
roots: ['<rootDir>/src'],
testMatch: ['**/*.spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
clearMocks: true,
};
+4432
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -5,6 +5,8 @@
"type": "commonjs",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand",
"test:watch": "jest --watch",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
"prisma:generate": "prisma generate",
@@ -29,8 +31,11 @@
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^25.9.3",
"jest": "^30.4.2",
"prisma": "^7.0.1",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
}
+152
View File
@@ -0,0 +1,152 @@
import { BillingService } from './billing.service';
function createPrismaMock() {
const accountState = { tenantId: 'tenant-1', balanceCents: 1000, smsUnits: 20, creditCents: 200 };
return {
accountState,
billingPlan: {
findMany: jest.fn(),
create: jest.fn(),
findUnique: jest.fn(),
},
tenantAccount: {
findMany: jest.fn(),
create: jest.fn(),
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
update: jest.fn().mockImplementation(({ data }) => {
accountState.balanceCents = data.balanceCents;
accountState.smsUnits = data.smsUnits;
return Promise.resolve({ ...accountState });
}),
},
accountTransaction: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })),
},
rechargeOrder: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })),
},
smsBillingRecord: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'bill-1', ...data })),
},
billingRule: {
findMany: jest.fn(),
create: jest.fn(),
},
};
}
describe('BillingService', () => {
it('estimates SMS cost using 70/67 billing units', () => {
const service = new BillingService(createPrismaMock() as never);
expect(
service.estimateSmsCost({ tenantId: 'tenant-1', content: 'a'.repeat(70), phoneCount: 3, unitPrice: 5 }),
).toEqual(
expect.objectContaining({
contentLength: 70,
billingUnitsPerMessage: 1,
totalBillingUnits: 3,
amountCents: 15,
}),
);
expect(
service.estimateSmsCost({ tenantId: 'tenant-1', content: 'a'.repeat(71), phoneCount: 2, unitPrice: 5 }),
).toEqual(
expect.objectContaining({
contentLength: 71,
billingUnitsPerMessage: 2,
totalBillingUnits: 4,
amountCents: 20,
}),
);
});
it('checks balance, credit, and package units before sending', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1100, smsUnits: 20 })).resolves.toEqual(
expect.objectContaining({ availableAmount: 1200, availableSmsUnits: 20, canSend: true }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1300, smsUnits: 20 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 21 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
});
it('creates recharge orders and account transactions from plans', async () => {
const prisma = createPrismaMock();
prisma.billingPlan.findUnique.mockResolvedValue({ id: 'plan-1', priceCents: 500, smsUnits: 100 });
const service = new BillingService(prisma as never);
const order = await service.createRechargeOrder({ tenantId: 'tenant-1', planId: 'plan-1', remark: 'manual top up' });
expect(order).toEqual(expect.objectContaining({ amountCents: 500, smsUnits: 100, status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 1500, smsUnits: 120 },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
transactionType: 'recharge',
amountCents: 500,
smsUnits: 100,
balanceAfter: 1500,
relatedType: 'recharge_order',
relatedId: 'order-1',
}),
});
});
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
await service.freeze({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 2, relatedType: 'sms_batch_task', relatedId: 'task-1' });
await service.charge({ tenantId: 'tenant-1', amountCents: 50, smsUnits: 1, relatedType: 'sms_message_record', relatedId: 'msg-1' });
await service.release({ tenantId: 'tenant-1', amountCents: 25, smsUnits: 1 });
await service.refund({ tenantId: 'tenant-1', amountCents: 10, smsUnits: 1 });
await service.adjust({ tenantId: 'tenant-1', amountCents: 5, smsUnits: 0 });
expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([
'frozen',
'charged',
'released',
'refunded',
'adjusted',
]);
expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890, smsUnits: 19 }));
});
it('creates SMS billing records linked to message and task identifiers', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
const record = await service.createSmsBillingRecord({
tenantId: 'tenant-1',
applicationId: 'app-1',
taskId: 'task-1',
messageId: 'msg-1',
phoneNumber: '13800000001',
content: 'a'.repeat(134),
unitPrice: 4,
});
expect(record).toEqual(
expect.objectContaining({
tenantId: 'tenant-1',
taskId: 'task-1',
messageId: 'msg-1',
billingUnits: 2,
amountCents: 8,
billingStatus: 'estimated',
}),
);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { ChannelsService } from './channels.service';
function createPrismaMock() {
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
return {
smsChannel: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
},
channelHealthMetric: { findMany: jest.fn() },
smsChannelGroup: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
},
smsChannelGroupItem: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
},
channelRouteRule: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'route-1', ...data })),
},
channelReportField: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
},
signatureReportMaterial: {
findMany: jest.fn(),
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
},
channelSignatureReportTask: {
findMany: jest.fn(),
create: jest.fn().mockResolvedValue(reportTask),
findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
},
channelSignatureReportRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'record-1', ...data })),
findMany: jest.fn(),
},
reportExportFile: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'export-1', ...data })),
},
reportReceiptImport: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'import-1', ...data })),
},
smsSignature: {
update: jest.fn(),
},
};
}
describe('ChannelsService', () => {
it('creates CMPP channels and route rules with first-version defaults', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createChannel({
code: 'CMPP-A',
name: '主通道',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
});
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', channelId: 'channel-1' });
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
data: expect.objectContaining({
protocol: 'CMPP',
cmppVersion: '3.0',
rateLimitPerSecond: 100,
status: 'active',
}),
});
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
groupId: 'group-1',
channelId: 'channel-1',
priority: 100,
status: 'active',
}),
});
});
it('upserts signature report material per channel field', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.upsertReportMaterial({
signatureId: 'sig-1',
channelId: 'channel-1',
fieldCode: 'license',
fieldValue: '营业执照',
fileObjectId: 'file-1',
});
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith({
where: { signatureId_channelId_fieldCode: { signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' } },
update: { fieldValue: '营业执照', fileObjectId: 'file-1' },
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license' }),
});
});
it('records report task creation, export, receipt import, and signature status synchronization', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', createdById: 'user-1' });
await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 });
await service.importReportReceipt('report-task-1', {
fileName: 'receipt.csv',
rowCount: 10,
successCount: 9,
failedCount: 1,
reason: 'one rejected',
});
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'create', statusAfter: 'pending' }),
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'exporting', reason: undefined },
});
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
where: { id: 'report-task-1' },
data: { status: 'rejected', reason: 'one rejected' },
});
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
where: { id: 'sig-1' },
data: { reportStatus: 'rejected' },
});
});
});
@@ -0,0 +1,100 @@
import { OperationsService } from './operations.service';
function createPrismaMock() {
return {
smsBatchTask: {
findMany: jest.fn(),
count: jest.fn().mockResolvedValue(3),
},
smsMessageRecord: {
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
},
smsReceiptRecord: {
findMany: jest.fn().mockResolvedValue([]),
},
smsUplinkMessage: {
findMany: jest.fn().mockResolvedValue([{ id: 'uplink-1', messageId: 'MSG-1' }]),
count: jest.fn().mockResolvedValue(1),
},
smsBillingRecord: {
findMany: jest.fn().mockResolvedValue([{ id: 'bill-1', messageId: 'MSG-1' }]),
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
},
accountTransaction: {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
},
operationLog: {
findMany: jest.fn(),
groupBy: jest.fn(),
},
};
}
describe('OperationsService', () => {
it('filters send-chain messages by tenant, application, channel, task, phone, and status', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await service.listMessages({
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
taskId: 'task-1',
phoneNumber: '13800000001',
status: 'delivered',
});
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
where: {
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
batchTaskId: 'task-1',
phoneNumber: '13800000001',
status: 'delivered',
},
include: { submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
take: 500,
});
});
it('builds dashboard and statistics aggregates', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.dashboard({ tenantId: 'tenant-1' })).resolves.toEqual(
expect.objectContaining({ taskCount: 3, uplinkCount: 1 }),
);
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith({
by: ['applicationId'],
where: expect.objectContaining({ tenantId: 'tenant-1' }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
});
it('returns trace details and reconciliation diffs', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
await expect(service.trace({ tenantId: 'tenant-1', taskId: 'task-1', messageId: 'MSG-1' })).resolves.toEqual({
messages: [{ messageId: 'MSG-1' }],
billingRecords: [{ id: 'bill-1', messageId: 'MSG-1' }],
uplinks: [{ id: 'uplink-1', messageId: 'MSG-1' }],
});
await expect(service.reconciliation({ tenantId: 'tenant-1', taskId: 'task-1' })).resolves.toEqual(
expect.objectContaining({
diff: {
messageVsBillingAmountCents: 0,
billingVsTransactionAmountCents: 0,
messageVsBillingUnits: 0,
},
}),
);
});
});
@@ -0,0 +1,213 @@
import { RiskReviewService } from './risk-review.service';
function createPrismaMock(overrides: Record<string, unknown> = {}) {
return {
riskRule: {
findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }),
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
globalBlacklist: {
findMany: jest.fn().mockResolvedValue([]),
},
enterpriseBlacklist: {
findMany: jest.fn().mockResolvedValue([]),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue(null),
},
smsTemplate: {
findUnique: jest.fn().mockResolvedValue(null),
},
smsSendTask: {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'risk-task-1', ...data }),
),
findUnique: jest.fn().mockResolvedValue({ id: 'risk-task-1', riskHits: [] }),
update: jest.fn(),
findMany: jest.fn(),
},
riskHitRecord: {
createMany: jest.fn(),
findMany: jest.fn(),
},
...overrides,
};
}
describe('RiskReviewService', () => {
it('rejects tasks over the application max phone threshold', async () => {
const prisma = createPrismaMock();
prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 });
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-max',
code: 'MAX_PHONES_PER_TASK',
name: '单任务最大号码数',
metric: 'phoneTotal',
thresholdValue: 100000,
action: 'block',
priority: 10,
},
]);
prisma.smsSendTask.findUnique.mockResolvedValue({
id: 'risk-task-1',
status: 'rejected',
riskHits: [{ ruleCode: 'MAX_PHONES_PER_TASK' }],
});
const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({
tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'hello',
phones: ['13800000001', '13800000002', '13800000003'],
});
expect(result.canSubmit).toBe(false);
expect(result.status).toBe('rejected');
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ ruleCode: 'MAX_PHONES_PER_TASK', actualValue: 3, action: 'block' })],
});
});
it('routes duplicate and blacklist ratio hits to manual review', async () => {
const prisma = createPrismaMock();
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]);
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-dup',
code: 'DUPLICATE_PHONE_RATIO',
name: '重复号码比例',
metric: 'duplicateRatio',
thresholdValue: 0.2,
action: 'manual_review',
priority: 20,
},
{
id: 'rule-black',
code: 'BLACKLIST_HIT_RATIO',
name: '黑名单命中比例',
metric: 'blacklistHitRatio',
thresholdValue: 0.2,
action: 'manual_review',
priority: 40,
},
]);
const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({
tenantId: 'tenant-1',
content: 'hello',
phones: ['13800000001', '13800000001', '13800000002'],
});
expect(result.status).toBe('pending_review');
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
duplicateRatio: 0.3333,
blacklistHitRatio: 0.3333,
status: 'pending_review',
riskDecision: 'manual_review',
}),
});
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }),
expect.objectContaining({ ruleCode: 'BLACKLIST_HIT_RATIO' }),
]),
});
});
it('rejects illegal phone and template variable anomalies', async () => {
const prisma = createPrismaMock();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1',
category: 'notice',
variables: [{ name: 'code', required: true }],
});
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-illegal',
code: 'ILLEGAL_PHONE_RATIO',
name: '非法号码比例',
metric: 'illegalRatio',
thresholdValue: 0.1,
action: 'block',
priority: 30,
},
{
id: 'rule-var',
code: 'TEMPLATE_VARIABLE_ANOMALY',
name: '模板变量异常',
metric: 'variableIssueCount',
thresholdValue: 0,
action: 'block',
priority: 70,
},
]);
const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({
tenantId: 'tenant-1',
templateId: 'tpl-1',
content: '验证码 ${code}',
phones: ['13800000001', 'not-a-phone'],
variables: { extra: 'value' },
});
expect(result.status).toBe('rejected');
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
illegalRatio: 0.5,
variableIssues: expect.arrayContaining([
{ type: 'missing_required_variable', name: 'code' },
{ type: 'unexpected_variable', name: 'extra' },
]),
}),
});
});
it('marks non-working marketing bulk and frequent task creation for manual review', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.count.mockResolvedValue(11);
prisma.riskRule.findMany.mockResolvedValue([
{
id: 'rule-night',
code: 'NON_WORKING_MARKETING_BULK',
name: '非工作时间大批量营销发送',
metric: 'nonWorkingMarketingPhones',
thresholdValue: 2,
action: 'manual_review',
priority: 50,
},
{
id: 'rule-frequency',
code: 'TASK_CREATE_FREQUENCY',
name: '短时间任务创建频控',
metric: 'recentTaskCount',
thresholdValue: 10,
action: 'manual_review',
priority: 60,
},
]);
const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({
tenantId: 'tenant-1',
category: 'marketing',
content: 'promo',
phones: ['13800000001', '13800000002', '13800000003'],
requestedAt: '2026-07-01T22:00:00+08:00',
});
expect(result.status).toBe('pending_review');
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }),
expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 11 }),
]),
});
});
});
@@ -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();
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"],
"declaration": false
}
}