From 9a69110c7d4d82b5da1ad427fd591bb58eb721ac Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 16:20:10 +0000 Subject: [PATCH] feat(spa): guard host/player API contract with typed client calls --- .../host/host-shell.component.spec.ts | 180 +++++++++++------- .../app/features/host/host-shell.component.ts | 41 ++-- .../player/player-shell.component.spec.ts | 4 +- frontend/src/api/client.ts | 93 ++++++++- 4 files changed, 226 insertions(+), 92 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 3a2156d..66f080d 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -12,6 +12,60 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } +function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) { + const roundQuestionId = options?.roundQuestionId ?? 41; + + return { + session: { + code: 'ABCD12', + status, + host_id: 1, + current_round: status === 'lobby' ? 2 : 1, + players_count: 2, + }, + round_question: + roundQuestionId === null + ? null + : { + id: roundQuestionId, + round_number: 1, + prompt: 'Q?', + shown_at: '2026-01-01T00:00:00Z', + answers: [], + }, + players: [ + { id: 1, nickname: 'Host', score: 0, is_connected: true }, + { id: 2, nickname: 'Mads', score: 120, is_connected: true }, + ], + phase_view_model: { + status, + round_number: 1, + players_count: 2, + constraints: { + min_players_to_start: 2, + max_players_mvp: 8, + min_players_reached: true, + max_players_allowed: true, + }, + host: { + can_start_round: status === 'lobby', + can_show_question: status === 'lie', + can_mix_answers: status === 'lie', + can_calculate_scores: status === 'guess', + can_reveal_scoreboard: status === 'reveal', + can_start_next_round: status === 'scoreboard', + can_finish_game: status === 'scoreboard', + }, + player: { + can_join: status === 'lobby', + can_submit_lie: status === 'lie', + can_submit_guess: status === 'guess', + can_view_final_result: status === 'finished', + }, + }, + }; +} + describe('HostShellComponent gameplay wiring', () => { afterEach(() => { vi.restoreAllMocks(); @@ -20,14 +74,13 @@ describe('HostShellComponent gameplay wiring', () => { it('runs startRound transition and refreshes session details', async () => { const fetchMock: FetchMock = vi .fn() - .mockResolvedValueOnce(jsonResponse(201, { ok: true })) .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'lie', current_round: 2 }, - round_question: { id: 41, prompt: 'Q?', answers: [] }, - players: [], + jsonResponse(201, { + session: { code: 'ABCD12', status: 'lie', current_round: 1 }, + round: { number: 1, category: { slug: 'history', name: 'History' } }, }) - ); + ) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie'))); vi.stubGlobal('fetch', fetchMock); @@ -42,20 +95,14 @@ describe('HostShellComponent gameplay wiring', () => { '/lobby/sessions/ABCD12/rounds/start', expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) }) ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - '/lobby/sessions/ABCD12', - expect.objectContaining({ method: 'GET' }) - ); + expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); expect(component.session?.session.status).toBe('lie'); expect(component.roundQuestionId).toBe('41'); expect(component.loading).toBe(false); }); it('captures scoreboard error for retry path', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(500, { error: 'Scoreboard unavailable' }) - ); + const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); vi.stubGlobal('fetch', fetchMock); @@ -64,29 +111,44 @@ describe('HostShellComponent gameplay wiring', () => { await component.loadScoreboard(); - expect(fetchMock).toHaveBeenCalledWith( - '/lobby/sessions/ABCD12/scoreboard', - expect.objectContaining({ method: 'GET' }) - ); + expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' })); expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable'); expect(component.loading).toBe(false); }); it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => { - const sessionAfterAction = { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 77, prompt: 'Q?', answers: [] }, - players: [], - }; - const fetchMock: FetchMock = vi .fn() - .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce(jsonResponse(200, sessionAfterAction)) - .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce(jsonResponse(200, sessionAfterAction)) - .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce(jsonResponse(200, sessionAfterAction)); + .mockResolvedValueOnce( + jsonResponse(200, { + round_question: { + id: 77, + round_number: 1, + prompt: 'Q?', + shown_at: '2026-01-01T00:00:00Z', + lie_deadline_at: '2026-01-01T00:00:45Z', + }, + config: { lie_seconds: 45 }, + }) + ) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 }))) + .mockResolvedValueOnce( + jsonResponse(200, { + session: { code: 'ABCD12', status: 'guess', current_round: 1 }, + round_question: { id: 77, round_number: 1 }, + answers: [{ text: 'A' }], + }) + ) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) + .mockResolvedValueOnce( + jsonResponse(200, { + session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + round_question: { id: 77, round_number: 1 }, + events_created: 2, + leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], + }) + ) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); @@ -98,22 +160,6 @@ describe('HostShellComponent gameplay wiring', () => { await component.mixAnswers(); await component.calculateScores(); - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - '/lobby/sessions/ABCD12/questions/show', - expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 3, - '/lobby/sessions/ABCD12/questions/77/answers/mix', - expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 5, - '/lobby/sessions/ABCD12/questions/77/scores/calculate', - expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) - ); - expect(component.error).toBe(''); expect(component.loading).toBe(false); }); @@ -122,20 +168,14 @@ describe('HostShellComponent gameplay wiring', () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } })) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'lobby', current_round: 2 }, - round_question: null, - players: [], - }) - ); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; component.scoreboardPayload = '{"leaderboard":[]}'; - component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}'; + component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; await component.startNextRound(); @@ -145,11 +185,7 @@ describe('HostShellComponent gameplay wiring', () => { '/lobby/sessions/ABCD12/rounds/next', expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - '/lobby/sessions/ABCD12', - expect.objectContaining({ method: 'GET' }) - ); + expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); expect(component.session?.session.status).toBe('lobby'); expect(component.scoreboardPayload).toBe(''); expect(component.finalLeaderboardPayload).toBe(''); @@ -171,13 +207,7 @@ describe('HostShellComponent gameplay wiring', () => { ], }) ) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'finished', current_round: 2 }, - round_question: null, - players: [], - }) - ); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('finished', { roundQuestionId: null }))); vi.stubGlobal('fetch', fetchMock); @@ -185,7 +215,6 @@ describe('HostShellComponent gameplay wiring', () => { component.sessionCode = 'ABCD12'; await component.finishGame(); - expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); await component.finishGame(); @@ -196,8 +225,23 @@ describe('HostShellComponent gameplay wiring', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) ); expect(component.finishError).toBe(''); - expect(component.finalLeaderboardPayload).toContain('\"status\": \"finished\"'); + expect(component.finalLeaderboardPayload).toContain('"status": "finished"'); expect(component.finalWinner?.nickname).toBe('Luna'); expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); }); + + it('guards next-round and finish actions when session code is missing', async () => { + const fetchMock: FetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const component = new HostShellComponent(); + component.sessionCode = ' '; + + await component.startNextRound(); + await component.finishGame(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(component.nextRoundError).toContain('Session code is required'); + expect(component.finishError).toContain('Session code is required'); + }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index b5f7a9c..899d3d6 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; +import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; interface SessionDetail { @@ -11,17 +12,8 @@ interface SessionDetail { players: Array<{ id: number; nickname: string; score: number }>; } -interface LeaderboardEntry { - id: number; - nickname: string; - score: number; -} - -interface LeaderboardResponse { - session: { code: string; status: string; current_round: number }; - leaderboard: LeaderboardEntry[]; - winner?: LeaderboardEntry | null; -} +type LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; +type LeaderboardResponse = FinishGameResponse; @Component({ selector: 'app-host-shell', @@ -85,9 +77,14 @@ export class HostShellComponent implements OnInit { finalWinner: LeaderboardEntry | null = null; session: SessionDetail | null = null; - private readonly controller = createVerticalSliceController(createApiClient()); + private readonly api = createApiClient(); + private readonly controller = createVerticalSliceController(this.api); ngOnInit(): void { + if (typeof window === 'undefined') { + return; + } + const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const codeFromRoute = match?.[1] ?? ''; @@ -99,7 +96,7 @@ export class HostShellComponent implements OnInit { } this.sessionCode = this.normalizeCode(candidate); - window.sessionStorage.setItem('wpp.host-session-code', this.sessionCode); + this.persistSessionCode(this.sessionCode); void this.refreshSession(); } @@ -107,6 +104,12 @@ export class HostShellComponent implements OnInit { return value.trim().toUpperCase(); } + private persistSessionCode(code: string): void { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem('wpp.host-session-code', code); + } + } + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { const response = await fetch(path, { method, @@ -139,7 +142,7 @@ export class HostShellComponent implements OnInit { } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; - window.sessionStorage.setItem('wpp.host-session-code', this.sessionCode); + this.persistSessionCode(this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; if (this.session.session.status !== 'finished') { this.resetFinalLeaderboard(); @@ -159,7 +162,7 @@ export class HostShellComponent implements OnInit { } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; - window.sessionStorage.setItem('wpp.host-session-code', this.sessionCode); + this.persistSessionCode(this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; this.scoreboardPayload = ''; this.resetFinalLeaderboard(); @@ -214,6 +217,9 @@ export class HostShellComponent implements OnInit { this.error = ''; try { const code = this.normalizeCode(this.sessionCode); + if (!code) { + throw new Error('Session code is required'); + } await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); this.scoreboardPayload = ''; this.resetFinalLeaderboard(); @@ -231,6 +237,9 @@ export class HostShellComponent implements OnInit { this.error = ''; try { const code = this.normalizeCode(this.sessionCode); + if (!code) { + throw new Error('Session code is required'); + } const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {}); this.finalLeaderboardPayload = JSON.stringify(payload, null, 2); this.finalLeaderboard = [...payload.leaderboard].sort((a, b) => { @@ -248,8 +257,6 @@ export class HostShellComponent implements OnInit { } } - - private resetFinalLeaderboard(): void { this.finalLeaderboardPayload = ''; this.finalLeaderboard = []; diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 708b5b9..363a878 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -97,7 +97,7 @@ describe('PlayerShellComponent gameplay wiring', () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' })) - .mockResolvedValueOnce(jsonResponse(200, { ok: true })) + .mockResolvedValueOnce(jsonResponse(201, { lie: { id: 1, player_id: 9, round_question_id: 11, text: 'my lie', created_at: '2026-01-01T00:00:01Z' }, window: { lie_deadline_at: '2026-01-01T00:00:45Z' } })) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); @@ -161,7 +161,7 @@ describe('PlayerShellComponent gameplay wiring', () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' })) - .mockResolvedValueOnce(jsonResponse(200, { ok: true })) + .mockResolvedValueOnce(jsonResponse(201, { guess: { id: 2, player_id: 9, round_question_id: 11, selected_text: 'B', is_correct: false, fooled_player_id: 3, created_at: '2026-01-01T00:00:10Z' }, window: { guess_deadline_at: '2026-01-01T00:01:30Z' } })) .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0727107..397365c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,17 +1,35 @@ import { + mapCalculateScoresResponse, + mapFinishGameResponse, mapHealthResponse, mapJoinSessionResponse, + mapMixAnswersResponse, + mapNextRoundResponse, + mapScoreboardResponse, mapSessionDetailResponse, - mapStartRoundResponse + mapShowQuestionResponse, + mapStartRoundResponse, + mapSubmitGuessResponse, + mapSubmitLieResponse } from './mappers'; import type { ApiResult, + CalculateScoresResponse, + FinishGameResponse, HealthResponse, JoinSessionRequest, JoinSessionResponse, + MixAnswersResponse, + NextRoundResponse, + ScoreboardResponse, SessionDetailResponse, + ShowQuestionResponse, StartRoundRequest, - StartRoundResponse + StartRoundResponse, + SubmitGuessRequest, + SubmitGuessResponse, + SubmitLieRequest, + SubmitLieResponse } from './types'; export interface ApiClient { @@ -19,6 +37,14 @@ export interface ApiClient { getSession(code: string): Promise>; joinSession(payload: JoinSessionRequest): Promise>; startRound(code: string, payload: StartRoundRequest): Promise>; + showQuestion(code: string): Promise>; + mixAnswers(code: string, roundQuestionId: number): Promise>; + calculateScores(code: string, roundQuestionId: number): Promise>; + getScoreboard(code: string): Promise>; + startNextRound(code: string): Promise>; + finishGame(code: string): Promise>; + submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise>; + submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise>; } export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient { @@ -86,11 +112,13 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): } } + const normalizeCode = (value: string): string => value.trim().toUpperCase(); + return { health: () => request('/healthz', 'GET', mapHealthResponse), getSession: (code: string) => request( - `/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`, 'GET', mapSessionDetailResponse ), @@ -100,16 +128,71 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): 'POST', mapJoinSessionResponse, { - code: payload.code.trim().toUpperCase(), + code: normalizeCode(payload.code), nickname: payload.nickname.trim() } ), startRound: (code: string, payload: StartRoundRequest) => request( - `/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`, + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`, 'POST', mapStartRoundResponse, payload + ), + showQuestion: (code: string) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`, + 'POST', + mapShowQuestionResponse, + {} + ), + mixAnswers: (code: string, roundQuestionId: number) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix`, + 'POST', + mapMixAnswersResponse, + {} + ), + calculateScores: (code: string, roundQuestionId: number) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate`, + 'POST', + mapCalculateScoresResponse, + {} + ), + getScoreboard: (code: string) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`, + 'GET', + mapScoreboardResponse + ), + startNextRound: (code: string) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`, + 'POST', + mapNextRoundResponse, + {} + ), + finishGame: (code: string) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`, + 'POST', + mapFinishGameResponse, + {} + ), + submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit`, + 'POST', + mapSubmitLieResponse, + payload + ), + submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) => + request( + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit`, + 'POST', + mapSubmitGuessResponse, + payload ) }; } -- 2.39.5