Files
lislgosms/tools/testing/verify-receipt-finality.mjs
T

316 lines
12 KiB
JavaScript

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();
}