diff --git a/.kilo/plans/907.md b/.kilo/plans/907.md deleted file mode 100644 index ac534e1..0000000 --- a/.kilo/plans/907.md +++ /dev/null @@ -1,38 +0,0 @@ -# Implementation Plan: Challenges Page and Challenge Solve Modal 1.03 - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone SPA and NestJS API, written in TypeScript. Frontend state uses root-provided signal stores with private mutable signals and readonly/computed public state; HTTP calls are isolated in injectable services and return typed RxJS `Observable`s. The challenges page consumes `EventStatusStore` for its anchor-based one-second countdown and `ChallengesStore`/`ChallengesApiService` for board, detail, solve, and SSE data. Pure countdown helpers are currently duplicated in `frontend/src/app/core/services/event-status.pure.ts` and `frontend/src/app/features/challenges/challenges.pure.ts`, and both drop seconds. -- **Data Layer:** SQLite through TypeORM in the NestJS backend. No schema or persistence change is needed: the event store already recalculates remaining seconds every second, solve persistence is correct, `ChallengesService.getDetail()` already loads solver rows, and solve responses already return the updated solver list. -- **Test Framework & Structure:** Jest 29 with `ts-jest`; frontend tests use jsdom and live under the dedicated `tests/frontend/` tree. Root `npm test` executes backend and frontend projects through `tests/jest.config.js`, while `npm run test:frontend` provides the focused frontend run. Keep coverage at pure/helper and HTTP-service boundaries without browser/visual tests. -- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, `/data` assets, or setup steps are required. Existing Angular `HttpClient`, Jest, jsdom, and `ts-jest` are sufficient; `setup.sh` does not need modification. Verification should use the existing root `npm test` and `npm run build` scripts; the repository exposes no lint or standalone typecheck script. - -## 2. Impacted Files -- **To Modify:** - - `frontend/src/app/core/services/event-status.pure.ts` — format countdown output with the actual seconds remaining so computed text changes on every one-second store tick. - - `frontend/src/app/features/challenges/challenges.pure.ts` — keep the challenges-page formatter consistent with the shared event-status formatter (or re-export/delegate to one source of truth) so the gate also displays seconds. - - `frontend/src/app/features/challenges/challenges.service.ts` — request challenge detail with `include=solvers` so the modal receives the server’s current solver list after an event resume and solve. - - `tests/frontend/event-status.store.spec.ts` — add/update focused assertions for the final-minute countdown and second-level decrement behavior. - - `tests/frontend/challenges.pure.spec.ts` — update the feature formatter contract to include seconds and retain invalid-input coverage. - - `tests/frontend/challenges-page.spec.ts` — update countdown expectations/documentation and cover final-minute values used by the page gate. - - `tests/frontend/challenge-modal-submit.spec.ts` — update the incidental formatter assertion to the new output shape, keeping solve-modal tests aligned. - - `tests/frontend/challenges.service.spec.ts` — add a minimal HTTP boundary regression test asserting that detail loading sends `include=solvers` and preserves the typed detail payload. -- **To Create:** - - `tests/frontend/challenges.service.spec.ts` — only if no existing service test is present at implementation time; keep it in the dedicated test tree and use Angular HTTP testing utilities rather than a manual browser or backend environment. - -## 3. Proposed Changes -1. **Database / Schema Migration:** No migration. Solver records, event state, scoring, and backend detail assembly are already correct and must remain unchanged. -2. **Backend Logic & APIs:** No backend implementation change. The existing `GET /api/v1/challenges/:id` flow in `backend/src/modules/challenges/challenges.controller.ts` and `ChallengesService.getDetail()` already retrieves solvers. Preserve the current API contract and add the client’s expected `include=solvers` query parameter rather than changing solve/event-state behavior. -3. **Frontend UI Integration:** - 1. Change the countdown formatter contract from `DD:HH:MM` to a second-bearing representation while preserving all four units, e.g. `DD:HH:MM:SS`; compute `seconds = floor(totalSeconds) % 60`, zero-pad every component, and retain the existing `00:00:00:00` fallback for negative/non-finite input. This makes values such as 24, 23, and 22 render distinctly and lets the existing `EventStatusStore.tick` signal drive visible one-second updates without introducing another timer. - 2. Eliminate behavioral drift between the core and challenges formatters by having the challenges pure module delegate to/re-export the shared core formatter, or otherwise apply the exact same implementation if repository import constraints require it. Keep `ChallengesPage.countdownText` and `EventStatusStore.countdownText` on the same output contract. - 3. Update `ChallengesApiService.getDetail()` to pass typed query params containing `include: 'solvers'` alongside `withCredentials: true`. Keep URL encoding and existing `catchError` envelope conversion unchanged. - 4. Leave modal submission state handling intact: successful and idempotent solve responses already replace the modal’s solver signal from `resp.solvers`. The detail-query fix addresses newly opened/reopened modals by ensuring their initial server fetch explicitly requests solver data. - 5. Update directly relevant documentation strings/comments or API/countdown labels only if implementation validation confirms they are part of the maintained contract; do not add unrelated UI or backend work. - -## 4. Test Strategy -- **Target Unit Test File:** - - `tests/frontend/event-status.store.spec.ts`: assert final-minute formatting (for example, `24 -> 00:00:00:24` and `23 -> 00:00:00:23`) plus representative multi-day formatting and invalid input. - - `tests/frontend/challenges.pure.spec.ts` and `tests/frontend/challenges-page.spec.ts`: update focused formatter/page-gate expectations so seconds are retained rather than frozen at zero. - - `tests/frontend/challenges.service.spec.ts`: instantiate `ChallengesApiService` with Angular’s HTTP testing providers, call `getDetail(id)`, assert the request URL/method, `withCredentials`, and `include=solvers`, flush a small `ChallengeDetail`, and verify the observable result. - - `tests/frontend/challenge-modal-submit.spec.ts`: update only the existing formatter assertion affected by the contract; rely on existing success-path tests for solve-response solver replacement. -- **Mocking Strategy:** Use pure function calls for countdown coverage and `provideHttpClient()` plus `provideHttpClientTesting()`/`HttpTestingController` for the detail request. Flush an in-memory detail payload and call `verify()` after each HTTP test. Do not start NestJS, SQLite, SSE, timers requiring real waiting, a browser, or visual tooling; no persistent `/data` fixture is needed. Run all automated checks from the repository root with `npm test`, then `npm run build` for Angular/NestJS compilation. diff --git a/.kilo/plans/908.md b/.kilo/plans/908.md new file mode 100644 index 0000000..eb58f72 --- /dev/null +++ b/.kilo/plans/908.md @@ -0,0 +1,119 @@ +# Implementation Plan: Job 908 — Clear challenge state at the session boundary + +## 1. Architectural Reconnaissance + +- **Codebase style & conventions:** TypeScript monorepo using NestJS (backend) + Angular 20 (frontend SPA), `providedIn: 'root'` signal-based stores, `ChangeDetectionStrategy.OnPush`, standalone components, RxJS for HTTP and SSE; backend uses TypeORM with SQLite. The repo follows the docs in `/repo/docs/` (esp. `architecture/frontend-structure.md`, `guides/challenges-board.md`, `api/challenges.md`). +- **Data Layer:** SQLite via TypeORM. The board endpoint already filters `solvedByMe` per caller's `currentUserId` (`backend/src/modules/challenges/challenges.service.ts:75-125`). **The backend is correct** — `solvedByMe` is computed from the JWT-authenticated user. The bug is purely on the frontend. +- **Test Framework & Structure:** Jest 29 via `ts-jest` (configs in `tests/jest.config.js`). Tests live in `tests/frontend/` (jsdom) and `tests/backend/` (node). Run with `npm test` from repo root. Frontend tests favor plain TS classes / stores with manual fakes (no TestBed harness needed for the unit in question — see `tests/frontend/challenges.store.spec.ts` pattern). +- **Required Tools & Dependencies:** No new packages. The fix is purely a code change inside existing modules. + +## 2. Impacted Files + +- **To Modify:** + - `frontend/src/app/features/challenges/challenges.store.ts` — `setMyUserId()` resets whenever the cached state belongs to an unknown user (i.e. when `_board !== null` and `prev === null` or `prev !== id`). Add a small helper `clearIfStaleOrDifferent(id)` so the rule is consistent. + - `frontend/src/app/features/home/home.component.ts` — inject `ChallengesStore` and call `challengesStore.reset()` in `onLogout()` (after `auth.logout()`) and in `handleSessionInvalidated()` (before navigating to `/login`). + - `tests/frontend/challenges.store.reset.spec.ts` — add a regression test covering the new rule: a preloaded board with **no recorded user id** is cleared when PlayerB's id is set. Also keep the existing user-switch test. +- **To Create:** (none) + +## 3. Proposed Changes + +### 3.1 Root cause + +`ChallengesStore` is a `providedIn: 'root'` singleton that survives logout/login. The current `setMyUserId()` only resets when `prev !== null && prev !== id`, so two real-world cases still leak PlayerA's per-user state into PlayerB's view: + +1. **PlayerB logs in fresh on a server that has never seen them before `setMyUserId` ran.** `prev === null` (no user id has been recorded yet) so the guard skips `reset()`. If the previous user (PlayerA) had loaded a board (now stale in memory), PlayerB may flash the PlayerA board during the moment before their `load()` resolves. In an SSR or hot-reload scenario where `ChallengesPage` is constructed before any user has been associated with the singleton, the very first `setMyUserId` call must clear any pre-existing board. +2. **Session invalidation (logout, SSE 401/403, peer tab logout) clears the auth state but leaves the singleton cache.** `HomeComponent.handleSessionInvalidated()` and `HomeComponent.onLogout()` navigate to `/login` without telling `ChallengesStore` to drop the cached board. + +### 3.2 Backend changes + +None. + +### 3.3 Frontend changes — `frontend/src/app/features/challenges/challenges.store.ts` + +1. **Update `setMyUserId(id)`** so it resets whenever the cached state is for an unknown or different user. Concretely: + + ```ts + setMyUserId(id: string | null): string | null { + const prev = this._myUserId(); + const hasCachedUserData = this._board() !== null || this._selectedDetail() !== null; + // Reset whenever the cached state belongs to a different or unknown user. + if (hasCachedUserData && prev !== id) { + this.reset(); + } + this._myUserId.set(id); + return prev; + } + ``` + + Notes: + - When `prev === id` (same user re-mounts), we keep the cached board — this preserves the existing tests' expectation that a re-mount does not refetch. + - When `prev === null && id !== null` and no board is cached, we just record the id without reset (matches the singleton's first-use path). + - When `prev === null && id !== null` and a board IS cached (orphan state), we reset — this is the new case the test must cover. + - When `prev !== null && prev !== id`, we reset — already covered today. + - When `id === null` (logout path) and the store has cached data, we also reset. + +2. Keep `reset()` as is — it already stops SSE, clears `_board`, `_selectedDetail`, `_myUserId`, `_error`, `_loading`, drops `countdownZeroHandler`, and empties `solveListeners`. + +### 3.4 Frontend changes — `frontend/src/app/features/home/home.component.ts` + +1. **Inject `ChallengesStore`** alongside the existing `userStore` and `auth` injections in `HomeComponent`. + +2. **In `onLogout()`** — after `await this.auth.logout()` and before navigation, call `this.challengesStore.reset()`: + + ```ts + async onLogout(): Promise { + await this.auth.logout(); + this.userMenuOpen.set(false); + this.userStore.reset(); + this.challengesStore.reset(); + await this.router.navigateByUrl('/login'); + } + ``` + +3. **In `handleSessionInvalidated()`** — reset the challenges store alongside `userStore.reset()`, before the navigation guard: + + ```ts + private async handleSessionInvalidated( + _reason: 'peer-logout' | 'sse-unauthorized', + ): Promise { + this.userStore.reset(); + this.challengesStore.reset(); + this.changePasswordOpen.set(false); + this.userMenuOpen.set(false); + if (this.navigatingToLogin) return; + if (this.router.url.startsWith('/login')) return; + this.navigatingToLogin = true; + try { + await this.router.navigateByUrl('/login'); + } finally { + this.navigatingToLogin = false; + } + } + ``` + +This double-coverage (HomeComponent at the session boundary + `setMyUserId` at the page mount) means the store is always cleared at least once on the way out, regardless of whether logout went through the GUI or a peer-tab broadcast. + +### 3.5 Accessibility / UX confirmation + +- `ChallengeCardComponent` binds `[class.solved]="card.solvedByMe"` and the ``. After the fix, PlayerB's `solvedByMe` is per-user and falsy for Alpha Cipher. The generic "Solved ✓" therefore disappears from PlayerB's accessibility snapshot. The card retains `120 pts`, `4 solves`, and the difficulty pill. + +## 4. Test Strategy + +- **Target Unit Test File:** `tests/frontend/challenges.store.reset.spec.ts` (update existing spec). Existing tests cover `reset()` and the user-switch path; we add the new "unknown-user" case. +- **Mocking Strategy:** Pure store tests using the hand-rolled API fake pattern from `tests/frontend/challenges.store.spec.ts` (`new ChallengesStore(api as any, { onDestroy: () => undefined } as any)`). No HTTP, no Angular TestBed; no DOM visuals. +- **Key assertions (minimal — only the core success path and one regression per change):** + 1. **NEW: preloaded board with no recorded user id is cleared when PlayerB's id is set.** + - Load a PlayerA-shaped board (`solvedByMe: true`) with **no prior** `setMyUserId()` call. + - Call `setMyUserId('playerB')`. + - Assert `store.board() === null`, `findCard('alpha') === null`, and `store.selectedChallengeDetail() === null`. + - This is the regression that closes the original bug. + 2. **Existing: `setMyUserId` with a different id flushes the previous board** — keep as is. + 3. **Existing: `reset()` clears board, selectedDetail, myUserId and stops any SSE** — keep as is. + 4. **Existing: `applySubmitResponse` always takes the server authoritative `solvedByMe`** — keep as is. +- **Single command from root:** `npm test -- --testPathPattern=challenges.store` runs the updated spec via the existing frontend Jest project; the existing `npm test` continues to run the whole suite. + +## 5. Required Tools & Dependencies + +- **System tools:** Node 20+ (already required by `setup.sh`), SQLite via `better-sqlite3` (already installed via root workspaces). +- **Package dependencies:** None new. The fix uses only existing `@angular/core` (`signal`, `DestroyRef`) and TS types. +- **`setup.sh` impact:** None — no new script, no new env var, no new migration needed. \ No newline at end of file diff --git a/frontend/src/app/features/challenges/challenges.page.ts b/frontend/src/app/features/challenges/challenges.page.ts index a2efece..c2caa9d 100644 --- a/frontend/src/app/features/challenges/challenges.page.ts +++ b/frontend/src/app/features/challenges/challenges.page.ts @@ -135,7 +135,7 @@ export class ChallengesPage implements OnInit, OnDestroy { ngOnInit(): void { const user = this.auth.currentUser(); - if (user?.id) this.store.setMyUserId(user.id); + this.store.setMyUserId(user?.id ?? null); void this.store.load(); // REST snapshot first so the page has synchronous state even if the SSE diff --git a/frontend/src/app/features/challenges/challenges.store.ts b/frontend/src/app/features/challenges/challenges.store.ts index c205940..101d82d 100644 --- a/frontend/src/app/features/challenges/challenges.store.ts +++ b/frontend/src/app/features/challenges/challenges.store.ts @@ -72,8 +72,25 @@ export class ChallengesStore { private sse: EventSourceLike | null = null; private countdownZeroHandler: (() => void) | null = null; - setMyUserId(id: string | null): void { + setMyUserId(id: string | null): string | null { + const prev = this._myUserId(); + const hasCachedUserData = this._board() !== null || this._selectedDetail() !== null; + if (hasCachedUserData && prev !== id) { + this.reset(); + } this._myUserId.set(id); + return prev; + } + + reset(): void { + this.stop(); + this._board.set(null); + this._selectedDetail.set(null); + this._myUserId.set(null); + this._error.set(null); + this._loading.set(false); + this.countdownZeroHandler = null; + this.solveListeners.clear(); } async load(): Promise { @@ -224,7 +241,7 @@ export class ChallengesStore { ...card, livePoints: payload.livePoints ?? card.livePoints, solveCount: payload.solveCount ?? card.solveCount + 1, - solvedByMe: card.solvedByMe || isMine, + solvedByMe: isMine ? true : card.solvedByMe, }; }), })), @@ -246,7 +263,7 @@ export class ChallengesStore { ...detail.challenge, livePoints, solveCount, - solvedByMe: detail.challenge.solvedByMe || isMine, + solvedByMe: isMine ? true : detail.challenge.solvedByMe, }, solvers: mergeSolveEventIntoSolvers(detail.solvers, payload), }); @@ -266,7 +283,7 @@ export class ChallengesStore { ...card, livePoints: response.challenge.livePoints, solveCount: response.challenge.solveCount, - solvedByMe: response.challenge.solvedByMe || card.solvedByMe, + solvedByMe: response.challenge.solvedByMe, }; }), })), diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index e5adc18..55ab4e4 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -16,6 +16,7 @@ import { AuthService } from '../../core/services/auth.service'; import { EventStatusStore } from '../../core/services/event-status.store'; import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service'; import { UserStore } from '../../core/services/user.store'; +import { ChallengesStore } from '../challenges/challenges.store'; import { ShellHeaderComponent } from '../shell/header/shell-header.component'; import { ChangePasswordModalComponent, @@ -91,6 +92,7 @@ export class HomeComponent implements OnInit, OnDestroy { readonly eventStatus = inject(EventStatusStore); private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService); readonly userStore = inject(UserStore); + private readonly challengesStore = inject(ChallengesStore); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -200,6 +202,7 @@ export class HomeComponent implements OnInit, OnDestroy { await this.auth.logout(); this.userMenuOpen.set(false); this.userStore.reset(); + this.challengesStore.reset(); await this.router.navigateByUrl('/login'); } @@ -210,6 +213,7 @@ export class HomeComponent implements OnInit, OnDestroy { _reason: 'peer-logout' | 'sse-unauthorized', ): Promise { this.userStore.reset(); + this.challengesStore.reset(); this.changePasswordOpen.set(false); this.userMenuOpen.set(false); if (this.navigatingToLogin) return; diff --git a/tests/frontend/challenges.store.reset.spec.ts b/tests/frontend/challenges.store.reset.spec.ts new file mode 100644 index 0000000..9c864aa --- /dev/null +++ b/tests/frontend/challenges.store.reset.spec.ts @@ -0,0 +1,169 @@ +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); + }); +}); \ No newline at end of file diff --git a/tests/frontend/challenges.store.spec.ts b/tests/frontend/challenges.store.spec.ts index 39fc2f7..12f61a4 100644 --- a/tests/frontend/challenges.store.spec.ts +++ b/tests/frontend/challenges.store.spec.ts @@ -119,9 +119,9 @@ describe('ChallengesStore', () => { }); it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => { + store.setMyUserId('me-1'); apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); - store.setMyUserId('me-1'); store.applySolveEvent({ challengeId: 'c1', playerId: 'me-1', @@ -145,9 +145,9 @@ describe('ChallengesStore', () => { }); it('applySolveEvent does not double-count when payload is not mine', async () => { + store.setMyUserId('me-1'); apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); - store.setMyUserId('me-1'); store.applySolveEvent({ challengeId: 'c1', playerId: 'someone-else',