|
|
|
@@ -0,0 +1,471 @@
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { createRequire } from 'node:module';
|
|
|
|
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
|
|
|
import { mkdirSync } from 'node:fs';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
|
|
|
|
const url = new URL(process.env.OPERATIONS_TEST_DATABASE_URL || '');
|
|
|
|
|
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_operations_six_'));
|
|
|
|
|
const redis = new URL(process.env.OPERATIONS_TEST_REDIS_URL || 'redis://127.0.0.1:16452');
|
|
|
|
|
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',
|
|
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
// Only isolated fixtures are written. No send API, worker, or Gateway is started.
|
|
|
|
|
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
|
|
|
|
require('reflect-metadata');
|
|
|
|
|
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
|
|
|
|
value() {
|
|
|
|
|
return Number(this);
|
|
|
|
|
},
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
const { NestFactory } = require('@nestjs/core');
|
|
|
|
|
const { AppModule } = require('./dist/app.module');
|
|
|
|
|
const { PrismaService } = require('./dist/prisma/prisma.service');
|
|
|
|
|
const { UsersService } = require('./dist/users/users.service');
|
|
|
|
|
const { SessionService } = require('./dist/auth/session.service');
|
|
|
|
|
const { HomeProjection } = require('./dist/home-dashboard/home-projection');
|
|
|
|
|
const { todayKey, startOfDay } = require('./dist/signature-analytics/analytics-date');
|
|
|
|
|
const { hourlySendTrend } = require('./dist/home-dashboard/home-read');
|
|
|
|
|
const app = await NestFactory.create(AppModule, { logger: ['error'] });
|
|
|
|
|
app.setGlobalPrefix('api');
|
|
|
|
|
let browser;
|
|
|
|
|
const pass = (name) => console.log('PASS', name);
|
|
|
|
|
try {
|
|
|
|
|
await app.listen(Number(process.env.OPERATIONS_TEST_PORT || 17451), '127.0.0.1');
|
|
|
|
|
const base = (await app.getUrl()) + '/api';
|
|
|
|
|
const db = app.get(PrismaService),
|
|
|
|
|
sessions = app.get(SessionService),
|
|
|
|
|
users = app.get(UsersService);
|
|
|
|
|
const stamp = randomUUID().slice(0, 8),
|
|
|
|
|
key = () => randomUUID();
|
|
|
|
|
const tenant = await db.tenant.create({ data: { name: '六项验收企业' + stamp, code: key() } });
|
|
|
|
|
const makeApp = (name) =>
|
|
|
|
|
db.smsApplication.create({
|
|
|
|
|
data: {
|
|
|
|
|
tenantId: tenant.id,
|
|
|
|
|
name,
|
|
|
|
|
cmppAccount: key(),
|
|
|
|
|
cmppEnterpriseCode: '000001',
|
|
|
|
|
secretHash: 'unused',
|
|
|
|
|
interfaceEnabled: false,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const application = await makeApp('通知应用' + stamp),
|
|
|
|
|
otherApp = await makeApp('其他应用' + stamp);
|
|
|
|
|
const makeSignature = (applicationId, name) =>
|
|
|
|
|
db.smsSignature.create({ data: { tenantId: tenant.id, applicationId, name, auditStatus: 'approved' } });
|
|
|
|
|
const signature = await makeSignature(application.id, '【验收甲】'),
|
|
|
|
|
signature2 = await makeSignature(application.id, '【验收乙】');
|
|
|
|
|
const otherSignature = await makeSignature(otherApp.id, '【其他应用】');
|
|
|
|
|
const user = await users.create({
|
|
|
|
|
username: 'operations' + 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 = async (route, body, method = body === undefined ? 'GET' : 'PUT', head = headers) => {
|
|
|
|
|
const response = await fetch(base + route, {
|
|
|
|
|
method,
|
|
|
|
|
headers: head,
|
|
|
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
|
|
|
});
|
|
|
|
|
return { status: response.status, data: await response.json() };
|
|
|
|
|
};
|
|
|
|
|
const templateBody = (name, sig = signature) => ({
|
|
|
|
|
tenantId: tenant.id,
|
|
|
|
|
applicationId: sig.applicationId,
|
|
|
|
|
signatureId: sig.id,
|
|
|
|
|
name,
|
|
|
|
|
content: sig.name + '验证码${code123}',
|
|
|
|
|
variables: [{ name: 'code123', example: '中文示例', required: true }],
|
|
|
|
|
});
|
|
|
|
|
const create = (body) => req('/admin/enterprise-templates', body, 'POST');
|
|
|
|
|
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);
|
|
|
|
|
const clientHeaders = {
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
cookie: sessions.cookieName('client') + '=' + clientSession.token,
|
|
|
|
|
};
|
|
|
|
|
const first = await create(templateBody('通知模板' + stamp));
|
|
|
|
|
assert.equal(first.status, 201, JSON.stringify(first));
|
|
|
|
|
const duplicate = await create(templateBody(' 通知模板' + stamp + ' ', signature2));
|
|
|
|
|
assert.equal(duplicate.status, 400);
|
|
|
|
|
assert.match(duplicate.data.message, /同一.*应用|当前.*应用/);
|
|
|
|
|
assert.equal((await create(templateBody('通知模板' + stamp, otherSignature))).status, 201);
|
|
|
|
|
assert.equal((await req('/admin/enterprise-templates/' + first.data.id, { name: '通知模板' + stamp })).status, 200);
|
|
|
|
|
const concurrent = await Promise.all([
|
|
|
|
|
create(templateBody('并发' + stamp)),
|
|
|
|
|
create(templateBody('并发' + stamp, signature2)),
|
|
|
|
|
]);
|
|
|
|
|
assert.deepEqual(concurrent.map((r) => r.status).sort(), [201, 400]);
|
|
|
|
|
for (const variable of ['中文', 'a_b', 'a-b', 'a b', '12']) {
|
|
|
|
|
const body = templateBody('非法' + key());
|
|
|
|
|
body.content = signature.name + '${' + variable + '}';
|
|
|
|
|
body.variables[0].name = variable;
|
|
|
|
|
assert.equal((await create(body)).status, 400);
|
|
|
|
|
}
|
|
|
|
|
const numeric = templateBody('数字变量' + stamp);
|
|
|
|
|
numeric.content = signature.name + '${123}';
|
|
|
|
|
numeric.variables[0].name = '123';
|
|
|
|
|
assert.equal((await create(numeric)).status, 201);
|
|
|
|
|
const extra = await create(templateBody('修改前' + stamp));
|
|
|
|
|
assert.equal((await req('/admin/enterprise-templates/' + extra.data.id, { name: first.data.name })).status, 400);
|
|
|
|
|
await db.smsTemplate.update({ where: { id: extra.data.id }, data: { auditStatus: 'deleted' } });
|
|
|
|
|
assert.equal((await create(templateBody('修改前' + stamp))).status, 201);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await req('/admin/enterprise-templates/' + extra.data.id + '/status', { status: 'approved' }, 'POST')).status,
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
assert.equal((await req('/admin/enterprise-templates', undefined, 'GET', {})).status, 401);
|
|
|
|
|
const clientBody = templateBody('客户模板' + stamp);
|
|
|
|
|
delete clientBody.tenantId;
|
|
|
|
|
assert.equal((await req('/client/templates', clientBody, 'POST', clientHeaders)).status, 201);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await req(
|
|
|
|
|
'/client/templates',
|
|
|
|
|
{ ...clientBody, signatureId: signature2.id, content: signature2.name + '验证码${code123}' },
|
|
|
|
|
'POST',
|
|
|
|
|
clientHeaders,
|
|
|
|
|
)
|
|
|
|
|
).status,
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(
|
|
|
|
|
await req(
|
|
|
|
|
'/client/templates',
|
|
|
|
|
{ ...clientBody, name: '非法客户模板', content: signature.name + '${中文}', variables: [{ name: '中文' }] },
|
|
|
|
|
'POST',
|
|
|
|
|
clientHeaders,
|
|
|
|
|
)
|
|
|
|
|
).status,
|
|
|
|
|
400,
|
|
|
|
|
);
|
|
|
|
|
assert.equal((await req('/admin/enterprise-templates', undefined, 'GET', clientHeaders)).status, 401);
|
|
|
|
|
pass(
|
|
|
|
|
'real API application/name uniqueness across signatures, concurrent saves, rename, restore, cross-application and variable validation',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const channel = await db.smsChannel.create({
|
|
|
|
|
data: {
|
|
|
|
|
code: key(),
|
|
|
|
|
name: '验收通道' + stamp,
|
|
|
|
|
carrier: 'mobile',
|
|
|
|
|
carriers: ['mobile', 'unicom', 'telecom'],
|
|
|
|
|
status: 'active',
|
|
|
|
|
gatewayHost: '127.0.0.1',
|
|
|
|
|
gatewayPort: 1,
|
|
|
|
|
account: key(),
|
|
|
|
|
passwordCipher: 'unused',
|
|
|
|
|
srcId: '1069',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const groups = [];
|
|
|
|
|
for (const [carrier, province] of [
|
|
|
|
|
['mobile', '全国'],
|
|
|
|
|
['unicom', '全国'],
|
|
|
|
|
['unicom', '湖北'],
|
|
|
|
|
['telecom', '全国'],
|
|
|
|
|
]) {
|
|
|
|
|
const group = await db.smsChannelGroup.create({
|
|
|
|
|
data: {
|
|
|
|
|
code: key(),
|
|
|
|
|
name: carrier + province + stamp,
|
|
|
|
|
carrier,
|
|
|
|
|
items: { create: { channelId: channel.id, carrier, province } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
groups.push(group);
|
|
|
|
|
await db.channelRouteRule.create({
|
|
|
|
|
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const filters = {
|
|
|
|
|
enterpriseKeyword: tenant.name,
|
|
|
|
|
applicationKeyword: application.name,
|
|
|
|
|
channelKeyword: channel.name,
|
|
|
|
|
objectKeyword: signature.name,
|
|
|
|
|
pageSize: '1',
|
|
|
|
|
};
|
|
|
|
|
const details = await req('/admin/report-details?' + new URLSearchParams(filters));
|
|
|
|
|
assert.equal(details.status, 200);
|
|
|
|
|
assert.equal(details.data.total, 3);
|
|
|
|
|
assert.equal(details.data.items.length, 1);
|
|
|
|
|
assert.equal(details.data.items[0].virtual, true);
|
|
|
|
|
for (const field of ['enterpriseKeyword', 'applicationKeyword', 'channelKeyword', 'objectKeyword']) {
|
|
|
|
|
const empty = await req('/admin/report-details?' + new URLSearchParams({ ...filters, [field]: '不匹配' }));
|
|
|
|
|
assert.equal(empty.data.total, 0, field);
|
|
|
|
|
}
|
|
|
|
|
const page2 = await req('/admin/report-details?' + new URLSearchParams({ ...filters, page: '2' }));
|
|
|
|
|
assert.notEqual(page2.data.items[0].id, details.data.items[0].id);
|
|
|
|
|
pass('four independent AND search fields apply before pagination and retain virtual unreported rows');
|
|
|
|
|
|
|
|
|
|
const date = todayKey(new Date()),
|
|
|
|
|
start = startOfDay(date);
|
|
|
|
|
const beforeTrend = await hourlySendTrend(db, date);
|
|
|
|
|
const messages = [];
|
|
|
|
|
for (const [index, status] of ['submitted', 'unknown', 'delivered', 'failed', 'queued', 'rejected'].entries()) {
|
|
|
|
|
messages.push(
|
|
|
|
|
await db.smsMessageRecord.create({
|
|
|
|
|
data: {
|
|
|
|
|
tenantId: tenant.id,
|
|
|
|
|
applicationId: application.id,
|
|
|
|
|
signatureId: signature.id,
|
|
|
|
|
channelId: channel.id,
|
|
|
|
|
messageId: key(),
|
|
|
|
|
phoneNumber: '13800000000',
|
|
|
|
|
content: signature.name + '隔离记录',
|
|
|
|
|
status,
|
|
|
|
|
queuedAt: new Date(+start + (index ? 3600000 : 0)),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await db.smsMessageRecord.create({
|
|
|
|
|
data: {
|
|
|
|
|
messageId: key(),
|
|
|
|
|
phoneNumber: '13800000000',
|
|
|
|
|
content: '前日',
|
|
|
|
|
status: 'delivered',
|
|
|
|
|
queuedAt: new Date(+start - 1),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await db.smsSubmitRecord.createMany({
|
|
|
|
|
data: [1, 2].map(() => ({
|
|
|
|
|
messageRecordId: messages[2].id,
|
|
|
|
|
channelId: channel.id,
|
|
|
|
|
submitId: key(),
|
|
|
|
|
submitStatus: 'submitted',
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
const records = await req(
|
|
|
|
|
'/admin/operations/messages?' +
|
|
|
|
|
new URLSearchParams({ applicationId: application.id, status: 'unknown', page: '1', pageSize: '20' }),
|
|
|
|
|
);
|
|
|
|
|
assert.equal(records.status, 200);
|
|
|
|
|
assert.deepEqual(records.data.items.map((r) => r.status).sort(), ['submitted', 'unknown']);
|
|
|
|
|
const trend = await hourlySendTrend(db, date);
|
|
|
|
|
assert.equal(trend.length, 24);
|
|
|
|
|
assert.equal(trend[0].submittedCount - beforeTrend[0].submittedCount, 1);
|
|
|
|
|
assert.equal(trend[1].submittedCount - beforeTrend[1].submittedCount, 5);
|
|
|
|
|
assert.equal(trend[1].successCount - beforeTrend[1].successCount, 1);
|
|
|
|
|
assert.equal(trend[2].submittedCount - beforeTrend[2].submittedCount, 0);
|
|
|
|
|
await app.get(HomeProjection).tick();
|
|
|
|
|
const home = await req('/admin/operations/home/summary');
|
|
|
|
|
assert.equal(home.status, 200, JSON.stringify(home));
|
|
|
|
|
assert.deepEqual(home.data.hourlySendTrend, trend);
|
|
|
|
|
pass(
|
|
|
|
|
'unknown maps to submitted and legacy unknown; home uses Beijing hours, zero fill and business rows, not supplier retries',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Force a failure after member deletion: the entire configuration transaction must roll back.
|
|
|
|
|
await db.$executeRawUnsafe(
|
|
|
|
|
`CREATE FUNCTION qa_reject_channel_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='sms_channel.update' THEN RAISE EXCEPTION 'qa rollback'; END IF; RETURN NEW; END $$`,
|
|
|
|
|
);
|
|
|
|
|
await db.$executeRawUnsafe(
|
|
|
|
|
`CREATE TRIGGER qa_reject_channel_audit BEFORE INSERT ON "OperationLog" FOR EACH ROW EXECUTE FUNCTION qa_reject_channel_audit()`,
|
|
|
|
|
);
|
|
|
|
|
assert.equal((await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'telecom'] })).status, 500);
|
|
|
|
|
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 4);
|
|
|
|
|
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, [
|
|
|
|
|
'mobile',
|
|
|
|
|
'unicom',
|
|
|
|
|
'telecom',
|
|
|
|
|
]);
|
|
|
|
|
await db.$executeRawUnsafe('DROP TRIGGER qa_reject_channel_audit ON "OperationLog"');
|
|
|
|
|
await db.$executeRawUnsafe('DROP FUNCTION qa_reject_channel_audit()');
|
|
|
|
|
assert.equal((await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'telecom'] })).status, 200);
|
|
|
|
|
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 2);
|
|
|
|
|
assert.equal(await db.channelRouteRule.count({ where: { applicationId: application.id } }), 4);
|
|
|
|
|
const audit = await db.operationLog.findFirst({
|
|
|
|
|
where: { resourceId: channel.id, action: 'sms_channel.update' },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(audit.detail.removedGroupItems.length, 2);
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
db.smsChannelGroupItem.create({ data: { channelId: channel.id, groupId: groups[1].id, carrier: 'unicom' } }),
|
|
|
|
|
/not compatible/,
|
|
|
|
|
);
|
|
|
|
|
pass(
|
|
|
|
|
'carrier reduction is atomic, removes national/province memberships, preserves routes and other carriers, records audit and rejects stale membership writes',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { Client } = require('pg');
|
|
|
|
|
const writer = new Client({ connectionString: url.toString() });
|
|
|
|
|
await writer.connect();
|
|
|
|
|
let settled = false,
|
|
|
|
|
insertion;
|
|
|
|
|
try {
|
|
|
|
|
await db.$transaction(async (tx) => {
|
|
|
|
|
await tx.smsChannel.update({ where: { id: channel.id }, data: { carriers: ['mobile'] } });
|
|
|
|
|
insertion = writer
|
|
|
|
|
.query('INSERT INTO "SmsChannelGroupItem" (id,"groupId","channelId",carrier) VALUES ($1,$2,$3,$4)', [
|
|
|
|
|
key(),
|
|
|
|
|
groups[3].id,
|
|
|
|
|
channel.id,
|
|
|
|
|
'telecom',
|
|
|
|
|
])
|
|
|
|
|
.then(
|
|
|
|
|
() => ({ success: true }),
|
|
|
|
|
(error) => ({ code: error.code }),
|
|
|
|
|
)
|
|
|
|
|
.finally(() => {
|
|
|
|
|
settled = true;
|
|
|
|
|
});
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
|
assert.equal(settled, false);
|
|
|
|
|
await tx.smsChannelGroupItem.deleteMany({ where: { channelId: channel.id, groupId: groups[3].id } });
|
|
|
|
|
});
|
|
|
|
|
assert.equal((await insertion).code, '23514');
|
|
|
|
|
} finally {
|
|
|
|
|
await writer.end();
|
|
|
|
|
}
|
|
|
|
|
await db.smsChannel.update({ where: { id: channel.id }, data: { carriers: ['mobile', 'telecom'] } });
|
|
|
|
|
await db.smsChannelGroupItem.create({ data: { channelId: channel.id, groupId: groups[3].id, carrier: 'telecom' } });
|
|
|
|
|
pass('real concurrent member insertion waits for carrier change then rejects stale capability');
|
|
|
|
|
|
|
|
|
|
if (process.env.OPERATIONS_TEST_BROWSER_URL) {
|
|
|
|
|
const uiUrl = new URL(process.env.OPERATIONS_TEST_BROWSER_URL);
|
|
|
|
|
assert(['localhost', '127.0.0.1'].includes(uiUrl.hostname));
|
|
|
|
|
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
|
|
|
|
const evidence = process.env.OPERATIONS_TEST_EVIDENCE_DIR;
|
|
|
|
|
assert(evidence && path.isAbsolute(evidence));
|
|
|
|
|
mkdirSync(evidence, { recursive: true });
|
|
|
|
|
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
|
|
|
|
const context = await browser.newContext();
|
|
|
|
|
await context.addCookies([
|
|
|
|
|
{ name: cookie, value: session.token, url: uiUrl.origin, httpOnly: true, sameSite: 'Lax' },
|
|
|
|
|
]);
|
|
|
|
|
const auth = await req('/admin/auth/session');
|
|
|
|
|
assert.equal(auth.status, 200);
|
|
|
|
|
await context.addInitScript(
|
|
|
|
|
(value) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(value)),
|
|
|
|
|
auth.data,
|
|
|
|
|
);
|
|
|
|
|
const page = await context.newPage(),
|
|
|
|
|
errors = [];
|
|
|
|
|
page.on('pageerror', (e) => errors.push(e.message));
|
|
|
|
|
for (const [width, height] of [
|
|
|
|
|
[1600, 1000],
|
|
|
|
|
[1366, 768],
|
|
|
|
|
[390, 844],
|
|
|
|
|
]) {
|
|
|
|
|
await page.setViewportSize({ width, height });
|
|
|
|
|
await page.goto(uiUrl.origin + '/admin');
|
|
|
|
|
await page.getByRole('heading', { name: '小时发送曲线', exact: true }).waitFor();
|
|
|
|
|
await page.locator('[aria-label="小时发送曲线"] canvas').waitFor();
|
|
|
|
|
const curve = await page.getByRole('heading', { name: '小时发送曲线', exact: true }).boundingBox();
|
|
|
|
|
const rank = await page.getByRole('heading', { name: /企业消费排行/ }).boundingBox();
|
|
|
|
|
assert(curve.y < rank.y);
|
|
|
|
|
await page.screenshot({ path: path.join(evidence, `home-${width}.png`), fullPage: true });
|
|
|
|
|
await page.reload();
|
|
|
|
|
await page.locator('[aria-label="小时发送曲线"] canvas').waitFor();
|
|
|
|
|
await page.goto(uiUrl.origin + '/admin/report-tasks');
|
|
|
|
|
for (const label of ['企业', '应用', '通道', '报备对象'])
|
|
|
|
|
await page.getByRole('textbox', { name: label, exact: true }).waitFor();
|
|
|
|
|
await page.getByRole('textbox', { name: '企业', exact: true }).fill(tenant.name);
|
|
|
|
|
await page.getByRole('button', { name: '查询', exact: true }).click();
|
|
|
|
|
await page.screenshot({ path: path.join(evidence, `reports-${width}.png`), fullPage: true });
|
|
|
|
|
await page.goto(uiUrl.origin + '/admin/enterprise-templates');
|
|
|
|
|
await page.getByRole('textbox', { name: '企业应用', exact: true }).fill(application.name);
|
|
|
|
|
await page.getByRole('textbox', { name: '模板名称', exact: true }).fill(first.data.name);
|
|
|
|
|
await page.getByRole('button', { name: '查询', exact: true }).click();
|
|
|
|
|
await page
|
|
|
|
|
.getByRole('article')
|
|
|
|
|
.filter({ hasText: first.data.name })
|
|
|
|
|
.filter({ hasText: application.name })
|
|
|
|
|
.getByRole('button', { name: '编辑', exact: true })
|
|
|
|
|
.click();
|
|
|
|
|
await page.getByRole('dialog').getByRole('button', { name: /^签名/ }).click();
|
|
|
|
|
await page.getByPlaceholder('搜索短信签名').fill('验收甲');
|
|
|
|
|
await page.getByRole('option', { name: signature.name, exact: true }).waitFor();
|
|
|
|
|
await page.screenshot({ path: path.join(evidence, `template-search-${width}.png`), fullPage: true });
|
|
|
|
|
await page.getByRole('option', { name: signature.name, exact: true }).click();
|
|
|
|
|
await page.getByRole('textbox', { name: /模板内容/ }).fill(signature.name + '${中文}');
|
|
|
|
|
assert(await page.getByRole('button', { name: '保存', exact: true }).isDisabled());
|
|
|
|
|
await page.getByRole('button', { name: '取消', exact: true }).click();
|
|
|
|
|
await page.getByRole('button', { name: '放弃并关闭', exact: true }).click();
|
|
|
|
|
await page.goto(uiUrl.origin + '/admin/channels');
|
|
|
|
|
await page.getByRole('textbox', { name: '通道名称', exact: true }).fill(channel.name);
|
|
|
|
|
await page.getByRole('button', { name: '查询', exact: true }).click();
|
|
|
|
|
await page
|
|
|
|
|
.getByRole('article')
|
|
|
|
|
.filter({ hasText: channel.name })
|
|
|
|
|
.getByRole('button', { name: '编辑', exact: true })
|
|
|
|
|
.click();
|
|
|
|
|
await page.getByRole('checkbox', { name: '电信', exact: true }).uncheck();
|
|
|
|
|
await page
|
|
|
|
|
.getByRole('alert')
|
|
|
|
|
.filter({ hasText: /通道组/ })
|
|
|
|
|
.waitFor();
|
|
|
|
|
assert(await page.getByRole('button', { name: '确认', exact: true }).isDisabled());
|
|
|
|
|
await page.screenshot({ path: path.join(evidence, `carrier-warning-${width}.png`), fullPage: true });
|
|
|
|
|
await page.getByRole('checkbox', { name: /我已确认/ }).check();
|
|
|
|
|
assert(await page.getByRole('button', { name: '确认', exact: true }).isEnabled());
|
|
|
|
|
await page.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).last().click();
|
|
|
|
|
assert.equal(await db.smsChannelGroupItem.count({ where: { channelId: channel.id } }), 2);
|
|
|
|
|
await page.goto(uiUrl.origin + '/admin/analytics');
|
|
|
|
|
await page.getByRole('heading', { name: '签名通道发送质量', exact: true }).waitFor();
|
|
|
|
|
await page
|
|
|
|
|
.getByRole('columnheader', { name: '业务短信', exact: true, includeHidden: true })
|
|
|
|
|
.waitFor({ state: 'attached' });
|
|
|
|
|
assert.equal(
|
|
|
|
|
await page.getByRole('columnheader', { name: '通道提交', exact: true, includeHidden: true }).count(),
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
await page.screenshot({ path: path.join(evidence, `quality-${width}.png`), fullPage: true });
|
|
|
|
|
await page.goto(uiUrl.origin + '/admin/sms-records');
|
|
|
|
|
await page.getByRole('button', { name: '发送状态', exact: true }).click();
|
|
|
|
|
await page.getByRole('option', { name: '未知', exact: true }).click();
|
|
|
|
|
const filteredResponse = page.waitForResponse(
|
|
|
|
|
(r) =>
|
|
|
|
|
r.url().includes('/api/admin/operations/messages?') &&
|
|
|
|
|
new URL(r.url()).searchParams.get('status') === 'unknown',
|
|
|
|
|
);
|
|
|
|
|
await page.getByRole('button', { name: '查询', exact: true }).click();
|
|
|
|
|
const filtered = await (await filteredResponse).json();
|
|
|
|
|
assert(filtered.items.some((r) => r.status === 'submitted'));
|
|
|
|
|
await page.getByText('提交成功', { exact: true }).first().waitFor();
|
|
|
|
|
await page.screenshot({ path: path.join(evidence, `unknown-${width}.png`), fullPage: true });
|
|
|
|
|
}
|
|
|
|
|
assert.deepEqual(errors, []);
|
|
|
|
|
pass(
|
|
|
|
|
'real authenticated UI: three viewport sizes, refresh, routing, searchable signatures, validation, channel warning and cancel',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
await browser?.close();
|
|
|
|
|
await app.close();
|
|
|
|
|
}
|