Files
thebet365/apps/api/src/domains/betting/bets.service.spec.ts
2026-06-13 17:38:25 +08:00

154 lines
4.5 KiB
TypeScript

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<unknown>) => 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();
});
});