fix: 固化长短信回执终态并隔离发送尝试归属
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
const url = new URL(process.env.COMPLETION_TEST_DATABASE_URL || '');
|
||||
assert(['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||
require('reflect-metadata');
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service');
|
||||
const { SendChainService } = require('./dist/send-chain/send-chain.service');
|
||||
const { BillingService } = require('./dist/billing/billing.service');
|
||||
const db = new PrismaService();
|
||||
const chain = new SendChainService(db, new BillingService(db), {}, {});
|
||||
const key = () => randomUUID();
|
||||
const results = [];
|
||||
try {
|
||||
const tenant = await db.tenant.create({ data: { name: '隔离审查', code: key() } });
|
||||
const app = await db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: '隔离',
|
||||
cmppAccount: key(),
|
||||
cmppEnterpriseCode: '000001',
|
||||
secretHash: 'disabled',
|
||||
interfaceEnabled: true,
|
||||
},
|
||||
});
|
||||
const channel = await db.smsChannel.create({
|
||||
data: {
|
||||
name: '隔离不联网',
|
||||
code: key(),
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: key(),
|
||||
passwordCipher: 'unused',
|
||||
srcId: '1069',
|
||||
carriers: ['mobile'],
|
||||
status: 'disabled',
|
||||
},
|
||||
});
|
||||
async function fixture(business = false) {
|
||||
const mid = key(),
|
||||
sid = key();
|
||||
const m = await db.smsMessageRecord.create({
|
||||
data: {
|
||||
messageId: mid,
|
||||
phoneNumber: '13800138000',
|
||||
content: '隔离'.repeat(90),
|
||||
billingUnits: 2,
|
||||
status: 'submitted',
|
||||
channelId: channel.id,
|
||||
submitId: sid,
|
||||
...(business
|
||||
? { tenantId: tenant.id, applicationId: app.id, cmppSubmitSequenceId: '42', cmppRegisteredDelivery: true }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const s = await db.smsSubmitRecord.create({
|
||||
data: {
|
||||
messageRecordId: m.id,
|
||||
tenantId: m.tenantId,
|
||||
channelId: channel.id,
|
||||
submitId: sid,
|
||||
submitStatus: 'accepted',
|
||||
},
|
||||
});
|
||||
return { m, s };
|
||||
}
|
||||
const receipt = (m, g, status = 'delivered') => ({
|
||||
messageId: m.messageId,
|
||||
channelId: channel.id,
|
||||
gatewayMessageId: g,
|
||||
phoneNumber: m.phoneNumber,
|
||||
receiptStatus: status,
|
||||
rawStatus: status === 'delivered' ? 'DELIVRD' : 'FAIL',
|
||||
deliveredAt: new Date().toISOString(),
|
||||
});
|
||||
async function segment(m, s, index, g) {
|
||||
return chain.handleSubmitSegmentResult({
|
||||
messageId: m.messageId,
|
||||
channelId: channel.id,
|
||||
submitId: s.submitId,
|
||||
gatewayMessageId: g,
|
||||
sequenceId: index,
|
||||
segmentIndex: index,
|
||||
segmentTotal: 2,
|
||||
submitStatus: 'accepted',
|
||||
});
|
||||
}
|
||||
// 1. Two attempts reuse one supplier ID: only target attempt should change.
|
||||
{
|
||||
const { m, s } = await fixture();
|
||||
const g = key();
|
||||
await segment(m, s, 1, g);
|
||||
const ch2 = await db.smsChannel.create({
|
||||
data: {
|
||||
name: '隔离通道2',
|
||||
code: key(),
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 2,
|
||||
account: key(),
|
||||
passwordCipher: 'unused',
|
||||
srcId: '1069',
|
||||
carriers: ['mobile'],
|
||||
status: 'disabled',
|
||||
},
|
||||
});
|
||||
const other = await db.smsSubmitRecord.create({
|
||||
data: { messageRecordId: m.id, channelId: ch2.id, submitId: key(), submitStatus: 'accepted' },
|
||||
});
|
||||
await db.smsMessageSegmentAudit.create({
|
||||
data: {
|
||||
messageRecordId: m.id,
|
||||
submitRecordId: other.id,
|
||||
submitId: other.submitId,
|
||||
channelId: ch2.id,
|
||||
gatewayMessageId: g,
|
||||
segmentIndex: 1,
|
||||
segmentTotal: 2,
|
||||
},
|
||||
});
|
||||
const resolved = await chain.resolveReceiptMessage(receipt(m, g));
|
||||
await chain.handleReceipt(receipt(m, g));
|
||||
const rows = await db.smsMessageSegmentAudit.findMany({ where: { messageRecordId: m.id } });
|
||||
assert.equal(rows.filter((r) => r.receiptStatus === 'delivered').length, 1);
|
||||
assert.equal(resolved.channelId, channel.id);
|
||||
assert.equal(rows.find((r) => r.submitRecordId === other.id).receiptStatus, null);
|
||||
results.push({
|
||||
passed: 'cross_attempt_update',
|
||||
updatedAttempts: 1,
|
||||
expected: 1,
|
||||
matchedWrongChannel: resolved.channelId !== channel.id,
|
||||
});
|
||||
}
|
||||
// 2. Final failure preserves accounting and downstream result despite contradictory success.
|
||||
{
|
||||
const { m, s } = await fixture(true);
|
||||
const gs = [key(), key()];
|
||||
await segment(m, s, 1, gs[0]);
|
||||
await segment(m, s, 2, gs[1]);
|
||||
await db.smsMessageRecord.update({
|
||||
where: { id: m.id },
|
||||
data: { status: 'failed', receiptStatus: 'undelivered', amountCents: 100n },
|
||||
});
|
||||
await db.smsBillingRecord.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: app.id,
|
||||
messageId: m.messageId,
|
||||
phoneNumber: m.phoneNumber,
|
||||
contentLength: 180,
|
||||
billingUnits: 2,
|
||||
unitPrice: 50n,
|
||||
amountCents: 100n,
|
||||
billingStatus: 'refunded',
|
||||
},
|
||||
});
|
||||
await db.cmppDownstreamDelivery.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
applicationId: app.id,
|
||||
messageRecordId: m.id,
|
||||
messageId: m.messageId,
|
||||
dedupeKey: 'receipt:' + m.id,
|
||||
deliveryType: 'receipt',
|
||||
payload: { receiptStatus: 'undelivered' },
|
||||
status: 'delivered',
|
||||
},
|
||||
});
|
||||
await chain.handleReceipt(receipt(m, gs[0]));
|
||||
await chain.handleReceipt(receipt(m, gs[1]));
|
||||
const updated = await db.smsMessageRecord.findUnique({ where: { id: m.id } });
|
||||
const bill = await db.smsBillingRecord.findFirst({ where: { messageId: m.messageId } });
|
||||
const notice = await db.cmppDownstreamDelivery.findUnique({ where: { dedupeKey: 'receipt:' + m.id } });
|
||||
assert.equal(updated.status, 'failed');
|
||||
assert.equal(await db.smsReceiptAnomaly.count({ where: { messageRecordId: m.id } }), 1);
|
||||
assert.equal(
|
||||
await db.smsMessageSegmentAudit.count({ where: { messageRecordId: m.id, receiptStatus: 'delivered' } }),
|
||||
0,
|
||||
);
|
||||
assert.equal(bill.billingStatus, 'refunded');
|
||||
assert.equal(notice.payload.receiptStatus, 'undelivered');
|
||||
results.push({
|
||||
passed: 'contradictory_final',
|
||||
message: updated.status,
|
||||
billing: bill.billingStatus,
|
||||
notice: notice.payload.receiptStatus,
|
||||
});
|
||||
}
|
||||
// 3. Second-fragment receipt precedes its SubmitSegmentResult metadata.
|
||||
{
|
||||
const { m, s } = await fixture();
|
||||
const gs = [key(), key()];
|
||||
await segment(m, s, 1, gs[0]);
|
||||
await assert.rejects(() => chain.handleReceipt(receipt(m, gs[1])), /提交尝试关联/);
|
||||
await segment(m, s, 2, gs[1]);
|
||||
await chain.handleReceipt(receipt(m, gs[1]));
|
||||
await chain.handleReceipt(receipt(m, gs[0]));
|
||||
const rows = await db.smsMessageSegmentAudit.findMany({
|
||||
where: { messageRecordId: m.id },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
const updated = await db.smsMessageRecord.findUnique({ where: { id: m.id } });
|
||||
const count = await db.smsReceiptRecord.count({ where: { messageRecordId: m.id, receiptStatus: 'delivered' } });
|
||||
assert.equal(count, 2);
|
||||
assert.equal(updated.status, 'delivered');
|
||||
results.push({
|
||||
passed: 'early_fragment_defers_then_recovers',
|
||||
receiptFacts: count,
|
||||
message: updated.status,
|
||||
segments: rows.map((r) => ({ index: r.segmentIndex, status: r.receiptStatus })),
|
||||
});
|
||||
}
|
||||
// 4. Late failed fragments must not repeat route decisions after final failure.
|
||||
{
|
||||
const { m, s } = await fixture(true);
|
||||
const task = await db.smsBatchTask.create({
|
||||
data: { tenantId: tenant.id, applicationId: app.id, taskNo: key(), content: '隔离', phoneTotal: 1 },
|
||||
});
|
||||
await db.smsMessageRecord.update({ where: { id: m.id }, data: { batchTaskId: task.id } });
|
||||
const gs = [key(), key()];
|
||||
await segment(m, s, 1, gs[0]);
|
||||
await segment(m, s, 2, gs[1]);
|
||||
const priorFind = chain.findApplicationRoute,
|
||||
priorSelect = chain.selectChannelForMessage;
|
||||
let selections = 0;
|
||||
chain.findApplicationRoute = async () => ({ group: { retryEnabled: true, retryTimeLimitMinutes: 60 } });
|
||||
chain.selectChannelForMessage = async () => {
|
||||
selections++;
|
||||
throw new (require('@nestjs/common').BadRequestException)('isolated no route');
|
||||
};
|
||||
try {
|
||||
await chain.handleReceipt(receipt(m, gs[0], 'undelivered'));
|
||||
const afterFirst = await db.smsMessageRecord.findUnique({ where: { id: m.id } });
|
||||
assert.equal(afterFirst.status, 'failed');
|
||||
await Promise.all(Array.from({ length: 8 }, () => chain.handleReceipt(receipt(m, gs[1], 'undelivered'))));
|
||||
assert.equal(selections, 1);
|
||||
results.push({
|
||||
passed: 'repeated_terminal_routing',
|
||||
routeSelections: selections,
|
||||
statusAfterFirst: afterFirst.status,
|
||||
routingBoundaryIsolated: true,
|
||||
});
|
||||
} finally {
|
||||
chain.findApplicationRoute = priorFind;
|
||||
chain.selectChannelForMessage = priorSelect;
|
||||
}
|
||||
}
|
||||
// 5. Same-channel collision between a primary ID and another attempt's fragment is ambiguous.
|
||||
{
|
||||
const { m, s } = await fixture();
|
||||
const g = key();
|
||||
await segment(m, s, 1, g);
|
||||
const other = await db.smsSubmitRecord.create({
|
||||
data: {
|
||||
messageRecordId: m.id,
|
||||
channelId: channel.id,
|
||||
submitId: key(),
|
||||
gatewayMessageId: g,
|
||||
submitStatus: 'accepted',
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => chain.handleReceipt(receipt(m, g)), /提交尝试关联/);
|
||||
assert.equal(await db.smsReceiptRecord.count({ where: { messageRecordId: m.id } }), 0);
|
||||
assert.equal(
|
||||
await db.smsMessageSegmentAudit.count({ where: { messageRecordId: m.id, receiptStatus: 'delivered' } }),
|
||||
0,
|
||||
);
|
||||
results.push({ passed: 'ambiguous_primary_fragment_rejected', otherAttempt: other.id });
|
||||
}
|
||||
// 6. Unknown is not a final failure; all actual fragments may still complete it.
|
||||
{
|
||||
const { m, s } = await fixture();
|
||||
const gs = [key(), key()];
|
||||
await segment(m, s, 1, gs[0]);
|
||||
await segment(m, s, 2, gs[1]);
|
||||
await db.smsMessageRecord.update({ where: { id: m.id }, data: { status: 'unknown' } });
|
||||
await Promise.all(gs.map((g) => chain.handleReceipt(receipt(m, g))));
|
||||
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
|
||||
results.push({ passed: 'unknown_and_concurrent_fragments_recover' });
|
||||
}
|
||||
// 7. Late submit rejection cannot reopen a final failure or create a successor.
|
||||
{
|
||||
const { m, s } = await fixture();
|
||||
const g = key();
|
||||
await segment(m, s, 1, g);
|
||||
await db.smsMessageRecord.update({ where: { id: m.id }, data: { status: 'failed', receiptStatus: 'undelivered' } });
|
||||
await chain.handleSubmitResult({
|
||||
messageId: m.messageId,
|
||||
channelId: channel.id,
|
||||
submitId: s.submitId,
|
||||
gatewayMessageId: g,
|
||||
submitStatus: 'rejected',
|
||||
});
|
||||
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'failed');
|
||||
assert.equal(await db.smsSubmitRecord.count({ where: { messageRecordId: m.id } }), 1);
|
||||
results.push({ passed: 'late_submit_rejection_preserves_final' });
|
||||
}
|
||||
// 8. Legacy audits missing the FK must join the verified attempt before aggregation.
|
||||
{
|
||||
const { m, s } = await fixture();
|
||||
const gs = [key(), key()];
|
||||
await segment(m, s, 1, gs[0]);
|
||||
await segment(m, s, 2, gs[1]);
|
||||
await db.smsMessageSegmentAudit.updateMany({ where: { messageRecordId: m.id }, data: { submitRecordId: null } });
|
||||
for (const g of gs) await chain.handleReceipt(receipt(m, g));
|
||||
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
|
||||
assert.equal(await db.smsMessageSegmentAudit.count({ where: { messageRecordId: m.id, submitRecordId: s.id } }), 2);
|
||||
results.push({ passed: 'legacy_fragment_relation_recovered' });
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user