import { describe, expect, it, vi } from 'vitest'; import { createVerticalSliceController, type SessionContext, type SessionContextStore } from '../src/spa/vertical-slice'; import type { ApiClient } from '../src/api/client'; function makeApiMock(overrides?: Partial): ApiClient { const base: ApiClient = { health: vi.fn(), getSession: vi.fn().mockResolvedValue({ ok: true, status: 200, data: { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, players: [], round_question: null, phase_view_model: { status: 'lobby', round_number: 1, players_count: 3, constraints: { min_players_to_start: 3, max_players_mvp: 5, 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 } } } }), joinSession: vi.fn().mockResolvedValue({ ok: true, status: 201, data: { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } } }), startRound: vi.fn().mockResolvedValue({ ok: true, status: 201, data: { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round: { number: 1, category: { slug: 'history', name: 'History' } } } }) }; return { ...base, ...overrides }; } function makeSessionContextStore(initial: SessionContext | null = null): SessionContextStore { let value = initial; return { get: vi.fn(() => value), set: vi.fn((next: SessionContext) => { value = next; return next; }) }; } describe('vertical slice controller: lobby -> join -> start round', () => { it('uses createSessionContextStore by default (no manual injection)', async () => { vi.resetModules(); const defaultStore = { get: vi.fn(() => null), set: vi.fn((next: SessionContext) => next), clear: vi.fn() }; vi.doMock('../src/spa/session-context-store', async () => { const actual = await vi.importActual('../src/spa/session-context-store'); return { ...actual, createSessionContextStore: vi.fn(() => defaultStore) }; }); const { createVerticalSliceController: createControllerWithMock } = await import('../src/spa/vertical-slice'); const api = makeApiMock(); const controller = createControllerWithMock(api); await controller.joinLobby('ABCD12', 'Maja'); expect(defaultStore.set).toHaveBeenCalledWith({ sessionCode: 'ABCD12', playerId: 9, token: 'token-1' }); vi.doUnmock('../src/spa/session-context-store'); vi.resetModules(); }); it('tracks loading and success state for join + start flow', async () => { const api = makeApiMock(); const controller = createVerticalSliceController(api); const beforeJoinPromise = controller.joinLobby('abcd12', 'Maja'); expect(controller.getState().joinState).toBe('loading'); await beforeJoinPromise; const postJoin = controller.getState(); expect(postJoin.joinState).toBe('success'); expect(postJoin.session?.session.code).toBe('ABCD12'); const beforeStartPromise = controller.startRound('abcd12', 'history'); expect(controller.getState().startRoundState).toBe('loading'); await beforeStartPromise; const postStart = controller.getState(); expect(postStart.startRoundState).toBe('success'); }); it('persists session context after join and syncs normalized session code', async () => { const api = makeApiMock(); const sessionContextStore = makeSessionContextStore(); const controller = createVerticalSliceController(api, sessionContextStore); await controller.joinLobby('abcd12', 'Maja'); expect(sessionContextStore.set).toHaveBeenCalledWith({ sessionCode: 'ABCD12', playerId: 9, token: 'token-1' }); expect(controller.getState().sessionCode).toBe('ABCD12'); }); it('uses stored session code as fallback for join + hydrate flow when input code is empty', async () => { const api = makeApiMock(); const sessionContextStore = makeSessionContextStore({ sessionCode: 'wxyz99', playerId: 5, token: 'token-old' }); const controller = createVerticalSliceController(api, sessionContextStore); await controller.joinLobby(' ', 'Maja'); expect(api.joinSession).toHaveBeenCalledWith({ code: 'WXYZ99', nickname: 'Maja' }); expect(api.getSession).toHaveBeenCalledWith('ABCD12'); }); it('surfaces a friendly error when join fails', async () => { const api = makeApiMock({ joinSession: vi.fn().mockResolvedValue({ ok: false, status: 404, error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } } }) }); const controller = createVerticalSliceController(api); await controller.joinLobby('missing', 'Maja'); const state = controller.getState(); expect(state.joinState).toBe('error'); expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.'); }); it('surfaces a friendly error when round start fails', async () => { const api = makeApiMock({ startRound: vi.fn().mockResolvedValue({ ok: false, status: 400, error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } } }) }); const controller = createVerticalSliceController(api); await controller.startRound('ABCD12', 'history'); const state = controller.getState(); expect(state.startRoundState).toBe('error'); expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.'); }); it('shows local validation error and avoids API call when hydrating without any session code', async () => { const api = makeApiMock(); const controller = createVerticalSliceController(api, makeSessionContextStore(null)); await controller.hydrateLobby(' '); const state = controller.getState(); expect(state.errorMessage).toBe('Session code is required.'); expect(state.loadingSession).toBe(false); expect(api.getSession).not.toHaveBeenCalled(); }); it('shows local validation error and avoids API call when starting round without any session code', async () => { const api = makeApiMock(); const controller = createVerticalSliceController(api, makeSessionContextStore(null)); await controller.startRound(' ', 'history'); const state = controller.getState(); expect(state.startRoundState).toBe('error'); expect(state.errorMessage).toBe('Session code is required.'); expect(api.startRound).not.toHaveBeenCalled(); }); it('uses joined session code when starting round without a reload', async () => { const api = makeApiMock(); const controller = createVerticalSliceController(api); await controller.joinLobby(' abcd12 ', 'Maja'); await controller.startRound('', 'history'); expect(api.startRound).toHaveBeenCalledWith('ABCD12', { category_slug: 'history' }); expect(controller.getState().sessionCode).toBe('ABCD12'); }); });