feat: add smoke tests, agent credit ledger, and player cashback page

Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 16:05:48 +08:00
parent 9c6c5e51f3
commit d5e7c8edb3
52 changed files with 3357 additions and 67 deletions

View File

@@ -0,0 +1,152 @@
import { Injectable } from '@nestjs/common';
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 { 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,
) {}
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);
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 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));
}
}
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),
};
}
}
}