Files
lislgosms/tools/testing/verify-home-dashboard.mjs
T
2026-09-17 13:11:35 +08:00

211 lines
8.2 KiB
JavaScript

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
const url = new URL(process.env.HOME_TEST_DATABASE_URL || '');
assert(
['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_home_'),
'isolated local database required',
);
Object.assign(process.env, { DATABASE_URL: url.toString(), NODE_ENV: 'test' });
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { HomeProjection } = require('./dist/home-dashboard/home-projection');
const { HomeService } = require('./dist/home-dashboard/home.service');
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService(),
projector = new HomeProjection(db),
read = new HomeService(db);
const T = todayKey(),
now = new Date(),
stamp = randomUUID().slice(0, 8),
pass = (s) => console.log('PASS', s);
const at = (day, hour = 1) => new Date(startOfDay(day).getTime() + hour * 3600000);
try {
assert.equal(
await db.smsMessageRecord.count(),
0,
'use a fresh disposable test database; existing data is never cleared',
);
const tenant = await db.tenant.create({ data: { name: '首页验收企业甲', code: stamp } });
const second = await db.tenant.create({ data: { name: '首页验收企业乙', code: stamp + 'b' } });
for (const t of [tenant, second])
await db.tenantAccount.create({ data: { tenantId: t.id, balanceCents: 185675000n } });
const channel = await db.smsChannel.create({
data: {
name: '首页隔离通道',
code: stamp,
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'no-send',
passwordCipher: 'isolated',
srcId: '1069',
carriers: ['mobile'],
status: 'disabled',
},
});
let serial = 0;
async function sms(day, states, units = 3, receiveDays = [], total = units) {
const id = `${stamp}-${++serial}`;
const delivered = states.length === total && states.every((s) => s === 'delivered');
const m = await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
messageId: id,
phoneNumber: '13800000000',
content: '隔离数据库验收,不发送',
billingUnits: units,
unitPrice: 500n,
amountCents: BigInt(units) * 500n,
status: delivered ? 'delivered' : 'failed',
queuedAt: at(day),
},
});
const attempt = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
channelId: channel.id,
submitId: id,
submitStatus: 'accepted',
costUnitPrice: 300n,
gatewayMessageId: id + '-0',
},
});
for (let i = 0; i < states.length; i++) {
await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: m.id,
submitRecordId: attempt.id,
channelId: channel.id,
submitId: id,
segmentIndex: i + 1,
segmentTotal: total,
gatewayMessageId: id + '-' + i,
receiptStatus: states[i],
submitStatus: 'accepted',
},
});
await receipt(m, attempt, id + '-' + i, states[i], receiveDays[i] ?? T);
}
return { m, attempt };
}
async function receipt(m, attempt, gatewayId, status, date, receivedAt = at(date)) {
return db.upstreamReceiptInbox.create({
data: {
receiptKey: randomUUID(),
incomingChannelId: channel.id,
upstreamAccount: 'no-send',
upstreamHost: '127.0.0.1',
upstreamPort: 1,
protocol: 'cmpp',
protocolVersion: '3.0',
gatewayMessageId: gatewayId,
receiptStatus: status,
rawStatus: status,
deliveredAt: receivedAt,
gatewayReceivedAt: receivedAt,
receivedAt: now,
status: 'matched',
matchedMessageRecordId: m.id,
matchedSubmitRecordId: attempt.id,
matchedChannelId: channel.id,
},
});
}
for (let offset = 0; offset < 4; offset++) {
const day = addDays(T, -offset);
await sms(day, ['delivered', 'delivered', 'delivered']);
await sms(day, ['failed']);
await sms(day, ['delivered', 'delivered']);
await sms(day, ['delivered'], 1);
}
await sms(addDays(T, -4), ['delivered', 'delivered', 'delivered']);
const cross = await sms(addDays(T, -2), ['delivered', 'delivered', 'delivered'], 3, [
addDays(T, -1),
addDays(T, -1),
T,
]);
await receipt(cross.m, cross.attempt, cross.attempt.submitId + '-2', 'delivered', T, at(T, 2));
// Already completed yesterday: today's duplicate must not count.
const yesterday = await sms(addDays(T, -1), ['delivered'], 1, [addDays(T, -1)]);
await receipt(yesterday.m, yesterday.attempt, yesterday.attempt.submitId + '-0', 'delivered', T);
for (const [type, amount, day, relatedType] of [
['refunded', 123456n, T, 'sms_message_record'],
['released', 100n, T, 'sms_message_record'],
['released', 999n, T, 'other'],
['refunded', 999n, addDays(T, -1), 'sms_message_record'],
])
await db.accountTransaction.create({
data: { tenantId: tenant.id, transactionType: type, amountCents: amount, relatedType, createdAt: at(day) },
});
await db.smsBillingRecord.create({
data: {
tenantId: tenant.id,
contentLength: 5,
billingUnits: 10,
unitPrice: 500n,
amountCents: 5000n,
billingStatus: 'charged',
createdAt: at(T),
},
});
await assert.rejects(read.summary('qa'), /初始化/);
await projector.tick();
const s = await read.summary('qa');
assert.equal(s.today.sent, 4);
assert.equal(s.today.delivered, 2);
assert.equal(s.today.successRate, 50);
assert.equal(s.today.receiptUnits, 43);
assert.equal(s.today.successUnits, 19);
assert.equal(s.today.revenueCents, 9500);
assert.equal(s.today.profitCents, 3800);
assert.equal(s.enterpriseSpendRanks[0].todayReturnedCents, 123556);
assert.equal(s.enterpriseSpendRanks[0].todaySpendCents, 5000);
pass('four-day cohorts, long missing/partial success, cross-midnight completion, global replay dedup, refund ledger');
const r = await read.breakdown('qa', s.snapshotToken, 'receipt'),
f = await read.breakdown('qa', s.snapshotToken, 'revenue');
assert.equal(r.items.length, 4);
assert.equal(f.items.length, 4);
assert.equal(
r.items.reduce((a, b) => a + b.total, 0),
s.today.receiptUnits,
);
assert.equal(
f.items.reduce((a, b) => a + b.revenueCents, 0),
s.today.revenueCents,
);
await assert.rejects(read.breakdown('other', s.snapshotToken, 'receipt'), /不可访问/);
await assert.rejects(read.breakdown('qa', 'invalid', 'receipt'), /无效/);
await assert.rejects(read.breakdown('qa', s.snapshotToken, 'receipt', new Date(now.getTime() + 3600000)), /过期/);
pass('snapshot total reconciliation, ownership, validation, expiry');
await sms(T, ['delivered'], 1);
await projector.tick();
assert.deepEqual(await read.breakdown('qa', s.snapshotToken, 'receipt'), r);
assert.equal((await read.summary('qa')).today.successUnits, 20);
pass('old immutable snapshot remains consistent after new receipt publication');
// Transaction rollback must leave both source and pending work unchanged.
const before = await db.homeProjectionDirty.count();
await assert.rejects(
db.$transaction(async (tx) => {
await tx.smsMessageRecord.update({ where: { id: cross.m.id }, data: { unitPrice: 999n } });
throw new Error('injected rollback');
}),
/injected/,
);
assert.equal(await db.homeProjectionDirty.count(), before);
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: cross.m.id } })).unitPrice, 500n);
const lock = db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(17100917)`;
await new Promise((r) => setTimeout(r, 500));
});
await new Promise((r) => setTimeout(r, 100));
assert.equal((await projector.tick()).busy, true);
await lock;
pass('source/dirty atomic rollback and independent worker atomic claim');
console.log(
JSON.stringify({ database: url.pathname, fixtureMessages: await db.smsMessageRecord.count(), status: 'PASS' }),
);
} finally {
await db.$disconnect();
}