This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user