feat(spa): guard host/player API contract with typed client calls
All checks were successful
CI / test-and-quality (push) Successful in 2m13s
CI / test-and-quality (pull_request) Successful in 2m9s

This commit is contained in:
2026-03-01 16:20:10 +00:00
parent 82711dd537
commit 9a69110c7d
4 changed files with 226 additions and 92 deletions

View File

@@ -12,6 +12,60 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) {
const roundQuestionId = options?.roundQuestionId ?? 41;
return {
session: {
code: 'ABCD12',
status,
host_id: 1,
current_round: status === 'lobby' ? 2 : 1,
players_count: 2,
},
round_question:
roundQuestionId === null
? null
: {
id: roundQuestionId,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-01-01T00:00:00Z',
answers: [],
},
players: [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
],
phase_view_model: {
status,
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: status === 'lobby',
can_show_question: status === 'lie',
can_mix_answers: status === 'lie',
can_calculate_scores: status === 'guess',
can_reveal_scoreboard: status === 'reveal',
can_start_next_round: status === 'scoreboard',
can_finish_game: status === 'scoreboard',
},
player: {
can_join: status === 'lobby',
can_submit_lie: status === 'lie',
can_submit_guess: status === 'guess',
can_view_final_result: status === 'finished',
},
},
};
}
describe('HostShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -20,14 +74,13 @@ describe('HostShellComponent gameplay wiring', () => {
it('runs startRound transition and refreshes session details', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(201, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'lie', current_round: 2 },
round_question: { id: 41, prompt: 'Q?', answers: [] },
players: [],
jsonResponse(201, {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } },
})
);
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie')));
vi.stubGlobal('fetch', fetchMock);
@@ -42,20 +95,14 @@ describe('HostShellComponent gameplay wiring', () => {
'/lobby/sessions/ABCD12/rounds/start',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('41');
expect(component.loading).toBe(false);
});
it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(500, { error: 'Scoreboard unavailable' })
);
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
vi.stubGlobal('fetch', fetchMock);
@@ -64,29 +111,44 @@ describe('HostShellComponent gameplay wiring', () => {
await component.loadScoreboard();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12/scoreboard',
expect.objectContaining({ method: 'GET' })
);
expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' }));
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
expect(component.loading).toBe(false);
});
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
const sessionAfterAction = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, prompt: 'Q?', answers: [] },
players: [],
};
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction));
.mockResolvedValueOnce(
jsonResponse(200, {
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-01-01T00:00:00Z',
lie_deadline_at: '2026-01-01T00:00:45Z',
},
config: { lie_seconds: 45 },
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 })))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, round_number: 1 },
answers: [{ text: 'A' }],
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }],
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock);
@@ -98,22 +160,6 @@ describe('HostShellComponent gameplay wiring', () => {
await component.mixAnswers();
await component.calculateScores();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/show',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
5,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(component.error).toBe('');
expect(component.loading).toBe(false);
});
@@ -122,20 +168,14 @@ describe('HostShellComponent gameplay wiring', () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'lobby', current_round: 2 },
round_question: null,
players: [],
})
);
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
await component.startNextRound();
@@ -145,11 +185,7 @@ describe('HostShellComponent gameplay wiring', () => {
'/lobby/sessions/ABCD12/rounds/next',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
expect(component.session?.session.status).toBe('lobby');
expect(component.scoreboardPayload).toBe('');
expect(component.finalLeaderboardPayload).toBe('');
@@ -171,13 +207,7 @@ describe('HostShellComponent gameplay wiring', () => {
],
})
)
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
round_question: null,
players: [],
})
);
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('finished', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
@@ -185,7 +215,6 @@ describe('HostShellComponent gameplay wiring', () => {
component.sessionCode = 'ABCD12';
await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
await component.finishGame();
@@ -196,8 +225,23 @@ describe('HostShellComponent gameplay wiring', () => {
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(component.finishError).toBe('');
expect(component.finalLeaderboardPayload).toContain('\"status\": \"finished\"');
expect(component.finalLeaderboardPayload).toContain('"status": "finished"');
expect(component.finalWinner?.nickname).toBe('Luna');
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
});
it('guards next-round and finish actions when session code is missing', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' ';
await component.startNextRound();
await component.finishGame();
expect(fetchMock).not.toHaveBeenCalled();
expect(component.nextRoundError).toContain('Session code is required');
expect(component.finishError).toContain('Session code is required');
});
});