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(); 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 { 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 { 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), }; } } }