fix(issue-301): gate client actions from canonical phase flags
CI / test-and-quality (push) Successful in 2m20s
CI / test-and-quality (pull_request) Successful in 2m28s

This commit is contained in:
2026-03-16 10:28:12 +00:00
parent 076faf2ff1
commit 57ca237565
5 changed files with 123 additions and 41 deletions
@@ -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();
});
@@ -29,9 +29,9 @@ type LeaderboardResponse = FinishGameResponse;
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.calculate_scores') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !canMixAnswers">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !canCalculateScores">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.finish_game') }}</button>
@@ -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<void> {
if (!this.canUseLegacyMidRoundHostAction) {
if (!this.canShowQuestion) {
return;
}
@@ -231,7 +239,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async mixAnswers(): Promise<void> {
if (!this.canUseLegacyMidRoundHostAction) {
if (!this.canMixAnswers) {
return;
}
@@ -244,7 +252,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async calculateScores(): Promise<void> {
if (!this.canUseLegacyMidRoundHostAction) {
if (!this.canCalculateScores) {
return;
}
@@ -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();