316 lines
12 KiB
JavaScript
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();
|
|
}
|