Files
HIPCTF2/tests/frontend/challenges-page.spec.ts
T

273 lines
8.7 KiB
TypeScript

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);
});
});