320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
// Local-only real PostgreSQL, Redis and Nest callback validation. No supplier connection or SMS submitter.
|
|
import assert from 'node:assert/strict';
|
|
import { createRequire } from 'node:module';
|
|
import { randomUUID, randomBytes } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
const url = new URL(process.env.PROTOCOL_TEST_DATABASE_URL || '');
|
|
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'));
|
|
const redisUrl = new URL(process.env.PROTOCOL_TEST_REDIS_URL || 'redis://127.0.0.1:16441');
|
|
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname));
|
|
Object.assign(process.env, {
|
|
NODE_ENV: 'test',
|
|
DATABASE_URL: url.href,
|
|
REDIS_URL: redisUrl.href,
|
|
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
|
|
CMPP_PROCESS_ROLE: 'gateway-callback',
|
|
GATEWAY_CONTROL_URL: 'http://127.0.0.1:1',
|
|
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
|
|
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
|
|
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
|
|
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
|
|
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
|
|
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
|
|
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
|
|
API_ENABLE_SEND_WORKER: 'false',
|
|
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
|
|
});
|
|
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
|
require('reflect-metadata');
|
|
// Match the existing bootstrap money serializer. Protocol field conversion is separately asserted below.
|
|
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
|
configurable: true,
|
|
value() {
|
|
assert(this <= BigInt(Number.MAX_SAFE_INTEGER));
|
|
return Number(this);
|
|
},
|
|
});
|
|
const { NestFactory } = require('@nestjs/core');
|
|
const { GatewayCallbackModule } = require('./dist/gateway-callback.module');
|
|
const { PrismaService } = require('./dist/prisma/prisma.service');
|
|
const { SendChainService } = require('./dist/send-chain/send-chain.service');
|
|
const { protocolFieldsToJson } = require('./dist/common/protocol-uint32');
|
|
const { Client } = require('pg');
|
|
const Redis = require('ioredis');
|
|
const key = () => randomUUID();
|
|
const pass = (name, detail) => console.log(JSON.stringify({ passed: name, ...detail }));
|
|
const app = await NestFactory.create(GatewayCallbackModule, { logger: ['error'] });
|
|
app.setGlobalPrefix('api');
|
|
const redis = new Redis(redisUrl.href);
|
|
try {
|
|
await app.listen(0, '127.0.0.1');
|
|
const base = await app.getUrl(),
|
|
db = app.get(PrismaService),
|
|
chain = app.get(SendChainService);
|
|
assert.equal(
|
|
await db.smsMessageRecord.count(),
|
|
0,
|
|
'Use a fresh isolated database: fixed maximum Msg_Id fixtures must not collide with earlier runs',
|
|
);
|
|
const post = async (path, body, expected = 201) => {
|
|
const r = await fetch(`${base}/api/gateway/events/${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const json = await r.json();
|
|
assert.equal(r.status, expected, JSON.stringify(json));
|
|
return json;
|
|
};
|
|
const tenant = await db.tenant.create({ data: { name: 'protocol QA', code: key() } });
|
|
const application = await db.smsApplication.create({
|
|
data: {
|
|
tenantId: tenant.id,
|
|
name: 'protocol QA',
|
|
cmppAccount: key(),
|
|
cmppEnterpriseCode: '000001',
|
|
secretHash: 'disabled',
|
|
interfaceEnabled: true,
|
|
},
|
|
});
|
|
const channel = await db.smsChannel.create({
|
|
data: {
|
|
name: 'protocol QA disabled',
|
|
code: key(),
|
|
gatewayHost: '127.0.0.1',
|
|
gatewayPort: 1,
|
|
account: key(),
|
|
passwordCipher: 'unused',
|
|
srcId: '1069',
|
|
carriers: ['mobile'],
|
|
status: 'disabled',
|
|
},
|
|
});
|
|
const maxMsgId = '18446744073709551615';
|
|
for (const [index, sequenceId] of [0, 2147483647, 2147483648, 4294967295].entries()) {
|
|
const gatewayMessageId = (BigInt(maxMsgId) - BigInt(index)).toString();
|
|
const m = await db.smsMessageRecord.create({
|
|
data: {
|
|
messageId: key(),
|
|
phoneNumber: `1380013800${index}`,
|
|
content: 'isolated',
|
|
billingUnits: 1,
|
|
status: 'submitted',
|
|
channelId: channel.id,
|
|
submitId: key(),
|
|
tenantId: tenant.id,
|
|
applicationId: application.id,
|
|
cmppSubmitSequenceId: String(sequenceId),
|
|
cmppRegisteredDelivery: true,
|
|
},
|
|
});
|
|
await db.smsSubmitRecord.create({
|
|
data: {
|
|
messageRecordId: m.id,
|
|
tenantId: tenant.id,
|
|
channelId: channel.id,
|
|
submitId: m.submitId,
|
|
submitStatus: 'accepted',
|
|
},
|
|
});
|
|
await post('submit-segment-result', {
|
|
messageId: m.messageId,
|
|
channelId: channel.id,
|
|
submitId: m.submitId,
|
|
gatewayMessageId,
|
|
sequenceId,
|
|
segmentIndex: 1,
|
|
segmentTotal: 1,
|
|
submitStatus: 'accepted',
|
|
});
|
|
const receipt = {
|
|
messageId: m.messageId,
|
|
channelId: channel.id,
|
|
sequenceId,
|
|
gatewayMessageId,
|
|
phoneNumber: m.phoneNumber,
|
|
receiptStatus: 'delivered',
|
|
rawStatus: 'DELIVRD',
|
|
deliveredAt: new Date().toISOString(),
|
|
};
|
|
const stream = `qa:protocol:${key()}`;
|
|
try {
|
|
await redis.xadd(stream, '*', 'payload', JSON.stringify(receipt));
|
|
const entries = await redis.xrange(stream, '-', '+');
|
|
const wire = JSON.parse(entries[0][1][1]);
|
|
assert.equal(wire.gatewayMessageId, gatewayMessageId);
|
|
assert.equal(wire.sequenceId, sequenceId);
|
|
const [intake, concurrent] = await Promise.all([post('receipt/intake', wire), post('receipt/intake', wire)]);
|
|
assert.equal(intake.inboxId, concurrent.inboxId);
|
|
for (let i = 0; i < 100; i++) {
|
|
const inbox = await db.upstreamReceiptInbox.findUnique({ where: { id: intake.inboxId } });
|
|
if (inbox.status === 'matched') break;
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
}
|
|
const batch = await post('batch', {
|
|
batchId: key(),
|
|
gatewayInstanceId: 'qa',
|
|
events: [{ eventId: key(), type: 'receipt_intake', payload: wire }],
|
|
});
|
|
assert(
|
|
batch.results.every((r) => r.accepted),
|
|
JSON.stringify(batch),
|
|
);
|
|
const inbox = await db.upstreamReceiptInbox.findUnique({ where: { id: intake.inboxId } });
|
|
assert.equal(inbox.status, 'matched');
|
|
assert.equal(inbox.sequenceId, BigInt(sequenceId));
|
|
const receipts = await db.smsReceiptRecord.findMany({ where: { messageRecordId: m.id } });
|
|
assert.equal(receipts.length, 1);
|
|
assert.equal(receipts[0].sequenceId, BigInt(sequenceId));
|
|
const [submit, segment] = await Promise.all([
|
|
db.smsSubmitRecord.findFirst({ where: { messageRecordId: m.id } }),
|
|
db.smsMessageSegmentAudit.findFirst({ where: { messageRecordId: m.id } }),
|
|
]);
|
|
assert.equal(submit.sequenceId, BigInt(sequenceId));
|
|
assert.equal(segment.sequenceId, BigInt(sequenceId));
|
|
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
|
|
const delivery = await db.cmppDownstreamDelivery.findFirst({ where: { messageRecordId: m.id } });
|
|
assert(delivery);
|
|
assert.equal(delivery.payload.submitSequenceId, sequenceId);
|
|
await chain.acknowledgeDownstreamDelivery({
|
|
id: delivery.id,
|
|
claimId: key(),
|
|
connectionId: 'qa',
|
|
sequenceId: String(sequenceId),
|
|
messageId: gatewayMessageId,
|
|
result: sequenceId,
|
|
});
|
|
const ack = await db.cmppDownstreamDelivery.findUnique({
|
|
where: { id: delivery.id },
|
|
include: { attempts: true },
|
|
});
|
|
assert.equal(ack.ackResult, BigInt(sequenceId));
|
|
assert.equal(ack.attempts[0].ackResult, BigInt(sequenceId));
|
|
assert.equal(ack.status === 'delivered', sequenceId === 0);
|
|
assert.equal(protocolFieldsToJson(ack).ackResult, sequenceId);
|
|
const uplink = {
|
|
eventId: key(),
|
|
channelId: channel.id,
|
|
sequenceId,
|
|
gatewayMessageId,
|
|
phoneNumber: m.phoneNumber,
|
|
destId: '1069',
|
|
content: 'QA',
|
|
receivedAt: new Date().toISOString(),
|
|
};
|
|
const first = await post('uplink', uplink),
|
|
duplicate = await post('uplink', uplink);
|
|
assert.equal(first.id, duplicate.id);
|
|
assert.equal(first.sequenceId, sequenceId);
|
|
assert.equal(first.gatewayMessageId, gatewayMessageId);
|
|
assert.equal((await db.smsUplinkMessage.findUnique({ where: { id: first.id } })).sequenceId, BigInt(sequenceId));
|
|
pass('uint32_real_chain', {
|
|
sequenceId,
|
|
gatewayMessageId,
|
|
deduplicated: true,
|
|
sevenColumns: true,
|
|
notificationQueued: true,
|
|
});
|
|
if (sequenceId === 4294967295) {
|
|
// Model the emergency legacy case: receipt fact exists but optional sequence was omitted.
|
|
const notices = await db.cmppDownstreamDelivery.count({ where: { messageRecordId: m.id } });
|
|
await db.upstreamReceiptInbox.update({ where: { id: inbox.id }, data: { sequenceId: null } });
|
|
await db.smsReceiptRecord.update({ where: { id: receipts[0].id }, data: { sequenceId: null } });
|
|
await post('receipt/intake', wire);
|
|
await post('receipt', { ...wire, receiptStatus: 'undelivered', rawStatus: 'REJECTD' });
|
|
assert.equal((await db.upstreamReceiptInbox.findUnique({ where: { id: inbox.id } })).sequenceId, null);
|
|
assert.equal((await db.smsReceiptRecord.findUnique({ where: { id: receipts[0].id } })).sequenceId, null);
|
|
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: m.id } })).status, 'delivered');
|
|
assert.equal(await db.cmppDownstreamDelivery.count({ where: { messageRecordId: m.id } }), notices);
|
|
pass('historical_null_and_contradictory_high_sequence_no_reopen');
|
|
}
|
|
} finally {
|
|
await redis.del(stream);
|
|
}
|
|
}
|
|
const count = await db.upstreamReceiptInbox.count();
|
|
for (const bad of [-1, 4294967296, 1.5, '', '0'])
|
|
await post('receipt/intake', { channelId: channel.id, sequenceId: bad }, 400);
|
|
assert.equal(await db.upstreamReceiptInbox.count(), count);
|
|
const invalid = await post('batch', {
|
|
batchId: key(),
|
|
gatewayInstanceId: 'qa',
|
|
events: [{ eventId: 'bad', type: 'receipt_intake', payload: { channelId: channel.id, sequenceId: -1 } }],
|
|
});
|
|
assert.equal(invalid.results[0].accepted, false);
|
|
assert.equal(invalid.results[0].retryable, false);
|
|
pass('invalid_events_no_writes');
|
|
const models = [
|
|
'UpstreamReceiptInbox',
|
|
'SmsReceiptRecord',
|
|
'SmsUplinkMessage',
|
|
'SmsSubmitRecord',
|
|
'SmsMessageSegmentAudit',
|
|
'CmppDownstreamDelivery',
|
|
'CmppDownstreamDeliveryAttempt',
|
|
];
|
|
for (const [i, table] of models.entries()) {
|
|
const col = i < 5 ? 'sequenceId' : 'ackResult';
|
|
for (const bad of [-1, 4294967296])
|
|
await assert.rejects(db.$executeRawUnsafe(`UPDATE "${table}" SET "${col}" = ${bad}`));
|
|
}
|
|
pass('database_constraints_all_seven');
|
|
// Rehearse the exact migration with old values and an unrelated index, then a bad historical value and lock contention.
|
|
const sql = readFileSync(
|
|
new URL('../../api/prisma/migrations/20260920160000_cmpp_protocol_uint32/migration.sql', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const pg = new Client({ connectionString: url.href }),
|
|
blocker = new Client({ connectionString: url.href });
|
|
await pg.connect();
|
|
await blocker.connect();
|
|
try {
|
|
const schema = 'qa_' + key().replaceAll('-', '');
|
|
await pg.query(`CREATE SCHEMA "${schema}"; SET search_path TO "${schema}"`);
|
|
for (const [i, table] of models.entries()) {
|
|
const col = i < 5 ? 'sequenceId' : 'ackResult';
|
|
await pg.query(
|
|
`CREATE TABLE "${table}" (id int, "${col}" integer); CREATE INDEX "${table}_qa" ON "${table}"(id); INSERT INTO "${table}" VALUES (1,NULL),(2,0),(3,2147483647)`,
|
|
);
|
|
}
|
|
await pg.query('UPDATE "CmppDownstreamDeliveryAttempt" SET "ackResult"=-1 WHERE id=2');
|
|
await assert.rejects(pg.query(sql));
|
|
await pg.query('ROLLBACK');
|
|
assert.equal(
|
|
(await pg.query('SELECT pg_typeof("sequenceId")::text AS typ FROM "UpstreamReceiptInbox" LIMIT 1')).rows[0].typ,
|
|
'integer',
|
|
);
|
|
await pg.query('UPDATE "CmppDownstreamDeliveryAttempt" SET "ackResult"=0 WHERE id=2');
|
|
await blocker.query(`BEGIN; LOCK TABLE "${schema}"."UpstreamReceiptInbox" IN ACCESS EXCLUSIVE MODE`);
|
|
await assert.rejects(pg.query(sql), (e) => e.code === '55P03');
|
|
await pg.query('ROLLBACK');
|
|
await blocker.query('ROLLBACK');
|
|
const before = await pg.query('SELECT pg_current_wal_lsn() AS lsn');
|
|
const started = Date.now();
|
|
await pg.query(sql);
|
|
const wal = await pg.query('SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), $1) AS bytes', [before.rows[0].lsn]);
|
|
for (const [i, table] of models.entries()) {
|
|
const col = i < 5 ? 'sequenceId' : 'ackResult';
|
|
assert.deepEqual(
|
|
(await pg.query(`SELECT "${col}" FROM "${table}" ORDER BY id`)).rows.map((r) => r[col]),
|
|
[null, '0', '2147483647'],
|
|
);
|
|
}
|
|
assert.equal(
|
|
Number((await pg.query('SELECT count(*) AS n FROM pg_indexes WHERE schemaname=$1', [schema])).rows[0].n),
|
|
7,
|
|
);
|
|
pass('migration_rollback_lock_timeout_and_preservation', {
|
|
durationMs: Date.now() - started,
|
|
walBytes: wal.rows[0].bytes,
|
|
fixtureRows: 21,
|
|
});
|
|
} finally {
|
|
await pg.end();
|
|
await blocker.end();
|
|
}
|
|
} finally {
|
|
await redis.quit();
|
|
await app.close();
|
|
}
|