jest.mock('@angular/common/http', () => ({ HttpClient: class {}, HttpErrorResponse: class {}, })); import { Injector, runInInjectionContext } from '@angular/core'; import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store'; import { EventStatusStore } from '../../frontend/src/app/core/services/event-status.store'; import { formatDdHhMm } from '../../frontend/src/app/core/services/event-status.pure'; import { BoardResponse, ChallengeDetail, SolveResponse } from '../../frontend/src/app/features/challenges/challenges.pure'; import { of } from 'rxjs'; function makeBoard(): BoardResponse { return { columns: [ { id: 'cat1', abbreviation: 'CRY', name: 'Cryptography', iconPath: '', cards: [ { id: 'c1', name: 'alpha', descriptionMd: '', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'LOW', livePoints: 100, solveCount: 0, solvedByMe: false, }, ], }, ], solvedChallengeIds: [], perChallengeLivePoints: { c1: 100 }, }; } function makeDetail(id = 'c1'): ChallengeDetail { return { challenge: { id, name: 'alpha', descriptionMd: '# Alpha', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'LOW', livePoints: 90, solveCount: 1, solvedByMe: false, }, solvers: [], }; } describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => { let eventStore: EventStatusStore; let challenges: ChallengesStore; let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock; getEventState: jest.Mock }; let injector: Injector; beforeEach(() => { injector = Injector.create({ providers: [] }); eventStore = runInInjectionContext(injector, () => new EventStatusStore()); apiSpy = { getBoard: jest.fn(), submit: jest.fn(), getDetail: jest.fn(), getEventState: jest.fn(), }; challenges = new ChallengesStore(apiSpy as any, { onDestroy: () => undefined } as any); }); afterEach(() => { if (challenges) challenges.stop(); if (eventStore) eventStore.stop(); }); it('starts in unconfigured state so the gate panel would render', () => { expect(eventStore.state()).toBe('unconfigured'); expect(formatDdHhMm(0)).toBe('00:00:00'); }); it('shows live countdown in DD:HH:mm when the admin sets a countdown window', () => { const future = new Date(Date.now() + 90_061_000).toISOString(); const past = new Date(Date.now() - 60_000).toISOString(); eventStore.applyServerStatus({ state: 'countdown', serverNowUtc: new Date().toISOString(), eventStartUtc: future, eventEndUtc: past, secondsToStart: 90_061, secondsToEnd: null, }); expect(eventStore.state()).toBe('countdown'); expect(eventStore.countdownText()).toBe('01:01:01'); }); it('single auto-reload fires exactly once when secondsToStart hits zero', () => { const handler = jest.fn(); eventStore.reloadAtCountdownZero(handler); eventStore.applyServerStatus({ state: 'countdown', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() + 5000).toISOString(), eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), secondsToStart: 5, secondsToEnd: null, }); eventStore.applyServerStatus({ state: 'countdown', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() - 1000).toISOString(), eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), secondsToStart: 0, secondsToEnd: null, }); (eventStore as any).checkZero(); (eventStore as any).checkZero(); expect(handler).toHaveBeenCalledTimes(1); }); it('single auto-reload also fires once when secondsToEnd hits zero during running', () => { const handler = jest.fn(); eventStore.reloadAtCountdownZero(handler); eventStore.applyServerStatus({ state: 'running', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() - 60_000).toISOString(), eventEndUtc: new Date(Date.now() - 1000).toISOString(), secondsToStart: null, secondsToEnd: 0, }); (eventStore as any).checkZero(); (eventStore as any).checkZero(); expect(handler).toHaveBeenCalledTimes(1); }); it('reload latch is re-armed after a fresh applyServerStatus (countdown -> running)', () => { const handler = jest.fn(); eventStore.reloadAtCountdownZero(handler); eventStore.applyServerStatus({ state: 'countdown', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() - 1000).toISOString(), eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), secondsToStart: 0, secondsToEnd: null, }); (eventStore as any).checkZero(); expect(handler).toHaveBeenCalledTimes(1); eventStore.applyServerStatus({ state: 'running', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() - 60_000).toISOString(), eventEndUtc: new Date(Date.now() + 3600_000).toISOString(), secondsToStart: null, secondsToEnd: 3600, }); (eventStore as any).checkZero(); expect(handler).toHaveBeenCalledTimes(1); eventStore.applyServerStatus({ state: 'running', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() - 60_000).toISOString(), eventEndUtc: new Date(Date.now() - 1000).toISOString(), secondsToStart: null, secondsToEnd: 0, }); (eventStore as any).checkZero(); expect(handler).toHaveBeenCalledTimes(2); }); it('isRunning flips true only when state is running', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await challenges.load(); challenges.applyStatusPayload({ state: 'countdown', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() + 1000).toISOString(), eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), secondsToStart: 60, secondsToEnd: null, }); expect(challenges.isRunning()).toBe(false); challenges.applyStatusPayload({ state: 'running', serverNowUtc: new Date().toISOString(), eventStartUtc: new Date(Date.now() - 60_000).toISOString(), eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), secondsToStart: null, secondsToEnd: 3600, }); expect(challenges.isRunning()).toBe(true); }); it('stale-modal guard: loadDetail for a different id does not overwrite the current selected detail', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await challenges.load(); apiSpy.getDetail.mockImplementation((id: string) => of(makeDetail(id))); challenges.clearSelectedDetail(); const slow = challenges.loadDetail('c1'); const fast = challenges.loadDetail('c2'); await Promise.all([slow, fast]); expect(challenges.selectedChallengeDetail()?.challenge.id).toBe('c2'); }); it('clean teardown: stop() closes the SSE source and clears the interval', () => { const sseClose = jest.fn(); let registeredHandler: ((ev: MessageEvent) => void) | null = null; const fakeSource = { addEventListener: (name: string, fn: any) => { if (name === 'solve') registeredHandler = fn; }, close: sseClose, }; challenges.wireSse(() => fakeSource as any); expect(registeredHandler).not.toBeNull(); expect((challenges as any).sse).toBe(fakeSource); expect((challenges as any).intervalId).not.toBeNull(); challenges.stop(); expect(sseClose).toHaveBeenCalledTimes(1); expect((challenges as any).sse).toBeNull(); expect((challenges as any).intervalId).toBeNull(); }); it('submit propagates the SolveResponse and the board reflects the new livePoints + solvedByMe', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await challenges.load(); const resp: SolveResponse = { status: 'solved', challenge: { id: 'c1', name: 'alpha', descriptionMd: '', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'LOW', livePoints: 80, solveCount: 3, solvedByMe: true, }, awarded: { basePoints: 80, rankBonus: 0, awardedPoints: 80, awardedAtUtc: '', position: 4 }, solvers: [], }; apiSpy.submit.mockReturnValueOnce(of(resp)); await challenges.submit('c1', 'flag{X}'); const card = challenges.findCard('c1')!; expect(card.solvedByMe).toBe(true); expect(card.livePoints).toBe(80); expect(card.solveCount).toBe(3); }); });