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:
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
@@ -9,13 +10,20 @@ import {
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
||||
import { ContentService } from '../../domains/operations/content/content.service';
|
||||
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
|
||||
import { jsonResponse } from '../../shared/common/filters';
|
||||
import { getUploadRoot } from '../../shared/uploads/upload-paths';
|
||||
import { UsersService } from '../../domains/identity/users.service';
|
||||
import { AgentsService } from '../../domains/agent/agents.service';
|
||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||
@@ -33,6 +41,7 @@ import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { P } from './admin-permissions';
|
||||
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
|
||||
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -47,6 +56,77 @@ import {
|
||||
} from 'class-validator';
|
||||
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
||||
|
||||
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const;
|
||||
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];
|
||||
|
||||
const IMAGE_MIME_EXT: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
'image/svg+xml': '.svg',
|
||||
};
|
||||
|
||||
type UploadedImage = {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type AdminUploadUser = {
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
function uploadCategory(value?: string): UploadCategory {
|
||||
const category = (value || 'contents').trim();
|
||||
if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
|
||||
return category as UploadCategory;
|
||||
}
|
||||
throw new BadRequestException('Unsupported upload category');
|
||||
}
|
||||
|
||||
function requiredUploadPermission(category: UploadCategory) {
|
||||
return category === 'teams' ? P.matches : P.content;
|
||||
}
|
||||
|
||||
function assertUploadPermission(user: AdminUploadUser | undefined, category: UploadCategory) {
|
||||
if (user?.role === 'SUPER_ADMIN') return;
|
||||
const required = requiredUploadPermission(category);
|
||||
if (!user?.permissions?.includes(required)) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('Image file is required');
|
||||
}
|
||||
if (!IMAGE_MIME_EXT[file.mimetype]) {
|
||||
throw new BadRequestException('Only PNG, JPG, WEBP, GIF or SVG images are allowed');
|
||||
}
|
||||
if (file.mimetype === 'image/svg+xml') {
|
||||
const sample = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)).toLowerCase();
|
||||
if (sample.includes('<script') || sample.includes('javascript:') || /\son[a-z]+\s*=/.test(sample)) {
|
||||
throw new BadRequestException('Unsafe SVG content is not allowed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFilename(file: UploadedImage) {
|
||||
const fromMime = IMAGE_MIME_EXT[file.mimetype];
|
||||
const fromName = extname(file.originalname || '').toLowerCase();
|
||||
const ext = fromMime || fromName || '.img';
|
||||
const base = (file.originalname || 'asset')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 42) || 'asset';
|
||||
return `${Date.now()}-${base}-${randomUUID().slice(0, 8)}${ext}`;
|
||||
}
|
||||
|
||||
class CreateUserDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
@@ -746,6 +826,7 @@ export class AdminController {
|
||||
private systemConfig: SystemConfigService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
private databaseReset: DatabaseResetService,
|
||||
private smokeTests: SmokeTestService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
@@ -985,6 +1066,25 @@ export class AdminController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('agents/credit-transactions')
|
||||
@RequirePermissions(P.agentsView, P.reports)
|
||||
async listAgentCreditTransactions(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('agentId') agentId?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('transactionType') transactionType?: string,
|
||||
) {
|
||||
const result = await this.agents.listCreditTransactions({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
|
||||
agentId: agentId ? BigInt(agentId) : undefined,
|
||||
keyword,
|
||||
transactionType,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('agents/:id')
|
||||
@RequirePermissions(P.agentsView)
|
||||
async getAgentDetail(@Param('id') id: string) {
|
||||
@@ -1731,6 +1831,33 @@ export class AdminController {
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('uploads')
|
||||
@RequirePermissions(P.content, P.matches)
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async uploadAsset(
|
||||
@CurrentUser() user: AdminUploadUser,
|
||||
@UploadedFile() file: UploadedImage | undefined,
|
||||
@Query('category') rawCategory?: string,
|
||||
) {
|
||||
const category = uploadCategory(rawCategory);
|
||||
assertUploadPermission(user, category);
|
||||
assertImageFile(file);
|
||||
|
||||
const filename = uploadFilename(file);
|
||||
const root = getUploadRoot();
|
||||
const targetDir = join(root, category);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(join(targetDir, filename), file.buffer);
|
||||
|
||||
return jsonResponse({
|
||||
category,
|
||||
filename,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
url: `/uploads/${category}/${filename}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
@RequirePermissions(P.content, P.reports)
|
||||
async listContents(
|
||||
@@ -1786,6 +1913,45 @@ export class AdminController {
|
||||
return jsonResponse(messages);
|
||||
}
|
||||
|
||||
@Get('smoke-tests/suites')
|
||||
@RequirePermissions(P.settings)
|
||||
async smokeTestSuites() {
|
||||
return jsonResponse({
|
||||
suites: this.smokeTests.listSuites(),
|
||||
cases: this.smokeTests.listCases(),
|
||||
lastRun: this.smokeTests.getLastRun(),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('smoke-tests/last-run')
|
||||
@RequirePermissions(P.settings)
|
||||
async smokeTestLastRun() {
|
||||
return jsonResponse(this.smokeTests.getLastRun());
|
||||
}
|
||||
|
||||
@Post('smoke-tests/run')
|
||||
@RequirePermissions(P.settings)
|
||||
async runSmokeTests(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() body: { suites?: string[] },
|
||||
) {
|
||||
const summary = await this.smokeTests.run(body?.suites, operatorId);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'RUN_SMOKE_TESTS',
|
||||
module: 'SYSTEM',
|
||||
targetId: summary.runId,
|
||||
afterData: {
|
||||
passed: summary.passed,
|
||||
failed: summary.failed,
|
||||
total: summary.total,
|
||||
suites: summary.suites,
|
||||
},
|
||||
});
|
||||
return jsonResponse(summary);
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
@RequirePermissions(P.audit)
|
||||
async auditLogs(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ContentModule } from '../../domains/operations/content/content.module';
|
||||
import { I18nModule } from '../../domains/operations/i18n/i18n.module';
|
||||
import { BetsModule } from '../../domains/betting/bets.module';
|
||||
import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,6 +28,7 @@ import { DatabaseModule } from '../../infrastructure/database/database.module';
|
||||
I18nModule,
|
||||
BetsModule,
|
||||
DatabaseModule,
|
||||
SmokeTestModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminDashboardService, PermissionsGuard],
|
||||
|
||||
@@ -693,6 +693,108 @@ export class AgentsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listCreditTransactions(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
agentId?: bigint;
|
||||
keyword?: string;
|
||||
transactionType?: string;
|
||||
}) {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.AgentCreditTransactionWhereInput = {};
|
||||
|
||||
if (params.transactionType?.trim()) {
|
||||
where.transactionType = params.transactionType.trim();
|
||||
}
|
||||
|
||||
const keyword = params.keyword?.trim();
|
||||
if (keyword) {
|
||||
const matched = await this.prisma.user.findMany({
|
||||
where: {
|
||||
userType: 'AGENT',
|
||||
deletedAt: null,
|
||||
username: { contains: keyword, mode: 'insensitive' },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 50,
|
||||
});
|
||||
const agentUserIds = matched.map((u) => u.id);
|
||||
if (!agentUserIds.length) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
if (params.agentId) {
|
||||
if (!agentUserIds.some((id) => id === params.agentId)) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
where.agentId = params.agentId;
|
||||
} else {
|
||||
where.agentId = { in: agentUserIds };
|
||||
}
|
||||
} else if (params.agentId) {
|
||||
where.agentId = params.agentId;
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.agentCreditTransaction.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.agentCreditTransaction.count({ where }),
|
||||
]);
|
||||
|
||||
const agentIds = [...new Set(rows.map((r) => r.agentId))];
|
||||
const operatorIds = [
|
||||
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
|
||||
];
|
||||
|
||||
const [agentUsers, operators] = await Promise.all([
|
||||
agentIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: agentIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [],
|
||||
operatorIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const agentNameById = new Map(agentUsers.map((u) => [u.id.toString(), u.username]));
|
||||
const operatorNameById = new Map(operators.map((u) => [u.id.toString(), u.username]));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id.toString(),
|
||||
agentId: row.agentId.toString(),
|
||||
agentUsername: agentNameById.get(row.agentId.toString()) ?? null,
|
||||
transactionType: row.transactionType,
|
||||
amount: row.amount.toString(),
|
||||
creditBefore: row.creditBefore.toString(),
|
||||
creditAfter: row.creditAfter.toString(),
|
||||
referenceType: row.referenceType,
|
||||
referenceId: row.referenceId,
|
||||
operatorId: row.operatorId?.toString() ?? null,
|
||||
operatorUsername: row.operatorId
|
||||
? (operatorNameById.get(row.operatorId.toString()) ?? null)
|
||||
: null,
|
||||
requestId: row.requestId,
|
||||
remark: row.remark,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async updateAgentAdmin(
|
||||
agentId: bigint,
|
||||
data: {
|
||||
|
||||
@@ -474,10 +474,33 @@ export class CashbackService {
|
||||
}
|
||||
|
||||
async getUserCashbacks(userId: bigint) {
|
||||
return this.prisma.cashbackItem.findMany({
|
||||
where: { userId },
|
||||
include: { batch: true },
|
||||
const items = await this.prisma.cashbackItem.findMany({
|
||||
where: { userId, batch: { status: 'CONFIRMED' } },
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
batchNo: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
confirmedAt: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
batchNo: item.batch.batchNo,
|
||||
periodStart: item.batch.periodStart,
|
||||
periodEnd: item.batch.periodEnd,
|
||||
confirmedAt: item.batch.confirmedAt,
|
||||
effectiveStake: item.effectiveStake.toString(),
|
||||
betCount: item.betCount,
|
||||
rate: item.rate.toString(),
|
||||
amount: item.amount.toString(),
|
||||
createdAt: item.createdAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import type { AgentsService } from '../../agent/agents.service';
|
||||
import type { BetsService } from '../../betting/bets.service';
|
||||
import type { SettlementService } from '../../settlement/settlement.service';
|
||||
import type { WalletService } from '../../ledger/wallet.service';
|
||||
import type { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { expectEqual, expectThrows, expectTrue } from './smoke-test.helpers';
|
||||
import type { SmokeTestCaseDef } from './smoke-test.cases';
|
||||
import {
|
||||
BetFlowFixtureIds,
|
||||
createBetFlowFixture,
|
||||
teardownBetFlowFixture,
|
||||
} from './smoke-test.bet-flow.fixture';
|
||||
|
||||
export const BET_FLOW_PROBE_COUNT = 5;
|
||||
|
||||
export type BetFlowProbeDeps = {
|
||||
prisma: PrismaService;
|
||||
wallet: WalletService;
|
||||
bets: BetsService;
|
||||
settlement: SettlementService;
|
||||
agents: AgentsService;
|
||||
};
|
||||
|
||||
async function confirmMatchSettlement(
|
||||
deps: BetFlowProbeDeps,
|
||||
fx: BetFlowFixtureIds,
|
||||
score: { htHome: number; htAway: number; ftHome: number; ftAway: number },
|
||||
) {
|
||||
await deps.settlement.recordScore(
|
||||
fx.matchId,
|
||||
score.htHome,
|
||||
score.htAway,
|
||||
score.ftHome,
|
||||
score.ftAway,
|
||||
fx.operatorId,
|
||||
);
|
||||
const preview = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId);
|
||||
await deps.settlement.confirmSettlement(preview.batch.id, fx.operatorId);
|
||||
}
|
||||
|
||||
export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[] {
|
||||
return [
|
||||
{
|
||||
id: 'BF001',
|
||||
suite: 'bet-flow',
|
||||
name: '单关赢:冻结后结算派彩',
|
||||
uatRef: '8/14',
|
||||
description: '下注 100@2.0 主胜,比分 2-1,余额 1000→1100',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
|
||||
try {
|
||||
const bet = await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
100,
|
||||
`smoke-win-${fx.runId}`,
|
||||
);
|
||||
|
||||
expectEqual('bet.status', bet.status, 'PENDING');
|
||||
expectEqual('bet.stake', bet.stake.toString(), '100');
|
||||
|
||||
let w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available after freeze', w.availableBalance.toString(), '900');
|
||||
expectEqual('frozen after freeze', w.frozenBalance.toString(), '100');
|
||||
|
||||
await confirmMatchSettlement(deps, fx, { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 });
|
||||
|
||||
const settled = await deps.prisma.bet.findUnique({ where: { id: bet.id } });
|
||||
expectEqual('settled status', settled?.status, 'WON');
|
||||
expectEqual('actualReturn', settled?.actualReturn.toString(), '200');
|
||||
|
||||
w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available after settle', w.availableBalance.toString(), '1100');
|
||||
expectEqual('frozen after settle', w.frozenBalance.toString(), '0');
|
||||
|
||||
const txs = await deps.prisma.walletTransaction.findMany({
|
||||
where: { userId: fx.playerId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
expectEqual(
|
||||
'wallet tx types',
|
||||
txs.map((t) => t.transactionType).join(','),
|
||||
'MANUAL_DEPOSIT,BET_FREEZE,BET_SETTLE_WIN',
|
||||
);
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF002',
|
||||
suite: 'bet-flow',
|
||||
name: '单关输:冻结本金被消耗',
|
||||
uatRef: '14',
|
||||
description: '和局选项 + 2-1 比分,余额 1000→900',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
|
||||
try {
|
||||
const bet = await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.drawSelectionId,
|
||||
fx.drawOddsVersion,
|
||||
100,
|
||||
`smoke-lose-${fx.runId}`,
|
||||
);
|
||||
|
||||
let w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available after freeze', w.availableBalance.toString(), '900');
|
||||
expectEqual('frozen after freeze', w.frozenBalance.toString(), '100');
|
||||
|
||||
await confirmMatchSettlement(deps, fx, { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 });
|
||||
|
||||
const settled = await deps.prisma.bet.findUnique({ where: { id: bet.id } });
|
||||
expectEqual('settled status', settled?.status, 'LOST');
|
||||
expectEqual('actualReturn', settled?.actualReturn.toString(), '0');
|
||||
|
||||
w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available after settle', w.availableBalance.toString(), '900');
|
||||
expectEqual('frozen after settle', w.frozenBalance.toString(), '0');
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF003',
|
||||
suite: 'bet-flow',
|
||||
name: '下注幂等:相同 requestId 不重复扣款',
|
||||
uatRef: '8',
|
||||
description: 'userId + requestId 唯一,重复提交返回同一注单',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
|
||||
try {
|
||||
const requestId = `smoke-idem-${fx.runId}`;
|
||||
const first = await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
50,
|
||||
requestId,
|
||||
);
|
||||
const second = await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
50,
|
||||
requestId,
|
||||
);
|
||||
|
||||
expectEqual('same bet id', second.id.toString(), first.id.toString());
|
||||
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
|
||||
expectEqual('bet count', count, 1);
|
||||
|
||||
const w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available', w.availableBalance.toString(), '450');
|
||||
expectEqual('frozen', w.frozenBalance.toString(), '50');
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF004',
|
||||
suite: 'bet-flow',
|
||||
name: '余额不足:拒绝下注',
|
||||
uatRef: '8',
|
||||
description: '可用 50 时下 100 注单应失败且无冻结',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 50 });
|
||||
try {
|
||||
await expectThrows(
|
||||
'placeSingleBet',
|
||||
async () => {
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
100,
|
||||
`smoke-insuf-${fx.runId}`,
|
||||
);
|
||||
},
|
||||
'Insufficient balance',
|
||||
);
|
||||
|
||||
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
|
||||
expectEqual('bet count', count, 0);
|
||||
|
||||
const w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available', w.availableBalance.toString(), '50');
|
||||
expectEqual('frozen', w.frozenBalance.toString(), '0');
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF005',
|
||||
suite: 'bet-flow',
|
||||
name: '代理额度:结算后 usedCredit 同步',
|
||||
uatRef: '4/14',
|
||||
description: '玩家输 100 后代理 usedCredit 1000→900',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, {
|
||||
initialBalance: 1000,
|
||||
withAgent: true,
|
||||
});
|
||||
try {
|
||||
expectTrue('agent exists', !!fx.agentId);
|
||||
|
||||
await deps.agents.recalculateUsedCredit(fx.agentId!);
|
||||
let profile = await deps.prisma.agentProfile.findUnique({
|
||||
where: { userId: fx.agentId! },
|
||||
});
|
||||
expectEqual('usedCredit before bet', profile?.usedCredit.toString(), '1000');
|
||||
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
fx.agentId!,
|
||||
fx.drawSelectionId,
|
||||
fx.drawOddsVersion,
|
||||
100,
|
||||
`smoke-agent-${fx.runId}`,
|
||||
);
|
||||
|
||||
await confirmMatchSettlement(deps, fx, { htHome: 0, htAway: 0, ftHome: 2, ftAway: 1 });
|
||||
|
||||
await deps.agents.recalculateUsedCredit(fx.agentId!);
|
||||
profile = await deps.prisma.agentProfile.findUnique({ where: { userId: fx.agentId! } });
|
||||
expectEqual('usedCredit after settle', profile?.usedCredit.toString(), '900');
|
||||
|
||||
const w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('player available', w.availableBalance.toString(), '900');
|
||||
expectTrue(
|
||||
'directPlayerLiability matches wallet',
|
||||
new Decimal(profile!.directPlayerLiability).eq(w.availableBalance.add(w.frozenBalance)),
|
||||
{
|
||||
liability: profile?.directPlayerLiability.toString(),
|
||||
wallet: w.availableBalance.add(w.frozenBalance).toString(),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import type { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import type { WalletService } from '../../ledger/wallet.service';
|
||||
|
||||
export interface BetFlowFixtureIds {
|
||||
runId: string;
|
||||
operatorId: bigint;
|
||||
playerId: bigint;
|
||||
agentId?: bigint;
|
||||
matchId: bigint;
|
||||
marketId: bigint;
|
||||
homeSelectionId: bigint;
|
||||
homeOddsVersion: bigint;
|
||||
drawSelectionId: bigint;
|
||||
drawOddsVersion: bigint;
|
||||
awaySelectionId: bigint;
|
||||
awayOddsVersion: bigint;
|
||||
leagueId: bigint;
|
||||
homeTeamId: bigint;
|
||||
awayTeamId: bigint;
|
||||
}
|
||||
|
||||
export async function createBetFlowFixture(
|
||||
prisma: PrismaService,
|
||||
wallet: WalletService,
|
||||
opts?: { initialBalance?: number; withAgent?: boolean },
|
||||
): Promise<BetFlowFixtureIds> {
|
||||
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const operator = await prisma.user.create({
|
||||
data: {
|
||||
username: `smoke_op_${runId}`,
|
||||
userType: 'ADMIN',
|
||||
auth: { create: { passwordHash: 'smoke-test' } },
|
||||
},
|
||||
});
|
||||
|
||||
const league = await prisma.league.create({
|
||||
data: {
|
||||
code: `SMK_L_${runId}`,
|
||||
sportType: 'FOOTBALL',
|
||||
},
|
||||
});
|
||||
|
||||
const homeTeam = await prisma.team.create({
|
||||
data: { code: `SMK_H_${runId}`, sportType: 'FOOTBALL' },
|
||||
});
|
||||
const awayTeam = await prisma.team.create({
|
||||
data: { code: `SMK_A_${runId}`, sportType: 'FOOTBALL' },
|
||||
});
|
||||
|
||||
const startTime = new Date(Date.now() + 48 * 60 * 60 * 1000);
|
||||
const match = await prisma.match.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
leagueId: league.id,
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime,
|
||||
status: 'PUBLISHED',
|
||||
publishTime: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const market = await prisma.market.create({
|
||||
data: {
|
||||
matchId: match.id,
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: [
|
||||
{
|
||||
selectionCode: 'HOME',
|
||||
selectionName: 'Home',
|
||||
odds: new Decimal('2.00'),
|
||||
oddsVersion: BigInt(1),
|
||||
status: 'OPEN',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
selectionCode: 'DRAW',
|
||||
selectionName: 'Draw',
|
||||
odds: new Decimal('3.00'),
|
||||
oddsVersion: BigInt(1),
|
||||
status: 'OPEN',
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
selectionCode: 'AWAY',
|
||||
selectionName: 'Away',
|
||||
odds: new Decimal('4.00'),
|
||||
oddsVersion: BigInt(1),
|
||||
status: 'OPEN',
|
||||
sortOrder: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
let agentId: bigint | undefined;
|
||||
if (opts?.withAgent) {
|
||||
const agent = await prisma.user.create({
|
||||
data: {
|
||||
username: `smoke_ag_${runId}`,
|
||||
userType: 'AGENT',
|
||||
auth: { create: { passwordHash: 'smoke-test' } },
|
||||
agentProfile: {
|
||||
create: { level: 1, creditLimit: new Decimal(100000) },
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.agentClosure.create({
|
||||
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
|
||||
});
|
||||
agentId = agent.id;
|
||||
}
|
||||
|
||||
const player = await prisma.user.create({
|
||||
data: {
|
||||
username: `smoke_pl_${runId}`,
|
||||
userType: 'PLAYER',
|
||||
parentId: agentId,
|
||||
auth: { create: { passwordHash: 'smoke-test' } },
|
||||
wallet: { create: { currency: 'USD' } },
|
||||
},
|
||||
});
|
||||
|
||||
const balance = opts?.initialBalance ?? 1000;
|
||||
if (balance > 0) {
|
||||
await wallet.deposit(player.id, balance, operator.id, 'smoke test seed');
|
||||
}
|
||||
|
||||
const home = market.selections.find((s) => s.selectionCode === 'HOME')!;
|
||||
const draw = market.selections.find((s) => s.selectionCode === 'DRAW')!;
|
||||
const away = market.selections.find((s) => s.selectionCode === 'AWAY')!;
|
||||
|
||||
return {
|
||||
runId,
|
||||
operatorId: operator.id,
|
||||
playerId: player.id,
|
||||
agentId,
|
||||
matchId: match.id,
|
||||
marketId: market.id,
|
||||
homeSelectionId: home.id,
|
||||
homeOddsVersion: home.oddsVersion,
|
||||
drawSelectionId: draw.id,
|
||||
drawOddsVersion: draw.oddsVersion,
|
||||
awaySelectionId: away.id,
|
||||
awayOddsVersion: away.oddsVersion,
|
||||
leagueId: league.id,
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function teardownBetFlowFixture(
|
||||
prisma: PrismaService,
|
||||
fx: BetFlowFixtureIds,
|
||||
): Promise<void> {
|
||||
const bets = await prisma.bet.findMany({
|
||||
where: { userId: fx.playerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const betIds = bets.map((b) => b.id);
|
||||
|
||||
const batches = await prisma.settlementBatch.findMany({
|
||||
where: { matchId: fx.matchId },
|
||||
select: { id: true },
|
||||
});
|
||||
const batchIds = batches.map((b) => b.id);
|
||||
|
||||
if (batchIds.length) {
|
||||
await prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } });
|
||||
await prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } });
|
||||
}
|
||||
if (betIds.length) {
|
||||
await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
|
||||
await prisma.bet.deleteMany({ where: { id: { in: betIds } } });
|
||||
}
|
||||
|
||||
await prisma.walletTransaction.deleteMany({ where: { userId: fx.playerId } });
|
||||
await prisma.wallet.deleteMany({ where: { userId: fx.playerId } });
|
||||
await prisma.matchScore.deleteMany({ where: { matchId: fx.matchId } });
|
||||
|
||||
const markets = await prisma.market.findMany({
|
||||
where: { matchId: fx.matchId },
|
||||
select: { id: true },
|
||||
});
|
||||
const marketIds = markets.map((m) => m.id);
|
||||
if (marketIds.length) {
|
||||
await prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
|
||||
await prisma.market.deleteMany({ where: { id: { in: marketIds } } });
|
||||
}
|
||||
|
||||
await prisma.match.deleteMany({ where: { id: fx.matchId } });
|
||||
|
||||
if (fx.agentId) {
|
||||
await prisma.agentClosure.deleteMany({
|
||||
where: { OR: [{ ancestorId: fx.agentId }, { descendantId: fx.agentId }] },
|
||||
});
|
||||
await prisma.agentProfile.deleteMany({ where: { userId: fx.agentId } });
|
||||
await prisma.userAuth.deleteMany({ where: { userId: fx.agentId } });
|
||||
await prisma.user.deleteMany({ where: { id: fx.agentId } });
|
||||
}
|
||||
|
||||
await prisma.userAuth.deleteMany({
|
||||
where: { userId: { in: [fx.playerId, fx.operatorId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { id: { in: [fx.playerId, fx.operatorId] } } });
|
||||
await prisma.team.deleteMany({ where: { id: { in: [fx.homeTeamId, fx.awayTeamId] } } });
|
||||
await prisma.league.deleteMany({ where: { id: fx.leagueId } });
|
||||
}
|
||||
762
apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts
Normal file
762
apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
canSelectForParlay,
|
||||
isQuarterHandicapOrTotal,
|
||||
PARLAY_MAX_LEGS,
|
||||
PARLAY_MIN_LEGS,
|
||||
} from '@thebet365/shared';
|
||||
import { BettingLimitsService } from '../../betting/betting-limits.service';
|
||||
import { resolveSelectionCode } from '../../settlement/domain/settlement-helpers';
|
||||
import {
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
settleSelection,
|
||||
type ScoreInput,
|
||||
} from '../../settlement/domain/settlement-calculator';
|
||||
import { resolveCashbackRateForBet } from '../cashback/cashback-rate.resolver';
|
||||
import { expectEqual, expectFalse, expectThrows, expectTrue } from './smoke-test.helpers';
|
||||
import type { SmokeTestCaseMeta } from './smoke-test.types';
|
||||
|
||||
export type SmokeTestRunner = () => void | Promise<void>;
|
||||
|
||||
export type SmokeTestCaseDef = SmokeTestCaseMeta & {
|
||||
run: SmokeTestRunner;
|
||||
};
|
||||
|
||||
const SCORE_2_1: ScoreInput = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
|
||||
const SCORE_0_0: ScoreInput = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||
const SCORE_1_1: ScoreInput = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
|
||||
function createBettingLimitsService() {
|
||||
const prisma = {
|
||||
systemConfig: { findUnique: async () => null },
|
||||
bet: {
|
||||
aggregate: async () => ({ _sum: { stake: new Decimal(0) } }),
|
||||
},
|
||||
};
|
||||
return new BettingLimitsService(prisma as never);
|
||||
}
|
||||
|
||||
export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
|
||||
// —— Settlement ——
|
||||
{
|
||||
id: 'S001',
|
||||
suite: 'settlement',
|
||||
name: '全场独赢:主胜/和局',
|
||||
uatRef: 'S001',
|
||||
description: '比分 2-1,主胜赢、和局输',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'HOME result',
|
||||
settleSelection({ marketType: 'FT_1X2', selectionCode: 'HOME', score: SCORE_2_1 }),
|
||||
'WIN',
|
||||
{ market: 'FT_1X2', selection: 'HOME', score: SCORE_2_1 },
|
||||
);
|
||||
expectEqual(
|
||||
'DRAW result',
|
||||
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: SCORE_2_1 }),
|
||||
'LOSE',
|
||||
{ market: 'FT_1X2', selection: 'DRAW', score: SCORE_2_1 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S002',
|
||||
suite: 'settlement',
|
||||
name: '全场独赢:和局命中',
|
||||
uatRef: 'S002',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'DRAW result',
|
||||
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: SCORE_1_1 }),
|
||||
'WIN',
|
||||
{ market: 'FT_1X2', selection: 'DRAW', score: SCORE_1_1 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S003',
|
||||
suite: 'settlement',
|
||||
name: '半场独赢:半场主胜',
|
||||
uatRef: 'S003',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'HT HOME',
|
||||
settleSelection({ marketType: 'HT_1X2', selectionCode: 'HOME', score: SCORE_2_1 }),
|
||||
'WIN',
|
||||
{ market: 'HT_1X2', ht: '1-0', ft: '2-1' },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S004',
|
||||
suite: 'settlement',
|
||||
name: '全场单双:0-0 为双',
|
||||
uatRef: 'S004',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'EVEN',
|
||||
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'EVEN', score: SCORE_0_0 }),
|
||||
'WIN',
|
||||
{ score: SCORE_0_0, totalGoals: 0 },
|
||||
);
|
||||
expectEqual(
|
||||
'ODD',
|
||||
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'ODD', score: SCORE_0_0 }),
|
||||
'LOSE',
|
||||
{ score: SCORE_0_0, totalGoals: 0 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S005',
|
||||
suite: 'settlement',
|
||||
name: '波胆:2-1 精确命中',
|
||||
uatRef: 'S005',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'SCORE_2_1',
|
||||
settleSelection({
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
selectionCode: 'SCORE_2_1',
|
||||
score: SCORE_2_1,
|
||||
}),
|
||||
'WIN',
|
||||
{ score: SCORE_2_1 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S006',
|
||||
suite: 'settlement',
|
||||
name: '波胆:主胜其他',
|
||||
uatRef: 'S006',
|
||||
run: () => {
|
||||
const score = { htHome: 2, htAway: 0, ftHome: 5, ftAway: 0 };
|
||||
expectEqual(
|
||||
'OTHER_HOME',
|
||||
settleSelection({
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
selectionCode: 'OTHER_HOME',
|
||||
score,
|
||||
templateScores: ['SCORE_1_0', 'SCORE_2_0'],
|
||||
}),
|
||||
'WIN',
|
||||
{ score, templateScores: ['SCORE_1_0', 'SCORE_2_0'] },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S008',
|
||||
suite: 'settlement',
|
||||
name: '下半场波胆:1-1',
|
||||
uatRef: 'S008',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'SH SCORE_1_1',
|
||||
settleSelection({
|
||||
marketType: 'SH_CORRECT_SCORE',
|
||||
selectionCode: 'SCORE_1_1',
|
||||
score: SCORE_2_1,
|
||||
}),
|
||||
'WIN',
|
||||
{ shGoals: '1-1', ft: '2-1' },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S009',
|
||||
suite: 'settlement',
|
||||
name: '让球 -1 全赢 + 派彩',
|
||||
uatRef: 'S009',
|
||||
run: () => {
|
||||
const score = { htHome: 0, htAway: 0, ftHome: 2, ftAway: 0 };
|
||||
const result = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -1,
|
||||
score,
|
||||
});
|
||||
expectEqual('settlement', result, 'WIN', { line: -1, score });
|
||||
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 185, {
|
||||
stake: 100,
|
||||
odds: 1.85,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S010',
|
||||
suite: 'settlement',
|
||||
name: '让球 -1 走水 + 退本',
|
||||
uatRef: 'S010',
|
||||
run: () => {
|
||||
const score = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 0 };
|
||||
const result = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -1,
|
||||
score,
|
||||
});
|
||||
expectEqual('settlement', result, 'PUSH', { line: -1, score });
|
||||
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 100, {
|
||||
stake: 100,
|
||||
odds: 1.85,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S011',
|
||||
suite: 'settlement',
|
||||
name: '让球 -0.25 半输',
|
||||
uatRef: 'S011',
|
||||
run: () => {
|
||||
const result = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -0.25,
|
||||
score: SCORE_0_0,
|
||||
});
|
||||
expectEqual('settlement', result, 'HALF_LOSE', { line: -0.25, score: SCORE_0_0 });
|
||||
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 50, {
|
||||
stake: 100,
|
||||
odds: 1.85,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S011B',
|
||||
suite: 'settlement',
|
||||
name: '半赢派彩系数',
|
||||
description: 'HALF_WIN 派彩 = stake + stake*(odds-1)/2',
|
||||
run: () => {
|
||||
expectEqual('HALF_WIN payout', calculatePayout(100, 1.85, 'HALF_WIN').toNumber(), 142.5, {
|
||||
stake: 100,
|
||||
odds: 1.85,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S012',
|
||||
suite: 'settlement',
|
||||
name: '让球 -0.5 全输(0-0)',
|
||||
uatRef: 'S012',
|
||||
run: () => {
|
||||
const result = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -0.5,
|
||||
score: SCORE_0_0,
|
||||
});
|
||||
expectEqual('settlement', result, 'LOSE', { line: -0.5, score: SCORE_0_0 });
|
||||
expectEqual('payout', calculatePayout(100, 1.85, result).toNumber(), 0, {
|
||||
stake: 100,
|
||||
odds: 1.85,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S013',
|
||||
suite: 'settlement',
|
||||
name: '大小 2.5:3 球大球赢',
|
||||
uatRef: 'S013',
|
||||
run: () => {
|
||||
expectEqual(
|
||||
'OVER 2.5',
|
||||
settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2.5,
|
||||
score: SCORE_2_1,
|
||||
}),
|
||||
'WIN',
|
||||
{ totalGoals: 3, line: 2.5 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S014',
|
||||
suite: 'settlement',
|
||||
name: '大小整数盘 2 球走水',
|
||||
uatRef: 'S014',
|
||||
run: () => {
|
||||
const score = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
expectEqual(
|
||||
'OVER 2 push',
|
||||
settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2,
|
||||
score,
|
||||
}),
|
||||
'PUSH',
|
||||
{ totalGoals: 2, line: 2 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S015',
|
||||
suite: 'settlement',
|
||||
name: '0-0 大小 2.5:小球赢',
|
||||
description: '0-0 下小球赢、大球输',
|
||||
run: () => {
|
||||
const under = settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 2.5,
|
||||
score: SCORE_0_0,
|
||||
});
|
||||
const over = settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2.5,
|
||||
score: SCORE_0_0,
|
||||
});
|
||||
expectEqual('UNDER', under, 'WIN', { line: 2.5, score: SCORE_0_0 });
|
||||
expectEqual('OVER', over, 'LOSE', { line: 2.5, score: SCORE_0_0 });
|
||||
expectEqual('UNDER payout', calculatePayout(100, 1.95, under).toNumber(), 195, {
|
||||
stake: 100,
|
||||
odds: 1.95,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S016',
|
||||
suite: 'settlement',
|
||||
name: '串关全中派彩',
|
||||
uatRef: 'S016',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'WIN' as const },
|
||||
{ odds: 2.0, result: 'WIN' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 360, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S017',
|
||||
suite: 'settlement',
|
||||
name: '串关一关输:全输',
|
||||
uatRef: 'S017',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'WIN' as const },
|
||||
{ odds: 2.0, result: 'LOSE' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S018',
|
||||
suite: 'settlement',
|
||||
name: '串关一关走水:赔率按 1.00',
|
||||
uatRef: 'S018',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'WIN' as const },
|
||||
{ odds: 2.0, result: 'PUSH' as const },
|
||||
{ odds: 1.9, result: 'WIN' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 342, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S019',
|
||||
suite: 'settlement',
|
||||
name: '串关全走水/作废:退本',
|
||||
uatRef: 'S019',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 1.8, result: 'PUSH' as const },
|
||||
{ odds: 2.0, result: 'VOID' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'PUSH', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'OUT001',
|
||||
suite: 'settlement',
|
||||
name: '冠军盘:猜中/猜错',
|
||||
description: 'OUTRIGHT_WINNER 按 winnerTeamCode 结算',
|
||||
run: () => {
|
||||
const input = { winnerTeamCode: 'BRA', selection: 'BRA' };
|
||||
expectEqual(
|
||||
'BRA wins',
|
||||
settleSelection({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
selectionCode: 'BRA',
|
||||
score: SCORE_0_0,
|
||||
winnerTeamCode: 'BRA',
|
||||
}),
|
||||
'WIN',
|
||||
input,
|
||||
);
|
||||
expectEqual(
|
||||
'ARG loses',
|
||||
settleSelection({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
selectionCode: 'ARG',
|
||||
score: SCORE_0_0,
|
||||
winnerTeamCode: 'BRA',
|
||||
}),
|
||||
'LOSE',
|
||||
{ winnerTeamCode: 'BRA', selection: 'ARG' },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// —— Settlement helpers ——
|
||||
{
|
||||
id: 'H001',
|
||||
suite: 'settlement_helpers',
|
||||
name: '选项代码解析',
|
||||
description: '数据库 code 优先,中文快照名兜底',
|
||||
run: () => {
|
||||
expectEqual('db code', resolveSelectionCode('UNDER', '小 2.5'), 'UNDER', {
|
||||
code: 'UNDER',
|
||||
name: '小 2.5',
|
||||
});
|
||||
expectEqual('主胜', resolveSelectionCode(null, '主胜'), 'HOME', { name: '主胜' });
|
||||
expectEqual('小 2.5', resolveSelectionCode('', '小 2.5'), 'UNDER', { name: '小 2.5' });
|
||||
expectEqual('大 2.5', resolveSelectionCode(undefined, '大 2.5'), 'OVER', { name: '大 2.5' });
|
||||
expectEqual('主 -0.5', resolveSelectionCode(null, '主 -0.5'), 'HOME', { name: '主 -0.5' });
|
||||
},
|
||||
},
|
||||
|
||||
// —— Betting rules ——
|
||||
{
|
||||
id: 'B003',
|
||||
suite: 'betting',
|
||||
name: '赔率版本不一致应拒绝',
|
||||
uatRef: 'B003',
|
||||
run: () => {
|
||||
const submitted = BigInt(1);
|
||||
const current = BigInt(2);
|
||||
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B006',
|
||||
suite: 'betting',
|
||||
name: '串关 2-5 场范围常量',
|
||||
uatRef: 'B006',
|
||||
run: () => {
|
||||
expectEqual('PARLAY_MIN_LEGS', PARLAY_MIN_LEGS, 2);
|
||||
expectEqual('PARLAY_MAX_LEGS', PARLAY_MAX_LEGS, 5);
|
||||
expectTrue('2 legs ok', 2 >= PARLAY_MIN_LEGS && 2 <= PARLAY_MAX_LEGS);
|
||||
expectTrue('5 legs ok', 5 >= PARLAY_MIN_LEGS && 5 <= PARLAY_MAX_LEGS);
|
||||
expectTrue('6 legs blocked', 6 > PARLAY_MAX_LEGS);
|
||||
expectTrue('1 leg blocked', 1 < PARLAY_MIN_LEGS);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B008',
|
||||
suite: 'betting',
|
||||
name: '四分之一盘不可串关',
|
||||
uatRef: 'B008',
|
||||
run: () => {
|
||||
expectTrue('-0.25 is quarter', isQuarterHandicapOrTotal(-0.25), { line: -0.25 });
|
||||
expectFalse('2.5 not quarter', isQuarterHandicapOrTotal(2.5), { line: 2.5 });
|
||||
expectFalse('-1 not quarter', isQuarterHandicapOrTotal(-1), { line: -1 });
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.25,
|
||||
});
|
||||
expectFalse('blocked', check.ok, { line: -0.25 }, check.ok ? '' : (check as { reason: string }).reason);
|
||||
if (!check.ok) {
|
||||
expectEqual('reason', check.reason, 'QUARTER_LINE', { line: -0.25 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B009',
|
||||
suite: 'betting',
|
||||
name: '串关超过 5 场应拒绝',
|
||||
uatRef: 'B009',
|
||||
run: () => {
|
||||
expectTrue('6 > max', 6 > PARLAY_MAX_LEGS, { legs: 6, max: PARLAY_MAX_LEGS });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B010',
|
||||
suite: 'betting',
|
||||
name: '冠军盘不可串关',
|
||||
uatRef: 'B010',
|
||||
run: () => {
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'OUTRIGHT_WINNER',
|
||||
isOutright: true,
|
||||
});
|
||||
expectFalse('blocked', check.ok, { marketType: 'OUTRIGHT_WINNER' });
|
||||
if (!check.ok) {
|
||||
expectEqual('reason', check.reason, 'OUTRIGHT', { marketType: 'OUTRIGHT_WINNER' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B011',
|
||||
suite: 'betting',
|
||||
name: '常规让球盘可串关',
|
||||
run: () => {
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.5,
|
||||
});
|
||||
expectTrue('allowed', check.ok, { marketType: 'FT_HANDICAP', line: -0.5 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B012',
|
||||
suite: 'betting',
|
||||
name: '显式禁止串关的盘口',
|
||||
run: () => {
|
||||
const check = canSelectForParlay({
|
||||
marketType: 'FT_1X2',
|
||||
allowParlay: false,
|
||||
});
|
||||
expectFalse('blocked', check.ok, { marketType: 'FT_1X2', allowParlay: false });
|
||||
if (!check.ok) {
|
||||
expectEqual('reason', check.reason, 'NOT_ALLOWED', { allowParlay: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// —— Betting limits ——
|
||||
{
|
||||
id: 'BL001',
|
||||
suite: 'betting_limits',
|
||||
name: '低于最小投注额拒绝',
|
||||
run: async () => {
|
||||
const service = createBettingLimitsService();
|
||||
await expectThrows(
|
||||
'min stake',
|
||||
() =>
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 0.5,
|
||||
potentialReturn: new Decimal(1),
|
||||
}),
|
||||
'Minimum stake is 1',
|
||||
{ stake: 0.5, minStake: 1 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BL002',
|
||||
suite: 'betting_limits',
|
||||
name: '超过单关最大投注额拒绝',
|
||||
run: async () => {
|
||||
const service = createBettingLimitsService();
|
||||
await expectThrows(
|
||||
'max stake',
|
||||
() =>
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 60000,
|
||||
potentialReturn: new Decimal(70000),
|
||||
}),
|
||||
'Maximum stake is 50000',
|
||||
{ stake: 60000, maxStakeSingle: 50000 },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BL003',
|
||||
suite: 'betting_limits',
|
||||
name: '超过最高派彩拒绝',
|
||||
run: async () => {
|
||||
const service = createBettingLimitsService();
|
||||
await expectThrows(
|
||||
'max payout',
|
||||
() =>
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 100,
|
||||
potentialReturn: new Decimal(600000),
|
||||
}),
|
||||
'Potential return exceeds limit',
|
||||
{ potentialReturn: 600000, maxPayoutSingle: 500000 },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// —— Agent credit ——
|
||||
{
|
||||
id: 'A001',
|
||||
suite: 'agent_credit',
|
||||
name: '代理上分占用额度',
|
||||
uatRef: 'A001',
|
||||
run: () => {
|
||||
const creditLimit = 10000;
|
||||
const usedCredit = 1000 + 500;
|
||||
const available = creditLimit - usedCredit;
|
||||
expectEqual('available credit', available, 8500, {
|
||||
creditLimit,
|
||||
usedCredit,
|
||||
formula: 'creditLimit - usedCredit',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'A002',
|
||||
suite: 'agent_credit',
|
||||
name: '下注冻结:总额不变、可用减少',
|
||||
uatRef: 'A002',
|
||||
run: () => {
|
||||
const balance = 1000;
|
||||
const frozenBefore = 0;
|
||||
const stake = 100;
|
||||
const totalBefore = balance + frozenBefore;
|
||||
const balanceAfter = balance - stake;
|
||||
const frozenAfter = frozenBefore + stake;
|
||||
const totalAfter = balanceAfter + frozenAfter;
|
||||
expectEqual('total unchanged', totalAfter, totalBefore, {
|
||||
before: { balance, frozen: frozenBefore },
|
||||
after: { balance: balanceAfter, frozen: frozenAfter },
|
||||
});
|
||||
expectEqual('available', balanceAfter, 900, { stake });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'A003',
|
||||
suite: 'agent_credit',
|
||||
name: '结算派彩:可用余额增加',
|
||||
description: '赢单释放本金并增加盈利',
|
||||
run: () => {
|
||||
const balance = 900;
|
||||
const frozen = 100;
|
||||
const payout = 185;
|
||||
const balanceAfter = balance + payout;
|
||||
const frozenAfter = frozen - 100;
|
||||
expectEqual('balance after win', balanceAfter, 1085, { payout, before: 900 });
|
||||
expectEqual('frozen released', frozenAfter, 0, { frozenBefore: 100, stake: 100 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'A005',
|
||||
suite: 'agent_credit',
|
||||
name: '额度为负禁止继续放款',
|
||||
uatRef: 'A005',
|
||||
run: () => {
|
||||
const creditLimit = 1000;
|
||||
const usedCredit = 1200;
|
||||
const available = creditLimit - usedCredit;
|
||||
expectTrue('negative available', available < 0, { creditLimit, usedCredit, available });
|
||||
expectFalse('deposit allowed', available > 0, { available });
|
||||
},
|
||||
},
|
||||
|
||||
// —— Cashback ——
|
||||
{
|
||||
id: 'CB001',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:玩家规则优先',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'USER',
|
||||
targetId: BigInt(100),
|
||||
rate: new Decimal('0.03'),
|
||||
marketType: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.03', {
|
||||
userRule: '0.03',
|
||||
agentDefault: '0.01',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CB002',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:玩法专属规则',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_HANDICAP'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'GLOBAL',
|
||||
targetId: null,
|
||||
rate: new Decimal('0.005'),
|
||||
marketType: 'FT_HANDICAP',
|
||||
},
|
||||
],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.005', { marketType: 'FT_HANDICAP' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CB003',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:无规则用代理默认',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.02'),
|
||||
rules: [],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.02', { agentDefault: '0.02', rules: [] });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CB004',
|
||||
suite: 'cashback',
|
||||
name: '返水比例:玩法不匹配回退默认',
|
||||
run: () => {
|
||||
const rate = resolveCashbackRateForBet({
|
||||
userId: BigInt(100),
|
||||
agentId: BigInt(200),
|
||||
marketTypes: ['FT_1X2'],
|
||||
agentDefaultRate: new Decimal('0.01'),
|
||||
rules: [
|
||||
{
|
||||
targetType: 'GLOBAL',
|
||||
targetId: null,
|
||||
rate: new Decimal('0.005'),
|
||||
marketType: 'FT_HANDICAP',
|
||||
},
|
||||
],
|
||||
});
|
||||
expectEqual('rate', rate.toString(), '0.01', {
|
||||
betMarket: 'FT_1X2',
|
||||
ruleMarket: 'FT_HANDICAP',
|
||||
agentDefault: '0.01',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const SMOKE_SUITE_META: Record<string, { name: string; description: string }> = {
|
||||
settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' },
|
||||
settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' },
|
||||
betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' },
|
||||
betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' },
|
||||
agent_credit: { name: '代理额度', description: '额度占用、冻结与放款规则(纯逻辑)' },
|
||||
cashback: { name: '返水规则', description: '返水比例优先级与回退' },
|
||||
database: { name: '数据库', description: '连接、种子账号、赛事盘口与配置探针(只读)' },
|
||||
'bet-flow': {
|
||||
name: '下注结算链路',
|
||||
description: '真实 DB:下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service';
|
||||
import type { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { expectEqual, expectTrue } from './smoke-test.helpers';
|
||||
import type { SmokeTestCaseDef } from './smoke-test.cases';
|
||||
|
||||
export const DATABASE_PROBE_COUNT = 8;
|
||||
|
||||
export function createDatabaseProbes(prisma: PrismaService): SmokeTestCaseDef[] {
|
||||
return [
|
||||
{
|
||||
id: 'DB001',
|
||||
suite: 'database',
|
||||
name: '数据库连接',
|
||||
description: 'SELECT 1 探活',
|
||||
run: async () => {
|
||||
const rows = await prisma.$queryRaw<Array<{ ok: number }>>`SELECT 1 AS ok`;
|
||||
expectEqual('ping', rows[0]?.ok, 1, { query: 'SELECT 1' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB002',
|
||||
suite: 'database',
|
||||
name: '演示管理员账号',
|
||||
description: 'seed admin / Admin@123',
|
||||
run: async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: 'admin' },
|
||||
select: { id: true, userType: true, deletedAt: true },
|
||||
});
|
||||
expectTrue('admin exists', !!user, { username: 'admin' });
|
||||
expectEqual('userType', user?.userType, 'ADMIN', { username: 'admin' });
|
||||
expectEqual('not deleted', user?.deletedAt ?? null, null, { username: 'admin' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB003',
|
||||
suite: 'database',
|
||||
name: '演示代理账号与额度',
|
||||
description: 'seed agent1 授信额度',
|
||||
run: async () => {
|
||||
const agent = await prisma.user.findUnique({
|
||||
where: { username: 'agent1' },
|
||||
select: {
|
||||
id: true,
|
||||
userType: true,
|
||||
agentProfile: { select: { creditLimit: true, level: true } },
|
||||
},
|
||||
});
|
||||
expectTrue('agent1 exists', !!agent, { username: 'agent1' });
|
||||
expectEqual('userType', agent?.userType, 'AGENT', { username: 'agent1' });
|
||||
const credit = agent?.agentProfile?.creditLimit;
|
||||
expectTrue('creditLimit > 0', !!credit && credit.gt(0), {
|
||||
username: 'agent1',
|
||||
creditLimit: credit?.toString(),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB004',
|
||||
suite: 'database',
|
||||
name: '演示玩家与钱包',
|
||||
description: 'seed player1 钱包余额',
|
||||
run: async () => {
|
||||
const player = await prisma.user.findUnique({
|
||||
where: { username: 'player1' },
|
||||
select: {
|
||||
id: true,
|
||||
userType: true,
|
||||
wallet: { select: { availableBalance: true, frozenBalance: true } },
|
||||
},
|
||||
});
|
||||
expectTrue('player1 exists', !!player, { username: 'player1' });
|
||||
expectEqual('userType', player?.userType, 'PLAYER', { username: 'player1' });
|
||||
expectTrue('wallet exists', !!player?.wallet, { username: 'player1' });
|
||||
expectTrue('availableBalance >= 0', !!player?.wallet && player.wallet.availableBalance.gte(0), {
|
||||
availableBalance: player?.wallet?.availableBalance.toString(),
|
||||
frozenBalance: player?.wallet?.frozenBalance.toString(),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB005',
|
||||
suite: 'database',
|
||||
name: '演示赛事与盘口',
|
||||
description: '至少一场 OPEN 赛事且含 FT_1X2 盘口',
|
||||
run: async () => {
|
||||
const matchCount = await prisma.match.count({
|
||||
where: { deletedAt: null, status: { in: ['OPEN', 'PUBLISHED', 'SUSPENDED', 'CLOSED'] } },
|
||||
});
|
||||
expectTrue('match count > 0', matchCount > 0, { matchCount });
|
||||
|
||||
const market = await prisma.market.findFirst({
|
||||
where: {
|
||||
marketType: 'FT_1X2',
|
||||
match: { deletedAt: null, status: { in: ['OPEN', 'PUBLISHED'] } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
marketType: true,
|
||||
selections: { select: { id: true, selectionCode: true, odds: true }, take: 3 },
|
||||
},
|
||||
});
|
||||
expectTrue('FT_1X2 market exists', !!market, { marketType: 'FT_1X2' });
|
||||
expectTrue('has 3 selections', (market?.selections.length ?? 0) >= 3, {
|
||||
selectionCount: market?.selections.length,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB006',
|
||||
suite: 'database',
|
||||
name: '投注限额配置完整',
|
||||
description: '6 项 bet.* 限额键可读',
|
||||
run: async () => {
|
||||
const keys = Object.values(BET_LIMIT_KEYS);
|
||||
for (const key of keys) {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
const value = row?.configValue ?? '(default)';
|
||||
const n = row ? Number(row.configValue) : NaN;
|
||||
const valid = !row || (Number.isFinite(n) && n >= 0);
|
||||
expectTrue(`${key} valid`, valid, { key, value });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB007',
|
||||
suite: 'database',
|
||||
name: '权限与角色种子',
|
||||
description: '超级管理员角色含 settings.manage',
|
||||
run: async () => {
|
||||
const role = await prisma.role.findFirst({
|
||||
where: { code: 'SUPER_ADMIN' },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
expectTrue('SUPER_ADMIN role exists', !!role, { code: 'SUPER_ADMIN' });
|
||||
const codes = role?.permissions.map((rp) => rp.permission.code) ?? [];
|
||||
expectTrue('has settings.manage', codes.includes('settings.manage'), { permissions: codes });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'DB008',
|
||||
suite: 'database',
|
||||
name: '今日注单聚合可查',
|
||||
description: 'bet 表聚合查询(只读)',
|
||||
run: async () => {
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const agg = await prisma.bet.aggregate({
|
||||
where: { placedAt: { gte: start } },
|
||||
_sum: { stake: true },
|
||||
_count: true,
|
||||
});
|
||||
expectTrue('aggregate ok', agg._count >= 0, {
|
||||
todayBetCount: agg._count,
|
||||
todayStakeSum: agg._sum.stake?.toString() ?? '0',
|
||||
});
|
||||
expectEqual('stake sum type', agg._sum.stake instanceof Decimal || agg._sum.stake == null, true, {
|
||||
stake: agg._sum.stake?.toString() ?? '0',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export type SmokeTestStep = {
|
||||
label: string;
|
||||
input?: string;
|
||||
expected: string;
|
||||
actual: string;
|
||||
};
|
||||
|
||||
let activeSteps: SmokeTestStep[] = [];
|
||||
|
||||
export function beginSmokeSteps() {
|
||||
activeSteps = [];
|
||||
}
|
||||
|
||||
export function drainSmokeSteps(): SmokeTestStep[] {
|
||||
const steps = [...activeSteps];
|
||||
activeSteps = [];
|
||||
return steps;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function recordStep(
|
||||
label: string,
|
||||
input: unknown,
|
||||
expected: unknown,
|
||||
actual: unknown,
|
||||
ok: boolean,
|
||||
) {
|
||||
activeSteps.push({
|
||||
label,
|
||||
input: input === undefined ? undefined : formatValue(input),
|
||||
expected: formatValue(expected),
|
||||
actual: formatValue(actual),
|
||||
});
|
||||
if (!ok) {
|
||||
throw new Error(`${label}: expected ${formatValue(expected)}, got ${formatValue(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function expectEqual<T>(label: string, actual: T, expected: T, input?: unknown) {
|
||||
const ok = actual === expected;
|
||||
recordStep(label, input, expected, actual, ok);
|
||||
}
|
||||
|
||||
export function expectTrue(label: string, condition: boolean, input?: unknown, failHint?: string) {
|
||||
recordStep(label, input, 'true', condition ? 'true' : failHint ?? 'false', condition);
|
||||
}
|
||||
|
||||
export function expectFalse(label: string, condition: boolean, input?: unknown, failHint?: string) {
|
||||
recordStep(label, input, 'false', condition ? failHint ?? 'true' : 'false', !condition);
|
||||
}
|
||||
|
||||
export async function expectThrows(
|
||||
label: string,
|
||||
fn: () => void | Promise<void>,
|
||||
messageIncludes: string,
|
||||
input?: unknown,
|
||||
) {
|
||||
try {
|
||||
await fn();
|
||||
recordStep(label, input, `error contains "${messageIncludes}"`, 'no error thrown', false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
recordStep(label, input, `error contains "${messageIncludes}"`, msg, msg.includes(messageIncludes));
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStepsForResult(steps: SmokeTestStep[]): string[] {
|
||||
return steps.map((step, index) => {
|
||||
const lines = [`${index + 1}. ${step.label}`];
|
||||
if (step.input) lines.push(` input: ${step.input}`);
|
||||
lines.push(` expected: ${step.expected}`);
|
||||
lines.push(` actual: ${step.actual}`);
|
||||
return lines.join('\n');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentsModule } from '../../agent/agents.module';
|
||||
import { BetsModule } from '../../betting/bets.module';
|
||||
import { WalletModule } from '../../ledger/wallet.module';
|
||||
import { SettlementModule } from '../../settlement/settlement.module';
|
||||
import { PrismaModule } from '../../../shared/prisma/prisma.module';
|
||||
import { SmokeTestService } from './smoke-test.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, WalletModule, BetsModule, SettlementModule, AgentsModule],
|
||||
providers: [SmokeTestService],
|
||||
exports: [SmokeTestService],
|
||||
})
|
||||
export class SmokeTestModule {}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type SmokeTestStatus = 'PASS' | 'FAIL' | 'SKIP';
|
||||
|
||||
export type SmokeTestCaseMeta = {
|
||||
id: string;
|
||||
suite: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
uatRef?: string;
|
||||
};
|
||||
|
||||
export type SmokeTestCaseResult = SmokeTestCaseMeta & {
|
||||
status: SmokeTestStatus;
|
||||
durationMs: number;
|
||||
stepCount?: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
export type SmokeTestRunSummary = {
|
||||
runId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
suites: string[];
|
||||
results: SmokeTestCaseResult[];
|
||||
};
|
||||
|
||||
export type SmokeTestSuiteInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
caseCount: number;
|
||||
};
|
||||
@@ -2,17 +2,16 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './shared/common/filters';
|
||||
import { getUploadRoot } from './shared/uploads/upload-paths';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
|
||||
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
|
||||
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
|
||||
app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
|
||||
16
apps/api/src/shared/uploads/upload-paths.ts
Normal file
16
apps/api/src/shared/uploads/upload-paths.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export function getUploadRoot() {
|
||||
if (process.env.UPLOAD_DIR?.trim()) {
|
||||
return resolve(process.env.UPLOAD_DIR.trim());
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
resolve(process.cwd(), '..', '..', 'uploads'),
|
||||
resolve(process.cwd(), 'uploads'),
|
||||
resolve(__dirname, '..', '..', 'uploads'),
|
||||
];
|
||||
|
||||
return candidates.find((p) => existsSync(p)) ?? candidates[0];
|
||||
}
|
||||
Reference in New Issue
Block a user