test: add first-version coverage
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Generated
+4432
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node"],
|
||||
"declaration": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# 第一版系统化测试计划
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
第一版测试优先保证短信业务主链路可回归:风控、计费、发送编排、通道路由与报备、查询统计、Gateway 追踪和模拟链路。不依赖真实运营商 CMPP 网关;本地没有 PostgreSQL、Redis、MinIO 时,优先使用 mock、测试替身或 spike 模拟器。
|
||||
|
||||
## 2. 测试分层
|
||||
|
||||
### 2.1 单元测试
|
||||
|
||||
- NestJS/API 使用 Jest + ts-jest。
|
||||
- Go Gateway 使用 Go 原生 `testing`。
|
||||
- 目标:覆盖纯业务规则、状态流转、查询条件、Gateway tracker/reconnector/health 等无需真实外部服务的逻辑。
|
||||
- 当前重点:
|
||||
- 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。
|
||||
- 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录。
|
||||
- 发送链路:批量任务创建、手机号拆分、发送入队、submit result 更新、receipt 更新、uplink 记录、72 小时未知转超时。
|
||||
- 通道与报备:通道创建、路由规则、签名报备任务、导出、回执导入、签名状态同步。
|
||||
- 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。
|
||||
- Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。
|
||||
|
||||
### 2.2 集成测试
|
||||
|
||||
- 第一版本轮采用“Service + mock Prisma/BullMQ/Redis”的轻集成方式,验证 NestJS service 编排和数据访问参数。
|
||||
- 后续如本地或 CI 具备 PostgreSQL/Redis/MinIO,可增加:
|
||||
- Prisma test database 集成测试。
|
||||
- BullMQ + Redis 队列消费集成测试。
|
||||
- MinIO 预签名上传集成测试。
|
||||
|
||||
### 2.3 契约测试
|
||||
|
||||
- 继续复用 `docs/contracts/gateway-queue-messages.schema.json`。
|
||||
- 继续使用 `tools/spike/validate-gateway-queue-contract.mjs` 校验 SubmitCommand、SubmitResult、ReceiptEvent、UplinkEvent 示例。
|
||||
- 队列消息变更必须先改 schema 和示例,再改 NestJS/Gateway 实现。
|
||||
|
||||
### 2.4 端到端 Smoke
|
||||
|
||||
- 当前端到端 smoke 由阶段验证脚本覆盖:
|
||||
- 队列契约校验。
|
||||
- Go Gateway 测试。
|
||||
- BullMQ spike。
|
||||
- Prisma Client 生成。
|
||||
- API build。
|
||||
- 前端 build。
|
||||
- 不接真实运营商网关。
|
||||
- 后续可在 PostgreSQL/Redis 可用时补 API HTTP smoke:创建任务 -> 入队 -> 模拟 submit result -> 回执 -> trace 查询。
|
||||
|
||||
### 2.5 性能 Smoke
|
||||
|
||||
- 继续复用 `npm run spike:bullmq`。
|
||||
- 验证 15000 条消息、并发 500 的入队和端到端队列链路吞吐。
|
||||
- 性能 smoke 只验证第一版“可稳定入队并调度 500 条短信/秒”的链路能力,不替代生产压测。
|
||||
|
||||
## 3. 执行命令
|
||||
|
||||
```bash
|
||||
npm run spike:contracts
|
||||
npm run test:api
|
||||
npm run test:gateway
|
||||
npm run spike:bullmq
|
||||
npm run verify:phase8
|
||||
```
|
||||
|
||||
API 目录内也可直接执行:
|
||||
|
||||
```bash
|
||||
npm --prefix api test
|
||||
```
|
||||
|
||||
Gateway 目录内如 Go 已在 PATH,可直接执行:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Windows 本项目推荐使用根脚本 `npm run test:gateway`,脚本会临时补充 Go 安装路径。
|
||||
|
||||
## 4. 已知边界
|
||||
|
||||
- 本轮 API 测试不连接真实 PostgreSQL、Redis、MinIO。
|
||||
- 发送 Worker 的 Redis 限速和 BullMQ 投递在 unit/light integration 中使用 mock;真实 Redis 链路由 `spike:bullmq` 覆盖。
|
||||
- 前端暂未新增测试框架;当前保留 `npm run build` 作为 smoke。若后续引入 Vitest/Playwright,应先覆盖登录页、客户端发送页、运营端监控页的加载 smoke。
|
||||
- Gateway 不连接真实运营商 SMSC;使用 gocmpp 适配测试和内部模拟器测试。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 第一版系统化测试进度
|
||||
|
||||
## 2026-07-01
|
||||
|
||||
### 新增测试基础
|
||||
|
||||
- API 引入 Jest + ts-jest。
|
||||
- API 新增脚本:
|
||||
- `npm --prefix api test`
|
||||
- `npm --prefix api test -- <spec>`
|
||||
- 根目录新增脚本:
|
||||
- `npm run test:api`
|
||||
- `npm run test:gateway`
|
||||
|
||||
### 新增 API 测试
|
||||
|
||||
| 测试文件 | 覆盖范围 |
|
||||
| --- | --- |
|
||||
| `api/src/risk-review/risk-review.service.spec.ts` | 最大号码数、重复率、非法号码率、黑名单率、模板变量异常、非工作时间营销大批量、短时间频控、直接拒绝、人工审核。 |
|
||||
| `api/src/billing/billing.service.spec.ts` | 70/67 费用预估、余额/授信/套餐检查、充值、冻结、扣费、释放、退款、调整、短信计费记录。 |
|
||||
| `api/src/send-chain/send-chain.service.spec.ts` | 批量任务创建、手机号去重拆分、发送入队、Gateway SubmitCommand 投递、submit result、receipt、uplink、72 小时未知转超时。 |
|
||||
| `api/src/channels/channels.service.spec.ts` | 通道创建、路由规则、报备材料 upsert、报备任务创建、导出、回执导入、签名报备状态同步。 |
|
||||
| `api/src/operations/operations.service.spec.ts` | 发送记录查询过滤、dashboard、statistics、trace、reconciliation。 |
|
||||
|
||||
### 新增 Gateway 测试
|
||||
|
||||
- `gateway/internal/tracker/tracker_test.go` 增加并发 SEQID/MSGID/GatewayMessageID 映射测试。
|
||||
- 既有 Gateway 测试继续覆盖:
|
||||
- tracker 基础映射和未知 submit resp。
|
||||
- reconnector 重连和最终错误返回。
|
||||
- health handler。
|
||||
- gocmpp adapter。
|
||||
- spike simulator。
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm --prefix api test -- risk-review.service.spec.ts
|
||||
npm --prefix api test -- billing.service.spec.ts
|
||||
npm --prefix api test -- send-chain.service.spec.ts
|
||||
npm --prefix api test -- channels.service.spec.ts
|
||||
npm --prefix api test -- operations.service.spec.ts
|
||||
npm run test:api
|
||||
npm run verify:phase8
|
||||
npm run spike:gateway
|
||||
npm --prefix api test
|
||||
npm run test:gateway
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- API Jest:5 个 test suite 通过,21 个测试通过。
|
||||
- Gateway:`npm run spike:gateway` 通过。
|
||||
- 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 735.83 TPS,满足 500 TPS。
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
- API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。
|
||||
|
||||
### 已知缺口
|
||||
|
||||
- 暂未新增前端测试框架,前端仍以 `npm run build` 作为 smoke。
|
||||
- 暂未跑真实 PostgreSQL/Redis/MinIO 集成测试。
|
||||
- BullMQ 真实链路仍由 `npm run spike:bullmq` 覆盖,不在 Jest 内启动 Redis。
|
||||
- Gateway 未接真实运营商 SMSC;真实 CMPP 互通需要运营商测试环境后补充联调记录。
|
||||
@@ -1,6 +1,10 @@
|
||||
package tracker
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrackerMapsMessageSequenceAndGatewayIDs(t *testing.T) {
|
||||
tr := New()
|
||||
@@ -34,3 +38,51 @@ func TestTrackerRejectsUnknownSubmitResp(t *testing.T) {
|
||||
t.Fatal("expected missing mapping error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerKeepsConcurrentSequenceAndGatewayMappingsSeparate(t *testing.T) {
|
||||
tr := New()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
messageID := fmt.Sprintf("msg-%03d", i)
|
||||
gatewayID := fmt.Sprintf("gw-%03d", i)
|
||||
sequenceID := uint32(2000 + i)
|
||||
|
||||
tr.TrackSubmit(messageID, sequenceID)
|
||||
mapping, err := tr.TrackSubmitResp(sequenceID, gatewayID)
|
||||
if err != nil {
|
||||
t.Errorf("track submit resp for %s: %v", messageID, err)
|
||||
return
|
||||
}
|
||||
if mapping.MessageID != messageID || mapping.SequenceID != sequenceID || mapping.GatewayMessageID != gatewayID {
|
||||
t.Errorf("unexpected mapping: %+v", mapping)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
messageID := fmt.Sprintf("msg-%03d", i)
|
||||
gatewayID := fmt.Sprintf("gw-%03d", i)
|
||||
sequenceID := uint32(2000 + i)
|
||||
|
||||
byGateway, err := tr.ByGatewayMessageID(gatewayID)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup gateway %s: %v", gatewayID, err)
|
||||
}
|
||||
if byGateway.MessageID != messageID || byGateway.SequenceID != sequenceID {
|
||||
t.Fatalf("gateway lookup crossed mappings: %+v", byGateway)
|
||||
}
|
||||
|
||||
byMessage, err := tr.ByMessageID(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup message %s: %v", messageID, err)
|
||||
}
|
||||
if byMessage.GatewayMessageID != gatewayID || byMessage.SequenceID != sequenceID {
|
||||
t.Fatalf("message lookup crossed mappings: %+v", byMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"spike:contracts": "node tools/spike/validate-gateway-queue-contract.mjs",
|
||||
"spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
|
||||
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
|
||||
"test:api": "npm --prefix api test",
|
||||
"test:gateway": "npm run spike:gateway",
|
||||
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
|
||||
"verify:phase2": "npm run verify:phase1",
|
||||
"verify:phase3": "npm run verify:phase2",
|
||||
|
||||
Reference in New Issue
Block a user