import { describe, expect, it, vi } from 'vitest'; import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client'; describe('createAngularApiClient', () => { it('reads health and session detail using Django-compatible endpoints', async () => { const get = vi.fn(async (url: string) => { if (url === '/healthz') { return { ok: true, service: 'partyhub' } as T; } if (url === '/lobby/sessions/ABCD12') { return { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, players: [ { id: 2, nickname: 'Maja', score: 0, is_connected: true }, { id: 3, nickname: 'Bo', score: 0, is_connected: false } ], round_question: null, phase_view_model: { status: 'lobby', 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: true, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } } as T; } throw { status: 404, error: { error: 'Not found' } }; }); const post = vi.fn(async (url: string, body: unknown) => { if (url === '/lobby/sessions/join') { expect(body).toEqual({ code: 'ABCD12', nickname: 'Maja' }); return { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } } as T; } if (url === '/lobby/sessions/ABCD12/rounds/start') { expect(body).toEqual({ category_slug: 'history' }); return { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round: { number: 1, category: { slug: 'history', name: 'History' } } } as T; } throw { status: 404, error: { error: 'Not found' } }; }); const http = { get, post }; const client = createAngularApiClient(http as AngularHttpClientLike); const health = await client.health(); expect(health.ok).toBe(true); if (health.ok) { expect(health.data.ok).toBe(true); expect(health.data.service).toBe('partyhub'); } const session = await client.getSession(' abcd12 '); expect(session.ok).toBe(true); if (session.ok) { expect(session.data.session.code).toBe('ABCD12'); expect(session.data.session.host_id).toBe(1); expect(session.data.phase_view_model.host.can_start_round).toBe(true); } const join = await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' }); expect(join.ok).toBe(true); const start = await client.startRound(' abcd12 ', { category_slug: 'history' }); expect(start.ok).toBe(true); expect(get).toHaveBeenNthCalledWith(1, '/healthz', { withCredentials: true }); expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', { withCredentials: true }); expect(post).toHaveBeenNthCalledWith( 1, '/lobby/sessions/join', { code: 'ABCD12', nickname: 'Maja' }, { withCredentials: true } ); expect(post).toHaveBeenNthCalledWith( 2, '/lobby/sessions/ABCD12/rounds/start', { category_slug: 'history' }, { withCredentials: true } ); }); it('normalizes baseUrl with trailing slash to keep Django endpoint paths canonical', async () => { const get = vi.fn(async (url: string) => { if (url === '/api/healthz') { return { ok: true, service: 'partyhub' } as T; } if (url === '/api/lobby/sessions/ABCD12') { return { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, players: [], round_question: null, phase_view_model: { status: 'lobby', 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: true, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } } as T; } throw { status: 404, error: { error: 'Not found' } }; }); const post = vi.fn(async (url: string) => { if (url === '/api/lobby/sessions/join') { return { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } } as T; } if (url === '/api/lobby/sessions/ABCD12/rounds/start') { return { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round: { number: 1, category: { slug: 'history', name: 'History' } } } as T; } throw { status: 404, error: { error: 'Not found' } }; }); const client = createAngularApiClient({ get, post } as AngularHttpClientLike, '/api/'); await client.health(); await client.getSession('abcd12'); await client.joinSession({ code: 'abcd12', nickname: 'Maja' }); await client.startRound('abcd12', { category_slug: 'history' }); expect(get).toHaveBeenNthCalledWith(1, '/api/healthz', { withCredentials: true }); expect(get).toHaveBeenNthCalledWith(2, '/api/lobby/sessions/ABCD12', { withCredentials: true }); expect(post).toHaveBeenNthCalledWith( 1, '/api/lobby/sessions/join', { code: 'ABCD12', nickname: 'Maja' }, { withCredentials: true } ); expect(post).toHaveBeenNthCalledWith( 2, '/api/lobby/sessions/ABCD12/rounds/start', { category_slug: 'history' }, { withCredentials: true } ); }); 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('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12') { return { session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 2 }, players: [ { id: 2, nickname: 'Maja', score: 10, is_connected: true }, { id: 3, nickname: 'Bo', score: 7, is_connected: true } ], round_question: { id: 77, round_number: 1, prompt: 'Q?', shown_at: '2026-03-01T18:00:00Z', answers: [{ text: 'A' }, { text: 'B' }] }, reveal: { round_question_id: 77, round_number: 1, prompt: 'Q?', correct_answer: 'A', lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }], guesses: [ { player_id: 3, nickname: 'Bo', selected_text: 'B', is_correct: false, fooled_player_id: 2, fooled_player_nickname: 'Maja', created_at: '2026-03-01T18:00:15Z' } ] }, phase_view_model: { status: 'scoreboard', 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: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: true, can_finish_game: true }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } } as T; } throw { status: 404, error: { error: 'Not found' } }; }); const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike); const session = await client.getSession('abcd12'); expect(session.ok).toBe(true); if (session.ok) { expect(session.data.session.status).toBe('scoreboard'); expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja'); expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); expect(session.data.phase_view_model.host.can_finish_game).toBe(true); } }); it('normalizes omitted fooled_player_id to null in canonical reveal payload guesses', async () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12') { return { session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 }, players: [ { id: 2, nickname: 'Maja', score: 10, is_connected: true }, { id: 3, nickname: 'Bo', score: 7, is_connected: true } ], round_question: { id: 77, round_number: 1, prompt: 'Q?', shown_at: '2026-03-01T18:00:00Z', answers: [{ text: 'A' }, { text: 'B' }] }, reveal: { round_question_id: 77, round_number: 1, prompt: 'Q?', correct_answer: 'A', lies: [], guesses: [ { player_id: 3, nickname: 'Bo', selected_text: 'A', is_correct: true, created_at: '2026-03-01T18:00:15Z' } ] }, phase_view_model: { status: 'reveal', 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: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: true, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } } as T; } throw { status: 404, error: { error: 'Not found' } }; }); const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike); const session = await client.getSession('abcd12'); expect(session.ok).toBe(true); if (session.ok) { expect(session.data.reveal?.guesses[0].fooled_player_id).toBeNull(); expect(session.data.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname'); } }); 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: 'scoreboard', 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: 'scoreboard', 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 () => { throw { status: 503, message: 'Service unavailable', error: { error: 'maintenance' } }; }), post: vi.fn(async () => { throw { status: 403, message: 'Forbidden', error: { error: 'Only host can start round' } }; }) }; const client = createAngularApiClient(http as AngularHttpClientLike); const health = await client.health(); expect(health.ok).toBe(false); if (!health.ok) { expect(health.status).toBe(503); expect(health.error.kind).toBe('http'); expect(health.error.payload).toEqual({ error: 'maintenance' }); expect(health.error.message).toContain('Service unavailable'); } const start = await client.startRound('ABCD12', { category_slug: 'history' }); expect(start.ok).toBe(false); if (!start.ok) { expect(start.status).toBe(403); expect(start.error.kind).toBe('http'); expect(start.error.payload).toEqual({ error: 'Only host can start round' }); } }); });