--- job: 904 title: Challenges Page and Challenge Solve Modal Automation Readiness status: investigation-complete --- # Implementation Plan: Challenges Page and Challenge Solve Modal — Job 904 ## 0. Pre-Implementation Status: **[ALREADY_IMPLEMENTED]** After tracing the entire `/challenges` flow end-to-end against the docs (`docs/guides/challenges-board.md`, `docs/guides/event-window.md`) and the source tree, every behavior the Job asks an admin/tester to automate is **already present and stable**. The only defect reported in the Job — *“Playwright tab, snapshot, and close operations timed out after 30000ms”* — is an external automation-harness symptom (an idle session after a logout), not a missing or broken feature in the SPA. **No source code or test edits are required to satisfy this Job.** Only the missing fast-running automation-harness guard tests listed in Section 4 are added, so that the SPA’s behaviour can be re-verified headlessly with `npm test` in the future without needing a browser. ### What already works (file references) | Behavior required by Job | Implemented in | |---------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Admin sets Stopped / Countdown / Running windows | `admin-general-event-window.spec.ts` + `AdminGeneralService` + `SettingsService` (settings table rows `eventStartUtc` / `eventEndUtc`). | | Page renders a live `DD:HH:mm` countdown | `ChallengesPage.countdownText` computed (`challenges.page.ts:110-120`) using `formatDdHhMm` from `challenges.pure.ts`, anchored on `EventStatusStore.currentServerTimeUtc` (`event-status.store.ts:37-44`) driven by the 1-second local tick. | | Single `window.location.reload()` at countdown zero | `ChallengesPage.ngOnInit` → `eventStatus.reloadAtCountdownZero(...)` (challenges.page.ts:150-152); the store fires the handler exactly once via `reloadOnZeroFired` latching (`event-status.store.ts:127-148`). | | Stale-modal resume flow (modal stays mounted across data refresh) | Modal is selected via `selectedCard = signal(findCard(id))` (challenges.page.ts:122,162-175). `loadDetail` updates `selectedSolvers` only if `selectedCard()?.id === id` (challenges.page.ts:170-174) so a slow stale fetch can’t overwrite a newer modal. `clearSelectedDetail()` is invoked on close. | | Flag submission rejected-then-accepted UX | `ChallengeModalComponent.onSubmit` (`challenge-modal.component.ts:179-201`): distinguishes `solved`, `already_solved`, `FLAG_INCORRECT`, and `EVENT_NOT_RUNNING` via `parseSolveError` / `messageForSolveError` in `challenges.pure.ts:150,206`; awards banner (`awarded` signal) + solvers list refresh + board card `solvedByMe` flip on success; second submit returns `already_solved` and shows the banner **plus** the inline error. | | Stable automation hooks (data-testids) | `[data-testid="challenges-page"]`, `challenges-grid`, `challenges-gate`, `challenges-countdown`, `category-column-`, `challenge-card-`, `points-`, `challenge-modal-`, `modal-solved-banner`, `modal-awarded-banner`, `modal-error`, `flag-input`, `solve-button`, `modal-solvers`. | | Clean teardown so logout does not leave a hung SSE source | `ChallengesStore.stop()` cancels the SSE source + interval (`challenges.store.ts:295-308`); `EventStatusStore.stop()` does the same on logout (`event-status.store.ts:150-167`); Home calls `clearAndBroadcast()` which closes transports and resets state. | Existing test coverage that proves the above at the harness level: - `tests/backend/challenges-board.spec.ts`, `challenges-status-rest.spec.ts`, `challenges-events-sse.spec.ts`, `challenges-submit-flag.spec.ts` - `tests/frontend/challenges.pure.spec.ts`, `challenges.store.spec.ts`, `event-status.store.spec.ts`, `authenticated-event-source.spec.ts`, `notification-interceptor.spec.ts` ## 1. Architectural Reconnaissance - **Codebase style & conventions:** TypeScript end-to-end. NestJS backend (modules / controllers / services + DTO + class-validator). Angular 17 standalone SPA with `signal`/`computed` stores, `ChangeDetectionStrategy.OnPush`, `inject()`-based DI, `data-testid` hooks for the test harness. RxJS only for HTTP; SSE uses a custom `AuthenticatedEventSourceService` over `fetch` because the browser `EventSource` API cannot send the JWT header. - **Data Layer:** SQLite (`better-sqlite3`) via a thin repository module. Time-related logic is pure (`EventStatusService.getState(now)`); the two `setting` rows (`eventStartUtc`, `eventEndUtc`) seeded by `SeedSystemData1700000000100`. No migrations needed for this Job. - **Test Framework & Structure:** Jest with `ts-jest`, two projects (`backend` → node, `frontend` → jsdom). Root command: `npm test` (from `/repo`). Tests live exclusively under `/repo/tests/{backend,frontend}/*.spec.ts`. Frontend tests are logic-focused — no visual / browser assertions. `npm run dev` starts both processes; `/data` is the persistent named volume already mounted for uploads + sqlite journal files. No new persistent files are needed for these tests. - **Required Tools & Dependencies:** None new. All required packages (`@angular/*`, `rxjs`, `jest`, `ts-jest`, `jest-environment-jsdom`, `@types/jest`, `@types/jsdom`, `jsdom`) are already in `package.json`. `setup.sh` already installs them and rebuilds `better-sqlite3`. Playwright is intentionally **not** added (matches the platform's “no UI test harness” rule and matches the failure mode described in the Job). ## 2. Impacted Files - **To Modify:** None. The Behavior is already implemented. - **To Create:** (small automation-readiness guard tests only — optional addition described in Section 4) - `tests/frontend/challenges-page.spec.ts` — pure-logic regression for the gate/countdown/auto-reload wiring. - `tests/frontend/challenge-modal-submit.spec.ts` — pure-logic regression for the rejected-then-accepted flag submit UX. These complement, not duplicate, the existing `challenges.store.spec.ts` and `challenges.pure.spec.ts`, by covering the **page** component (gate panel + auto-reload trigger) and the **modal** component (`onSubmit` branches + live-solve merge) which are currently only exercised indirectly. They use Angular `TestBed` against the same signal stores (mocking `ChallengesApiService` only), so they run in jsdom with no SSE server. ## 3. Proposed Changes 1. **Database / Schema Migration:** None required. 2. **Backend Logic & APIs:** None required. The four endpoints the Job’s tester needs (`GET /api/v1/challenges/board`, `GET /api/v1/challenges/:id`, `POST /api/v1/challenges/:id/solves`, `GET /api/v1/events`, `GET /api/v1/events/status`, plus `/api/v1/settings/event` for admin) are all in place and covered by `tests/backend/*`. 3. **Frontend UI Integration:** None required. The Challenges page is wired as documented: - Gate vs grid is selected by `isRunning()` computed from `EventStatusStore.state` (`challenges.page.ts:50-72`). - Auto-reload handler is registered in `ngOnInit` (idempotent) and re-armed on each `applyServerStatus` frame (`event-status.store.ts:81-91`). - Challenge modal listens to SSE `solve` events via `registerSolveListener(id, ...)` and is torn down cleanly on close / destroy. - Submit flow `solved` → `already_solved` is exercised by sending the same flag twice (the second call returns the `already_solved` envelope from the backend, which the modal maps to the inline error). ### Concrete changes for the optional guard tests - `tests/frontend/chenges-page.spec.ts` - Bootstrap `TestBed` with `provideHttpClientTesting()`, `provideRouter([])`, real `EventStatusStore`, fake `AuthenticatedEventSourceService` returning a stub `EventSourceLike`. - Assert that when `EventStatusStore.state === 'unconfigured'` the page template renders `[data-testid="challenges-gate"]` and **not** `[data-testid="challenges-grid"]`. - Drive `EventStatusStore.applyServerStatus({ state: 'running', … })` and assert the gate is replaced by the grid host div. - Assert `EventStatusStore.reloadAtCountdownZero` was called exactly once during `ngOnInit` by passing a spy handler. - Assert that `ChallengesStore.stop()` is invoked from the `DestroyRef` hook on `ngOnDestroy` (proves logout cleanly tears down the SSE source — directly addresses the Playwright hang reported in the Job). - `tests/frontend/challenge-modal-submit.spec.ts` - Mount `ChallengeModalComponent` via `TestBed.createComponent`, inject a stub `ChallengesStore` whose `submit` resolves to a `SolveResponse` with `status: 'solved'`, then to `status: 'already_solved'`. - Assert `errorMessage` is `null` after the first submit and `messageForSolveError('already_solved')` after the second; the `awarded` signal is set on both; the `submitted` event fires with the trimmed flag value; the flag input is cleared on success. - Assert the `FLAG_INCORRECT` branch by rejecting with `{ status: 400, body: { code: 'FLAG_INCORRECT' } }` and verifying `errorMessage === 'Incorrect flag. Try again.'` and `submitting() === false` afterwards. Both files follow the existing patterns in `tests/frontend/admin-challenges-form.spec.ts` and `tests/frontend/challenges.store.spec.ts` (signal stubs, no real HTTP, no `BrowserAnimationsModule`). ## 4. Test Strategy - **Target Unit Test Files:** - `tests/frontend/challenges-page.spec.ts` - `tests/frontend/challenge-modal-submit.spec.ts` - **Mocking Strategy:** - `ChallengesApiService` is replaced by an in-test `ChallengesApiService`-shaped object with `getBoard`, `getDetail`, `getEventState`, and `submit` returning `of(...)`. This avoids any HTTP call and lets the test resolve immediately in jsdom. - `AuthenticatedEventSourceService` is replaced by a small class returning a minimal `EventSourceLike` stub (`addEventListener` / `close`). This is exactly what `tests/frontend/authenticated-event-source.spec.ts` already does for the SSE transport and avoids opening a real socket during teardown — which is what was hanging the previous Playwright session. - Timer-based behaviour (`reloadAtCountdownZero`, the 1-second tick that drives the countdown) is exercised by setting `state = 'countdown'` with `secondsToStart = 0` directly on the store, so no fake timers are required. - All tests are pure `TestBed`-based; no `BrowserAnimationsModule`, no `localStorage`/`BroadcastChannel` (the logout path is not the target of these tests — that is already covered by `tests/frontend/auth-session-events.spec.ts` and `tests/frontend/auth-restore.spec.ts`). - **Running:** `npm test -- --testPathPattern=frontend/(challenges-page|challenge-modal-submit)` from `/repo` runs these in <1 second. No new dependencies, no UI, no changes to `setup.sh`. ## 5. Acceptance Criteria (matches the Job) 1. The Angular `/challenges` page renders the documented `data-testid` hook set (asserted by the new spec). 2. The SPA exposes a deterministic countdown-to-reload transition when the admin changes the event window to `countdown` → `running` → `stopped` (asserted by the existing `event-status.store.spec.ts` and the new `challenges-page.spec.ts`). 3. The challenge solve modal distinguishes `solved`, `already_solved`, and `FLAG_INCORRECT` and renders the correct inline error / awarded banner pair (asserted by the new `challenge-modal-submit.spec.ts` and the existing `challenges.pure.spec.ts`). 4. After logout, no SSE connection is left dangling: `DestroyRef`-driven teardown of `ChallengesStore` and `EventStatusStore` is asserted in the new page spec (covers the Playwright hang root cause). 5. `npm test` continues to run all suites (backend + frontend) in a single command from `/repo` with no new tools, ports, or browsers.