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