Files
lislgosms/tools/testing/verify-night-sending-postgres.mjs
T
2026-09-07 22:55:29 +08:00

285 lines
12 KiB
JavaScript

// Isolated PostgreSQL and Redis verification. Never calls a Gateway or business queue.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import pg from '../../api/node_modules/pg/lib/index.js';
import prisma from '../../api/node_modules/@prisma/client/default.js';
import adapter from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
import night from '../../api/dist/risk-review/night-sending-risk.service.js';
import risk from '../../api/dist/risk-review/risk-review.service.js';
import continuation from '../../api/dist/send-chain/send-review-continuation.service.js';
import bullmq from '../../api/node_modules/bullmq/dist/cjs/index.js';
const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL;
if (!connectionString) throw Error('QA_DATABASE_URL is required');
const schema = `qa_night_${randomUUID().replaceAll('-', '')}`;
assert.match(schema, /^qa_night_[a-f0-9]{32}$/);
const admin = new pg.Client({ connectionString });
const clients = [],
checks = [];
let queue;
await admin.connect();
try {
const before = (
await admin.query(
'SELECT (SELECT count(*) FROM public."SmsMessageRecord") messages,(SELECT count(*) FROM public."SmsSubmitRecord") submits',
)
).rows[0];
await admin.query(`CREATE SCHEMA "${schema}"`);
for (const table of [
'Tenant',
'SmsApplication',
'SmsBatchTask',
'SmsMessageRecord',
'SmsSubmitRecord',
'SmsSendTask',
'RiskRule',
'RiskHitRecord',
'User',
'SmsTemplate',
'TemplateVariable',
'SmsSignature',
'SensitiveWord',
]) {
await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`);
}
await admin.query(`SET search_path TO "${schema}"`);
// LIKE also copies these columns after the release; recreate them only inside
// this isolated schema so the forward migration is tested on every run.
await admin.query(
'ALTER TABLE "SmsSendTask" DROP COLUMN IF EXISTS "continuationLeaseOwner", DROP COLUMN IF EXISTS "continuationLeaseExpiresAt"',
);
await admin.query(
readFileSync(
new URL('../../api/prisma/migrations/20260907143000_night_sending_risk/migration.sql', import.meta.url),
'utf8',
),
);
for (let i = 0; i < 2; i++) {
const pool = new pg.Pool({ connectionString, max: 4, options: `-c search_path=${schema} -c timezone=UTC` });
clients.push(
new prisma.PrismaClient({ adapter: new adapter.PrismaPg(pool, { schema, disposeExternalPool: true }) }),
);
}
const [db, db2] = clients;
assert.equal((await db.$queryRawUnsafe('SELECT current_schema() AS name'))[0].name, schema);
for (const tenantId of ['t1', 't2'])
await db.tenant.create({ data: { id: tenantId, code: tenantId, name: tenantId } });
for (const [id, tenantId] of [
['a1', 't1'],
['a2', 't1'],
['a3', 't1'],
['b1', 't2'],
])
await db.smsApplication.create({
data: { id, tenantId, name: id, cmppAccount: id, cmppEnterpriseCode: 'qa', secretHash: 'isolated-unused' },
});
const ruleData = {
code: night.NIGHT_RULE_CODE,
name: '夜间累计发送量审核',
metric: 'nightSendingCount',
thresholdValue: 5000,
action: 'manual_review',
status: 'active',
config: { startTime: '21:00', endTime: '08:00', timeZone: 'Asia/Shanghai' },
};
const global = await db.riskRule.create({ data: { ...ruleData, id: 'global' } });
const now = new Date('2026-09-06T14:00:01Z');
const window = night.nightWindow(now, night.nightClock(null));
await db.nightSendingWindow.create({
data: {
id: `a1:${window.windowStartedAt.toISOString()}`,
tenantId: 't1',
applicationId: 'a1',
...window,
count: 4990,
baselineCount: 4990,
},
});
let sequence = 0;
async function message(app = 'a1', content = '【测试】通知验证码和任意内容', sourceType = 'cmpp', extra = {}) {
const id = `m${++sequence}`,
tenantId = app === 'b1' ? 't2' : 't1';
await db.smsBatchTask.create({
data: {
id: `task-${id}`,
taskNo: `task-${id}`,
tenantId,
applicationId: app,
content,
phoneTotal: 1,
sourceType,
status: 'queued',
},
});
return db.smsMessageRecord.create({
data: {
id,
messageId: id,
tenantId,
applicationId: app,
batchTaskId: `task-${id}`,
phoneNumber: '13800000001',
content,
status: 'queued',
...extra,
},
});
}
const services = [new night.NightSendingRiskService(db), new night.NightSendingRiskService(db2)];
const messages = [];
for (let i = 0; i < 20; i++) messages.push(await message('a1', undefined, ['cmpp', 'api', 'client'][i % 3]));
const results = await Promise.all(messages.map((m, i) => services[i % 2].guard([m.id], now)));
assert.equal(
results.reduce((sum, result) => sum + result.size, 0),
10,
);
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a1' } })).count, 5010);
assert.equal(await db.nightSendingReservation.count(), 20);
const held = await db.smsMessageRecord.findMany({ where: { status: 'pending_review' } });
const tasks = await db.smsSendTask.findMany();
assert.equal(tasks.length, 1);
assert.equal(tasks[0].phoneTotal, 10);
assert.equal(tasks[0].uniquePhoneTotal, 1);
checks.push('mixed sources + 2 instances concurrent boundary 4990->5010; same content aggregation');
await Promise.all(messages.map((m, i) => services[i % 2].guard([m.id], now)));
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a1' } })).count, 5010);
checks.push('duplicate jobs are idempotent');
const other = await message('a1', '另一种内容');
await services[0].guard([other.id], now);
assert.equal(await db.smsSendTask.count(), 2);
const midnight = await message();
await services[1].guard([midnight.id], new Date('2026-09-06T16:00:00Z'));
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a1' } })).count, 5012);
checks.push('different content separate review; midnight retains application total');
const a2 = await message('a2'),
b1 = await message('b1');
assert.equal((await services[0].guard([a2.id, b1.id], now)).size, 0);
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count, 1);
checks.push('application and tenant isolation');
const override = await db.riskRule.create({
data: { ...ruleData, applicationId: 'a2', tenantId: 't1', thresholdValue: 1 },
});
const overrideMessage = await message('a2');
assert.equal((await services[0].guard([overrideMessage.id], now)).size, 1);
await db.riskRule.update({ where: { id: override.id }, data: { status: 'inactive' } });
const fallback = await message('a2');
assert.equal((await services[0].guard([fallback.id], now)).size, 0);
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count, 3);
checks.push('individual threshold override + disable restores global without clearing count');
const day = await message('b1');
assert.equal((await services[0].guard([day.id], new Date('2026-09-07T04:00:00Z'))).size, 0);
assert.equal(await db.nightSendingReservation.count({ where: { messageRecordId: day.id } }), 0);
await services[0].guard([day.id], new Date('2026-09-07T13:00:00Z'));
assert.equal(await db.nightSendingReservation.count({ where: { messageRecordId: day.id } }), 1);
checks.push('daytime queue + nighttime execution/scheduled task evaluated at dispatch');
const retry = await message('a1', undefined, 'cmpp', { submitId: 'already-submitted', billingUnits: 3 });
await services[0].guard([retry.id], now);
assert.equal(await db.nightSendingReservation.count({ where: { messageRecordId: retry.id } }), 0);
checks.push('retry/segments do not consume new allowance');
const legacy = await message('a3', '旧版本本夜首次提交', 'api', { status: 'submitted', submitId: 'legacy' });
const previousDay = await message('a3', '昨日首次提交本夜重试', 'cmpp', {
status: 'submitted',
submitId: 'previous',
});
for (const [id, messageRecordId, createdAt] of [
['legacy-first', legacy.id, now],
['legacy-retry', legacy.id, now],
['previous-first', previousDay.id, new Date(now.getTime() - 86_400_000)],
['previous-retry', previousDay.id, now],
])
await db.smsSubmitRecord.create({
data: { id, submitId: id, messageRecordId, channelId: 'isolated-unused', createdAt },
});
const newAfterDeploy = await message('a3');
await services[0].guard([newAfterDeploy.id], now);
const initialized = await db.nightSendingWindow.findFirst({ where: { applicationId: 'a3' } });
assert.equal(initialized.baselineCount, 1);
assert.equal(initialized.count, 2);
checks.push('midnight deployment bootstrap counts first business submissions, not retry attempts');
const review = new risk.RiskReviewService(db);
const changed = await review.updateRule(global.id, {
thresholdValue: 1,
config: { startTime: '23:00', endTime: '07:00', timeZone: 'Asia/Shanghai' },
});
assert.equal(changed.thresholdValue, 1);
await assert.rejects(review.updateRule(global.id, { thresholdValue: 1.5 }));
await assert.rejects(review.updateRule(global.id, { action: 'block' }));
await assert.rejects(
review.updateRule(global.id, { config: { startTime: '21:00', endTime: '08:00', timeZone: 'UTC' } }),
);
checks.push('rule write validation and forced manual review');
await review.approveTask(tasks[0].id, { reason: '隔离审核' });
await review.approveTask(tasks[0].id, { reason: '重复请求' });
await assert.rejects(review.rejectTask(tasks[0].id, { reason: '相反决定' }));
const redis = new URL(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
queue = new bullmq.Queue(schema, {
connection: { host: redis.hostname, port: Number(redis.port || 6379), password: redis.password || undefined },
});
let failQueue = true;
const facade = {
getSendQueue: () => ({
add: (...args) => {
if (failQueue) throw Error('isolated injected queue outage');
return queue.add(...args);
},
}),
refreshTaskProgress: async () => {},
};
const resumes = new continuation.SendReviewContinuationService(db, {}, {}, {}, {}, facade, {
releaseMessageReservation: async () => {},
recordCmppFailureReceipt: async () => {},
});
await assert.rejects(resumes.handleReviewDecision(tasks[0].id, 'approved', '隔离审核'));
assert((await review.pendingNightContinuations()).some((t) => t.id === tasks[0].id));
failQueue = false;
await Promise.all([
resumes.handleReviewDecision(tasks[0].id, 'approved', '隔离审核'),
resumes.handleReviewDecision(tasks[0].id, 'approved', '隔离审核'),
]);
const queued = await queue.getJobCounts('wait', 'prioritized');
assert.equal(queued.wait + queued.prioritized, 10);
assert.equal(await db.nightSendingReservation.count({ where: { reviewTaskId: tasks[0].id, continuedAt: null } }), 0);
for (const m of held) assert.equal((await services[0].guard([m.id], now)).size, 0);
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: other.id } })).status, 'pending_review');
checks.push('review idempotency/conflict + real Redis recovery + approved range only');
const invalid = await message('a2');
await admin.query(
`ALTER TABLE "${schema}"."NightSendingReservation" ADD CONSTRAINT injected_failure CHECK ("messageRecordId" <> '${invalid.id}')`,
);
const countBefore = (await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count;
await assert.rejects(services[0].guard([invalid.id], now));
assert.equal((await db.nightSendingWindow.findFirst({ where: { applicationId: 'a2' } })).count, countBefore);
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: invalid.id } })).status, 'queued');
checks.push('transaction failure rolls back counter and review atomically');
const after = (
await admin.query(
'SELECT (SELECT count(*) FROM public."SmsMessageRecord") messages,(SELECT count(*) FROM public."SmsSubmitRecord") submits',
)
).rows[0];
assert.deepEqual(after, before);
console.log(
JSON.stringify(
{
passed: checks.length,
checks,
schema,
publicBefore: before,
publicAfter: after,
businessQueueWrites: 0,
gatewayCalls: 0,
},
null,
2,
),
);
} finally {
if (queue) {
await queue.obliterate({ force: true });
await queue.close();
}
for (const client of clients) await client.$disconnect();
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
await admin.end();
}