feat: Challenges Page and Challenge Solve Modal 1.00
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
# Implementation Plan: REST Event-State Snapshot, SSE De-dup, Solve Publish-Once, REST Error Notification
|
||||
|
||||
## 0. Goal
|
||||
|
||||
Four small refinements called out by the reviewer:
|
||||
|
||||
1. Add a typed REST endpoint that returns the authoritative `EventStatePayload` (a one-shot snapshot) and call it from `challenges.page.ts` before/alongside `wireSse` so the page has state synchronously even if the SSE stream hasn't delivered its first frame yet.
|
||||
2. In `challenges.store.ts`, stop double-dispatching SSE frames: register only the named `'solve'` listener (plus a one-time `'message'` listener for backward compat with `EventStatusStore` which still uses `'message'`). Move the `applyStatusPayload` call out of the store and back into the page's status wiring, since the status SSE is the responsibility of `EventStatusStore`, not the challenges store.
|
||||
3. In `backend/src/modules/challenges/challenges.service.ts`, remove the two `publishSolveEvent(...)` calls that fire on `already_solved` (existing-row branch and unique-constraint recovery branch). Only the fresh-insert branch (currently line 414) should publish — duplicate broadcasts are noise since the row is unchanged.
|
||||
4. Add a small functional REST error interceptor (`HttpInterceptorFn`) that pipes 4xx/5xx responses into a `NotificationService.error(...)` call so the modal/grid can surface failure state without bespoke try/catch everywhere. Register it in `frontend/src/main.ts`.
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Backend controller surface for events**: `backend/src/modules/challenges/events.controller.ts` exposes `GET /api/v1/events` (SSE) and `backend/src/modules/system/system.controller.ts` exposes `GET /api/v1/events/status` (legacy SSE, plural-segmented) and a public `GET /api/v1/event/status` snapshot. There is no plain JSON REST snapshot for the event window under `/api/v1/challenges`. The new endpoint will live next to the challenges controller (`ChallengesController`).
|
||||
- **DTO**: `EventStatusService.getState()` returns `EventStatePayload` which matches the snake_case status shape (`serverNowUtc`, `eventStartUtc`, `eventEndUtc`, `secondsToStart`, `secondsToEnd`). We reuse it.
|
||||
- **Frontend SSE wiring today**: `ChallengesStore.wireSse` registers the same handler for `'message'`, `'solve'`, `'status'`. Because the backend SSE is well-formed (`event: solve` / `event: status`), the dispatcher in `AuthenticatedEventSourceService.parseAndDispatch` fires both `'message'` AND the named event — so the store's handler runs twice per frame. Fix: the challenges store only needs `'solve'` (to update cards + notify modal listeners); the `'status'` frame is consumed by `EventStatusStore` via its existing `'message'` listener (we'll keep that).
|
||||
- **`EventStatusStore` SSE wiring**: it still uses `addEventListener('message', ...)` and ignores named events. That's correct for backward compat — it doesn't double-process frames.
|
||||
- **REST error notification**: the app currently has no toast/notification service. We'll add a minimal `NotificationService` with an `error(message)` method that logs to console in non-browser contexts and a small DOM-mountable component is out of scope; the interceptor calls it and any future toast UI can subscribe to its signal.
|
||||
- **Interceptors run in registration order on the request and reverse order on the response** (`withInterceptors([a, b])` → request runs a→b, response runs b→a). For a pure error inspector that wants to act on every response regardless of what auth/csrf did, we register it AFTER `authInterceptor` so it sees the final response on the way out.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
### Backend — To Modify
|
||||
- `backend/src/modules/challenges/challenges.controller.ts` — add `@Get('status')` returning `EventStatePayload`.
|
||||
- `backend/src/modules/challenges/challenges.service.ts` — remove two `publishSolveEvent(...)` calls in the existing-row branch and the constraint-recovery branch; keep the one in the fresh-insert branch.
|
||||
|
||||
### Frontend — To Create
|
||||
- `frontend/src/app/core/services/notification.service.ts` — minimal signal-based notification store with `error(message)`, `info(message)`, and a `messages` signal.
|
||||
- `frontend/src/app/core/interceptors/error-notification.interceptor.ts` — `HttpInterceptorFn` that maps non-2xx responses to `NotificationService.error(...)`.
|
||||
|
||||
### Frontend — To Modify
|
||||
- `frontend/src/app/features/challenges/challenges.service.ts` — add `getEventState()` returning `Observable<EventStatePayload>`.
|
||||
- `frontend/src/app/features/challenges/challenges.store.ts` — expose `applyStatus(payload)` (already exists), change `wireSse` to register ONLY `'solve'` (drop `'message'` and `'status'` listeners in this store). Move the local handler logic so it doesn't process status frames.
|
||||
- `frontend/src/app/features/challenges/challenges.page.ts` — call `store.getEventState()` once at boot (before `wireSse`) and pass the result to `eventStatus.applyServerStatus(payload)`; update `wireSse` listener if needed (no change required for status, since `EventStatusStore` handles it).
|
||||
- `frontend/src/main.ts` — register `errorNotificationInterceptor` AFTER `authInterceptor`.
|
||||
|
||||
### Tests — To Modify
|
||||
- `tests/backend/challenges-submit-flag.spec.ts` — change the SSE-solve-payload test to subscribe once after a fresh insert; assert that two consecutive correct submissions yield ONE `emitScoreboard` call, not two.
|
||||
- `tests/frontend/event-status.store.spec.ts` — add a small assertion that `currentServerTimeUtc` returns a valid ISO string and that `reloadAtCountdownZero` fires exactly once.
|
||||
- New test file `tests/frontend/notification-interceptor.spec.ts` — assert the interceptor calls `NotificationService.error(...)` on a 500 response and does NOT call it on a 2xx response.
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### Backend
|
||||
|
||||
1. **`challenges.controller.ts`** — add (after the existing `board`/`detail`/`submit` handlers):
|
||||
```ts
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Authenticated event-window snapshot (REST JSON)' })
|
||||
async eventState(): Promise<EventStatePayload> {
|
||||
return this.statusSvc.getState();
|
||||
}
|
||||
```
|
||||
Inject `EventStatusService` into the controller (add to constructor). The existing `getState()` already returns `EventStatePayload`. This endpoint sits at `/api/v1/challenges/status`. We avoid `events/status` to not collide with the existing legacy SSE route.
|
||||
|
||||
2. **`challenges.service.ts`** — remove the `publishSolveEvent(...)` call at line 294 (existing-row branch) and the one at line 368 (unique-constraint recovery branch). Keep only the line 414 call (fresh-insert path). The `publishSolveEvent` helper itself stays (it's still called from the fresh-insert path).
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **`notification.service.ts`** (new):
|
||||
```ts
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
export interface Notification { id: number; kind: 'error' | 'info'; message: string; ts: number; }
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationService {
|
||||
private next = 1;
|
||||
readonly messages = signal<Notification[]>([]);
|
||||
error(message: string): void {
|
||||
this.push('error', message);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[notification]', message);
|
||||
}
|
||||
info(message: string): void {
|
||||
this.push('info', message);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[notification]', message);
|
||||
}
|
||||
dismiss(id: number): void {
|
||||
this.messages.update((cur) => cur.filter((m) => m.id !== id));
|
||||
}
|
||||
private push(kind: Notification['kind'], message: string): void {
|
||||
const n: Notification = { id: this.next++, kind, message, ts: Date.now() };
|
||||
this.messages.update((cur) => [...cur, n]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **`error-notification.interceptor.ts`** (new):
|
||||
```ts
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
function friendlyMessage(status: number, body: any): string {
|
||||
const code = body?.code as string | undefined;
|
||||
const msg = (body?.message as string | undefined) ?? '';
|
||||
if (code === 'EVENT_NOT_RUNNING') return 'Submissions are only accepted while the event is running.';
|
||||
if (code === 'FLAG_INCORRECT') return 'Incorrect flag.';
|
||||
if (status === 0) return 'Network error. Please try again.';
|
||||
if (status === 401 || status === 403) return 'Your session is no longer valid.';
|
||||
if (status >= 500) return 'Server error. Please try again later.';
|
||||
return msg || `Request failed (${status}).`;
|
||||
}
|
||||
export const errorNotificationInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const notify = inject(NotificationService);
|
||||
return next(req).pipe(
|
||||
catchError((err) => {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
notify.error(friendlyMessage(err.status, err.error));
|
||||
} else {
|
||||
notify.error('Unexpected error.');
|
||||
}
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
3. **`challenges.service.ts`** — add:
|
||||
```ts
|
||||
getEventState(): Observable<EventStatePayload> {
|
||||
return this.http
|
||||
.get<EventStatePayload>('/api/v1/challenges/status', { withCredentials: true })
|
||||
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||
}
|
||||
```
|
||||
Add `EventStatePayload` to the import from `./challenges.pure`.
|
||||
|
||||
4. **`challenges.store.ts`** — update `wireSse` to register only `'solve'`:
|
||||
```ts
|
||||
const handler = (ev: MessageEvent | Event) => this.handleSseFrame(ev);
|
||||
src.addEventListener('solve', handler);
|
||||
```
|
||||
Drop the `addEventListener('message', ...)` and `addEventListener('status', ...)` calls inside `wireSse`. `handleSseFrame` already short-circuits non-`'solve'` events by virtue of the `me.type === 'solve'` check at the top, so no further change needed inside that method.
|
||||
|
||||
5. **`challenges.page.ts`** — at `ngOnInit`, before wiring the SSE:
|
||||
```ts
|
||||
// REST snapshot first so the page has synchronous state.
|
||||
this.store.getEventState().subscribe({
|
||||
next: (payload) => this.eventStatus.applyServerStatus(payload),
|
||||
error: () => undefined, // SSE will deliver the next frame; don't notify.
|
||||
});
|
||||
```
|
||||
No structural change to `wireSse` — the challenges store now only listens for `'solve'`; `EventStatusStore` continues to listen for `'message'` on the same source (which the transport dispatches in addition to `'solve'`).
|
||||
|
||||
6. **`main.ts`** — register the error interceptor last so it sees the final response:
|
||||
```ts
|
||||
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor, errorNotificationInterceptor])),
|
||||
```
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- **Backend** (`tests/backend/challenges-submit-flag.spec.ts`): change the SSE-payload test to subscribe ONCE on `hub.scoreboard$()` and assert that two consecutive correct submissions (with an artificial constraint-recovery path forced via the existing concurrency test) emit only ONE payload. Add a new test that asserts `GET /api/v1/challenges/status` returns the running state with `server_now_utc` + `event_start_utc` + `event_end_utc` + `seconds_to_start` + `seconds_to_end` for an authenticated user.
|
||||
- **Frontend** (`tests/frontend/event-status.store.spec.ts`): add `applyServerStatus` once, advance the tick, assert `currentServerTimeUtc()` is a valid ISO string and that `reloadAtCountdownZero` fires exactly once (not twice on consecutive ticks where `secondsToStart` is still 0).
|
||||
- **Frontend** (`tests/frontend/notification-interceptor.spec.ts`, new): create an `HttpClient` via `provideHttpClient(withInterceptors([errorNotificationInterceptor]))` and a fake backend that returns 200 for one URL and 500 for another; assert `NotificationService.messages()` only contains the 500 message.
|
||||
- All existing 504 tests must continue to pass.
|
||||
|
||||
## 5. Notes / Trade-offs
|
||||
|
||||
- We use a plain REST snapshot instead of "best-authenticated equivalent" of the public `/api/v1/event/status` because the public endpoint is unauthenticated and returns a slightly different shape (legacy `status` field, no `state` enum, only `countdownMs`). The new authenticated snapshot uses the canonical `EventStatePayload` shape and is reused by both the page bootstrap and (later) any admin tooling.
|
||||
- The error interceptor only fires for HTTP errors thrown by Angular's `HttpClient`. The `ChallengesApiService` wraps these in its own `toEnvelope()` and re-throws, so the interceptor sees `HttpErrorResponse` and the service's catchError re-emits the envelope. We do not double-notify.
|
||||
- Removing the two `publishSolveEvent` calls is safe because the fresh-insert path (line 414) is the only one that mutates state — the existing-row and constraint-recovery branches do not insert a new row, so emitting a "new solve" SSE frame for them is misleading to other connected clients (who would re-render an unchanged row).
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,156 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import {
|
||||
parseSolveError,
|
||||
messageForSolveError,
|
||||
formatDdHhMm,
|
||||
mergeSolveEventIntoSolvers,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
|
||||
describe('ChallengeModal — flag submission branches', () => {
|
||||
describe('parseSolveError + messageForSolveError', () => {
|
||||
it('maps FLAG_INCORRECT body to "Incorrect flag. Try again."', () => {
|
||||
const code = parseSolveError(400, { code: 'FLAG_INCORRECT' });
|
||||
expect(code).toBe('incorrect');
|
||||
expect(messageForSolveError(code)).toBe('Incorrect flag. Try again.');
|
||||
});
|
||||
|
||||
it('maps EVENT_NOT_RUNNING body to the not-running message', () => {
|
||||
const code = parseSolveError(409, { code: 'EVENT_NOT_RUNNING' });
|
||||
expect(code).toBe('not_running');
|
||||
expect(messageForSolveError(code)).toBe(
|
||||
'Submissions are only accepted while the event is running.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status 401 to unauthorized message', () => {
|
||||
expect(messageForSolveError(parseSolveError(401, {}))).toBe(
|
||||
'Your session has expired. Please sign in again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status 403 to forbidden message', () => {
|
||||
expect(messageForSolveError(parseSolveError(403, {}))).toBe(
|
||||
'You are not allowed to perform this action.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status 404 to not_found message', () => {
|
||||
expect(messageForSolveError(parseSolveError(404, {}))).toBe('Challenge not found.');
|
||||
});
|
||||
|
||||
it('maps status 0 to network error', () => {
|
||||
expect(messageForSolveError(parseSolveError(0, {}))).toBe('Network error. Please try again.');
|
||||
});
|
||||
|
||||
it('maps VALIDATION_FAILED body to validation message', () => {
|
||||
const code = parseSolveError(400, { code: 'VALIDATION_FAILED' });
|
||||
expect(code).toBe('validation');
|
||||
expect(messageForSolveError(code)).toBe('Invalid submission.');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" for unrecognized errors', () => {
|
||||
expect(messageForSolveError(parseSolveError(500, {}))).toBe(
|
||||
'Unexpected error. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the already_solved message for idempotent re-submit', () => {
|
||||
expect(messageForSolveError('already_solved')).toBe('You already solved this challenge.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejected-then-accepted flow sequencing', () => {
|
||||
it('orders messages so a wrong flag surfaces "Incorrect" and a successful submit surfaces "Awarded"', () => {
|
||||
const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' }));
|
||||
expect(wrong).toBe('Incorrect flag. Try again.');
|
||||
const awarded = formatDdHhMm(3600);
|
||||
expect(awarded).toBe('00:01:00');
|
||||
});
|
||||
|
||||
it('second submit yields already_solved without losing the awarded banner', () => {
|
||||
const envelope = { status: 200, body: { status: 'already_solved' } };
|
||||
const inline = messageForSolveError('already_solved');
|
||||
expect(inline).toBe('You already solved this challenge.');
|
||||
expect(envelope.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live solve merge on the modal solvers list', () => {
|
||||
it('keeps existing solvers sorted by solvedAtUtc and assigns positions in arrival order', () => {
|
||||
const merged = mergeSolveEventIntoSolvers(
|
||||
[
|
||||
{
|
||||
position: 1,
|
||||
playerId: 'alice',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
challengeId: 'c1',
|
||||
playerId: 'bob',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
},
|
||||
);
|
||||
expect(merged.map((r) => r.playerName)).toEqual(['alice', 'bob']);
|
||||
expect(merged.map((r) => r.position)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('does not double-insert when the same (playerId, solvedAtUtc) is replayed', () => {
|
||||
const seed = [
|
||||
{
|
||||
position: 1,
|
||||
playerId: 'alice',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
},
|
||||
];
|
||||
const dup = mergeSolveEventIntoSolvers(seed, {
|
||||
challengeId: 'c1',
|
||||
playerId: 'alice',
|
||||
playerName: 'alice',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
});
|
||||
expect(dup).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks gold/silver/bronze on the merged row based on the SSE position', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'gold',
|
||||
playerName: 'gold',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 50,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
});
|
||||
expect(merged[0].isFirst).toBe(true);
|
||||
expect(merged[0].isSecond).toBe(false);
|
||||
expect(merged[0].isThird).toBe(false);
|
||||
expect(merged[0].rankBonus).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
|
||||
import { EventStatusStore } from '../../frontend/src/app/core/services/event-status.store';
|
||||
import { formatDdHhMm } from '../../frontend/src/app/core/services/event-status.pure';
|
||||
import { BoardResponse, ChallengeDetail, SolveResponse } from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
function makeBoard(): BoardResponse {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
id: 'cat1',
|
||||
abbreviation: 'CRY',
|
||||
name: 'Cryptography',
|
||||
iconPath: '',
|
||||
cards: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'alpha',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 100,
|
||||
solveCount: 0,
|
||||
solvedByMe: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solvedChallengeIds: [],
|
||||
perChallengeLivePoints: { c1: 100 },
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(id = 'c1'): ChallengeDetail {
|
||||
return {
|
||||
challenge: {
|
||||
id,
|
||||
name: 'alpha',
|
||||
descriptionMd: '# Alpha',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 90,
|
||||
solveCount: 1,
|
||||
solvedByMe: false,
|
||||
},
|
||||
solvers: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
|
||||
let eventStore: EventStatusStore;
|
||||
let challenges: ChallengesStore;
|
||||
let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock; getEventState: jest.Mock };
|
||||
|
||||
let injector: Injector;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = Injector.create({ providers: [] });
|
||||
eventStore = runInInjectionContext(injector, () => new EventStatusStore());
|
||||
apiSpy = {
|
||||
getBoard: jest.fn(),
|
||||
submit: jest.fn(),
|
||||
getDetail: jest.fn(),
|
||||
getEventState: jest.fn(),
|
||||
};
|
||||
challenges = new ChallengesStore(apiSpy as any, { onDestroy: () => undefined } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (challenges) challenges.stop();
|
||||
if (eventStore) eventStore.stop();
|
||||
});
|
||||
|
||||
it('starts in unconfigured state so the gate panel would render', () => {
|
||||
expect(eventStore.state()).toBe('unconfigured');
|
||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
it('shows live countdown in DD:HH:mm when the admin sets a countdown window', () => {
|
||||
const future = new Date(Date.now() + 90_061_000).toISOString();
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: future,
|
||||
eventEndUtc: past,
|
||||
secondsToStart: 90_061,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
|
||||
expect(eventStore.state()).toBe('countdown');
|
||||
expect(eventStore.countdownText()).toBe('01:01:01');
|
||||
});
|
||||
|
||||
it('single auto-reload fires exactly once when secondsToStart hits zero', () => {
|
||||
const handler = jest.fn();
|
||||
eventStore.reloadAtCountdownZero(handler);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() + 5000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 5,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 0,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('single auto-reload also fires once when secondsToEnd hits zero during running', () => {
|
||||
const handler = jest.fn();
|
||||
eventStore.reloadAtCountdownZero(handler);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 0,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reload latch is re-armed after a fresh applyServerStatus (countdown -> running)', () => {
|
||||
const handler = jest.fn();
|
||||
eventStore.reloadAtCountdownZero(handler);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 0,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 3600_000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 0,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('isRunning flips true only when state is running', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await challenges.load();
|
||||
|
||||
challenges.applyStatusPayload({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() + 1000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 60,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
expect(challenges.isRunning()).toBe(false);
|
||||
|
||||
challenges.applyStatusPayload({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
expect(challenges.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('stale-modal guard: loadDetail for a different id does not overwrite the current selected detail', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await challenges.load();
|
||||
|
||||
apiSpy.getDetail.mockImplementation((id: string) => of(makeDetail(id)));
|
||||
challenges.clearSelectedDetail();
|
||||
|
||||
const slow = challenges.loadDetail('c1');
|
||||
const fast = challenges.loadDetail('c2');
|
||||
await Promise.all([slow, fast]);
|
||||
|
||||
expect(challenges.selectedChallengeDetail()?.challenge.id).toBe('c2');
|
||||
});
|
||||
|
||||
it('clean teardown: stop() closes the SSE source and clears the interval', () => {
|
||||
const sseClose = jest.fn();
|
||||
let registeredHandler: ((ev: MessageEvent) => void) | null = null;
|
||||
const fakeSource = {
|
||||
addEventListener: (name: string, fn: any) => {
|
||||
if (name === 'solve') registeredHandler = fn;
|
||||
},
|
||||
close: sseClose,
|
||||
};
|
||||
challenges.wireSse(() => fakeSource as any);
|
||||
expect(registeredHandler).not.toBeNull();
|
||||
expect((challenges as any).sse).toBe(fakeSource);
|
||||
expect((challenges as any).intervalId).not.toBeNull();
|
||||
|
||||
challenges.stop();
|
||||
expect(sseClose).toHaveBeenCalledTimes(1);
|
||||
expect((challenges as any).sse).toBeNull();
|
||||
expect((challenges as any).intervalId).toBeNull();
|
||||
});
|
||||
|
||||
it('submit propagates the SolveResponse and the board reflects the new livePoints + solvedByMe', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await challenges.load();
|
||||
const resp: SolveResponse = {
|
||||
status: 'solved',
|
||||
challenge: {
|
||||
id: 'c1',
|
||||
name: 'alpha',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 80,
|
||||
solveCount: 3,
|
||||
solvedByMe: true,
|
||||
},
|
||||
awarded: { basePoints: 80, rankBonus: 0, awardedPoints: 80, awardedAtUtc: '', position: 4 },
|
||||
solvers: [],
|
||||
};
|
||||
apiSpy.submit.mockReturnValueOnce(of(resp));
|
||||
await challenges.submit('c1', 'flag{X}');
|
||||
const card = challenges.findCard('c1')!;
|
||||
expect(card.solvedByMe).toBe(true);
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(card.solveCount).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user