102 lines
12 KiB
Markdown
102 lines
12 KiB
Markdown
---
|
||
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-<abbrev>`, `challenge-card-<uuid>`, `points-<uuid>`, `challenge-modal-<uuid>`, `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.
|