Files
lislgosms/tools/testing/verify-template-optout.mjs
T
2026-09-21 19:28:50 +08:00

404 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { writeFileSync } from 'node:fs';
const url = new URL(process.env.OPT_OUT_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_optout_'));
const redis = new URL(process.env.OPT_OUT_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
assert(['127.0.0.1', 'localhost'].includes(redis.hostname));
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redis.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED: 'true',
});
// No workers/publishers/Gateway are started. Commands stay in this isolated database.
const require = createRequire(new URL('../../api/package.json', import.meta.url));
Object.assign(process.env, {
GATEWAY_STARTUP_RECONNECT_DELAY_MS: '3600000',
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
CMPP_PROCESS_ROLE: 'api',
API_ENABLE_SEND_WORKER: 'false',
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
});
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core'),
{ AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service'),
{ SessionService } = require('./dist/auth/session.service');
const { UsersService } = require('./dist/users/users.service'),
{ SendChainService } = require('./dist/send-chain/send-chain.service');
const { completionContext } = require('./dist/send-chain/completion-context');
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
const pass = (name) => console.log('PASS', name);
try {
await app.listen(Number(process.env.OPT_OUT_TEST_PORT || 0), '127.0.0.1');
const base = (await app.getUrl()) + '/api',
db = app.get(PrismaService),
chain = app.get(SendChainService);
const stamp = randomUUID().slice(0, 8),
key = () => randomUUID();
const tenant = await db.tenant.create({ data: { name: '拒收策略验收企业', code: key() } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '拒收策略隔离应用',
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'unused',
interfaceEnabled: false,
templateMismatchMode: 'direct_send',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: '【拒收验收】', auditStatus: 'approved' },
});
const content = signature.name + '文'.repeat(65); // 71 characters, two parts.
assert.equal(content.length, 71);
const template = await db.smsTemplate.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
name: '拒收策略验收模板' + stamp,
content,
auditStatus: 'approved',
billingUnits: 2,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离通道甲' + stamp,
code: key(),
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
sendRegion: '全国',
unitPrice: 325,
config: { serviceId: 'SMS' },
},
});
const backup = await db.smsChannel.create({
data: {
name: '隔离通道乙',
code: key(),
carrier: 'mobile',
carriers: ['mobile'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
sendRegion: '全国',
unitPrice: 325,
config: { serviceId: 'SMS' },
},
});
for (const c of [channel, backup]) {
await db.cmppConnectionState.create({
data: { channelId: c.id, connectionId: key(), status: 'connected', currentConnections: 1 },
});
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
channelId: c.id,
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
},
});
}
const group = await db.smsChannelGroup.create({
data: {
code: key(),
name: '隔离移动组',
carrier: 'mobile',
items: {
create: [
{ channelId: channel.id, carrier: 'mobile', priority: 1 },
{ channelId: backup.id, carrier: 'mobile', priority: 2 },
],
},
},
});
const unicom = await db.smsChannelGroup.create({
data: {
code: key(),
name: '隔离联通组',
carrier: 'unicom',
items: { create: { channelId: channel.id, carrier: 'unicom' } },
},
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: unicom.id, carrier: 'unicom' },
});
const users = app.get(UsersService),
sessions = app.get(SessionService);
const user = await users.create({
username: 'optout' + stamp,
email: stamp + '@example.invalid',
displayName: '隔离运营验收',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const session = await sessions.create(user.id, 'admin', 0),
cookie = sessions.cookieName('admin');
const headers = { 'content-type': 'application/json', cookie: cookie + '=' + session.token };
const req = (path, body, method = body === undefined ? 'GET' : 'PUT', head = headers) =>
fetch(base + path, { method, headers: head, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const path = '/admin/enterprise-templates/' + template.id + '/opt-out-policy';
assert.equal((await req(path, undefined, 'GET', {})).status, 401);
const customer = await users.create({
username: 'client' + stamp,
email: 'c' + stamp + '@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
const clientSession = await sessions.create(customer.id, 'client', 0);
assert.equal(
(await req(path, undefined, 'GET', { cookie: sessions.cookieName('client') + '=' + clientSession.token })).status,
401,
);
assert.equal((await req(path, { rules: [], preserveFragments: false })).status, 400);
assert.equal(
(await req(path, { rules: [{ channelId: 'foreign', action: 'add' }], preserveFragments: true })).status,
400,
);
const rules = [
{ channelId: channel.id, action: 'add' },
{ channelId: backup.id, action: 'remove' },
];
assert.equal((await req(path, { rules, preserveFragments: true })).status, 200);
assert.deepEqual((await req(path).then((r) => r.json())).rules, rules);
assert.equal(
await db.operationLog.count({ where: { resourceId: template.id, action: 'sms_template.opt_out_policy.update' } }),
1,
);
pass('real HTTP policy save/read, authentication, scope, mandatory fragment protection and durable audit');
const reduced = await req('/admin/channels/' + channel.id, { carriers: ['mobile'] });
assert.equal(reduced.status, 200, await reduced.text());
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, ['mobile']);
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 0);
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: group.id, channelId: channel.id } }), 1);
pass('carrier reduction removes incompatible members and preserves supported group members');
const batch = await db.smsBatchTask.create({
data: { tenantId: tenant.id, applicationId: application.id, taskNo: key(), content, phoneTotal: 1 },
});
const message = await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
batchTaskId: batch.id,
messageId: key(),
phoneNumber: '13800138000',
carrier: 'mobile',
content,
billingUnits: 2,
unitPrice: 425,
amountCents: 850,
},
});
// No templateId intentionally: direct_send must still match its template.
const route = await chain.selectChannelForMessage(message);
assert.equal(route.contentPolicy.content, content + '拒收请回复R');
await chain.submitMessageToGateway(message, route, 0);
const first = await db.smsSubmitRecord.findFirstOrThrow({ where: { messageRecordId: message.id } });
const written = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const outbox = await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } });
assert.equal(written.originalContent, content);
assert.equal(written.content, outbox.payload.content);
assert.equal(first.sentContent, written.content);
assert.equal(written.billingUnits, 2);
assert.equal(Number(written.amountCents), 850);
assert.equal(Number(first.costAmountCents), 650);
const route2 = await chain.selectChannelForMessage(written, { excludeChannelIds: [channel.id] });
await chain.submitMessageToGateway(written, route2, 1, first.id);
const retried = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
assert.equal(retried.content, content);
assert.equal(retried.originalContent, content);
assert.equal(
(await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } })).payload.content,
content + '拒收请回复R',
);
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id, status: 'pending' } }), 2);
pass('single submit and alternate-channel retry: real transactional content/Outbox snapshots and unchanged charges');
const before = await db.smsSubmitRecord.count();
await assert.rejects(
db.$transaction((tx) =>
completionContext.run({ tx, messageRecordId: message.id }, async () => {
await chain.submitMessageToGateway(retried, route, 2);
throw new Error('rollback verification');
}),
),
/rollback verification/,
);
assert.equal(await db.smsSubmitRecord.count(), before);
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, content);
pass('failed transaction rolls back content, submit and Outbox together');
const gateway = chain.submission.gatewaySubmit;
const many = [];
for (let i = 0; i < 2; i++)
many.push(
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
batchTaskId: batch.id,
messageId: key(),
phoneNumber: '13800138000',
carrier: 'mobile',
content,
billingUnits: 2,
},
}),
);
await gateway.processSendJobBatch(many.map((m) => ({ messageRecordId: m.id })));
for (const m of many) {
const row = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: m.id } });
assert.equal(row.content, content + '拒收请回复R');
assert.equal(row.originalContent, content);
}
pass('microbatch uses the same policy and persists original text');
for (const state of [
{ status: 'delivered', submitStatus: 'accepted', receiptStatus: 'delivered' },
{ status: 'submit_failed', submitStatus: 'rejected' },
{ status: 'failed', submitStatus: 'accepted', receiptStatus: 'undelivered' },
{ status: 'timeout', submitStatus: 'accepted' },
]) {
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
messageId: key(),
phoneNumber: '13800138000',
content,
...state,
},
});
}
const quality = await req('/admin/operations/signature-quality?keyword=' + encodeURIComponent(tenant.name)).then(
(r) => r.json(),
);
const stat = quality.items.find((i) => i.signatureId === signature.id);
assert.equal(stat.total, stat.successCount + stat.submitFailureCount + stat.failureCount + stat.unknownCount);
assert.equal(stat.failureCount, 1);
assert(stat.unknownCount >= 1);
pass('real quality query yields four exclusive categories, including timeout without receipt');
if (process.env.OPT_OUT_TEST_BROWSER === 'true') {
const {
chromium,
} = require('C:/Users/hectorzhao/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
const ui = process.env.OPT_OUT_TEST_UI_URL || 'http://127.0.0.1:17438';
assert.equal(new URL(ui).hostname, '127.0.0.1');
const auth = await req('/admin/auth/session').then((r) => r.json());
await page
.context()
.addCookies([{ name: cookie, value: session.token, url: ui, httpOnly: true, sameSite: 'Lax' }]);
await page.addInitScript((data) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(data)), auth);
for (const [width, height] of [
[1600, 1000],
[1366, 768],
[390, 844],
]) {
await page.setViewportSize({ width, height });
await page.goto(ui + '/#/admin/enterprise-templates');
const row = page.locator('.admin-enterprise-template-row').filter({ hasText: template.name }).first();
await row.getByRole('button', { name: '拒收指令', exact: true }).click();
await page.getByLabel(channel.name + '的拒收指令').waitFor();
assert(await page.getByLabel('避免影响消息分片数').isDisabled());
await page.screenshot({ path: `.local-data/template-optout-20260920/template-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '保存策略', exact: true }).click();
await page.getByRole('heading', { name: '模板拒收指令', exact: true }).waitFor({ state: 'hidden' });
await page.goto(ui + '/#/admin/analytics');
await page.locator('.quality-status-bar__track').first().waitFor();
assert.match(await page.locator('.quality-status-bar__track').first().getAttribute('title'), /未收到回执/);
await page.screenshot({ path: `.local-data/template-optout-20260920/quality-${width}.png`, fullPage: true });
await page.reload();
await page.locator('.quality-status-bar__track').first().waitFor();
await page.goto(ui + '/#/admin/sms-records');
await page
.locator('.admin-sms-record-card')
.filter({ hasText: '拒收请回复R' })
.first()
.getByRole('button', { name: '查看发送详情' })
.click();
await page.getByRole('heading', { name: '原始短信内容', exact: true }).waitFor();
await page.getByRole('heading', { name: /第 1 次提交通道内容/ }).waitFor();
await page.screenshot({ path: `.local-data/template-optout-20260920/detail-${width}.png`, fullPage: true });
await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'unicom'] });
await page.goto(ui + '/#/admin/channels');
await page.getByLabel('通道名称', { exact: true }).fill(channel.name);
await page.getByRole('button', { name: '查询', exact: true }).click();
await page
.locator('.sms-channel-table__row')
.filter({ hasText: channel.name })
.getByRole('button', { name: '编辑', exact: true })
.click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor();
await page.getByRole('checkbox', { name: '联通', exact: true }).uncheck();
assert(await page.getByRole('button', { name: '确认', exact: true }).isDisabled());
await page.getByRole('checkbox', { name: /我已确认移除运营商/ }).check();
await page.screenshot({ path: `.local-data/template-optout-20260920/channel-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '确认', exact: true }).click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor({ state: 'hidden' });
assert.deepEqual((await db.smsChannel.findUniqueOrThrow({ where: { id: channel.id } })).carriers, ['mobile']);
}
assert.deepEqual(errors, []);
pass('real API browser saves, locked checkbox, route/refresh, four-segment bar and three sizes');
} finally {
await browser.close();
}
}
assert.equal(
await db.gatewaySubmitOutbox.count({
where: { messageRecordId: { in: [message.id, ...many.map((m) => m.id)] }, status: { not: 'pending' } },
}),
0,
);
writeFileSync(
'.local-data/template-optout-20260920/fixture.json',
JSON.stringify({ tenantId: tenant.id, templateId: template.id, messageId: message.id }),
);
pass('no command published, no Gateway/SMSC started, no external SMS sent');
} finally {
await app.close();
}