AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
# 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).
|
||||
@@ -1,94 +0,0 @@
|
||||
# Implementation Plan: Job 903 — Admin Challenges Import must Replace, Modal must Auto-Close
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:**
|
||||
- Backend: NestJS (TypeScript, `strict`, class-based `Injectable` services, TypeORM with `better-sqlite3`, async/await, controllers delegating to services).
|
||||
- Frontend: Angular 20 standalone components with `ChangeDetectionStrategy.OnPush`, signal `input()` / `output()`, `@if`/`@for` control flow, RxJS only for the search debounce, smart/dumb split. Pure helpers live in `*.pure.ts` and are tested directly without a TestBed.
|
||||
- Tests: Jest + ts-jest configured in `tests/jest.config.js` as two projects (`backend`, `frontend`). Backend specs use `Test.createTestingModule` with in-memory SQLite and a unique `mkdtempSync` upload dir per file (no shared FS races). Frontend specs in `tests/frontend/` import pure helpers directly (no DOM) — the only TestBed specs are for components that must exercise `@Input` bindings.
|
||||
- `npm test` at the repo root runs **all** tests for **all** components via the Jest project selector.
|
||||
|
||||
- **Data Layer:**
|
||||
- SQLite via TypeORM; relevant tables `challenge`, `challenge_file`, `category`, `solve`.
|
||||
- File storage: `data/uploads/challenges/<uuid>` for committed files, `.staging/<uuid>` for in-flight uploads.
|
||||
- Repository injection via `@InjectRepository(Entity)`; transactions via `this.dataSource.transaction(async (manager) => { ... })`.
|
||||
|
||||
- **Test Framework & Structure:**
|
||||
- Backend tests in `/repo/tests/backend/*.spec.ts` (Jest, `testEnvironment: 'node'`).
|
||||
- Frontend tests in `/repo/tests/frontend/*.spec.ts` (Jest + `jsdom`); pure-helper specs do not need TestBed.
|
||||
- Single command: `npm test` (already wired in root `package.json`).
|
||||
- New specs to add: one backend spec for the replace-all-on-confirmed-import behavior, one frontend spec for the auto-close + list-refresh behavior of the import modal. Both must be runnable with `npm test`.
|
||||
|
||||
- **Required Tools & Dependencies:** No new tools or packages. Existing `better-sqlite3`, TypeORM, NestJS, Angular, and Jest setup is sufficient.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
- **To Modify:**
|
||||
- `backend/src/modules/admin/challenges.service.ts` — change `importConfirmed()` so it wipes every pre-existing `challenge` (and cascade-deletes their `challenge_file` + `solve` rows) before re-inserting from the document. Re-link the now-unreferenced disk files for unlink and unlink any orphaned file rows after the swap.
|
||||
- `frontend/src/app/features/admin/challenges/challenges.component.ts` — after a successful confirmed import (`onImportConfirm`), call `closeImport()` so the dialog disappears (it currently stays open showing the result message and a manual Close button).
|
||||
- `docs/guides/admin-challenges.md` — clarify that **Import with `?confirm=overwrite` replaces the entire challenge set** (not just upserts by name). Update the "Importing the same document twice is idempotent" line to state the full-replace semantics.
|
||||
- `docs/architecture/key-files.md` — update the one-line responsibility for `challenges.service.ts` to mention the replace-on-confirmed-import contract.
|
||||
|
||||
- **To Create:**
|
||||
- `tests/backend/admin-challenges-import-replace.spec.ts` — focused contract test asserting that an overwrite-confirmed import removes pre-existing challenges (including their files and solves) and leaves only the imported set; checks both DB and the on-disk `challenges/` directory.
|
||||
- `tests/frontend/admin-challenges-import-autoclose.spec.ts` — pure/smart test asserting that after a successful `onImportConfirm`, the import modal closes (i.e. `importOpen()` flips to `false`) and the result is still surfaced via the page-level status banner.
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### 3.1 Backend — `AdminChallengesService.importConfirmed` becomes a full replace
|
||||
|
||||
In `backend/src/modules/admin/challenges.service.ts`, refactor `importConfirmed` (currently lines 412–522) so the confirmed path:
|
||||
|
||||
1. **Stage every file up front** (unchanged logic, lines 421–446). Keep the early `rollbackStaged` on validation failure.
|
||||
2. **Inside one transaction** (unchanged transaction wrapper, line 450):
|
||||
a. Load every existing challenge row with `manager.getRepository(ChallengeEntity).find()` (no filter).
|
||||
b. Capture each existing challenge's `ChallengeFileEntity` rows (`find({ where: { challengeId: In(ids) } })`) so we know which `storedFilename`s are still referenced after the transaction.
|
||||
c. Delete every existing challenge row with a single `manager.delete(ChallengeEntity, {})` — TypeORM cascades to `challenge_file` and `solve` rows via the existing FK cascade. This is the **replace** semantics.
|
||||
d. Then iterate `doc.challenges` and (as today) skip entries whose category is unknown, otherwise create a new challenge row + new file rows pointing at the already-staged tokens. Result counts stay `{ imported, skipped, warnings }`.
|
||||
3. **After commit, clean up disk** — for the captured pre-existing stored filenames, call `fileService.removeFinal(storedFilename)` best-effort (same helper used by `remove()`). Newly-inserted challenges' files are then committed via the existing `commit(item.staged.token)` loop (lines 506–517).
|
||||
4. **Do not break the preview path.** `previewImport` keeps the existing conflict/unknown-category logic; the destructive replace only happens when `?confirm=overwrite` (controller path `admin-challenges.controller.ts:87-92`).
|
||||
5. **No changes to DTOs or to the export shape.** `exportAll()` already produces the versioned envelope that the new behavior requires for round-tripping.
|
||||
|
||||
**Edge cases to handle explicitly:**
|
||||
- Empty `doc.challenges` ⇒ result `{ imported: 0, skipped: [], warnings: [] }`; existing rows are wiped, files unlinked, no new rows created.
|
||||
- Unknown-category rows ⇒ counted in `skipped`; do **not** preserve them, do **not** abort the wipe. The wipe happens before the per-challenge inserts.
|
||||
- Disk-unlink failure for a removed file ⇒ swallow + log via `ChallengeFilesService.removeFinal` (matches existing delete behavior, never throws).
|
||||
|
||||
### 3.2 Frontend — `AdminChallengesComponent.onImportConfirm` closes the modal
|
||||
|
||||
In `frontend/src/app/features/admin/challenges/challenges.component.ts`:
|
||||
|
||||
- In `onImportConfirm()` (lines 455–470), after `await this.load()` succeeds and `importResult` is set, additionally call `closeImport()`. `closeImport()` already clears `importDoc`, `importPreview`, `importResult`, and `importError` (lines 447–453) and resets `importOpen` to `false`.
|
||||
- The page-level status banner is already populated by `this.statusMessage.set(summarizeImportResult(result))` immediately before `closeImport()`, so the user still sees the "Imported N challenges" message after the dialog closes.
|
||||
- The `<app-challenge-import-modal>` template already renders a "Close" button while `result()` is set; that branch becomes unreachable after the fix (the modal will close before the user can click it), so no template change is required. The result branch is kept as a defensive fallback for any future flow that intentionally keeps the modal open (e.g. partial-failure display).
|
||||
|
||||
### 3.3 Docs — clarify the replace semantics
|
||||
|
||||
- `docs/guides/admin-challenges.md` — under the "Import" section (line 103) replace the third bullet ("Click Overwrite & Import … the server upserts challenges inside one transaction by case-insensitive name") with: "Click **Overwrite & Import** → the client posts the same document with `?confirm=overwrite`. The server stages every file up front (rolling back any FS failure), then **wipes every existing challenge (cascade-deleting their `challenge_file` and `solve` rows and best-effort unlinking their disk files) and inserts the archive's challenges** in one transaction."
|
||||
- Update the last "Notes" line on idempotency (line 138) to: "Importing the same document twice is idempotent: every existing challenge (matching or not) is replaced by the archive contents."
|
||||
- `docs/architecture/key-files.md` — update the `challenges.service.ts` responsibility to "... CRUD, list aggregates, transactional deletion, export, full-replace import orchestration."
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
### 4.1 Backend contract test — `tests/backend/admin-challenges-import-replace.spec.ts`
|
||||
|
||||
- Mirror the wiring of `tests/backend/admin-challenges-import-export.spec.ts`: `:memory:` SQLite, unique `mkdtempSync(os.tmpdir() + '/hipctf-challenges-import-replace-…')` `UPLOAD_DIR`, all migrations including `UpgradeChallengeAdminSchema1700000000500`, TypeOrmModule.forFeature for `CategoryEntity`, `ChallengeEntity`, `ChallengeFileEntity`, `SolveEntity`.
|
||||
- AAA structure. Mocking strategy: no external boundaries — use real in-memory SQLite + real `ChallengeFilesService` writing to the temp dir. Isolation: per-file temp dir (no parallel FS races).
|
||||
- Tests (minimal, one logical assertion each):
|
||||
1. **Replace wipes unrelated challenges** — Seed `Alpha` (CRY, NC, port=1337, flag='flag{a}', with one staged file committed) and `Bravo` (CRY, WEB). Call `importConfirmed` with a single-challenge doc `{ name: 'Imported Charlie', … categorySystemKey: 'CRY', … }`. Assert `list({}).map(r => r.name)` is exactly `['Imported Charlie']` and that `chRepo.count()` equals 1.
|
||||
2. **Disk files of wiped challenges are unlinked** — Capture the stored filename of Alpha's committed file via `fileRepo.findOne({ where: { challengeId: alpha.id } })`. After the import, assert `fs.existsSync(path.join(finalDir, alphaStoredName))` is `false`, while the new challenge's stored file exists.
|
||||
3. **Empty archive wipes everything** — Re-seed Alpha, then `importConfirmed` with `{ challenges: [] }`. Assert `chRepo.count() === 0` and the previously-committed disk file is unlinked.
|
||||
4. **Unknown-category rows are skipped, not preserved** — Seed `Keep` (WEB). Import a doc containing one unknown-category challenge plus nothing else. Assert `result.skipped` has length 1 and `list({})` is empty (the wipe still happened).
|
||||
|
||||
### 4.2 Frontend modal test — `tests/frontend/admin-challenges-import-autoclose.spec.ts`
|
||||
|
||||
- Smart container test using `TestBed.createComponent(AdminChallengesComponent)` to exercise the live handler.
|
||||
- Mock `AdminService` (`jest.fn()` for `previewChallengeImport`, `confirmChallengeImport`, `listChallenges`, `listCategories`, `exportChallenges`) and stub the document `createElement('input')` file-picker flow by setting `importDoc`/`importPreview` directly via a small helper (or by calling the public `openImport()` + injecting the file via a fake `<input>` ref). Keep it minimal: directly drive the component signals is preferable to mocking the file picker.
|
||||
- AAA structure. Mocking strategy: only `AdminService` (network boundary). No DOM snapshotting; we assert on signal values (OnPush friendly) and DOM text via `fixture.nativeElement.textContent` if needed.
|
||||
- Tests:
|
||||
1. **`onImportConfirm` closes the modal after success** — arrange: `component.importOpen.set(true)`, `component.importDoc.set({ … })`, `component.importPreview.set({ total: 1, conflicts: [], unknownCategories: [], warnings: [] })`. Stub `admin.confirmChallengeImport` to resolve `{ imported: 1, skipped: [], warnings: [] }` and `admin.listChallenges` to resolve `[]`. Act: `await component.onImportConfirm()`. Assert `component.importOpen()` is `false`, `component.importResult()` is `null` (cleared by `closeImport`), and `component.statusMessage()` contains "Imported 1".
|
||||
2. **`onImportConfirm` keeps the modal open when the network call rejects** — arrange same, stub `confirmChallengeImport` to reject with a generic error. Act + assert `component.importOpen()` stays `true` and `component.importError()` is non-null.
|
||||
|
||||
### 4.3 Run command
|
||||
|
||||
- From the repo root: `npm test` (or `npm run test:backend` / `npm run test:frontend` for individual projects). Both new specs run inside the existing `ts-jest` configuration with no additional setup.
|
||||
Reference in New Issue
Block a user