test: add real environment smoke coverage

This commit is contained in:
hectorzhao
2026-07-01 17:42:19 +08:00
parent bbbbe269a8
commit 7fa91675b8
3 changed files with 313 additions and 5 deletions
@@ -15,6 +15,7 @@
6. 输出 500 条/秒压测报告。已完成,见 `docs/phase-8-500tps-performance-report.md` 6. 输出 500 条/秒压测报告。已完成,见 `docs/phase-8-500tps-performance-report.md`
7. 输出 Linux 上线部署文档和回滚方案。已完成,见 `docs/linux-deployment-and-rollback.md` 7. 输出 Linux 上线部署文档和回滚方案。已完成,见 `docs/linux-deployment-and-rollback.md`
8. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。 8. 运行 Prisma generate、API build、阶段验证脚本和 API health smoke。已完成。
9. 在真实 PostgreSQL/Redis/MinIO 下补充最小 seed 和核心 HTTP smoke。已完成。
## 验收记录 ## 验收记录
@@ -49,10 +50,15 @@
- `npm run verify:phase8` 通过。 - `npm run verify:phase8` 通过。
- 队列契约校验通过。 - 队列契约校验通过。
- Go Gateway `go test ./...` 通过。 - Go Gateway `go test ./...` 通过。
- BullMQ Spike15000 条消息、并发 500、端到端约 825.48 TPS,满足 500 条/秒指标。 - BullMQ Spike15000 条消息、并发 500、端到端约 705.65 TPS,满足 500 条/秒指标。
- Prisma Client 生成通过。 - Prisma Client 生成通过。
- API build 通过。 - API build 通过。
- 前端 build 通过,仍存在 Vite chunk size warning。 - 前端 build 通过,仍存在 Vite chunk size warning。
- 真实环境 smoke 通过:
- 依赖连通性:PostgreSQL 5432、Redis 6379、MinIO S3 9000、MinIO Console 9001 均通过 TCP 探测。
- 迁移:`npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。
- API 启动:`API_PORT=3101 node api/dist/main.js` 可连接真实 `.env` 并返回 health `ok`
- 核心链路:`node tools/smoke/real-env-smoke.mjs` 通过,覆盖登录、企业/客户、人工充值、余额检查、发送任务、MinIO 预签名上传、运营日志、trace 和 reconciliation。
- 500 条/秒压测报告已输出。 - 500 条/秒压测报告已输出。
- Linux 上线部署与回滚方案已输出。 - Linux 上线部署与回滚方案已输出。
@@ -63,6 +69,7 @@
- 输出 500 条/秒压测报告:已完成。 - 输出 500 条/秒压测报告:已完成。
- 输出 Linux 部署方案:已完成,覆盖 Docker Compose、systemd、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。 - 输出 Linux 部署方案:已完成,覆盖 Docker Compose、systemd、环境变量、数据库迁移、日志目录、备份恢复、服务健康检查和回滚步骤。
- API health smoke 通过:`/api/health` 返回 `ok` - API health smoke 通过:`/api/health` 返回 `ok`
- 真实 PostgreSQL/Redis/MinIO 核心 smoke 通过:最小 seed 与登录、充值、发送、上传、日志、追踪、对账链路均可执行。
## 结论 ## 结论
@@ -71,4 +78,4 @@
## 风险与说明 ## 风险与说明
- 阶段 8 的统计查询以第一版可运营为目标,优先使用 Prisma 聚合与分页查询;大表分区、物化统计、Redis 热统计将在后续版本增强。 - 阶段 8 的统计查询以第一版可运营为目标,优先使用 Prisma 聚合与分页查询;大表分区、物化统计、Redis 热统计将在后续版本增强。
- 本地没有 PostgreSQL 服务时,仅执行 Prisma Client 生成和 TypeScript 构建,不执行数据库迁移 - 当前真实环境 smoke 不包含真实运营商 SMSC/CMPP 互通;该项需运营商测试环境后补充联调记录
+9 -3
View File
@@ -42,22 +42,28 @@ npm --prefix api test -- channels.service.spec.ts
npm --prefix api test -- operations.service.spec.ts npm --prefix api test -- operations.service.spec.ts
npm run test:api npm run test:api
npm run verify:phase8 npm run verify:phase8
npm run build
npm run spike:gateway npm run spike:gateway
npm --prefix api test npm --prefix api test
npm run test:gateway npm run test:gateway
node tools/smoke/real-env-smoke.mjs
``` ```
### 当前结果 ### 当前结果
- API Jest5 个 test suite 通过,21 个测试通过。 - API Jest5 个 test suite 通过,22 个测试通过。
- Gateway`npm run spike:gateway` 通过。 - Gateway`npm run spike:gateway` 通过。
- 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 735.83 TPS,满足 500 TPS。 - 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 705.65 TPS,满足 500 TPS。
- 前端 build 通过,仍存在既有 Vite chunk size warning。 - 前端 build 通过,仍存在既有 Vite chunk size warning。
- API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。 - API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。
- 真实 PostgreSQL/Redis/MinIO smoke 通过:
- PostgreSQL `localhost:5432`、Redis `localhost:6379`、MinIO `localhost:9000/9001` 端口均连通。
- `npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。
- API 以真实 `.env` 启动,`GET http://127.0.0.1:3101/api/health` 返回 `ok`
- `tools/smoke/real-env-smoke.mjs` 已补充最小幂等 seed,并验证登录、企业/客户、人工充值、余额检查、发送任务、MinIO 预签名上传、运营日志、发送链路追踪和账务对账聚合。
### 已知缺口 ### 已知缺口
- 暂未新增前端测试框架,前端仍以 `npm run build` 作为 smoke。 - 暂未新增前端测试框架,前端仍以 `npm run build` 作为 smoke。
- 暂未跑真实 PostgreSQL/Redis/MinIO 集成测试。
- BullMQ 真实链路仍由 `npm run spike:bullmq` 覆盖,不在 Jest 内启动 Redis。 - BullMQ 真实链路仍由 `npm run spike:bullmq` 覆盖,不在 Jest 内启动 Redis。
- Gateway 未接真实运营商 SMSC;真实 CMPP 互通需要运营商测试环境后补充联调记录。 - Gateway 未接真实运营商 SMSC;真实 CMPP 互通需要运营商测试环境后补充联调记录。
+295
View File
@@ -0,0 +1,295 @@
import { createHash } from 'node:crypto';
import { PrismaPg } from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
import { PrismaClient } from '../../api/node_modules/@prisma/client/index.js';
const prisma = new PrismaClient({
adapter: new PrismaPg(
process.env.DATABASE_URL ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
),
});
const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:3101/api';
const tenantCode = 'smoke-tenant';
const username = 'smoke_client';
const password = 'SmokePass123!';
function hashPassword(value) {
return createHash('sha256').update(value).digest('hex');
}
async function ensureSmokeData() {
const tenant = await prisma.tenant.upsert({
where: { code: tenantCode },
update: { name: 'Smoke Test Enterprise', status: 'active' },
create: { code: tenantCode, name: 'Smoke Test Enterprise', status: 'active' },
});
const user = await prisma.user.upsert({
where: { username },
update: {
tenantId: tenant.id,
displayName: 'Smoke Client Admin',
passwordHash: hashPassword(password),
status: 'active',
},
create: {
tenantId: tenant.id,
username,
displayName: 'Smoke Client Admin',
passwordHash: hashPassword(password),
status: 'active',
},
});
await prisma.tenantAccount.upsert({
where: { tenantId: tenant.id },
update: { balanceCents: { increment: 0 }, smsUnits: { increment: 0 }, creditCents: 5000, status: 'active' },
create: { tenantId: tenant.id, balanceCents: 100000, smsUnits: 10000, creditCents: 5000, status: 'active' },
});
const channel = await prisma.smsChannel.upsert({
where: { code: 'SMOKE_CMPP' },
update: {
name: 'Smoke CMPP Channel',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
account: 'smoke-account',
passwordCipher: 'smoke-password',
srcId: '10690000',
rateLimitPerSecond: 500,
unitPrice: 5,
status: 'active',
},
create: {
code: 'SMOKE_CMPP',
name: 'Smoke CMPP Channel',
carrier: 'all',
protocol: 'CMPP',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
account: 'smoke-account',
passwordCipher: 'smoke-password',
srcId: '10690000',
rateLimitPerSecond: 500,
unitPrice: 5,
status: 'active',
},
});
const group = await prisma.smsChannelGroup.upsert({
where: { code: 'SMOKE_GROUP' },
update: { name: 'Smoke Channel Group', status: 'active' },
create: { code: 'SMOKE_GROUP', name: 'Smoke Channel Group', status: 'active' },
});
const groupItem = await prisma.smsChannelGroupItem.findFirst({
where: { groupId: group.id, channelId: channel.id },
});
if (!groupItem) {
await prisma.smsChannelGroupItem.create({
data: { groupId: group.id, channelId: channel.id, priority: 1, weight: 1, rateLimitPerSecond: 500 },
});
}
const routeRule = await prisma.channelRouteRule.findFirst({
where: { tenantId: tenant.id, groupId: group.id, status: 'active' },
});
if (!routeRule) {
await prisma.channelRouteRule.create({
data: { tenantId: tenant.id, groupId: group.id, priority: 1, status: 'active' },
});
}
const application =
(await prisma.smsApplication.findFirst({ where: { tenantId: tenant.id, name: 'Smoke SMS App' } })) ??
(await prisma.smsApplication.create({
data: {
tenantId: tenant.id,
name: 'Smoke SMS App',
scene: 'smoke',
secretHash: hashPassword('smoke-secret'),
dailyLimit: 100000,
maxPhonesPerTask: 100000,
templateMismatchMode: 'reject',
status: 'active',
},
}));
const signature =
(await prisma.smsSignature.findFirst({ where: { tenantId: tenant.id, applicationId: application.id, name: '烟测签名' } })) ??
(await prisma.smsSignature.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
name: '烟测签名',
purpose: 'smoke',
auditStatus: 'approved',
reportStatus: 'approved',
},
}));
const template =
(await prisma.smsTemplate.findFirst({ where: { tenantId: tenant.id, applicationId: application.id, name: 'Smoke Verify Code' } })) ??
(await prisma.smsTemplate.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
name: 'Smoke Verify Code',
content: '您的验证码为${code}5分钟内有效。',
category: 'verification',
auditStatus: 'approved',
billingUnits: 1,
variables: { create: [{ name: 'code', example: '123456', required: true }] },
},
}));
return { tenant, user, channel, application, signature, template };
}
async function request(path, options = {}) {
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers ?? {}),
},
});
const text = await response.text();
let body;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
if (!response.ok) {
throw new Error(`${options.method ?? 'GET'} ${path} failed: ${response.status} ${text}`);
}
return body;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function run() {
const data = await ensureSmokeData();
const tenantHeader = { 'x-tenant-id': data.tenant.id };
const health = await request('/health');
assert(health.status === 'ok', 'health should return ok');
const login = await request('/client/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
assert(login.accessToken && login.user?.tenantId === data.tenant.id, 'login should return smoke tenant user');
const tenants = await request('/admin/tenants');
assert(tenants.some((tenant) => tenant.code === tenantCode), 'tenant list should contain smoke tenant');
const recharge = await request('/admin/billing/manual-recharges', {
method: 'POST',
body: JSON.stringify({
tenantId: data.tenant.id,
amountCents: 1234,
smsUnits: 10,
operatorId: data.user.id,
remark: 'real env smoke manual recharge',
}),
});
assert(recharge.id, 'manual recharge should create an order');
const transactions = await request('/client/billing/transactions', { headers: tenantHeader });
assert(transactions.some((item) => item.relatedId === recharge.id), 'transactions should include recharge delta');
const check = await request('/admin/billing/check', {
method: 'POST',
body: JSON.stringify({ tenantId: data.tenant.id, amountCents: 1, smsUnits: 1 }),
});
assert(check.canSend === true, 'billing check should allow the smoke send');
const task = await request('/client/send/batch-tasks', {
method: 'POST',
body: JSON.stringify({
tenantId: data.tenant.id,
applicationId: data.application.id,
templateId: data.template.id,
content: '您的验证码为1234565分钟内有效。',
category: 'verification',
phones: ['13800138000', '13900139000'],
variables: { code: '123456' },
createdById: data.user.id,
sourceIp: '127.0.0.1',
userAgent: 'real-env-smoke',
}),
});
assert(task.id && task.messages?.length === 2, 'send batch task should create two messages');
const taskList = await request('/client/operations/batch-tasks', { headers: tenantHeader });
assert(taskList.some((item) => item.id === task.id), 'client task list should include smoke task');
const upload = await request('/admin/files/presigned-upload', {
method: 'POST',
body: JSON.stringify({ objectKey: `smoke/${Date.now()}-upload.txt`, expiresInSeconds: 300 }),
});
assert(upload.uploadUrl?.startsWith('http'), 'presigned upload should return an upload URL');
const putResponse = await fetch(upload.uploadUrl, {
method: 'PUT',
body: 'cmpp real env smoke upload',
headers: { 'content-type': 'text/plain' },
});
assert(putResponse.ok, `MinIO presigned PUT should succeed, got ${putResponse.status}`);
const log = await request('/admin/operation-logs', {
method: 'POST',
body: JSON.stringify({
tenantId: data.tenant.id,
userId: data.user.id,
action: 'smoke.verify',
resource: 'real-env-smoke',
resourceId: task.id,
ipAddress: '127.0.0.1',
userAgent: 'real-env-smoke',
detail: { taskNo: task.taskNo, rechargeOrderId: recharge.id, objectKey: upload.objectKey },
}),
});
assert(log.id, 'operation log should be created');
const logs = await request('/admin/operations/audit-logs', { headers: tenantHeader });
assert(logs.some((item) => item.id === log.id), 'operations audit logs should include smoke log');
const trace = await request(`/admin/operations/trace?tenantId=${data.tenant.id}&taskId=${task.id}`);
assert(trace.messages?.length >= 2, 'trace should include smoke messages');
const reconciliation = await request(`/admin/operations/reconciliation?tenantId=${data.tenant.id}&taskId=${task.id}`);
assert(reconciliation.messages?._count?._all >= 2, 'reconciliation should return message aggregate');
console.log(
JSON.stringify(
{
ok: true,
apiBaseUrl,
tenantId: data.tenant.id,
userId: data.user.id,
rechargeOrderId: recharge.id,
batchTaskId: task.id,
messageCount: task.messages.length,
uploadedObjectKey: upload.objectKey,
operationLogId: log.id,
},
null,
2,
),
);
}
run()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});