AI Implementation feature(908): Challenges Page and Challenge Solve Modal 1.04 #50
@@ -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.
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 `<span *ngIf="card.solvedByMe" class="check" aria-label="Solved">✓</span>`. 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.
|
||||
@@ -25,7 +25,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
||||
|
||||
| Symbol | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, and peer-invalidation subscription. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, peer-invalidation subscription, and per-session resets of both `UserStore` and `ChallengesStore` (the latter flushes the per-user board cache on logout / SSE unauthorized / peer-logout). |
|
||||
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. |
|
||||
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. |
|
||||
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping; wires an optional `onUnauthorized` callback for the new SSE `401`/`403` event. |
|
||||
@@ -33,7 +33,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. |
|
||||
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
|
||||
| `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. |
|
||||
| `ChallengesStore` | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board payload, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. |
|
||||
| `ChallengesStore` | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board payload, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, public `reset()` for the session-boundary flush, and `setMyUserId(id)` (now returns the previous id and resets whenever the cached state belongs to an unknown or different user). |
|
||||
| `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. |
|
||||
| `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). |
|
||||
| `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. |
|
||||
@@ -74,7 +74,7 @@ on destruction.
|
||||
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. |
|
||||
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
|
||||
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page; owns gate logic, modal lifecycle, SSE wiring, and the countdown-zero reload. |
|
||||
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. |
|
||||
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, public `reset()`, and per-user `setMyUserId` flush. |
|
||||
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
|
||||
|
||||
# Event-status pure helpers
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Key Files Index
|
||||
description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, and the authenticated player-facing challenges board.
|
||||
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications]
|
||||
timestamp: 2026-07-23T01:19:00Z
|
||||
description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, the authenticated player-facing challenges board, and the per-user `solvedByMe` reset on the session boundary.
|
||||
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session]
|
||||
timestamp: 2026-07-23T03:45:00Z
|
||||
---
|
||||
|
||||
# Backend
|
||||
@@ -52,7 +52,7 @@ timestamp: 2026-07-23T01:19:00Z
|
||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. |
|
||||
| `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. |
|
||||
| `frontend/src/app/core/services/admin.service.ts` | Calls admin settings, theme-list, category, and upload APIs. |
|
||||
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. |
|
||||
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services; on logout and on session invalidation also calls `challengesStore.reset()` to flush any per-user board cache before navigating to `/login`. |
|
||||
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. |
|
||||
| `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. |
|
||||
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing, abort support, and a separate `'unauthorized'` event for `401`/`403`. |
|
||||
@@ -60,7 +60,7 @@ timestamp: 2026-07-23T01:19:00Z
|
||||
| `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). |
|
||||
| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. |
|
||||
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. |
|
||||
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. |
|
||||
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, resets both `UserStore` and `ChallengesStore`, and navigates to `/login` when the session is invalidated elsewhere. |
|
||||
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
|
||||
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
|
||||
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
|
||||
@@ -73,7 +73,7 @@ timestamp: 2026-07-23T01:19:00Z
|
||||
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
|
||||
| `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. |
|
||||
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). |
|
||||
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. |
|
||||
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle, public `reset()` for the session-boundary flush, and `setMyUserId(id)` which now returns the previous id and clears the cache whenever the cached state belongs to an unknown or different user. |
|
||||
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. |
|
||||
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). |
|
||||
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
|
||||
@@ -83,7 +83,8 @@ timestamp: 2026-07-23T01:19:00Z
|
||||
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). |
|
||||
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). |
|
||||
| `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. |
|
||||
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. |
|
||||
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. The "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()` to exercise the realistic page-mount sequence. |
|
||||
| `tests/frontend/challenges.store.reset.spec.ts` | Per-user `solvedByMe` regression suite: `reset()` clears board/detail/myUserId/SSE; switching user id flushes the previous board; **orphan board** (cached with no recorded user id) is cleared when PlayerB's id is set; a non-mine solve never flips `solvedByMe` from `false` to `true`; `applySubmitResponse` takes the server value verbatim. |
|
||||
| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. |
|
||||
| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. |
|
||||
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
|
||||
|
||||
@@ -28,7 +28,14 @@ The first time the page mounts it triggers `ChallengesStore.load()`
|
||||
which calls `GET /api/v1/challenges/board`. It also calls
|
||||
`GET /api/v1/challenges/status` once to populate the event state
|
||||
synchronously, then opens the authenticated SSE stream on
|
||||
`/api/v1/events` for live `solve` frames.
|
||||
`/api/v1/events` for live `solve` frames`.
|
||||
|
||||
The mount order is significant: `ChallengesPage.ngOnInit()` calls
|
||||
`store.setMyUserId(user?.id ?? null)` **before** `store.load()`. If the
|
||||
singleton's cache belongs to an unknown or different user, `setMyUserId`
|
||||
calls `reset()` first so the new board is never blended with a previous
|
||||
user's `solvedByMe`. See
|
||||
[Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md).
|
||||
|
||||
# How to access (tester steps)
|
||||
|
||||
@@ -209,4 +216,5 @@ destruction of the page the SSE source is closed via
|
||||
- [System Endpoints](/api/system.md) (legacy `/events/status`)
|
||||
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport)
|
||||
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) (logout/login, peer-tab, SSE-unauthorized reset)
|
||||
- [Notifications](/guides/notifications.md) (error interceptor + service)
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
type: guide
|
||||
title: Per-User `solvedByMe` Across the Session Boundary
|
||||
description: How the `ChallengesStore` signal cache guarantees that PlayerA's solved-by-me flags never leak into PlayerB's view across logout/login, including SSE-unauthorized and peer-logout invalidation.
|
||||
tags: [guide, challenges, solvedByMe, session, logout, store, tester]
|
||||
timestamp: 2026-07-23T03:45:00Z
|
||||
---
|
||||
|
||||
# What this feature does
|
||||
|
||||
`ChallengesStore` is a `providedIn: 'root'` Angular singleton that survives
|
||||
route navigation and SPA reloads. Its board cache holds a `BoardResponse`
|
||||
whose cards carry a per-user `solvedByMe: boolean` flag (the `✓` glyph on
|
||||
the card and the `You solved this challenge.` modal banner).
|
||||
|
||||
Because `solvedByMe` is **server-computed from the JWT-authenticated user**
|
||||
(`backend/src/modules/challenges/challenges.service.ts:75-125`), the
|
||||
backend has always returned the correct value per request. The risk was
|
||||
purely on the frontend: if PlayerA loaded the board, then logged out, and
|
||||
PlayerB logged in **before** the new board response arrived, PlayerB could
|
||||
briefly see PlayerA's `solvedByMe` flags (or worse, `applySolveEvent`
|
||||
from a third-party solve could overwrite PlayerB's `solvedByMe` because
|
||||
the store still believed it was PlayerA).
|
||||
|
||||
This change closes that gap at three coordinated points:
|
||||
|
||||
1. `ChallengesStore.setMyUserId(id)` — now returns the previous id, and
|
||||
whenever the cached state belongs to a different or unknown user, the
|
||||
store calls `reset()` before recording the new id. This catches the
|
||||
"orphan board loaded before the first `setMyUserId`" path (SSR, hot
|
||||
reload, page mount before auth hydration).
|
||||
2. `ChallengesStore.reset()` — new public method; stops the SSE source,
|
||||
clears the one-second tick interval, and wipes `_board`,
|
||||
`_selectedDetail`, `_myUserId`, `_error`, `_loading`, the countdown
|
||||
reload handler, and all `solveListeners`.
|
||||
3. `HomeComponent.onLogout()` and `HomeComponent.handleSessionInvalidated()`
|
||||
— both now inject `ChallengesStore` and call `challengesStore.reset()`
|
||||
alongside the existing `userStore.reset()`. This guarantees the board
|
||||
cache is cleared on the way out, regardless of whether logout went
|
||||
through the GUI menu or was triggered by a peer-tab broadcast / SSE
|
||||
`401`/`403`.
|
||||
|
||||
Additionally, the mutation paths (`applySolveEvent`, `applySubmitResponse`)
|
||||
stopped OR-ing `solvedByMe`. They now take the server's authoritative
|
||||
value verbatim, so a third-party solve cannot falsely flip a player's
|
||||
`solvedByMe` from `false` to `true` even within a single session.
|
||||
|
||||
# Why a new concept doc
|
||||
|
||||
The fix spans three files that previously did not coordinate
|
||||
(`ChallengesStore`, `HomeComponent`, `ChallengesPage`) and adds a public
|
||||
`reset()` method to a previously opaque singleton. A tester verifying
|
||||
the per-user invariant should not have to diff the store to learn the
|
||||
reset rules, and a developer wiring a new "logout-adjacent" feature
|
||||
should not have to guess that the challenges board also needs to be
|
||||
flushed.
|
||||
|
||||
# Tester flows
|
||||
|
||||
> The following assume a configured event window with the event in
|
||||
> `running` state and at least one challenge (`alpha`) that has been
|
||||
> pre-solved by PlayerA.
|
||||
|
||||
## User switch via menu logout
|
||||
|
||||
1. Sign in as **PlayerA** and navigate to `/challenges`.
|
||||
2. Confirm `alpha` shows the `✓` glyph (`.check` inside
|
||||
`[data-testid="challenge-card-alpha"]`) and the success border.
|
||||
3. Open the user menu (`[data-testid="user-menu-trigger"]`) and click
|
||||
**Logout**.
|
||||
4. The SPA navigates to `/login`; the `ChallengesStore` cache is cleared.
|
||||
5. Sign in as **PlayerB** (a different account that has not solved
|
||||
`alpha`).
|
||||
6. Navigate to `/challenges`. Confirm `alpha` shows:
|
||||
* `120 pts` and `4 solves` (the global counter — unchanged).
|
||||
* **No** `✓` glyph and **no** success border on the card.
|
||||
* **No** `[data-testid="modal-solved-banner"]` (`You solved this
|
||||
challenge.`) when the card is opened.
|
||||
|
||||
## User switch via peer-tab SSE unauthorized
|
||||
|
||||
1. Open the SPA in **Tab A** and **Tab B**, both signed in as PlayerA
|
||||
on `/challenges`. `alpha` shows `✓`.
|
||||
2. From the server side, revoke PlayerA's refresh-cookie/session (e.g. an
|
||||
admin row-edit or a manual `refresh_token` deletion).
|
||||
3. `EventStatusStore`'s SSE reconnect returns `401`. `Tab A` clears state
|
||||
and navigates to `/login`. The `ChallengesStore` cache in `Tab A` is
|
||||
reset.
|
||||
4. In **Tab B**, the user menu can also be used to sign out as PlayerB
|
||||
(or wait for the broadcast from Tab A). Confirm the board does not
|
||||
briefly show PlayerA's `solvedByMe` on PlayerB's view during the
|
||||
transition.
|
||||
|
||||
## Third-party solve does not flip `solvedByMe`
|
||||
|
||||
1. Sign in as **PlayerB**. Confirm `alpha` shows `solvedByMe = false`.
|
||||
2. From a second browser session, sign in as **PlayerA** and solve
|
||||
`alpha`. The SSE `solve` frame is broadcast.
|
||||
3. In PlayerB's tab, confirm `alpha`'s `✓` does **not** appear, the
|
||||
border does **not** flip to success, and the modal does **not** show
|
||||
the solved banner. `livePoints` and `solveCount` may still update
|
||||
(those are global, not per-user).
|
||||
|
||||
## Negative cases
|
||||
|
||||
| Action | Expected |
|
||||
|---|---|
|
||||
| Reload `/challenges` as the same PlayerA | The board cache is preserved across the SPA reload (signal singletons survive), so `alpha` still shows `✓` after the reload completes its `load()`. |
|
||||
| Navigate away from `/challenges` and back | The singleton keeps the cache; `alpha` still shows `✓`. |
|
||||
| Have PlayerB solve `alpha` for the first time | The submit response drives `applySubmitResponse`; `solvedByMe` flips to `true`, `✓` appears, and the card border flips to success. |
|
||||
| Logout PlayerB then log PlayerB back in (same user) | The store is reset on logout, so the new `load()` fetches a fresh board and `solvedByMe` is recomputed by the server. |
|
||||
|
||||
# Visual / interactive elements
|
||||
|
||||
There are **no new UI controls**. The existing card and modal selectors
|
||||
are the regression targets:
|
||||
|
||||
| Element | Selector | After user-switch |
|
||||
|---|---|---|
|
||||
| Solved check | `.check` inside `[data-testid="challenge-card-<uuid>"]` | **Absent** for PlayerB if PlayerA solved it. |
|
||||
| Solved border | `[data-testid="challenge-card-<uuid>"]` (border color) | Default border color, not success. |
|
||||
| Modal solved banner | `[data-testid="modal-solved-banner"]` | **Absent** for PlayerB. |
|
||||
| Awarded banner | `[data-testid="modal-awarded-banner"]` | **Absent** (no award yet for PlayerB). |
|
||||
|
||||
# How it works (developer map)
|
||||
|
||||
## `ChallengesStore` singleton lifecycle
|
||||
|
||||
`ChallengesStore` is `providedIn: 'root'`, so a single instance is
|
||||
shared by every consumer (`ChallengesPage`, `HomeComponent`, any future
|
||||
feature that injects it). The store never calls `reset()` itself based
|
||||
on time — it only resets in response to explicit calls or to a
|
||||
`setMyUserId` change.
|
||||
|
||||
### `setMyUserId(id: string | null): string | null`
|
||||
|
||||
Returns the previous user id (or `null`). If the store currently holds
|
||||
cached board or detail data **and** the previous id differs from the new
|
||||
id, it calls `reset()` first. The four cases:
|
||||
|
||||
| Previous `_myUserId` | New `id` | Cached state present? | Action |
|
||||
|---|---|---|---|
|
||||
| `null` | `playerB` | No | Just record the id. |
|
||||
| `null` | `playerB` | Yes (orphan) | **Reset**, then record. *(new — closes the bug)* |
|
||||
| `playerA` | `playerA` | Yes | Just record (re-mount path; keeps cache). |
|
||||
| `playerA` | `playerB` | Yes | **Reset**, then record. |
|
||||
| `playerA` | `null` | Yes | **Reset**, then record `null`. |
|
||||
|
||||
### `reset(): void`
|
||||
|
||||
New public method. Calls `stop()` (closes the SSE source, clears the
|
||||
1-second tick interval), then nulls every signal and clears
|
||||
`solveListeners` and `countdownZeroHandler`. Idempotent.
|
||||
|
||||
### Mutation paths take server values verbatim
|
||||
|
||||
`applySolveEvent` previously computed
|
||||
`solvedByMe: card.solvedByMe || isMine`. This was wrong because if
|
||||
`card.solvedByMe` was `true` from a stale cache and `isMine` was `false`
|
||||
(a third party solved), it would leave the flag `true`. It is now
|
||||
`solvedByMe: isMine ? true : card.solvedByMe`, so a non-mine event can
|
||||
never promote `false` → `true`. `applySubmitResponse` no longer ORs
|
||||
either — it takes `response.challenge.solvedByMe` directly, since the
|
||||
server is authoritative.
|
||||
|
||||
## `ChallengesPage` mount
|
||||
|
||||
`ChallengesPage.ngOnInit()` now calls
|
||||
`this.store.setMyUserId(user?.id ?? null)` (passing `null` explicitly
|
||||
when there is no user, instead of the previous `if (user?.id)` guard).
|
||||
This ensures the store gets the call even on the unauthenticated edge
|
||||
case so the cache-reset decision runs once.
|
||||
|
||||
## `HomeComponent` session-boundary reset
|
||||
|
||||
`HomeComponent` injects `ChallengesStore` and calls
|
||||
`challengesStore.reset()` in two places:
|
||||
|
||||
| Trigger | Method |
|
||||
|---|---|
|
||||
| User clicked Logout in the menu | `onLogout()` (after `await this.auth.logout()`, before `navigateByUrl('/login')`) |
|
||||
| Peer-tab broadcast or SSE `401`/`403` | `handleSessionInvalidated()` (alongside `userStore.reset()` and the modal/menu close) |
|
||||
|
||||
The `ChallengesStore` is reset **before** navigation so the SPA never
|
||||
renders `/challenges` with a stale board.
|
||||
|
||||
## Test coverage
|
||||
|
||||
| File | What it pins down |
|
||||
|---|---|
|
||||
| `tests/frontend/challenges.store.reset.spec.ts` | New: `reset()` clears board / detail / myUserId / SSE; `setMyUserId` flushes when switching; **regression for orphan board** (cached board with no recorded user id is cleared when PlayerB's id is set); third-party solve does not flip `solvedByMe`; `applySubmitResponse` takes the server value verbatim. |
|
||||
| `tests/frontend/challenges.store.spec.ts` | Updated: the "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()`, so they exercise the realistic page-mount sequence. |
|
||||
|
||||
# See also
|
||||
|
||||
- [Challenges Board](/guides/challenges-board.md)
|
||||
- [Challenges Endpoints](/api/challenges.md)
|
||||
- [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [Key Files Index](/architecture/key-files.md)
|
||||
@@ -30,9 +30,11 @@ This change adds a same-browser **cross-tab session-invalidation channel**:
|
||||
`sessionStorage` state **without** rebroadcasting, then notifies
|
||||
subscribers.
|
||||
4. `HomeComponent` subscribes to the new peer-invalidation event, resets
|
||||
`UserStore`, closes any open change-password / user-menu overlays, and
|
||||
navigates to `/login`. A re-entrancy flag prevents duplicate navigations
|
||||
when the originating tab already navigated.
|
||||
both `UserStore` and `ChallengesStore` (so the per-user board cache is
|
||||
never leaked to the next signed-in user), closes any open
|
||||
change-password / user-menu overlays, and navigates to `/login`. A
|
||||
re-entrancy flag prevents duplicate navigations when the originating
|
||||
tab already navigated.
|
||||
5. `AuthenticatedEventSourceService` was extended to dispatch a new
|
||||
`'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic
|
||||
network failures still dispatch `'error'` and do **not** invalidate.
|
||||
@@ -182,8 +184,10 @@ unhandled by the store; the SPA does not navigate on transport failure.
|
||||
* `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from
|
||||
`onPeerInvalidation`.
|
||||
* `handleSessionInvalidated(reason)`:
|
||||
* Calls `userStore.reset()` and closes `changePasswordOpen` /
|
||||
`userMenuOpen` modal signals.
|
||||
* Calls `userStore.reset()` and `challengesStore.reset()` and closes
|
||||
`changePasswordOpen` / `userMenuOpen` modal signals. The challenges
|
||||
reset guarantees the next signed-in user does not briefly see the
|
||||
previous user's `solvedByMe` flags on `/challenges`.
|
||||
* Guards re-entry with `navigatingToLogin` and short-circuits when the
|
||||
router is already on `/login`.
|
||||
* Otherwise awaits `router.navigateByUrl('/login')`.
|
||||
@@ -199,6 +203,8 @@ unhandled by the store; the SPA does not navigate on transport failure.
|
||||
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||
- [Session Restoration on Reload](/guides/session-restoration.md)
|
||||
- [Challenges Board](/guides/challenges-board.md)
|
||||
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
- [Auth Endpoints](/api/auth.md)
|
||||
|
||||
+5
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
||||
controls.
|
||||
|
||||
The docs below are organized by purpose so agents can pull just the slice
|
||||
they need. Last regenerated 2026-07-23T03:09:36Z.
|
||||
they need. Last regenerated 2026-07-23T03:45:00Z.
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -90,6 +90,10 @@ they need. Last regenerated 2026-07-23T03:09:36Z.
|
||||
* [Challenges Board](/guides/challenges-board.md) - How a signed-in
|
||||
player navigates `/challenges`, opens the modal, submits a flag, and
|
||||
watches live solve updates.
|
||||
* [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) -
|
||||
How `ChallengesStore`, `HomeComponent`, and the challenges page
|
||||
cooperate so PlayerA's `solvedByMe` flags never leak into PlayerB's
|
||||
view across logout/login or peer-tab invalidation.
|
||||
* [Change Password](/guides/change-password.md) - How a signed-in user
|
||||
changes their own password, including every error branch and the
|
||||
refresh-token revocation behavior.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
|
||||
@@ -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<void> {
|
||||
this.userStore.reset();
|
||||
this.challengesStore.reset();
|
||||
this.changePasswordOpen.set(false);
|
||||
this.userMenuOpen.set(false);
|
||||
if (this.navigatingToLogin) return;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user