401 lines
17 KiB
JavaScript
401 lines
17 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 } }), 1);
|
|
pass('carrier reduction saves with active group references preserved');
|
|
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();
|
|
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();
|
|
}
|