// 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(); }