import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || ''); assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_')); process.env.DATABASE_URL = url.toString(); process.env.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 { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service'); const { SignatureAnalyticsRead } = require('./dist/signature-analytics/analytics-read'); const { OperationsQualityQueries } = require('./dist/operations/queries/quality.queries'); const { analyticsJob } = require('./dist/signature-analytics/analytics-job'); const { detectRetirement } = require('./dist/signature-analytics/retirement-batch'); const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching'); const { todayKey, addDays, startOfDay, databaseDay } = require('./dist/signature-analytics/analytics-date'); const db = new PrismaService(), writer = new SignatureAnalyticsService(db), reader = new SignatureAnalyticsRead(db); const prefix = randomUUID().slice(0, 8), T = todayKey(), D = addDays(T, -1), old = addDays(T, -5); const pass = (name) => console.log('PASS', name); const at = (date, seconds = 3600) => new Date(startOfDay(date).getTime() + seconds * 1000); try { const tenant = await db.tenant.create({ data: { name: `签名日报验收${prefix}`, code: prefix } }); const app = await db.smsApplication.create({ data: { tenantId: tenant.id, name: '应用甲', cmppAccount: prefix, cmppEnterpriseCode: '000001', secretHash: 'isolated-no-login', interfaceEnabled: false, }, }); const channel = await db.smsChannel.create({ data: { name: '隔离通道', code: prefix, gatewayHost: '127.0.0.1', gatewayPort: 1, account: 'isolated', passwordCipher: 'not-a-secret', srcId: '10690000', carriers: ['mobile'], status: 'disabled', }, }); const signature = await db.smsSignature.create({ data: { tenantId: tenant.id, applicationId: app.id, name: `【验收${prefix}】`, auditStatus: 'approved' }, }); await db.channelSignatureReportTask.create({ data: { tenantId: tenant.id, signatureId: signature.id, channelId: channel.id, reportType: 'signature', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', approvedAt: at(addDays(T, -60)), createdAt: at(addDays(T, -60)), }, }); async function message({ date = D, submitDate = date, units = 1, segments = units, delivered = true, phone = '13800000001', sig = signature.id, content = signature.name + '隔离数据', } = {}) { const m = await db.smsMessageRecord.create({ data: { messageId: randomUUID(), tenantId: tenant.id, applicationId: app.id, signatureId: sig, phoneNumber: phone, content, carrier: 'mobile', queuedAt: at(date), submittedAt: at(submitDate), billingUnits: units, status: delivered && segments === units ? 'delivered' : 'submitted', submitStatus: 'accepted', receiptStatus: delivered && segments === units ? 'delivered' : null, deliveredAt: delivered && segments === units ? at(submitDate, 3602) : null, }, }); const s = await db.smsSubmitRecord.create({ data: { messageRecordId: m.id, tenantId: tenant.id, channelId: channel.id, submitId: randomUUID(), submitStatus: 'accepted', submittedAt: at(submitDate), gatewayMessageId: randomUUID(), createdAt: at(submitDate), }, }); for (let index = 1; index <= segments; index++) await db.smsMessageSegmentAudit.create({ data: { messageRecordId: m.id, submitRecordId: s.id, channelId: channel.id, submitId: s.submitId, segmentTotal: units, segmentIndex: index, submitStatus: 'accepted', receiptStatus: delivered ? 'delivered' : null, deliveredAt: delivered ? at(submitDate, 3600 + index) : null, }, }); return { m, s }; } const complete = await message({ units: 3, phone: '13800000011' }); const partial = await message({ units: 3, segments: 2, phone: '13800000012' }); await message({ date: addDays(D, -1), submitDate: D, phone: '13800000013' }); await message({ sig: null, content: `【未登记${prefix}】隔离`, phone: '13800000014' }); await message({ date: T, phone: '13800000015' }); const oldMessage = await message({ date: old, phone: '13800000016' }); // A rerun uses an isolated database; do not reset or touch any real tenant environment. await writer.generate(D); const quality = await reader.quality({ date: D, keyword: prefix }); const item = quality.items.find((x) => x.signatureId === signature.id); assert(item); assert.equal(item.total, 2); assert.equal(item.channelSubmitTotal, 3); assert.equal(item.breakdowns[0].successCount, 2); assert.equal(item.breakdowns[0].unknownCount, 1); pass('日报业务/尝试归日分离、三段成功与缺片未知'); const activity = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix }); assert.equal(activity.dimensions.length, 1); assert.equal(activity.items.length, 1); assert.equal(activity.items[0].acceptedBusinessCount, 3); assert.equal(activity.items[0].deliveredBusinessCount, 2); assert.equal(activity.coverage[0].date, D); assert.equal(activity.complete, false); pass('企业/通道服务端分页与D-1至D-30缺口元数据'); assert.equal((await reader.unreported({ date: D, keyword: prefix })).total, 1); const live = await new OperationsQualityQueries(db).signatureQuality({ date: T, keyword: prefix }); assert.equal(live.dataSource, 'live'); assert.equal(live.items[0].total, 1); pass('当天真实查询、历史只读日报、未报备日报'); await writer.generate(old, true); const before = await reader.quality({ date: old, keyword: prefix }); await db.smsMessageRecord.update({ where: { id: oldMessage.m.id }, data: { status: 'failed', receiptStatus: 'undelivered' }, }); assert.equal((await writer.generate(old)).skipped, true); assert.deepEqual((await reader.quality({ date: old, keyword: prefix })).items, before.items); await assert.rejects(writer.generate(old, true), /覆盖/); pass('T-4及更早冻结、显式补建不覆盖'); await db.signatureRetirementRule.create({ data: { ruleType: 'enterprise_global', targetKey: '', mobileWindowDays: 7, mobileThreshold: 100, unicomWindowDays: 7, unicomThreshold: 100, telecomWindowDays: 7, telecomThreshold: 100, }, }); await db.smsSubmitRecord.create({ data: { messageRecordId: oldMessage.m.id, channelId: channel.id, submitId: randomUUID(), submitStatus: 'accepted', submittedAt: at(addDays(old, 1)), }, }); const detection = await detectRetirement(db, T); const decision = await db.signatureRetirementDetection.findFirst({ where: { signatureId: signature.id, dimensionType: 'enterprise', detectionDate: databaseDay(T) }, }); assert(decision.notificationContent.includes('发送4条')); assert(detection.alerted >= 1); const count = await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } }); assert.equal((await detectRetirement(db, T)).skipped, true); assert.equal(await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } }), count); pass('批量退网窗口聚合、重复任务提前退出'); await message({ phone: '13800000021' }); await message({ phone: '13800000021' }); const matched = await resolveUplinkMatch( db, { channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() }, channel, ); assert.equal(matched.matchStatus, 'matched'); assert.equal(matched.applicationId, app.id); assert.equal(matched.messageRecordId, undefined); const beforeSending = await resolveUplinkMatch( db, { channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 3000).toISOString() }, channel, ); assert.equal(beforeSending.matchStatus, 'unmatched'); pass('上行同应用多短信仍归属唯一、未来下发不参与'); const channel2 = await db.smsChannel.create({ data: { name: '隔离通道乙', code: prefix + 'b', gatewayHost: '127.0.0.1', gatewayPort: 1, account: 'isolated', passwordCipher: 'not-a-secret', srcId: '10690001', carriers: ['mobile'], status: 'disabled', }, }); const wrong = await resolveUplinkMatch( db, { channelId: channel2.id, messageId: complete.m.messageId, phoneNumber: complete.m.phoneNumber, destId: '1069000199', receivedAt: at(D, 7200).toISOString(), }, channel2, ); assert.equal(wrong.matchStatus, 'unmatched'); assert.equal(wrong.messageId, undefined); const app2 = await db.smsApplication.create({ data: { tenantId: tenant.id, name: '应用乙', cmppAccount: prefix + 'b', cmppEnterpriseCode: '000002', secretHash: 'isolated-no-login', interfaceEnabled: false, }, }); const other = await message({ phone: '13800000021' }); await db.smsMessageRecord.update({ where: { id: other.m.id }, data: { applicationId: app2.id } }); const multi = await resolveUplinkMatch( db, { channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() }, channel, ); assert.equal(multi.matchStatus, 'ambiguous'); assert.equal(multi.candidates.length, 2); pass('错误messageId/其他通道拒绝归属、第三条不同应用不会被截断漏掉'); // Fault injection is confined to this disposable QA database. const late = await db.smsMessageSegmentAudit.create({ data: { messageRecordId: partial.m.id, submitRecordId: partial.s.id, channelId: channel.id, submitId: partial.s.submitId, segmentTotal: 3, segmentIndex: 3, submitStatus: 'accepted', receiptStatus: 'delivered', deliveredAt: at(D, 3610), }, }); await db.signatureAnalyticsRun.update({ where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(D) } }, data: { refreshFor: databaseDay(D) }, }); const prior = quality.generationId; await writer.generate(D); const refreshed = await reader.quality({ date: D, keyword: prefix }); assert.notEqual(refreshed.generationId, prior); assert.equal(refreshed.items[0].breakdowns[0].successCount, 6); assert((await db.signatureQualityDaily.count({ where: { generationId: prior } })) > 0); const secondDay = addDays(T, -2); await message({ date: secondDay, units: 2 }); await message({ date: secondDay, units: 4 }); const legacy = await message({ date: secondDay, units: 4, segments: 0 }); await db.smsReceiptRecord.create({ data: { receiptKey: randomUUID(), messageRecordId: legacy.m.id, messageId: legacy.m.messageId, channelId: channel.id, gatewayMessageId: legacy.s.gatewayMessageId, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: at(secondDay, 3605), }, }); await writer.generate(secondDay); const multiLength = await reader.quality({ date: secondDay, keyword: prefix }); assert.equal(multiLength.items[0].breakdowns[0].successCount, 3); assert.equal(multiLength.items[0].breakdowns[0].averageArrivalMs, 3667); const outOfRange = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix, page: 99 }); assert.equal(outOfRange.total, 1); assert.equal(outOfRange.dimensions.length, 0); pass('两段/四段、历史无分段明确回执兼容、耗时加权、超出页码仍有正确总数'); await writer.generate(addDays(T, -3)); pass('最近三日生成、迟到长短信分段刷新、旧版本保留'); const failedScope = 'qa-failure-' + prefix, rollbackGeneration = randomUUID(); await assert.rejects( analyticsJob(db, failedScope, D, async (tx) => { await tx.signatureAnalyticsGeneration.create({ data: { id: rollbackGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() }, }); throw new Error('injected'); }), /injected/, ); assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: rollbackGeneration } }), 0); const failed = await db.signatureAnalyticsRun.findUnique({ where: { scope_businessDate: { scope: failedScope, businessDate: databaseDay(D) } }, }); assert.equal(failed.state, 'retry_wait'); await db.signatureAnalyticsRun.update({ where: { id: failed.id }, data: { nextAttemptAt: new Date(0) } }); assert.equal((await analyticsJob(db, failedScope, D, async () => 42)).result, 42); pass('中途失败原子回滚、持久重试与恢复'); const scope = 'qa-concurrent-' + prefix; let release, entered; const gate = new Promise((r) => (release = r)), ready = new Promise((r) => (entered = r)); const owner = analyticsJob(db, scope, D, async () => { entered(); await gate; return 1; }); await ready; assert.equal((await analyticsJob(db, scope, D, async () => 2)).skipped, true); release(); assert.equal((await owner).result, 1); const fencedScope = 'qa-fenced-' + prefix, fencedGeneration = randomUUID(); await assert.rejects( analyticsJob(db, fencedScope, D, async (tx) => { await tx.signatureAnalyticsGeneration.create({ data: { id: fencedGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() }, }); await db.signatureAnalyticsRun.update({ where: { scope_businessDate: { scope: fencedScope, businessDate: databaseDay(D) } }, data: { fence: { increment: 1 }, owner: 'replacement' }, }); }), ); assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: fencedGeneration } }), 0); pass('双worker唯一认领、旧fence禁止发布并回滚候选版本'); const checkpointScope = 'qa-checkpoint-' + prefix; await assert.rejects( analyticsJob( db, checkpointScope, D, async () => { throw new Error('checkpoint fault'); }, new Date(), async () => ({ version: 1 }), ), /checkpoint fault/, ); await db.signatureAnalyticsRun.update({ where: { scope_businessDate: { scope: checkpointScope, businessDate: databaseDay(D) } }, data: { nextAttemptAt: new Date(0) }, }); const resumed = await analyticsJob( db, checkpointScope, D, async (_tx, _generation, checkpoint) => checkpoint, new Date(), async () => ({ version: 2 }), ); assert.deepEqual(resumed.result, { version: 1 }); pass('重试沿用首轮冻结规则快照'); assert(late.id); console.log( JSON.stringify({ fixturePrefix: prefix, tenantId: tenant.id, applicationId: app.id, signatureId: signature.id, partial: partial.s.id, complete: complete.s.id, }), ); } finally { await db.onModuleDestroy(); }