169 lines
5.5 KiB
TypeScript
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);
|
|
});
|
|
}); |