Files
HIPCTF2/tests/frontend/challenges.store.reset.spec.ts
T

169 lines
5.5 KiB
TypeScript

jest.mock('@angular/common/http', () => ({
HttpClient: class {},
HttpErrorResponse: class {},
}));
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
import {
BoardResponse,
SolveResponse,
} from '../../frontend/src/app/features/challenges/challenges.pure';
import { of } from 'rxjs';
function makeBoard(solvedByMe: boolean): BoardResponse {
return {
columns: [
{
id: 'cat1',
abbreviation: 'CRY',
name: 'Cryptography',
iconPath: '',
cards: [
{
id: 'alpha',
name: 'Alpha Cipher',
descriptionMd: '',
categoryId: 'cat1',
categoryAbbreviation: 'CRY',
categoryIconPath: '',
difficulty: 'LOW',
livePoints: 120,
solveCount: 4,
solvedByMe,
},
],
},
],
solvedChallengeIds: solvedByMe ? ['alpha'] : [],
perChallengeLivePoints: { alpha: 120 },
};
}
function createStore(): {
store: ChallengesStore;
api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock };
} {
const api = {
getBoard: jest.fn(),
submit: jest.fn(),
getDetail: jest.fn(),
};
const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any);
return { store, api };
}
describe('ChallengesStore — per-user solvedByMe after logout/login', () => {
it('reset() clears board, selectedDetail, myUserId and stops any SSE', () => {
const { store, api } = createStore();
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
return store.load().then(() => {
expect(store.board()).not.toBeNull();
// wire a fake SSE source and ensure reset closes it
const closed = { v: false };
const fakeSrc: any = {
addEventListener: () => undefined,
close: () => { closed.v = true; },
};
store.wireSse(() => fakeSrc as any);
store.reset();
expect(store.board()).toBeNull();
expect(store.selectedChallengeDetail()).toBeNull();
expect(closed.v).toBe(true);
});
});
it('setMyUserId with a different id flushes the previous board', async () => {
const { store, api } = createStore();
// Record PlayerA first, then load so the cached board is owned by PlayerA.
store.setMyUserId('playerA');
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
await store.load();
expect(store.findCard('alpha')?.solvedByMe).toBe(true);
// Switching to PlayerB flushes the cached PlayerA board.
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
const prev = store.setMyUserId('playerB');
expect(prev).toBe('playerA');
expect(store.board()).toBeNull();
await store.load();
expect(store.findCard('alpha')?.solvedByMe).toBe(false);
});
it('preloaded board with no recorded user id is cleared when PlayerB id is set', async () => {
const { store, api } = createStore();
// Simulate the bug scenario: a board was loaded into the singleton
// (e.g. by an earlier PlayerA session or by an SSR/refresh path that
// fetched before setMyUserId ran) but no user id has been recorded yet.
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
await store.load();
expect(store.findCard('alpha')).not.toBeNull();
expect(store.findCard('alpha')!.solvedByMe).toBe(true);
// First-ever setMyUserId for a new user must flush the orphan board.
const prev = store.setMyUserId('playerB');
expect(prev).toBeNull();
expect(store.board()).toBeNull();
expect(store.selectedChallengeDetail()).toBeNull();
expect(store.findCard('alpha')).toBeNull();
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
await store.load();
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
});
it('applySolveEvent for another user does not flip solvedByMe from false to true', async () => {
const { store, api } = createStore();
store.setMyUserId('me');
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
await store.load();
store.applySolveEvent({
challengeId: 'alpha',
playerId: 'someone-else',
playerName: 'other',
awardedPoints: 50,
rankBonus: 0,
awardedAtUtc: '2026-07-23T00:00:00.000Z',
position: 5,
livePoints: 110,
solveCount: 5,
initialPoints: 200,
minimumPoints: 50,
decaySolves: 10,
});
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
});
it('applySubmitResponse always takes the server authoritative solvedByMe (no stale OR)', async () => {
const { store, api } = createStore();
store.setMyUserId('me');
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
await store.load();
expect(store.findCard('alpha')!.solvedByMe).toBe(true);
const resp: SolveResponse = {
status: 'solved',
challenge: {
id: 'alpha',
name: 'Alpha Cipher',
descriptionMd: '',
categoryId: 'cat1',
categoryAbbreviation: 'CRY',
categoryIconPath: '',
difficulty: 'LOW',
livePoints: 100,
solveCount: 5,
solvedByMe: false,
},
awarded: { basePoints: 100, rankBonus: 0, awardedPoints: 100, awardedAtUtc: '', position: 6 },
solvers: [],
};
api.submit.mockReturnValueOnce(of(resp));
await store.submit('alpha', 'flag{x}');
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
expect(store.findCard('alpha')!.livePoints).toBe(100);
expect(store.findCard('alpha')!.solveCount).toBe(5);
});
});