import { Decimal } from '@prisma/client/runtime/library'; import { BetsService } from './bets.service'; import { expectAppError } from '../../testing/prisma-mock'; describe('BetsService', () => { const tx = { bet: { findUnique: jest.fn(), create: jest.fn(), }, marketSelection: { findUnique: jest.fn(), }, }; const prisma = { ...tx, $transaction: jest.fn(async (fn: (client: typeof tx) => Promise) => fn(tx)), }; const funds = { freezeBet: jest.fn(), }; const bettingLimits = { validateBet: jest.fn(), }; let service: BetsService; function selection(id: bigint, oddsVersion: bigint, matchId = 88n) { return { id, marketId: id + 100n, selectionCode: id === 1n ? 'HOME' : 'AWAY', selectionName: `Selection ${id}`, nameI18n: null, odds: new Decimal(id === 1n ? 1.9 : 2.1), oddsVersion, status: 'OPEN', market: { matchId, marketType: 'FT_1X2', period: 'FT', nameI18n: null, lineValue: null, allowSingle: true, allowParlay: true, showOnPlayer: true, status: 'OPEN', match: { status: 'PUBLISHED', sportType: 'FOOTBALL', isOutright: false, startTime: new Date(Date.now() + 60 * 60 * 1000), }, }, }; } beforeEach(() => { jest.clearAllMocks(); service = new BetsService(prisma as never, funds as never, bettingLimits as never); tx.bet.findUnique.mockResolvedValue(null); tx.bet.create.mockResolvedValue({ id: 123n, betNo: 'BET-OK', selections: [] }); bettingLimits.validateBet.mockResolvedValue(undefined); tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) => Promise.resolve(selection(where.id, where.id)), ); }); it('rejects a parlay with multiple legs from the same match', async () => { await expect( service.placeParlayBet( 7n, 3n, [ { selectionId: 1n, oddsVersion: 1n }, { selectionId: 2n, oddsVersion: 2n }, ], 50, 'REQ-SAME-MATCH', ), ).rejects.toMatchObject(expectAppError('PARLAY_SAME_MATCH_FORBIDDEN')); expect(tx.bet.create).not.toHaveBeenCalled(); expect(bettingLimits.validateBet).not.toHaveBeenCalled(); expect(funds.freezeBet).not.toHaveBeenCalled(); }); it('allows a parlay only when every leg is from a different match', async () => { tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) => Promise.resolve(selection(where.id, where.id, where.id === 1n ? 88n : 99n)), ); await service.placeParlayBet( 7n, 3n, [ { selectionId: 1n, oddsVersion: 1n }, { selectionId: 2n, oddsVersion: 2n }, ], 50, 'REQ-SAME-MATCH', ); expect(tx.bet.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ betType: 'PARLAY', selections: expect.objectContaining({ create: expect.arrayContaining([ expect.objectContaining({ matchId: 88n, selectionId: 1n }), expect.objectContaining({ matchId: 99n, selectionId: 2n }), ]), }), }), }), ); expect(bettingLimits.validateBet).toHaveBeenCalledWith( expect.objectContaining({ betType: 'PARLAY', tx }), ); expect(funds.freezeBet).toHaveBeenCalledWith( expect.objectContaining({ userId: 7n, stake: expect.any(Decimal), tx }), ); }); it('rejects a single bet when the market disables single betting', async () => { tx.marketSelection.findUnique.mockResolvedValue({ ...selection(1n, 1n), market: { ...selection(1n, 1n).market, allowSingle: false, }, }); await expect( service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-SINGLE-DISABLED'), ).rejects.toMatchObject(expectAppError('SINGLE_MARKET_NOT_ALLOWED')); expect(tx.bet.create).not.toHaveBeenCalled(); expect(bettingLimits.validateBet).not.toHaveBeenCalled(); expect(funds.freezeBet).not.toHaveBeenCalled(); }); it('revalidates odds version inside the placement transaction', async () => { tx.marketSelection.findUnique.mockResolvedValue(selection(1n, 2n)); await expect( service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-ODDS-CHANGED'), ).rejects.toMatchObject(expectAppError('ODDS_CHANGED')); expect(tx.bet.create).not.toHaveBeenCalled(); expect(funds.freezeBet).not.toHaveBeenCalled(); }); });