diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 44e4d07..ce93893 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,17 +1,31 @@ -import type { ApiResult, HealthResponse, SessionDetailResponse } from './types'; +import type { + ApiResult, + HealthResponse, + JoinSessionRequest, + JoinSessionResponse, + SessionDetailResponse, + StartRoundRequest, + StartRoundResponse +} from './types'; export interface ApiClient { health(): Promise>; getSession(code: string): Promise>; + joinSession(payload: JoinSessionRequest): Promise>; + startRound(code: string, payload: StartRoundRequest): Promise>; } export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient { - async function request(path: string): Promise> { + async function request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise> { let response: Response; try { response = await fetchImpl(`${baseUrl}${path}`, { - method: 'GET', - headers: { Accept: 'application/json' } + method, + headers: { + Accept: 'application/json', + ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }) + }, + ...(payload === undefined ? {} : { body: JSON.stringify(payload) }) }); } catch { return { @@ -21,9 +35,9 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): }; } - let payload: unknown; + let responsePayload: unknown; try { - payload = await response.json(); + responsePayload = await response.json(); } catch { return { ok: false, @@ -40,17 +54,28 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): kind: 'http', status: response.status, message: `HTTP ${response.status}`, - payload + payload: responsePayload } }; } - return { ok: true, status: response.status, data: payload as T }; + return { ok: true, status: response.status, data: responsePayload as T }; } return { - health: () => request('/healthz'), + health: () => request('/healthz', 'GET'), getSession: (code: string) => - request(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`) + request(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'), + joinSession: (payload: JoinSessionRequest) => + request('/lobby/sessions/join', 'POST', { + code: payload.code.trim().toUpperCase(), + nickname: payload.nickname.trim() + }), + startRound: (code: string, payload: StartRoundRequest) => + request( + `/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`, + 'POST', + payload + ) }; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 2ba59f5..e1c4463 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -31,9 +31,30 @@ export interface SessionRoundQuestion { } export interface PhaseViewModel { - phase: string; - available_actions: string[]; - constraints: Record; + status: string; + round_number: number; + players_count: number; + constraints: { + min_players_to_start: number; + max_players_mvp: number; + min_players_reached: boolean; + max_players_allowed: boolean; + }; + host: { + can_start_round: boolean; + can_show_question: boolean; + can_mix_answers: boolean; + can_calculate_scores: boolean; + can_reveal_scoreboard: boolean; + can_start_next_round: boolean; + can_finish_game: boolean; + }; + player: { + can_join: boolean; + can_submit_lie: boolean; + can_submit_guess: boolean; + can_view_final_result: boolean; + }; } export interface SessionDetailResponse { @@ -43,6 +64,43 @@ export interface SessionDetailResponse { phase_view_model: PhaseViewModel; } +export interface JoinSessionRequest { + code: string; + nickname: string; +} + +export interface JoinSessionResponse { + player: { + id: number; + nickname: string; + session_token: string; + score: number; + }; + session: { + code: string; + status: string; + }; +} + +export interface StartRoundRequest { + category_slug: string; +} + +export interface StartRoundResponse { + session: { + code: string; + status: string; + current_round: number; + }; + round: { + number: number; + category: { + slug: string; + name: string; + }; + }; +} + export type ApiErrorKind = 'network' | 'http' | 'parse'; export interface ApiFailure { diff --git a/frontend/src/spa/vertical-slice.ts b/frontend/src/spa/vertical-slice.ts new file mode 100644 index 0000000..6c13e1f --- /dev/null +++ b/frontend/src/spa/vertical-slice.ts @@ -0,0 +1,87 @@ +import type { ApiClient } from '../api/client'; +import type { SessionDetailResponse } from '../api/types'; + +export type AsyncState = 'idle' | 'loading' | 'success' | 'error'; + +export interface VerticalSliceState { + sessionCode: string; + session: SessionDetailResponse | null; + joinState: AsyncState; + startRoundState: AsyncState; + loadingSession: boolean; + errorMessage: string | null; +} + +export interface VerticalSliceController { + getState(): VerticalSliceState; + hydrateLobby(sessionCode: string): Promise; + joinLobby(sessionCode: string, nickname: string): Promise; + startRound(sessionCode: string, categorySlug: string): Promise; +} + +export function createVerticalSliceController(api: ApiClient): VerticalSliceController { + const state: VerticalSliceState = { + sessionCode: '', + session: null, + joinState: 'idle', + startRoundState: 'idle', + loadingSession: false, + errorMessage: null + }; + + const normalizeCode = (value: string): string => value.trim().toUpperCase(); + + async function hydrateLobby(sessionCode: string): Promise { + state.loadingSession = true; + state.errorMessage = null; + state.sessionCode = normalizeCode(sessionCode); + + const result = await api.getSession(state.sessionCode); + state.loadingSession = false; + + if (!result.ok) { + state.errorMessage = 'Kunne ikke hente lobby-status.'; + return { ...state }; + } + + state.session = result.data; + return { ...state }; + } + + async function joinLobby(sessionCode: string, nickname: string): Promise { + state.joinState = 'loading'; + state.errorMessage = null; + + const join = await api.joinSession({ code: sessionCode, nickname }); + if (!join.ok) { + state.joinState = 'error'; + state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.'; + return { ...state }; + } + + state.joinState = 'success'; + return hydrateLobby(sessionCode); + } + + async function startRound(sessionCode: string, categorySlug: string): Promise { + state.startRoundState = 'loading'; + state.errorMessage = null; + + const start = await api.startRound(sessionCode, { category_slug: categorySlug }); + if (!start.ok) { + state.startRoundState = 'error'; + state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.'; + return { ...state }; + } + + state.startRoundState = 'success'; + return hydrateLobby(sessionCode); + } + + return { + getState: () => ({ ...state }), + hydrateLobby, + joinLobby, + startRound + }; +} diff --git a/frontend/tests/api-client.integration.test.ts b/frontend/tests/api-client.integration.test.ts index 8da278c..0a9d921 100644 --- a/frontend/tests/api-client.integration.test.ts +++ b/frontend/tests/api-client.integration.test.ts @@ -7,21 +7,68 @@ let server: Server; let baseUrl: string; beforeAll(async () => { - server = createServer((req: IncomingMessage, res: ServerResponse) => { + server = createServer(async (req: IncomingMessage, res: ServerResponse) => { if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' })); return; } - if (req.url === '/lobby/sessions/ABCD12') { + if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); res.end( JSON.stringify({ - session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, + session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, players: [], round_question: null, - phase_view_model: { phase: 'lobby', available_actions: ['start_round'], constraints: {} } + 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 + } + } + }) + ); + return; + } + + if (req.url === '/lobby/sessions/join' && req.method === 'POST') { + res.writeHead(201, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, + session: { code: 'ABCD12', status: 'lobby' } + }) + ); + return; + } + + if (req.url === '/lobby/sessions/ABCD12/rounds/start' && req.method === 'POST') { + res.writeHead(201, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + session: { code: 'ABCD12', status: 'lie', current_round: 1 }, + round: { number: 1, category: { slug: 'history', name: 'History' } } }) ); return; @@ -59,6 +106,20 @@ describe('createApiClient', () => { expect(session.ok).toBe(true); if (session.ok) { expect(session.data.session.code).toBe('ABCD12'); + expect(session.data.phase_view_model.host.can_start_round).toBe(true); + } + }); + + it('supports join + start round writes for lobby vertical slice', async () => { + const client = createApiClient(baseUrl); + + 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); + if (start.ok) { + expect(start.data.session.status).toBe('lie'); } }); diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts new file mode 100644 index 0000000..cbc368a --- /dev/null +++ b/frontend/tests/vertical-slice.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createVerticalSliceController } 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 }; +} + +describe('vertical slice controller: lobby -> join -> start round', () => { + 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('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' } } + }) + }); + + const controller = createVerticalSliceController(api); + await controller.joinLobby('missing', 'Maja'); + + const state = controller.getState(); + expect(state.joinState).toBe('error'); + expect(state.errorMessage).toContain('Join fejlede'); + }); + + 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' } } + }) + }); + + const controller = createVerticalSliceController(api); + await controller.startRound('ABCD12', 'history'); + + const state = controller.getState(); + expect(state.startRoundState).toBe('error'); + expect(state.errorMessage).toContain('Kunne ikke starte runden'); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 10d3e99..74e10ad 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,5 +8,5 @@ "lib": ["ES2022", "DOM"], "types": ["vitest/globals", "node"] }, - "include": ["src/api", "tests"] + "include": ["src", "tests"] }