From fb782432eaed91ec45a069c03e4ffd2a92b3e866 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 16:40:34 +0000 Subject: [PATCH] feat(spa): guard angular host/player api contracts --- frontend/src/api/angular-client.ts | 133 ++++++++++++++++- frontend/src/api/mappers.ts | 170 +++++++++++++++++++++- frontend/src/api/types.ts | 107 ++++++++++++++ frontend/tests/angular-api-client.test.ts | 120 +++++++++++++++ 4 files changed, 527 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/angular-client.ts b/frontend/src/api/angular-client.ts index e61818a..84686b1 100644 --- a/frontend/src/api/angular-client.ts +++ b/frontend/src/api/angular-client.ts @@ -1,18 +1,36 @@ import { + mapCalculateScoresResponse, + mapFinishGameResponse, mapHealthResponse, mapJoinSessionResponse, + mapMixAnswersResponse, + mapScoreboardResponse, mapSessionDetailResponse, - mapStartRoundResponse + mapShowQuestionResponse, + mapStartNextRoundResponse, + mapStartRoundResponse, + mapSubmitGuessResponse, + mapSubmitLieResponse } from './mappers'; import type { ApiFailure, ApiResult, + CalculateScoresResponse, + FinishGameResponse, HealthResponse, JoinSessionRequest, JoinSessionResponse, + MixAnswersResponse, + ScoreboardResponse, SessionDetailResponse, + ShowQuestionResponse, + StartNextRoundResponse, StartRoundRequest, - StartRoundResponse + StartRoundResponse, + SubmitGuessRequest, + SubmitGuessResponse, + SubmitLieRequest, + SubmitLieResponse } from './types'; export interface AngularHttpError { @@ -31,6 +49,18 @@ export interface AngularApiClient { 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>; } function toFailure(error: unknown): ApiFailure { @@ -128,6 +158,105 @@ export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = '' { withCredentials: true } ), mapStartRoundResponse + ), + showQuestion: (code: string) => + wrap( + () => + http.post( + buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`), + {}, + { withCredentials: true } + ), + mapShowQuestionResponse + ), + mixAnswers: (code: string, roundQuestionId: number) => + wrap( + () => + http.post( + buildUrl( + baseUrl, + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix` + ), + {}, + { withCredentials: true } + ), + mapMixAnswersResponse + ), + calculateScores: (code: string, roundQuestionId: number) => + wrap( + () => + http.post( + buildUrl( + baseUrl, + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate` + ), + {}, + { withCredentials: true } + ), + mapCalculateScoresResponse + ), + getScoreboard: (code: string) => + wrap( + () => + http.get( + buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`), + { withCredentials: true } + ), + mapScoreboardResponse + ), + startNextRound: (code: string) => + wrap( + () => + http.post( + buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`), + {}, + { withCredentials: true } + ), + mapStartNextRoundResponse + ), + finishGame: (code: string) => + wrap( + () => + http.post( + buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`), + {}, + { withCredentials: true } + ), + mapFinishGameResponse + ), + submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) => + wrap( + () => + http.post( + buildUrl( + baseUrl, + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit` + ), + { + player_id: payload.player_id, + session_token: payload.session_token, + text: payload.text + }, + { withCredentials: true } + ), + mapSubmitLieResponse + ), + submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) => + wrap( + () => + http.post( + buildUrl( + baseUrl, + `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit` + ), + { + player_id: payload.player_id, + session_token: payload.session_token, + selected_text: payload.selected_text + }, + { withCredentials: true } + ), + mapSubmitGuessResponse ) }; } diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index e857c6a..538a264 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -1,4 +1,17 @@ -import type { HealthResponse, JoinSessionResponse, SessionDetailResponse, StartRoundResponse } from './types'; +import type { + CalculateScoresResponse, + FinishGameResponse, + HealthResponse, + JoinSessionResponse, + MixAnswersResponse, + ScoreboardResponse, + SessionDetailResponse, + ShowQuestionResponse, + StartNextRoundResponse, + StartRoundResponse, + SubmitGuessResponse, + SubmitLieResponse +} from './types'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -189,3 +202,158 @@ export function mapStartRoundResponse(payload: unknown): StartRoundResponse { } }; } + +function mapLeaderboardEntry(payload: unknown, path: string): { id: number; nickname: string; score: number } { + const record = asRecord(payload, path); + return { + id: readNumber(record, 'id', path), + nickname: readString(record, 'nickname', path), + score: readNumber(record, 'score', path) + }; +} + +function mapSessionState(payload: unknown, path: string): { code: string; status: string; current_round: number } { + const session = asRecord(payload, path); + return { + code: readString(session, 'code', path), + status: readString(session, 'status', path), + current_round: readNumber(session, 'current_round', path) + }; +} + +export function mapShowQuestionResponse(payload: unknown): ShowQuestionResponse { + const root = asRecord(payload, 'show_question'); + const roundQuestion = asRecord(root.round_question, 'show_question.round_question'); + const config = asRecord(root.config, 'show_question.config'); + + return { + round_question: { + id: readNumber(roundQuestion, 'id', 'show_question.round_question'), + prompt: readString(roundQuestion, 'prompt', 'show_question.round_question'), + round_number: readNumber(roundQuestion, 'round_number', 'show_question.round_question'), + shown_at: readString(roundQuestion, 'shown_at', 'show_question.round_question'), + lie_deadline_at: readString(roundQuestion, 'lie_deadline_at', 'show_question.round_question') + }, + config: { + lie_seconds: readNumber(config, 'lie_seconds', 'show_question.config') + } + }; +} + +export function mapMixAnswersResponse(payload: unknown): MixAnswersResponse { + const root = asRecord(payload, 'mix_answers'); + const roundQuestion = asRecord(root.round_question, 'mix_answers.round_question'); + const answersRaw = root.answers; + if (!Array.isArray(answersRaw)) { + throw new Error('Invalid API contract: expected array at mix_answers.answers'); + } + + return { + session: mapSessionState(root.session, 'mix_answers.session'), + round_question: { + id: readNumber(roundQuestion, 'id', 'mix_answers.round_question'), + round_number: readNumber(roundQuestion, 'round_number', 'mix_answers.round_question') + }, + answers: answersRaw.map((answer, index) => { + const record = asRecord(answer, `mix_answers.answers[${index}]`); + return { text: readString(record, 'text', `mix_answers.answers[${index}]`) }; + }) + }; +} + +export function mapCalculateScoresResponse(payload: unknown): CalculateScoresResponse { + const root = asRecord(payload, 'calculate_scores'); + const roundQuestion = asRecord(root.round_question, 'calculate_scores.round_question'); + const leaderboardRaw = root.leaderboard; + if (!Array.isArray(leaderboardRaw)) { + throw new Error('Invalid API contract: expected array at calculate_scores.leaderboard'); + } + + return { + session: mapSessionState(root.session, 'calculate_scores.session'), + round_question: { + id: readNumber(roundQuestion, 'id', 'calculate_scores.round_question'), + round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question') + }, + events_created: readNumber(root, 'events_created', 'calculate_scores'), + leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`)) + }; +} + +export function mapScoreboardResponse(payload: unknown): ScoreboardResponse { + const root = asRecord(payload, 'scoreboard'); + const leaderboardRaw = root.leaderboard; + if (!Array.isArray(leaderboardRaw)) { + throw new Error('Invalid API contract: expected array at scoreboard.leaderboard'); + } + + return { + session: mapSessionState(root.session, 'scoreboard.session'), + leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `scoreboard.leaderboard[${index}]`)) + }; +} + +export function mapStartNextRoundResponse(payload: unknown): StartNextRoundResponse { + const root = asRecord(payload, 'start_next_round'); + return { session: mapSessionState(root.session, 'start_next_round.session') }; +} + +export function mapFinishGameResponse(payload: unknown): FinishGameResponse { + const root = asRecord(payload, 'finish_game'); + const leaderboardRaw = root.leaderboard; + if (!Array.isArray(leaderboardRaw)) { + throw new Error('Invalid API contract: expected array at finish_game.leaderboard'); + } + + const winnerRaw = root.winner; + + return { + session: mapSessionState(root.session, 'finish_game.session'), + winner: winnerRaw === null ? null : mapLeaderboardEntry(winnerRaw, 'finish_game.winner'), + leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `finish_game.leaderboard[${index}]`)) + }; +} + +export function mapSubmitLieResponse(payload: unknown): SubmitLieResponse { + const root = asRecord(payload, 'submit_lie'); + const lie = asRecord(root.lie, 'submit_lie.lie'); + const window = asRecord(root.window, 'submit_lie.window'); + + return { + lie: { + id: readNumber(lie, 'id', 'submit_lie.lie'), + player_id: readNumber(lie, 'player_id', 'submit_lie.lie'), + round_question_id: readNumber(lie, 'round_question_id', 'submit_lie.lie'), + text: readString(lie, 'text', 'submit_lie.lie'), + created_at: readString(lie, 'created_at', 'submit_lie.lie') + }, + window: { + lie_deadline_at: readString(window, 'lie_deadline_at', 'submit_lie.window') + } + }; +} + +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 fooledPlayerId = guess.fooled_player_id; + if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) { + throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id'); + } + + return { + guess: { + id: readNumber(guess, 'id', 'submit_guess.guess'), + player_id: readNumber(guess, 'player_id', 'submit_guess.guess'), + round_question_id: readNumber(guess, 'round_question_id', 'submit_guess.guess'), + selected_text: readString(guess, 'selected_text', 'submit_guess.guess'), + is_correct: readBoolean(guess, 'is_correct', 'submit_guess.guess'), + fooled_player_id: fooledPlayerId, + created_at: readString(guess, 'created_at', 'submit_guess.guess') + }, + window: { + guess_deadline_at: readString(window, 'guess_deadline_at', 'submit_guess.window') + } + }; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e1c4463..982928d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -101,6 +101,113 @@ export interface StartRoundResponse { }; } +export interface ShowQuestionResponse { + round_question: { + id: number; + prompt: string; + round_number: number; + shown_at: string; + lie_deadline_at: string; + }; + config: { + lie_seconds: number; + }; +} + +export interface MixAnswersResponse { + session: { + code: string; + status: string; + current_round: number; + }; + round_question: { + id: number; + round_number: number; + }; + answers: Array<{ text: string }>; +} + +export interface CalculateScoresResponse { + session: { + code: string; + status: string; + current_round: number; + }; + round_question: { + id: number; + round_number: number; + }; + events_created: number; + leaderboard: Array<{ id: number; nickname: string; score: number }>; +} + +export interface ScoreboardResponse { + session: { + code: string; + status: string; + current_round: number; + }; + leaderboard: Array<{ id: number; nickname: string; score: number }>; +} + +export interface StartNextRoundResponse { + session: { + code: string; + status: string; + current_round: number; + }; +} + +export interface FinishGameResponse { + session: { + code: string; + status: string; + current_round: number; + }; + winner: { id: number; nickname: string; score: number } | null; + leaderboard: Array<{ id: number; nickname: string; score: number }>; +} + +export interface SubmitLieRequest { + player_id: number; + session_token: string; + text: string; +} + +export interface SubmitLieResponse { + lie: { + id: number; + player_id: number; + round_question_id: number; + text: string; + created_at: string; + }; + window: { + lie_deadline_at: string; + }; +} + +export interface SubmitGuessRequest { + player_id: number; + session_token: string; + selected_text: string; +} + +export interface SubmitGuessResponse { + guess: { + id: number; + player_id: number; + round_question_id: number; + selected_text: string; + is_correct: boolean; + fooled_player_id: number | null; + created_at: string; + }; + window: { + guess_deadline_at: string; + }; +} + export type ApiErrorKind = 'network' | 'http' | 'parse'; export interface ApiFailure { diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index fb7555e..82c9336 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -206,6 +206,126 @@ describe('createAngularApiClient', () => { } }); + it('maps host/player gameplay endpoints through typed response mappers', async () => { + const get = vi.fn(async (url: string) => { + if (url === '/lobby/sessions/ABCD12/scoreboard') { + return { + session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + leaderboard: [ + { id: 2, nickname: 'Maja', score: 11 }, + { id: 3, nickname: 'Bo', score: 7 } + ] + } as T; + } + throw { status: 404, error: { error: 'Not found' } }; + }); + + const post = vi.fn(async (url: string, body: unknown) => { + if (url === '/lobby/sessions/ABCD12/questions/show') { + expect(body).toEqual({}); + return { + round_question: { + id: 77, + prompt: 'Prompt?', + round_number: 1, + shown_at: '2026-03-01T16:00:00Z', + lie_deadline_at: '2026-03-01T16:00:30Z' + }, + config: { lie_seconds: 30 } + } as T; + } + if (url === '/lobby/sessions/ABCD12/questions/77/answers/mix') { + expect(body).toEqual({}); + return { + session: { code: 'ABCD12', status: 'guess', current_round: 1 }, + round_question: { id: 77, round_number: 1 }, + answers: [{ text: 'A' }, { text: 'B' }] + } as T; + } + if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { + expect(body).toEqual({}); + return { + session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + round_question: { id: 77, round_number: 1 }, + events_created: 3, + leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }] + } as T; + } + if (url === '/lobby/sessions/ABCD12/rounds/next') { + expect(body).toEqual({}); + return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T; + } + if (url === '/lobby/sessions/ABCD12/finish') { + expect(body).toEqual({}); + return { + session: { code: 'ABCD12', status: 'finished', current_round: 2 }, + winner: { id: 2, nickname: 'Maja', score: 15 }, + leaderboard: [{ id: 2, nickname: 'Maja', score: 15 }] + } as T; + } + if (url === '/lobby/sessions/ABCD12/questions/77/lies/submit') { + expect(body).toEqual({ player_id: 9, session_token: 'tok', text: 'my lie' }); + return { + lie: { + id: 100, + player_id: 9, + round_question_id: 77, + text: 'my lie', + created_at: '2026-03-01T16:00:10Z' + }, + window: { lie_deadline_at: '2026-03-01T16:00:30Z' } + } as T; + } + if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') { + expect(body).toEqual({ player_id: 9, session_token: 'tok', selected_text: 'A' }); + return { + guess: { + id: 200, + player_id: 9, + round_question_id: 77, + selected_text: 'A', + is_correct: false, + fooled_player_id: 3, + created_at: '2026-03-01T16:01:00Z' + }, + window: { guess_deadline_at: '2026-03-01T16:01:30Z' } + } as T; + } + + throw { status: 404, error: { error: 'Not found' } }; + }); + + const client = createAngularApiClient({ get, post } as AngularHttpClientLike); + + const showQuestion = await client.showQuestion('abcd12'); + expect(showQuestion.ok).toBe(true); + + const mixAnswers = await client.mixAnswers('abcd12', 77); + expect(mixAnswers.ok).toBe(true); + + const calculateScores = await client.calculateScores('abcd12', 77); + expect(calculateScores.ok).toBe(true); + + const scoreboard = await client.getScoreboard('abcd12'); + expect(scoreboard.ok).toBe(true); + + const nextRound = await client.startNextRound('abcd12'); + expect(nextRound.ok).toBe(true); + + const finish = await client.finishGame('abcd12'); + expect(finish.ok).toBe(true); + + const submitLie = await client.submitLie('abcd12', 77, { player_id: 9, session_token: 'tok', text: 'my lie' }); + expect(submitLie.ok).toBe(true); + + const submitGuess = await client.submitGuess('abcd12', 77, { + player_id: 9, + session_token: 'tok', + selected_text: 'A' + }); + expect(submitGuess.ok).toBe(true); + }); + it('maps HttpErrorResponse-style failures to ApiResult errors', async () => { const http = { get: vi.fn(async () => { -- 2.39.5