401 lines
15 KiB
JavaScript
401 lines
15 KiB
JavaScript
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();
|
|
}
|