Files
thebet365/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts
Mars ce84226219 feat(admin+api): 代理停用默认、结算加固与冒烟配置探针
- 代理层级默认授信比例与停用冻结/禁登全局默认

- 结算预览去重、比分校验、串关当场判负与市场类型校验

- 站内信 Banner/公告自动通知开关;开发环境动态 API 端口

- 扩充 RBAC/结算/认证/返现单元测试与 agent skills
2026-06-23 11:08:41 +08:00

177 lines
5.4 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { appForbidden } from '../../../shared/common/app-error';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { AgentsService } from '../../agent/agents.service';
import { BetsService } from '../../betting/bets.service';
import { SettlementService } from '../../settlement/settlement.service';
import { WalletService } from '../../ledger/wallet.service';
import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases';
import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes';
import { CONFIG_PROBE_COUNT, createConfigProbes } from './smoke-test.config-probes';
import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes';
import {
beginSmokeSteps,
drainSmokeSteps,
formatStepsForResult,
} from './smoke-test.helpers';
import type {
SmokeTestCaseResult,
SmokeTestRunSummary,
SmokeTestSuiteInfo,
} from './smoke-test.types';
@Injectable()
export class SmokeTestService {
private lastRun: SmokeTestRunSummary | null = null;
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private bets: BetsService,
private settlement: SettlementService,
private agents: AgentsService,
) {}
isAllowed(): boolean {
if (process.env.ALLOW_SMOKE_TESTS === 'true') return true;
return process.env.NODE_ENV !== 'production';
}
assertAllowed() {
if (!this.isAllowed()) {
throw appForbidden('SMOKE_TESTS_FORBIDDEN');
}
}
listSuites(): SmokeTestSuiteInfo[] {
const counts = new Map<string, number>();
for (const c of SMOKE_TEST_CASES) {
counts.set(c.suite, (counts.get(c.suite) ?? 0) + 1);
}
counts.set('database', DATABASE_PROBE_COUNT);
counts.set('bet-flow', BET_FLOW_PROBE_COUNT);
counts.set('config', CONFIG_PROBE_COUNT);
return [...counts.entries()].map(([id, caseCount]) => ({
id,
name: SMOKE_SUITE_META[id]?.name ?? id,
description: SMOKE_SUITE_META[id]?.description ?? '',
caseCount,
}));
}
listCases(suites?: string[]) {
const allow = suites?.length ? new Set(suites) : null;
return SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite)).map(
({ id, suite, name, description, uatRef }) => ({ id, suite, name, description, uatRef }),
);
}
getLastRun() {
return this.lastRun;
}
async run(suites?: string[], operatorId?: bigint): Promise<SmokeTestRunSummary> {
const started = Date.now();
const runId = `SMOKE-${started}-${operatorId?.toString() ?? '0'}`;
const allow = suites?.length ? new Set(suites) : null;
const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite));
const runDb = !allow || allow.has('database');
const runBetFlow = !allow || allow.has('bet-flow');
const runConfig = !allow || allow.has('config');
const results: SmokeTestCaseResult[] = [];
for (const testCase of staticCases) {
results.push(await this.executeCase(testCase));
}
if (runDb) {
for (const probe of createDatabaseProbes(this.prisma)) {
results.push(await this.executeCase(probe));
}
}
if (runBetFlow) {
for (const probe of createBetFlowProbes({
prisma: this.prisma,
wallet: this.wallet,
bets: this.bets,
settlement: this.settlement,
agents: this.agents,
})) {
results.push(await this.executeCase(probe));
}
}
if (runConfig) {
for (const probe of createConfigProbes({
prisma: this.prisma,
agents: this.agents,
})) {
results.push(await this.executeCase(probe));
}
}
const finished = Date.now();
const passed = results.filter((r) => r.status === 'PASS').length;
const failed = results.filter((r) => r.status === 'FAIL').length;
const skipped = results.filter((r) => r.status === 'SKIP').length;
const summary: SmokeTestRunSummary = {
runId,
startedAt: new Date(started).toISOString(),
finishedAt: new Date(finished).toISOString(),
durationMs: finished - started,
total: results.length,
passed,
failed,
skipped,
suites: [...new Set(results.map((r) => r.suite))],
results,
};
this.lastRun = summary;
return summary;
}
private async executeCase(testCase: SmokeTestCaseDef): Promise<SmokeTestCaseResult> {
const t0 = performance.now();
const base = {
id: testCase.id,
suite: testCase.suite,
name: testCase.name,
description: testCase.description,
uatRef: testCase.uatRef,
};
beginSmokeSteps();
try {
await testCase.run();
const steps = drainSmokeSteps();
const durationMs = Math.max(0.01, Math.round((performance.now() - t0) * 100) / 100);
return {
...base,
status: 'PASS',
durationMs,
stepCount: steps.length,
message: steps.length ? `${steps.length} steps passed` : 'OK',
details: formatStepsForResult(steps),
};
} catch (err) {
const steps = drainSmokeSteps();
const message = err instanceof Error ? err.message : String(err);
const durationMs = Math.max(0.01, Math.round((performance.now() - t0) * 100) / 100);
return {
...base,
status: 'FAIL',
durationMs,
stepCount: steps.length,
error: message,
details: formatStepsForResult(steps),
};
}
}
}