Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 118 additions & 11 deletions backend/src/matches/matches.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import { Match, WinningTeam } from './entities/match.entity';
import { MatchPrediction } from './entities/match-prediction.entity';
import { CreatorEvent } from './entities/creator-event.entity';
import { MatchesService } from './matches.service';
import { PredictedOutcome } from './entities/match-prediction.entity';

describe('MatchesService', () => {
let service: MatchesService;
let matchRepository: jest.Mocked<Pick<Repository<Match>, 'findOne'>>;
let matchRepository: jest.Mocked<
Pick<Repository<Match>, 'findOne' | 'save'>
>;
let matchPredictionRepository: jest.Mocked<
Pick<Repository<MatchPrediction>, 'count' | 'findAndCount' | 'find'>
Pick<Repository<MatchPrediction>, 'count' | 'findAndCount' | 'find' | 'save'>
>;

const mockEvent = {
Expand Down Expand Up @@ -44,13 +47,15 @@ describe('MatchesService', () => {
beforeEach(async () => {
matchRepository = {
findOne: jest.fn(),
};
save: jest.fn(async (m: Match) => m),
} as any;

matchPredictionRepository = {
count: jest.fn(),
findAndCount: jest.fn(),
find: jest.fn(),
};
find: jest.fn().mockResolvedValue([]),
save: jest.fn(async (p: any) => p),
} as any;

const module: TestingModule = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -226,12 +231,6 @@ describe('MatchesService', () => {
winning_team: WinningTeam.TEAM_A,
};

beforeEach(() => {
(matchRepository as Record<string, unknown>).save = jest.fn(
async (m: Match) => m,
);
});

it('persists the result with submitted_by/submitted_at on happy path', async () => {
const unscored = { ...mockMatch, result_submitted: false } as Match;
matchRepository.findOne.mockResolvedValue(unscored);
Expand Down Expand Up @@ -314,5 +313,113 @@ describe('MatchesService', () => {
).rejects.toThrow(BadRequestException);
expect(matchRepository.save).not.toHaveBeenCalled();
});

it('scores predictions with the match multiplier on result submission', async () => {
const unscored = {
...mockMatch,
result_submitted: false,
points_multiplier: 3,
} as Match;
matchRepository.findOne.mockResolvedValue(unscored);

const predictions = [
{
id: 'pred-1',
predicted_outcome: PredictedOutcome.TEAM_A,
is_correct: null,
points_earned: 0,
},
{
id: 'pred-2',
predicted_outcome: PredictedOutcome.TEAM_B,
is_correct: null,
points_earned: 0,
},
{
id: 'pred-3',
predicted_outcome: PredictedOutcome.DRAW,
is_correct: null,
points_earned: 0,
},
] as MatchPrediction[];

matchPredictionRepository.find.mockResolvedValue(predictions);

await service.submitResult('match-1', validDto, 'admin-1');

expect(matchPredictionRepository.save).toHaveBeenCalledTimes(1);
const saved = (matchPredictionRepository.save as jest.Mock).mock
.calls[0][0] as MatchPrediction[];

const correct = saved.find((p) => p.id === 'pred-1')!;
expect(correct.is_correct).toBe(true);
expect(correct.points_earned).toBe(3);

const wrong1 = saved.find((p) => p.id === 'pred-2')!;
expect(wrong1.is_correct).toBe(false);
expect(wrong1.points_earned).toBe(0);

const wrong2 = saved.find((p) => p.id === 'pred-3')!;
expect(wrong2.is_correct).toBe(false);
expect(wrong2.points_earned).toBe(0);
});

it('defaults multiplier to 1 when not set', async () => {
const unscored = {
...mockMatch,
result_submitted: false,
points_multiplier: 1,
} as Match;
matchRepository.findOne.mockResolvedValue(unscored);

const predictions = [
{
id: 'pred-1',
predicted_outcome: PredictedOutcome.TEAM_A,
is_correct: null,
points_earned: 0,
},
] as MatchPrediction[];
matchPredictionRepository.find.mockResolvedValue(predictions);

await service.submitResult('match-1', validDto, 'admin-1');

const saved = (matchPredictionRepository.save as jest.Mock).mock
.calls[0][0] as MatchPrediction[];
expect(saved[0].points_earned).toBe(1);
});
});

describe('updateMultiplier', () => {
it('updates the multiplier before result submission', async () => {
const unscored = {
...mockMatch,
result_submitted: false,
points_multiplier: 1,
} as Match;
matchRepository.findOne.mockResolvedValue(unscored);

const result = await service.updateMultiplier('match-1', 5);

expect(result.points_multiplier).toBe(5);
expect(matchRepository.save).toHaveBeenCalledTimes(1);
});

it('rejects multiplier change after result has been submitted', async () => {
matchRepository.findOne.mockResolvedValue(mockMatch); // result_submitted: true

await expect(
service.updateMultiplier('match-1', 5),
).rejects.toThrow(ConflictException);
expect(matchRepository.save).not.toHaveBeenCalled();
});

it('throws NotFoundException for unknown match', async () => {
matchRepository.findOne.mockResolvedValue(null);

await expect(
service.updateMultiplier('missing', 3),
).rejects.toThrow(NotFoundException);
});
});
});
58 changes: 58 additions & 0 deletions backend/src/matches/matches.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,71 @@ export class MatchesService {

const saved = await this.matchRepository.save(match);

await this.scorePredictions(saved);

this.logger.log(
`Result submitted for match ${match.id}: ${match.team_a} ${dto.home_score} - ${dto.away_score} ${match.team_b} (${dto.winning_team}) by ${submittedBy}`,
);

return saved;
}

/**
* Update the points multiplier for a match. Rejects changes after the
* result has been submitted because predictions are already scored.
*/
async updateMultiplier(matchId: string, multiplier: number): Promise<Match> {
const match = await this.matchRepository.findOne({
where: { id: matchId },
});

if (!match) {
throw new NotFoundException(`Match with ID "${matchId}" not found`);
}

if (match.result_submitted) {
throw new ConflictException(
`Cannot change points_multiplier for match "${match.id}" — predictions have already been scored`,
);
}

match.points_multiplier = multiplier;
return this.matchRepository.save(match);
}

private static readonly BASE_CORRECT_POINTS = 1;

/**
* Score every prediction for a match after its result is submitted.
* Sets `is_correct` and computes `points_earned = base * multiplier`.
*/
private async scorePredictions(match: Match): Promise<void> {
const predictions = await this.matchPredictionRepository.find({
where: { match: { id: match.id } },
});

if (predictions.length === 0) {
return;
}

const multiplier = match.points_multiplier ?? 1;

for (const prediction of predictions) {
const correct =
prediction.predicted_outcome === (match.winning_team as string);
prediction.is_correct = correct;
prediction.points_earned = correct
? MatchesService.BASE_CORRECT_POINTS * multiplier
: 0;
}

await this.matchPredictionRepository.save(predictions);

this.logger.log(
`Scored ${predictions.length} prediction(s) for match ${match.id} (multiplier=${multiplier})`,
);
}

private validateResultConsistency(dto: SubmitMatchResultDto): void {
const { home_score, away_score, winning_team } = dto;

Expand Down
Loading