diff --git a/frontend/src/api/angular-client.ts b/frontend/src/api/angular-client.ts index 7c43c26..e61818a 100644 --- a/frontend/src/api/angular-client.ts +++ b/frontend/src/api/angular-client.ts @@ -1,3 +1,9 @@ +import { + mapHealthResponse, + mapJoinSessionResponse, + mapSessionDetailResponse, + mapStartRoundResponse +} from './mappers'; import type { ApiFailure, ApiResult, @@ -60,10 +66,10 @@ function buildUrl(baseUrl: string, path: string): string { return `${normalizeBaseUrl(baseUrl)}${path}`; } -async function wrap(call: () => Promise): Promise> { +async function wrap(call: () => Promise, mapper: (payload: unknown) => T): Promise> { + let payload: unknown; try { - const data = await call(); - return { ok: true, status: 200, data }; + payload = await call(); } catch (error: unknown) { return { ok: false, @@ -71,35 +77,57 @@ async function wrap(call: () => Promise): Promise> { error: toFailure(error) }; } + + try { + return { ok: true, status: 200, data: mapper(payload) }; + } catch (error: unknown) { + return { + ok: false, + status: 200, + error: { + kind: 'parse', + status: 200, + message: error instanceof Error ? error.message : 'Invalid API response contract', + payload + } + }; + } } export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient { return { - health: () => wrap(() => http.get(buildUrl(baseUrl, '/healthz'), { withCredentials: true })), + health: () => + wrap(() => http.get(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse), getSession: (code: string) => - wrap(() => - http.get(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), { - withCredentials: true - }) + wrap( + () => + http.get(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), { + withCredentials: true + }), + mapSessionDetailResponse ), joinSession: (payload: JoinSessionRequest) => - wrap(() => - http.post( - buildUrl(baseUrl, '/lobby/sessions/join'), - { - code: normalizeCode(payload.code), - nickname: payload.nickname.trim() - }, - { withCredentials: true } - ) + wrap( + () => + http.post( + buildUrl(baseUrl, '/lobby/sessions/join'), + { + code: normalizeCode(payload.code), + nickname: payload.nickname.trim() + }, + { withCredentials: true } + ), + mapJoinSessionResponse ), startRound: (code: string, payload: StartRoundRequest) => - wrap(() => - http.post( - buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`), - payload, - { withCredentials: true } - ) + wrap( + () => + http.post( + buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`), + payload, + { withCredentials: true } + ), + mapStartRoundResponse ) }; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ce93893..0727107 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,9 @@ +import { + mapHealthResponse, + mapJoinSessionResponse, + mapSessionDetailResponse, + mapStartRoundResponse +} from './mappers'; import type { ApiResult, HealthResponse, @@ -16,7 +22,12 @@ export interface ApiClient { } export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient { - async function request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise> { + async function request( + path: string, + method: 'GET' | 'POST', + mapper: (payload: unknown) => T, + payload?: unknown + ): Promise> { let response: Response; try { response = await fetchImpl(`${baseUrl}${path}`, { @@ -59,22 +70,45 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): }; } - return { ok: true, status: response.status, data: responsePayload as T }; + try { + return { ok: true, status: response.status, data: mapper(responsePayload) }; + } catch (error) { + return { + ok: false, + status: response.status, + error: { + kind: 'parse', + status: response.status, + message: error instanceof Error ? error.message : 'Invalid API response contract', + payload: responsePayload + } + }; + } } return { - health: () => request('/healthz', 'GET'), + health: () => request('/healthz', 'GET', mapHealthResponse), getSession: (code: string) => - request(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'), + request( + `/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, + 'GET', + mapSessionDetailResponse + ), joinSession: (payload: JoinSessionRequest) => - request('/lobby/sessions/join', 'POST', { - code: payload.code.trim().toUpperCase(), - nickname: payload.nickname.trim() - }), + request( + '/lobby/sessions/join', + 'POST', + mapJoinSessionResponse, + { + code: payload.code.trim().toUpperCase(), + nickname: payload.nickname.trim() + } + ), startRound: (code: string, payload: StartRoundRequest) => request( `/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`, 'POST', + mapStartRoundResponse, payload ) }; diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts new file mode 100644 index 0000000..e857c6a --- /dev/null +++ b/frontend/src/api/mappers.ts @@ -0,0 +1,191 @@ +import type { HealthResponse, JoinSessionResponse, SessionDetailResponse, StartRoundResponse } from './types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +function isNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +function asRecord(value: unknown, path: string): Record { + if (!isRecord(value)) { + throw new Error(`Invalid API contract: expected object at ${path}`); + } + return value; +} + +function readString(record: Record, key: string, path: string): string { + const value = record[key]; + if (!isString(value)) { + throw new Error(`Invalid API contract: expected string at ${path}.${key}`); + } + return value; +} + +function readNumber(record: Record, key: string, path: string): number { + const value = record[key]; + if (!isNumber(value)) { + throw new Error(`Invalid API contract: expected number at ${path}.${key}`); + } + return value; +} + +function readBoolean(record: Record, key: string, path: string): boolean { + const value = record[key]; + if (!isBoolean(value)) { + throw new Error(`Invalid API contract: expected boolean at ${path}.${key}`); + } + return value; +} + +export function mapHealthResponse(payload: unknown): HealthResponse { + const root = asRecord(payload, 'health'); + return { + ok: readBoolean(root, 'ok', 'health'), + service: readString(root, 'service', 'health') + }; +} + +function mapSessionDetail(payload: unknown): SessionDetailResponse { + const root = asRecord(payload, 'session_detail'); + const session = asRecord(root.session, 'session_detail.session'); + const players = root.players; + if (!Array.isArray(players)) { + throw new Error('Invalid API contract: expected array at session_detail.players'); + } + + const roundQuestionRaw = root.round_question; + let roundQuestion: SessionDetailResponse['round_question'] = null; + if (roundQuestionRaw !== null) { + const roundQuestionRecord = asRecord(roundQuestionRaw, 'session_detail.round_question'); + const answersRaw = roundQuestionRecord.answers; + if (!Array.isArray(answersRaw)) { + throw new Error('Invalid API contract: expected array at session_detail.round_question.answers'); + } + + roundQuestion = { + id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'), + round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'), + prompt: readString(roundQuestionRecord, 'prompt', 'session_detail.round_question'), + shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'), + answers: answersRaw.map((answer, index) => { + const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`); + return { text: readString(answerRecord, 'text', `session_detail.round_question.answers[${index}]`) }; + }) + }; + } + + const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model'); + const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints'); + const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); + const player = asRecord(phase.player, 'session_detail.phase_view_model.player'); + + return { + session: { + code: readString(session, 'code', 'session_detail.session'), + status: readString(session, 'status', 'session_detail.session'), + host_id: (() => { + const hostId = session.host_id; + if (hostId === null) { + return null; + } + if (!isNumber(hostId)) { + throw new Error('Invalid API contract: expected number|null at session_detail.session.host_id'); + } + return hostId; + })(), + current_round: readNumber(session, 'current_round', 'session_detail.session'), + players_count: readNumber(session, 'players_count', 'session_detail.session') + }, + players: players.map((item, index) => { + const record = asRecord(item, `session_detail.players[${index}]`); + return { + id: readNumber(record, 'id', `session_detail.players[${index}]`), + nickname: readString(record, 'nickname', `session_detail.players[${index}]`), + score: readNumber(record, 'score', `session_detail.players[${index}]`), + is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`) + }; + }), + round_question: roundQuestion, + phase_view_model: { + status: readString(phase, 'status', 'session_detail.phase_view_model'), + round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'), + players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'), + constraints: { + min_players_to_start: readNumber(constraints, 'min_players_to_start', 'session_detail.phase_view_model.constraints'), + max_players_mvp: readNumber(constraints, 'max_players_mvp', 'session_detail.phase_view_model.constraints'), + min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'), + max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints') + }, + host: { + can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'), + can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'), + can_mix_answers: readBoolean(host, 'can_mix_answers', 'session_detail.phase_view_model.host'), + can_calculate_scores: readBoolean(host, 'can_calculate_scores', 'session_detail.phase_view_model.host'), + can_reveal_scoreboard: readBoolean(host, 'can_reveal_scoreboard', 'session_detail.phase_view_model.host'), + can_start_next_round: readBoolean(host, 'can_start_next_round', 'session_detail.phase_view_model.host'), + can_finish_game: readBoolean(host, 'can_finish_game', 'session_detail.phase_view_model.host') + }, + player: { + can_join: readBoolean(player, 'can_join', 'session_detail.phase_view_model.player'), + can_submit_lie: readBoolean(player, 'can_submit_lie', 'session_detail.phase_view_model.player'), + can_submit_guess: readBoolean(player, 'can_submit_guess', 'session_detail.phase_view_model.player'), + can_view_final_result: readBoolean(player, 'can_view_final_result', 'session_detail.phase_view_model.player') + } + } + }; +} + +export function mapSessionDetailResponse(payload: unknown): SessionDetailResponse { + return mapSessionDetail(payload); +} + +export function mapJoinSessionResponse(payload: unknown): JoinSessionResponse { + const root = asRecord(payload, 'join_session'); + const player = asRecord(root.player, 'join_session.player'); + const session = asRecord(root.session, 'join_session.session'); + + return { + player: { + id: readNumber(player, 'id', 'join_session.player'), + nickname: readString(player, 'nickname', 'join_session.player'), + session_token: readString(player, 'session_token', 'join_session.player'), + score: readNumber(player, 'score', 'join_session.player') + }, + session: { + code: readString(session, 'code', 'join_session.session'), + status: readString(session, 'status', 'join_session.session') + } + }; +} + +export function mapStartRoundResponse(payload: unknown): StartRoundResponse { + const root = asRecord(payload, 'start_round'); + const session = asRecord(root.session, 'start_round.session'); + const round = asRecord(root.round, 'start_round.round'); + const category = asRecord(round.category, 'start_round.round.category'); + + return { + session: { + code: readString(session, 'code', 'start_round.session'), + status: readString(session, 'status', 'start_round.session'), + current_round: readNumber(session, 'current_round', 'start_round.session') + }, + round: { + number: readNumber(round, 'number', 'start_round.round'), + category: { + slug: readString(category, 'slug', 'start_round.round.category'), + name: readString(category, 'name', 'start_round.round.category') + } + } + }; +} diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index 20f0bac..fb7555e 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -189,6 +189,23 @@ describe('createAngularApiClient', () => { ); }); + it('returns parse error when successful payload breaks typed contract', async () => { + const http = { + get: vi.fn(async () => ({ ok: true } as T)), + post: vi.fn(async () => ({ ok: true } as T)) + }; + + const client = createAngularApiClient(http as AngularHttpClientLike); + const session = await client.getSession('ABCD12'); + + expect(session.ok).toBe(false); + if (!session.ok) { + expect(session.status).toBe(200); + expect(session.error.kind).toBe('parse'); + expect(session.error.message).toContain('Invalid API contract'); + } + }); + it('maps HttpErrorResponse-style failures to ApiResult errors', async () => { const http = { get: vi.fn(async () => { diff --git a/frontend/tests/api-client.integration.test.ts b/frontend/tests/api-client.integration.test.ts index 0a9d921..f44e104 100644 --- a/frontend/tests/api-client.integration.test.ts +++ b/frontend/tests/api-client.integration.test.ts @@ -74,6 +74,12 @@ beforeAll(async () => { return; } + if (req.url === '/lobby/sessions/BADMAP' && req.method === 'GET') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ session: { code: 'BADMAP' } })); + return; + } + if (req.url?.startsWith('/lobby/sessions/')) { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Session not found' })); @@ -123,6 +129,18 @@ describe('createApiClient', () => { } }); + it('returns parse error when response violates typed contract', async () => { + const client = createApiClient(baseUrl); + + const invalid = await client.getSession('badmap'); + expect(invalid.ok).toBe(false); + if (!invalid.ok) { + expect(invalid.status).toBe(200); + expect(invalid.error.kind).toBe('parse'); + expect(invalid.error.message).toContain('Invalid API contract'); + } + }); + it('returns consistent HTTP error shape for 4xx/5xx', async () => { const client = createApiClient(baseUrl);