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