From 076faf2ff1bd6a0d54a1cbe434b89e89cc45cd12 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 10:15:35 +0000 Subject: [PATCH 1/7] feat: gate client actions by canonical phase state --- .../host/host-shell.component.spec.ts | 24 ++++++ .../app/features/host/host-shell.component.ts | 73 ++++++++++++++++--- .../player/player-shell.component.spec.ts | 23 ++++++ .../features/player/player-shell.component.ts | 32 ++++++-- frontend/src/spa/gameplay-phase-machine.ts | 57 ++++++++++++++- 5 files changed, 188 insertions(+), 21 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 626db73..3c15591 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -245,6 +245,30 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.finishError).toContain('Session code is required'); }); + it('blocks illegal host actions outside canonical reveal/scoreboard boundaries', async () => { + const fetchMock: FetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const component = new HostShellComponent(); + component.sessionCode = 'ABCD12'; + + for (const status of ['lie', 'guess', 'reveal'] as const) { + component.session = sessionDetailPayload(status) as any; + await component.startNextRound(); + await component.finishGame(); + } + + component.session = sessionDetailPayload('reveal') as any; + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); + + component.session = sessionDetailPayload('scoreboard') as any; + await component.loadScoreboard(); + + expect(component.canLoadScoreboard).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('syncs host hash-route with latest phase after refresh without page reload', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 4253051..89baba5 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; +import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; @@ -27,16 +28,16 @@ type LeaderboardResponse = FinishGameResponse; - - - - - - - - - - + + + + + + + + + +

{{ copy('host.audio_locale_hint') }}: {{ locale }}

@@ -114,6 +115,30 @@ export class HostShellComponent implements OnInit, OnDestroy { this.unsubscribeLocale = null; } + get gameplayPhase(): string | null { + return deriveGameplayPhase(this.session as any); + } + + get canStartRound(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'startRound'); + } + + get canStartNextRound(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'startNextRound'); + } + + get canFinishGame(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'finishGame'); + } + + get canUseLegacyMidRoundHostAction(): boolean { + return false; + } + + get canLoadScoreboard(): boolean { + return !this.session || this.gameplayPhase === 'reveal'; + } + copy(key: string): string { return t(key, this.locale); } @@ -174,6 +199,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async startRound(): Promise { + if (!this.canStartRound) { + return; + } + await this.runAction(async () => { const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); if (!state.session || state.errorMessage) { @@ -190,6 +219,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async showQuestion(): Promise { + if (!this.canUseLegacyMidRoundHostAction) { + return; + } + await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); @@ -198,6 +231,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async mixAnswers(): Promise { + if (!this.canUseLegacyMidRoundHostAction) { + return; + } + await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); const roundQuestionId = this.roundQuestionId.trim(); @@ -207,6 +244,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async calculateScores(): Promise { + if (!this.canUseLegacyMidRoundHostAction) { + return; + } + await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); const roundQuestionId = this.roundQuestionId.trim(); @@ -216,6 +257,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async loadScoreboard(): Promise { + if (!this.canLoadScoreboard) { + return; + } + this.loading = true; this.scoreboardError = ''; this.error = ''; @@ -232,6 +277,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async startNextRound(): Promise { + if (!this.canStartNextRound) { + return; + } + this.loading = true; this.nextRoundError = ''; this.error = ''; @@ -252,6 +301,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async finishGame(): Promise { + if (!this.canFinishGame) { + return; + } + this.loading = true; this.finishError = ''; this.error = ''; diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 5bd1d1d..9ebcf84 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -199,6 +199,29 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it('blocks illegal player guess submission outside canonical guess phase', async () => { + const fetchMock: FetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + component.playerId = 9; + component.sessionToken = 'token-1'; + component.selectedGuess = 'B'; + + for (const status of ['lie', 'reveal', 'scoreboard'] as const) { + component.session = { + ...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any), + round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, + }; + + await component.submitGuess(); + } + + expect(component.canSubmitGuess).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('auto-refreshes player session to avoid host/player state desync between rounds', async () => { vi.useFakeTimers(); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 393723a..c1ae1e9 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -3,6 +3,10 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; +import { + deriveGameplayPhase, + isPlayerGameplayActionAllowed, +} from '../../../../../src/spa/gameplay-phase-machine'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; @@ -71,9 +75,9 @@ function resolveLocalStorage(): Storage | undefined {

{{ copy('common.status') }}: {{ session.session.status }}

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

- - - + + +
- - + +

{{ copy('player.final_leaderboard') }}

@@ -181,6 +185,18 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.restoreAudioGuard = null; } + get gameplayPhase(): string | null { + return deriveGameplayPhase(this.session as any); + } + + get canSubmitLie(): boolean { + return isPlayerGameplayActionAllowed(this.session as any, 'submitLie'); + } + + get canSubmitGuess(): boolean { + return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess'); + } + private readonly handleOnline = (): void => { this.connectionState = 'reconnecting'; void this.retryReconnect(); @@ -500,7 +516,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } async submitLie(): Promise { - if (!this.session?.round_question?.id) { + if (!this.session?.round_question?.id || !this.canSubmitLie) { return; } this.loading = true; @@ -528,7 +544,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } async submitGuess(): Promise { - if (!this.session?.round_question?.id || !this.selectedGuess) { + if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) { return; } this.loading = true; diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts index f0ba252..c9b059c 100644 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ b/frontend/src/spa/gameplay-phase-machine.ts @@ -1,6 +1,8 @@ -import type { SessionDetailResponse } from '../api/types'; +import type { PhaseViewModel, SessionDetailResponse } from '../api/types'; export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard'; +export type HostGameplayAction = 'startRound' | 'startNextRound' | 'finishGame'; +export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult'; export type GameplayPhaseEvent = | 'LIES_LOCKED' @@ -40,8 +42,7 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[ return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[]; } -export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null { - const status = session?.session.status; +function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null { if (!status) { return null; } @@ -56,3 +57,53 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game return null; } + +function deriveCanonicalPhaseStatus(phaseViewModel: PhaseViewModel | null | undefined): string | null { + if (!phaseViewModel) { + return null; + } + + const currentPhase = (phaseViewModel as PhaseViewModel & { current_phase?: string }).current_phase; + return currentPhase ?? phaseViewModel.status ?? null; +} + +export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null { + const canonicalStatus = deriveCanonicalPhaseStatus(session?.phase_view_model); + return derivePhaseFromStatus(canonicalStatus ?? session?.session.status); +} + +export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean { + if (!session) { + return true; + } + + const phase = deriveGameplayPhase(session); + const host = session.phase_view_model?.host; + switch (action) { + case 'startRound': + return phase === null || Boolean(host?.can_start_round ?? phase === null); + case 'startNextRound': + return phase === 'scoreboard' && Boolean(host?.can_start_next_round ?? true); + case 'finishGame': + return phase === 'scoreboard' && Boolean(host?.can_finish_game ?? true); + } +} + +export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean { + if (!session) { + return action === 'join'; + } + + const phase = deriveGameplayPhase(session); + const player = session.phase_view_model?.player; + switch (action) { + case 'join': + return Boolean(player?.can_join ?? true); + case 'submitLie': + return phase === 'lie' && Boolean(player?.can_submit_lie ?? true); + case 'submitGuess': + return phase === 'guess' && Boolean(player?.can_submit_guess ?? true); + case 'viewFinalResult': + return session.session.status === 'finished' && Boolean(player?.can_view_final_result ?? true); + } +} -- 2.39.5 From 57ca23756547e8ccb691cc5b5bcdb72d9956e5bb Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 10:28:12 +0000 Subject: [PATCH 2/7] fix(issue-301): gate client actions from canonical phase flags --- .../host/host-shell.component.spec.ts | 47 ++++++++++++++----- .../app/features/host/host-shell.component.ts | 36 ++++++++------ .../player/player-shell.component.spec.ts | 6 +-- frontend/src/spa/gameplay-phase-machine.ts | 33 +++++++++---- frontend/tests/gameplay-phase-machine.test.ts | 42 +++++++++++++++++ 5 files changed, 123 insertions(+), 41 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 3c15591..c25f188 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -50,11 +50,11 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb host: { can_start_round: status === 'lobby', can_show_question: status === 'lie', - can_mix_answers: status === 'lie', + can_mix_answers: status === 'lie' || status === 'guess', can_calculate_scores: status === 'guess', can_reveal_scoreboard: status === 'reveal', - can_start_next_round: status === 'scoreboard', - can_finish_game: status === 'scoreboard', + can_start_next_round: status === 'reveal', + can_finish_game: status === 'reveal', }, player: { can_join: status === 'lobby', @@ -116,7 +116,7 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.loading).toBe(false); }); - it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => { + it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce( @@ -156,12 +156,16 @@ describe('HostShellComponent gameplay wiring', () => { component.sessionCode = ' abcd12 '; component.roundQuestionId = ' 77 '; + component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any; await component.showQuestion(); + + component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; await component.mixAnswers(); await component.calculateScores(); expect(component.error).toBe(''); expect(component.loading).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(6); }); it('runs next-round transition without reload and clears scoreboard payload', async () => { @@ -245,27 +249,44 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.finishError).toContain('Session code is required'); }); - it('blocks illegal host actions outside canonical reveal/scoreboard boundaries', async () => { + it('blocks illegal host actions outside canonical phase permissions', async () => { const fetchMock: FetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; + component.roundQuestionId = '77'; - for (const status of ['lie', 'guess', 'reveal'] as const) { - component.session = sessionDetailPayload(status) as any; + for (const status of ['guess', 'reveal', 'scoreboard'] as const) { + component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; + await component.showQuestion(); + } + + for (const status of ['lie', 'reveal', 'scoreboard'] as const) { + component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; + await component.calculateScores(); + } + + for (const status of ['lie', 'guess', 'scoreboard'] as const) { + component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any; + await component.loadScoreboard(); await component.startNextRound(); await component.finishGame(); } - component.session = sessionDetailPayload('reveal') as any; + component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; + expect(component.canShowQuestion).toBe(false); + + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; + expect(component.canCalculateScores).toBe(false); + expect(component.canLoadScoreboard).toBe(true); + expect(component.canStartNextRound).toBe(true); + expect(component.canFinishGame).toBe(true); + + component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; + expect(component.canLoadScoreboard).toBe(false); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); - - component.session = sessionDetailPayload('scoreboard') as any; - await component.loadScoreboard(); - - expect(component.canLoadScoreboard).toBe(false); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 89baba5..9e6c5da 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -29,9 +29,9 @@ type LeaderboardResponse = FinishGameResponse; - - - + + + @@ -123,6 +123,22 @@ export class HostShellComponent implements OnInit, OnDestroy { return isHostGameplayActionAllowed(this.session as any, 'startRound'); } + get canShowQuestion(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'showQuestion'); + } + + get canMixAnswers(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'mixAnswers'); + } + + get canCalculateScores(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'calculateScores'); + } + + get canLoadScoreboard(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard'); + } + get canStartNextRound(): boolean { return isHostGameplayActionAllowed(this.session as any, 'startNextRound'); } @@ -131,14 +147,6 @@ export class HostShellComponent implements OnInit, OnDestroy { return isHostGameplayActionAllowed(this.session as any, 'finishGame'); } - get canUseLegacyMidRoundHostAction(): boolean { - return false; - } - - get canLoadScoreboard(): boolean { - return !this.session || this.gameplayPhase === 'reveal'; - } - copy(key: string): string { return t(key, this.locale); } @@ -219,7 +227,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } async showQuestion(): Promise { - if (!this.canUseLegacyMidRoundHostAction) { + if (!this.canShowQuestion) { return; } @@ -231,7 +239,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } async mixAnswers(): Promise { - if (!this.canUseLegacyMidRoundHostAction) { + if (!this.canMixAnswers) { return; } @@ -244,7 +252,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } async calculateScores(): Promise { - if (!this.canUseLegacyMidRoundHostAction) { + if (!this.canCalculateScores) { return; } diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 9ebcf84..36cf287 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -109,9 +109,8 @@ describe('PlayerShellComponent gameplay wiring', () => { component.sessionToken = 'token-1'; component.lieText = 'my lie'; component.session = { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, + ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [] }, - players: [], }; await component.submitLie(); @@ -173,9 +172,8 @@ describe('PlayerShellComponent gameplay wiring', () => { component.sessionToken = 'token-1'; component.selectedGuess = 'B'; component.session = { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, + ...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - players: [], }; await component.submitGuess(); diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts index c9b059c..257d220 100644 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ b/frontend/src/spa/gameplay-phase-machine.ts @@ -1,7 +1,14 @@ import type { PhaseViewModel, SessionDetailResponse } from '../api/types'; export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard'; -export type HostGameplayAction = 'startRound' | 'startNextRound' | 'finishGame'; +export type HostGameplayAction = + | 'startRound' + | 'showQuestion' + | 'mixAnswers' + | 'calculateScores' + | 'loadScoreboard' + | 'startNextRound' + | 'finishGame'; export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult'; export type GameplayPhaseEvent = @@ -77,15 +84,22 @@ export function isHostGameplayActionAllowed(session: SessionDetailResponse | nul return true; } - const phase = deriveGameplayPhase(session); const host = session.phase_view_model?.host; switch (action) { case 'startRound': - return phase === null || Boolean(host?.can_start_round ?? phase === null); + return Boolean(host?.can_start_round ?? false); + case 'showQuestion': + return Boolean(host?.can_show_question ?? false); + case 'mixAnswers': + return Boolean(host?.can_mix_answers ?? false); + case 'calculateScores': + return Boolean(host?.can_calculate_scores ?? false); + case 'loadScoreboard': + return Boolean(host?.can_reveal_scoreboard ?? false); case 'startNextRound': - return phase === 'scoreboard' && Boolean(host?.can_start_next_round ?? true); + return Boolean(host?.can_start_next_round ?? false); case 'finishGame': - return phase === 'scoreboard' && Boolean(host?.can_finish_game ?? true); + return Boolean(host?.can_finish_game ?? false); } } @@ -94,16 +108,15 @@ export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | n return action === 'join'; } - const phase = deriveGameplayPhase(session); const player = session.phase_view_model?.player; switch (action) { case 'join': - return Boolean(player?.can_join ?? true); + return Boolean(player?.can_join ?? false); case 'submitLie': - return phase === 'lie' && Boolean(player?.can_submit_lie ?? true); + return Boolean(player?.can_submit_lie ?? false); case 'submitGuess': - return phase === 'guess' && Boolean(player?.can_submit_guess ?? true); + return Boolean(player?.can_submit_guess ?? false); case 'viewFinalResult': - return session.session.status === 'finished' && Boolean(player?.can_view_final_result ?? true); + return Boolean(player?.can_view_final_result ?? false); } } diff --git a/frontend/tests/gameplay-phase-machine.test.ts b/frontend/tests/gameplay-phase-machine.test.ts index 2d71a7b..f6fb2b2 100644 --- a/frontend/tests/gameplay-phase-machine.test.ts +++ b/frontend/tests/gameplay-phase-machine.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { allowedGameplayEvents, deriveGameplayPhase, + isHostGameplayActionAllowed, + isPlayerGameplayActionAllowed, transitionGameplayPhase, type GameplayPhase } from '../src/spa/gameplay-phase-machine'; @@ -103,4 +105,44 @@ describe('gameplay phase machine skeleton', () => { }) ).toBe('scoreboard'); }); + + it('gates host and player actions from canonical phase_view_model permissions', () => { + const session = { + session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 }, + players: [], + round_question: { id: 77, prompt: 'Q?', answers: [] }, + phase_view_model: { + status: 'reveal', + 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: false, + can_show_question: false, + can_mix_answers: false, + can_calculate_scores: false, + can_reveal_scoreboard: true, + can_start_next_round: true, + can_finish_game: true + }, + player: { + can_join: false, + can_submit_lie: false, + can_submit_guess: false, + can_view_final_result: false + } + } + } as const; + + expect(deriveGameplayPhase(session as any)).toBe('reveal'); + expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true); + expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true); + expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true); + expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false); + }); }); -- 2.39.5 From fc68e30cf4fa5b7c3743b8c35cf1fe2af02c751a Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 11:29:45 +0000 Subject: [PATCH 3/7] fix(frontend): restore phase-gating build --- frontend/src/api/client.ts | 10 +++++----- frontend/tests/vertical-slice.test.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 397365c..65ef550 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,8 +4,8 @@ import { mapHealthResponse, mapJoinSessionResponse, mapMixAnswersResponse, - mapNextRoundResponse, mapScoreboardResponse, + mapStartNextRoundResponse, mapSessionDetailResponse, mapShowQuestionResponse, mapStartRoundResponse, @@ -20,8 +20,8 @@ import type { JoinSessionRequest, JoinSessionResponse, MixAnswersResponse, - NextRoundResponse, ScoreboardResponse, + StartNextRoundResponse, SessionDetailResponse, ShowQuestionResponse, StartRoundRequest, @@ -41,7 +41,7 @@ export interface ApiClient { mixAnswers(code: string, roundQuestionId: number): Promise>; calculateScores(code: string, roundQuestionId: number): Promise>; getScoreboard(code: string): Promise>; - startNextRound(code: string): Promise>; + startNextRound(code: string): Promise>; finishGame(code: string): Promise>; submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise>; submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise>; @@ -167,10 +167,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): mapScoreboardResponse ), startNextRound: (code: string) => - request( + request( `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`, 'POST', - mapNextRoundResponse, + mapStartNextRoundResponse, {} ), finishGame: (code: string) => diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts index 81544ee..6c4336e 100644 --- a/frontend/tests/vertical-slice.test.ts +++ b/frontend/tests/vertical-slice.test.ts @@ -56,7 +56,15 @@ function makeApiMock(overrides?: Partial): ApiClient { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round: { number: 1, category: { slug: 'history', name: 'History' } } } - }) + }), + showQuestion: vi.fn(), + mixAnswers: vi.fn(), + calculateScores: vi.fn(), + getScoreboard: vi.fn(), + startNextRound: vi.fn(), + finishGame: vi.fn(), + submitLie: vi.fn(), + submitGuess: vi.fn() }; return { ...base, ...overrides }; -- 2.39.5 From 3acaf3e370af2fd264b4ba0d878de7667e7198cb Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 12:06:57 +0000 Subject: [PATCH 4/7] test(frontend): include angular specs in vitest suite --- frontend/vitest.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 353adbd..1ca74b0 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'], + setupFiles: ['angular/src/test-setup.ts'], exclude: ['**/node_modules/**'] } }); -- 2.39.5 From f0142f33b62180f882a41f5db6ad8f37721e147a Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 12:50:33 +0000 Subject: [PATCH 5/7] test(issue-301): align host gating specs with canonical phases --- .../host/host-shell.component.spec.ts | 19 +++++++++++++++---- frontend/src/spa/gameplay-phase-machine.ts | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index cabc641..8535bfd 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -183,7 +183,11 @@ describe('HostShellComponent gameplay wiring', () => { const fetchMock: FetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))) + .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } })) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) + .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } })) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); @@ -206,8 +210,8 @@ describe('HostShellComponent gameplay wiring', () => { it('runs next-round transition without reload and clears scoreboard payload', async () => { const fetchMock: FetchMock = vi .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); + .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); vi.stubGlobal('fetch', fetchMock); @@ -216,6 +220,7 @@ describe('HostShellComponent gameplay wiring', () => { component.scoreboardPayload = '{"leaderboard":[]}'; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; await component.startNextRound(); @@ -252,6 +257,7 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; await component.finishGame(); expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); @@ -275,6 +281,7 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = ' '; + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; await component.startNextRound(); await component.finishGame(); @@ -359,8 +366,12 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); - component.session = sessionDetailPayload('scoreboard') as any; + component.session = sessionDetailPayload('reveal') as any; expect(component.canStartNextRound).toBe(true); expect(component.canFinishGame).toBe(true); + + component.session = sessionDetailPayload('scoreboard') as any; + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); }); }); diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts index 257d220..17d57db 100644 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ b/frontend/src/spa/gameplay-phase-machine.ts @@ -81,7 +81,7 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean { if (!session) { - return true; + return action === 'startRound'; } const host = session.phase_view_model?.host; -- 2.39.5 From 55fc7583895f3e95ec865dd8e9119145a7745461 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 13:33:49 +0000 Subject: [PATCH 6/7] test(gameplay): stabilize canonical host gating specs --- .../host/host-shell.component.spec.ts | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 8535bfd..d57ff4d 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component'; type FetchMock = ReturnType; +type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise; + function jsonResponse(status: number, body: unknown) { return { ok: status >= 200 && status < 300, @@ -12,6 +14,10 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } +function createFetchRouteMock(handler: FetchRouteHandler): FetchMock { + return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init))); +} + function sessionDetailPayload( status: string, options?: { @@ -180,14 +186,33 @@ describe('HostShellComponent gameplay wiring', () => { }); it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))) - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); + let refreshCount = 0; + const fetchMock = createFetchRouteMock((input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') { + return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }); + } + if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') { + return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } }); + } + if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { + return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } }); + } + if (method === 'GET' && url === '/lobby/sessions/ABCD12') { + refreshCount += 1; + if (refreshCount === 1) { + return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })); + } + if (refreshCount === 2) { + return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })); + } + return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })); + } + + throw new Error(`Unhandled fetch in test: ${method} ${url}`); + }); vi.stubGlobal('fetch', fetchMock); @@ -197,21 +222,35 @@ describe('HostShellComponent gameplay wiring', () => { component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any; await component.showQuestion(); + expect(component.session?.session.status).toBe('lie'); + expect(component.roundQuestionId).toBe('99'); component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any; await component.mixAnswers(); + expect(component.session?.session.status).toBe('guess'); + await component.calculateScores(); + expect(component.session?.session.status).toBe('reveal'); expect(component.error).toBe(''); expect(component.loading).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(6); }); it('runs next-round transition without reload and clears scoreboard payload', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); + const fetchMock = createFetchRouteMock((input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') { + return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }); + } + if (method === 'GET' && url === '/lobby/sessions/ABCD12') { + return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })); + } + + throw new Error(`Unhandled fetch in test: ${method} ${url}`); + }); vi.stubGlobal('fetch', fetchMock); @@ -363,14 +402,17 @@ describe('HostShellComponent gameplay wiring', () => { component.session = sessionDetailPayload('lie') as any; expect(component.canStartRound).toBe(false); + expect(component.canShowQuestion).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); component.session = sessionDetailPayload('reveal') as any; + expect(component.canLoadScoreboard).toBe(true); expect(component.canStartNextRound).toBe(true); expect(component.canFinishGame).toBe(true); component.session = sessionDetailPayload('scoreboard') as any; + expect(component.canLoadScoreboard).toBe(false); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); }); -- 2.39.5 From 33b428955bbb6001e485fddad5bcf20865bc4cab Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 13:53:00 +0000 Subject: [PATCH 7/7] test(frontend): install angular spec runtime in root suite --- frontend/package-lock.json | 134 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 11 +++ 2 files changed, 145 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7cecda4..80e5174 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,12 +7,125 @@ "": { "name": "wpp-frontend-api-client-baseline", "version": "0.1.0", + "dependencies": { + "@angular/common": "^19.2.0", + "@angular/compiler": "^19.2.0", + "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", + "@angular/platform-browser": "^19.2.0", + "@angular/router": "^19.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, "devDependencies": { "@types/node": "^22.13.10", "typescript": "^5.7.3", "vitest": "^2.1.9" } }, + "node_modules/@angular/common": { + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz", + "integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "19.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz", + "integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + } + }, + "node_modules/@angular/core": { + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz", + "integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0" + } + }, + "node_modules/@angular/forms": { + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz", + "integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.20", + "@angular/core": "19.2.20", + "@angular/platform-browser": "19.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz", + "integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/animations": "19.2.20", + "@angular/common": "19.2.20", + "@angular/core": "19.2.20" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz", + "integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.20", + "@angular/core": "19.2.20", + "@angular/platform-browser": "19.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1188,6 +1301,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1263,6 +1385,12 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1449,6 +1577,12 @@ "engines": { "node": ">=8" } + }, + "node_modules/zone.js": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", + "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 772a3c5..ba1dfc7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,17 @@ "test": "vitest run", "build": "tsc --noEmit" }, + "dependencies": { + "@angular/common": "^19.2.0", + "@angular/compiler": "^19.2.0", + "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", + "@angular/platform-browser": "^19.2.0", + "@angular/router": "^19.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, "devDependencies": { "@types/node": "^22.13.10", "typescript": "^5.7.3", -- 2.39.5