14 KiB
Implementation Plan: Job 901 — Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.01
0. Status: Already-Implemented Audit (per Job spec)
The Job description explicitly identifies which cases already pass and which are bugs to fix. Per the README/architecture docs (docs/guides/admin-challenges.md), the list, search, sort, import, export, delete, file staging, and discard-on-cancel flows are fully implemented and verified by the existing test suites (tests/frontend/admin-challenges-list.spec.ts, tests/frontend/admin-challenges-form.spec.ts, tests/backend/admin-challenges-*.spec.ts). The cases the spec marks PASS (1, 2, 5, 7) are therefore considered already implemented.
Cases that FAIL and need code changes:
- Case 3 — Connection NC with blank/non-numeric Port: submit is silently rejected;
validateChallengeForm's'Port is required for NC'message is computed inonOk()but never surfaced as acf-error-port(or general) inline error. Also<input type=number>strips non-digits before they reach the validator. - Case 4 — Blank IP with NC: the form auto-fills
portto1337and accepts the submission, but the requirement is that blank IP with NC should not silently succeed. (Re-read Job text: "no rejection — IP is optional and the form auto-fills port to 1337, so a blank IP with NC accepted submission with port 1337." — i.e. blank IP itself is OK; the issue is that auto-fill ofportto1337masks the real "Port is required" error. The Job marks this as a separate failing case to be discussed.) - Case 6 — Drag-and-drop oversized upload: the modal only wires
<input type=file>. Nodrop/dragoverhandler exists.
The plan therefore covers only the delta required to fix cases 3, 4, 6, plus regression tests for those fixes.
1. Architectural Reconnaissance
- Codebase style & conventions:
- TypeScript monorepo (
backend/NestJS +frontend/Angular).strictTS across both. - Frontend uses standalone components, OnPush, Angular signals for component state, Reactive Forms with
FormBuilder.nonNullable.group(...). - Errors are surfaced via per-field signals
fieldErrors(Record<field,string>) and a top-levelformErrorsignal; inline elements usedata-testid="cf-error-<field>". - Backend: NestJS modules;
admin-challenges.controller.ts+challenges.service.ts+challenge-files.service.ts. Zod-validated DTOs. Already enforces size limit (UPLOAD_SIZE_LIMITviaparseUploadSizeLimit) and per-upload oversize rejection — verified bytests/backend/uploads.spec.ts. - Styling is component-scoped via
styles: [...]literals (no global CSS edits required).
- TypeScript monorepo (
- Data Layer: SQLite via better-sqlite3, file-based under
./data/(persistent named volume/dataper project policy). - Test Framework & Structure:
- Jest with two projects (
tests/jest.config.js):backend(node) andfrontend(jsdom). - Single command from root:
npm test. - Tests live in
tests/backend/*.spec.tsandtests/frontend/*.spec.ts— never alongside source.
- Jest with two projects (
- Required Tools & Dependencies: No new packages required. All tooling (Angular Forms, Reactive Forms, Jest jsdom, ts-jest) already present.
2. Impacted Files
-
To Modify:
frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts- Plumb
clientErrorsfromvalidateChallengeFormintofieldErrorsinput (or a new localclientFieldErrorssignal) socf-error-portrenders. - Replace the silent "abort" path in
onOk()with: whenclientErrorsis non-empty, mark only the relevant controls touched/dirty (so untouched ones don't pop up), emit/show inline errors, switch to the tab that contains the first error, and short-circuit the submit. Clear these client errors when the user edits a field. - Change
<input id="cf-port" type="number" ...>totype="text" inputmode="numeric"so non-numeric characters are not silently stripped; addValidators.pattern(/^\d+$/)and an integer validator (Validators.min(1),Validators.max(65535)) on theportcontrol so<input type=number>quirks don't hide the value. Validate numeric-ness invalidateChallengeForm(it already checksNumber.isInteger). - Stop the protocol valueChanges side-effect from auto-filling
portto1337when switching to NC — instead leave itnullso the pure validator can fire "Port is required for NC". (This addresses Case 4: blank IP should not cause silent success driven by auto-fill port; with NC, blank port must be rejected visibly.) - Add a
(drop)/(dragover)/(dragleave)handler to the Files tab to support drag-and-drop uploads, reusing the existingonFileChangestaging pipeline. Reject oversize files using the sameglobalFileLimitcheck. - Add
preventDefaultondragoverso the browser does not navigate to the file when dropped outside the input.
- Plumb
frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts(template portion only):- Add a drag-and-drop zone (
<div class="drop-zone" (dragover)="onDragOver($event)" (drop)="onDrop($event)">) wrapping the file input. Show visual state on dragover.
- Add a drag-and-drop zone (
frontend/src/app/features/admin/challenges/challenge-form.pure.ts- Add a small helper
firstErrorTab(errors)that maps aChallengeFormErrorsmap to'general' | 'connection' | 'files'so the modal can auto-switch the active tab when a field is rejected. - Tighten
validateChallengeFormfor port: reject non-integer or out-of-range values for NC even if the user typed characters — already covered byNumber.isInteger/min/max. (No logic change needed; just rely on it.)
- Add a small helper
tests/frontend/admin-challenges-form.spec.ts- Add regression specs for cases 3 and 4: validate that submitting NC with blank port surfaces
cf-error-porttext "Port is required for NC"; validate that the auto-fill-to-1337 behavior is removed so a blank NC port triggers the inline error. - Add a regression spec for case 6: simulate a
dropevent with aDataTransfercontaining a smallFileand verifyAdminService.stageChallengeFileis called and a new staged entry appears; also verify oversize drop is rejected viacf-upload-errorwithout hitting the network.
- Add regression specs for cases 3 and 4: validate that submitting NC with blank port surfaces
-
To Create:
- None. All edits go into existing files. (No new files are required; we are extending existing modules per the Job "Add/Edit Modal 1.01" patch.)
3. Proposed Changes
3.1 Backend — no changes required
- The server already returns
400 VALIDATION_FAILEDwithdetails[].path = "port"for blank/non-numeric NC ports (backend/src/modules/admin/dto/challenges.dto.tsZod schema enforcesport: z.number().int().min(1).max(65535).optional()with conditional refinement forNC). The frontend simply must render the error, which is the responsibility ofvalidateChallengeForm(client-side, before any network call). - The file-size limit is already enforced by
challenge-files.service.ts(the staged upload endpoint returns 400 with a structured error envelope that the modal already maps tocf-upload-errorinonFileChange).
3.2 Frontend — challenge-form-modal.component.ts
-
Stop auto-filling
porton protocol change.- In the
protocol.valueChangessubscriber (currently lines 373–381), remove theelse if (p === 'NC' && ...)branch that setsportto1337. With this removed, switching to NC leavesport === null, so the next submit fires thevalidateChallengeFormpure-error'Port is required for NC'path, which we then surface (step 3).
- In the
-
Surface
validateChallengeFormerrors inline.- Add a private
clientFieldErrors = signal<ChallengeFormErrors | null>(null). - In
onOk():const clientErrors = validateChallengeForm(values); if (Object.keys(clientErrors).length > 0) { this.clientFieldErrors.set(clientErrors); // mark only those controls touched so the existing `showError()` template renders for (const f of Object.keys(clientErrors) as (keyof ChallengeFormErrors)[]) { const ctrl = this.form.controls[f as keyof ChallengeFormControls]; if (ctrl) { ctrl.markAsTouched(); ctrl.markAsDirty(); } } // auto-switch to the tab containing the first error const tab = firstErrorTab(clientErrors); if (tab) this.tab.set(tab); return; } this.clientFieldErrors.set(null); - Extend
showError(field)(currently lines 401–419) to preferclientFieldErrors()[field]over server errors and over Angular built-in validator errors. The pure-helper message is the most specific; the existing fall-through to required/min/max remains for untouched-after-clearing flows. - Clear the per-field
clientFieldErrorsentry when the user edits the corresponding control: subscribe tothis.form.valueChangesand on each change drop any key whose value now passes that field's pure check (cheap: re-runvalidateChallengeFormand diff). This keeps the error visible until the user actually fixes the input.
- Add a private
-
Replace
<input type="number">for port with<input type="text" inputmode="numeric">.- Rationale: native
type=numbersilently strips non-digits before Angular's FormControl sees them, which preventsvalidateChallengeFormfrom ever receiving a non-numeric value to reject. Usingtype="text" inputmode="numeric"keeps the on-screen numeric keypad on mobile but lets the validator surface a clear error. - Add to the
portcontrol:Validators.pattern(/^\d+$/)(inbuildChallengeFormGroup) so the Angular built-in path also catches it, givingshowError('port')an additional message via the existingpatternbranch. (Add apatternbranch inshowErrorreturning${humanLabel(field)} has an invalid value.)
- Rationale: native
-
Add drag-and-drop support to the Files tab.
- Template change (inside the
tab() === 'files'block):<div class="drop-zone" [class.dragover]="dragOver()" (dragover)="onDragOver($event)" (dragleave)="onDragLeave($event)" (drop)="onDrop($event)" data-testid="cf-drop-zone"> <p>Drop files here or use the picker below</p> <input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" /> </div> - Component:
Extract the existing
readonly dragOver = signal(false); onDragOver(ev: DragEvent): void { ev.preventDefault(); this.dragOver.set(true); } onDragLeave(ev: DragEvent): void { this.dragOver.set(false); } onDrop(ev: DragEvent): void { ev.preventDefault(); this.dragOver.set(false); const files = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : []; if (files.length === 0) return; // Reuse the same staging pipeline as the picker: void this.stageFiles(files); }for (const file of list)loop inonFileChangeinto a privatestageFiles(files: File[])method; bothonFileChangeandonDropcall it. Oversize check, error message, and placeholder staging remain identical, so case 5 behavior is preserved and case 6 is now covered.
- Template change (inside the
3.3 Frontend — challenge-form.pure.ts
- Add
firstErrorTab(errors)mappingname|descriptionMd|categoryId|difficulty|initialPoints|minimumPoints|decaySolves|flag → 'general',protocol|port|ipAddress → 'connection', empty otherwise.
3.4 Tests (regression only, in dedicated folder)
Add cases to tests/frontend/admin-challenges-form.spec.ts:
- Case 3 — blank NC port:
- Build the form, set values to a valid challenge but
protocol: 'NC'andport: null. - Call the modal's
onOk()indirectly: assert that after invoking submit,clientFieldErrorscontainsport === 'Port is required for NC'and thecf-error-portelement renders that text (mount the component viaTestBed.createComponent). - Assert that the port
<input>no longer hastype="number"(regression for non-numeric stripping).
- Build the form, set values to a valid challenge but
- Case 3 — non-numeric NC port:
- Simulate user typing
"12a3"into the port field. Assert the input retains the raw string andvalidateChallengeFormreturnsport: 'Port is required for NC'(or new "must be an integer" message).
- Simulate user typing
- Case 4 — blank IP with NC, blank port:
- Verify the modal does not auto-fill port to 1337 when switching to NC, and that submitting with
ipAddress=''andport=nullshows the inline port error.
- Verify the modal does not auto-fill port to 1337 when switching to NC, and that submitting with
- Case 6 — drag-and-drop:
- Stub
AdminService.stageChallengeFilevia TestBed providers; dispatch a syntheticdropevent with aFileindataTransfer.files; assert the staged entry appears andstageChallengeFilewas called. - Drop a file larger than
globalFileLimit; assertcf-upload-errortext appears and no network call is made.
- Stub
(For new tests the existing tests/frontend/admin-challenges-form.spec.ts already imports from the component; we extend it in place rather than creating new files.)
4. Test Strategy
- Target Unit Test File:
tests/frontend/admin-challenges-form.spec.ts(existing; extend with the four cases above).
- Mocking Strategy:
- All external boundaries (the network) are mocked via Angular's
provideHttpClientTestingplus a stub ofAdminServiceforstageChallengeFile/discardChallengeFile/createChallenge/updateChallenge. - The drag-and-drop test stubs
AdminService.stageChallengeFilewith a Jasmine spy that resolves{ token: 't-1', publicUrl: '/uploads/challenges/x', originalFilename, mimeType, sizeBytes }. - For oversize, assert the spy is not called and
fileUploadErroris set, using a syntheticFilebuilt from aUint8Arrayof the desired length. - Backend tests are not required because the server already rejects blank/non-numeric ports and oversize uploads (covered by
tests/backend/admin-challenges-api.spec.tsandtests/backend/uploads.spec.ts).
- All external boundaries (the network) are mocked via Angular's
5. Out of Scope / Non-changes
- No backend changes (server already returns the right error envelopes).
- No new dependencies, no
setup.shedits, no migration scripts, no DB schema changes. - No changes to import/export, list, search, sort, delete flows (already passing per Job spec).
- No drag-and-drop file picker behavior for import — that uses a
<input type=file>chooser insideAdminChallengesComponent.pickImportFile, which the Job does not flag.