diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 34e9531..9f3365e 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -60,6 +60,17 @@ function readBoolean(record: Record, key: string, path: string) return value; } +function readNullableNumber(record: Record, key: string, path: string): number | null { + const value = record[key]; + if (value === undefined || value === null) { + return null; + } + if (!isNumber(value)) { + throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`); + } + return value; +} + export function mapHealthResponse(payload: unknown): HealthResponse { const root = asRecord(payload, 'health'); return { @@ -131,11 +142,11 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse { }), guesses: guessesRaw.map((guess, index) => { const record = asRecord(guess, `session_detail.reveal.guesses[${index}]`); - const fooledPlayerIdRaw = record.fooled_player_id; - if (fooledPlayerIdRaw !== undefined && fooledPlayerIdRaw !== null && !isNumber(fooledPlayerIdRaw)) { - throw new Error(`Invalid API contract: expected number|null at session_detail.reveal.guesses[${index}].fooled_player_id`); - } - const fooledPlayerId = fooledPlayerIdRaw ?? null; + const fooledPlayerId = readNullableNumber( + record, + 'fooled_player_id', + `session_detail.reveal.guesses[${index}]` + ); const fooledPlayerNickname = record.fooled_player_nickname; if (fooledPlayerNickname !== undefined && !isString(fooledPlayerNickname)) { throw new Error(`Invalid API contract: expected string at session_detail.reveal.guesses[${index}].fooled_player_nickname`); @@ -389,11 +400,7 @@ export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse { const root = asRecord(payload, 'submit_guess'); const guess = asRecord(root.guess, 'submit_guess.guess'); const window = asRecord(root.window, 'submit_guess.window'); - const fooledPlayerIdRaw = guess.fooled_player_id; - if (fooledPlayerIdRaw !== undefined && fooledPlayerIdRaw !== null && !isNumber(fooledPlayerIdRaw)) { - throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id'); - } - const fooledPlayerId = fooledPlayerIdRaw ?? null; + const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess'); return { guess: { diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index 0fc0b40..584b289 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client'; +import { mapSubmitGuessResponse } from '../src/api/mappers'; describe('createAngularApiClient', () => { it('reads health and session detail using Django-compatible endpoints', async () => { @@ -384,6 +385,22 @@ describe('createAngularApiClient', () => { } }); + it('maps omitted fooled_player_id to null in submit guess mapper payloads', () => { + const mapped = mapSubmitGuessResponse({ + guess: { + id: 200, + player_id: 9, + round_question_id: 77, + selected_text: 'A', + is_correct: false, + created_at: '2026-03-01T16:01:00Z' + }, + window: { guess_deadline_at: '2026-03-01T16:01:30Z' } + }); + + expect(mapped.guess.fooled_player_id).toBeNull(); + }); + it('maps host/player gameplay endpoints through typed response mappers', async () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12/scoreboard') {