9.3 KiB
Implementation Plan: Admin Add/Edit Challenge — Category Validation Bug Fix (Job 902)
1. Architectural Reconnaissance
-
Codebase style & conventions:
- Monorepo using npm workspaces (
backend,frontend). TypeScript everywhere. - Backend: NestJS 10 with TypeORM, SQLite (better-sqlite3), class-validator-style validation through custom
ZodValidationPipe. - Frontend: Angular 17 standalone components with
OnPushchange detection, Angular Signals +effect(), typed reactive forms (FormBuilder.nonNullable), RxJS for streams. - Stylistic choices: pure helper functions live in
*.pure.tsnext to the components; component classes only orchestrate state and templates. - Error envelopes use stable codes (
VALIDATION_FAILED,CHALLENGE_NAME_TAKEN,CHALLENGE_INVALID_CATEGORY) returned fromApiError.badRequest(...)with adetails[]of{ path, message }.
- Monorepo using npm workspaces (
-
Data Layer: SQLite via TypeORM (
better-sqlite3).challenge.category_idisTEXT NOT NULLwithON DELETE RESTRICTFK tocategory.id. Thecategory.idcolumn is TEXT UUID v4 (seedatabase/challenges.md). -
Test Framework & Structure:
- Jest 29 with
ts-jest. Multi-project config intests/jest.config.jswith two projects:backend(node env) andfrontend(jsdom env). - Frontend tests live in
tests/frontend/*.spec.ts, backend tests intests/backend/*.spec.ts. Tests NEVER live next to source. - Single command from the repo root:
npm test(ornpm run test:backend/npm run test:frontend). - Frontend tests focus on logic / pure functions and DOM-free component behavior (no visual confirmation).
tests/frontend/admin-challenges-form.spec.tsalready exercisesvalidateChallengeForm,buildChallengeFormGroup,syncChallengeForm, etc.
- Jest 29 with
-
Required Tools & Dependencies: No new tools or dependencies are required for this fix. Zod 3 is already a backend dep; UUID validation can use the standard
z.string().uuid(). No new npm packages needed.setup.shrequires no changes.
2. Impacted Files
-
To Modify:
frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts— add a dedicated early-return guard for emptycategoryIdand a UUID-format validator on the control; tightenonOk().frontend/src/app/features/admin/challenges/challenge-form.pure.ts— add acategoryUuidPatternconstant and aCATEGORY_ID_REQUIREDhelper constant; tightenvalidateChallengeFormto flag any non-UUID value as a category error (defense in depth).backend/src/modules/admin/dto/challenges.dto.ts— changecategoryIdinbaseShapefromz.string().min(1).max(64)toz.string().uuid()so an empty string, whitespace, or non-UUID payload is rejected at the validation pipe with a400 VALIDATION_FAILEDand acategoryIddetail.
-
To Create:
tests/frontend/admin-challenges-form-job902.spec.ts— targeted unit tests covering the new client-side guards.tests/backend/admin-challenges-create-category-job902.spec.ts— targeted integration test covering the new server-side Zod rejection of empty/non-UUIDcategoryIdonPOST /api/v1/admin/challenges.
3. Proposed Changes
1. Database / Schema Migration
No schema migration. The category_id column already requires a valid existing row (FK + service-level assertCategoryExists). The fix is purely validation tightening on the wire format. The RepairCategorySchemaAndSystemCategories1700000000400 and UpgradeChallengeAdminSchema1700000000500 migrations remain untouched.
2. Backend Logic & APIs (backend/src/modules/admin/dto/challenges.dto.ts)
- Update
baseShape.categoryIdfromz.string().min(1).max(64)toz.string().uuid({ message: 'Category is required' }). - This single change makes the backend reject:
- Empty string (
"") → caught at the Zod pipe →400 VALIDATION_FAILEDwithdetails: [{ path: 'categoryId', message: 'Category is required' }]. - Whitespace-only → still rejected by
z.string().uuid()(UUIDs cannot contain whitespace). - Any non-UUID string (including stray placeholders) → rejected.
- Empty string (
CategoryIdParamSchemaforGET/PUT/DELETE /:idalready usesz.string().min(1).max(64); for consistency, also tighten it toz.string().uuid()so that a missing/invalid id route produces a uniform 400.- The
ImportChallengeSchema(used by import payloads) already passes acategorySystemKey, notcategoryId, so it is unaffected. mapServerFieldErrorsinchallenge-form.pure.tsalready mapspath === 'categoryId'to the per-field error, so the inlinecf-error-categorywill render automatically once the backend returns acategoryIddetail.
3. Frontend UI Integration (frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts)
- In
buildChallengeFormGroup, replace the bareValidators.requiredoncategoryIdwith a stronger validator set that:- Rejects empty strings (
Validators.required). - Enforces UUID v4 format using a small inline regex (e.g.
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) wrapped in a named validator error key (uuid) soshowError('categoryId')can render a meaningful message.
- Rejects empty strings (
- In
onOk():- Read
categoryId = this.form.controls.categoryId.valueBEFORE callingvalidateChallengeForm. - Add an explicit defensive guard that returns immediately when the trimmed value is empty or does not match the UUID pattern, sets
clientFieldErrors = { categoryId: 'Category is required' }, marks the control touched/dirty, switches the active tab to'general'viafirstErrorTab, and does NOT callsubmit.emit(...). This is in addition tovalidateChallengeForm(which already catches the empty case) — it exists as a belt-and-suspenders measure so the bug cannot recur even if the pure helper is bypassed.
- Read
- Keep the existing template —
<option value="">Select a category…</option>— and the existing[data-testid="cf-category"]selector untouched.
4. Frontend Pure Helper (frontend/src/app/features/admin/challenges/challenge-form.pure.ts)
- Export a new constant
CATEGORY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i. - In
validateChallengeForm, expand the category branch:(Same message on purpose: do not leak internal format expectations to the user; both states mean "you have not selected a valid category".)if (!values.categoryId) errs.categoryId = 'Category is required'; else if (!CATEGORY_ID_PATTERN.test(values.categoryId)) errs.categoryId = 'Category is required'; - Add
firstErrorTabalready returns'general'forerrors.categoryId— no change needed there.
4. Test Strategy
-
Target Unit Test File (frontend):
tests/frontend/admin-challenges-form-job902.spec.ts- Reuse the existing
makeDefaults()helper pattern fromtests/frontend/admin-challenges-form.spec.ts. - Test 1 —
validateChallengeForm({ categoryId: '' })produceserrs.categoryId === 'Category is required'. - Test 2 —
validateChallengeForm({ categoryId: ' ' })produces the same error (whitespace string). - Test 3 —
validateChallengeForm({ categoryId: 'not-a-uuid' })produces the same error (defense-in-depth against non-UUID garbage). - Test 4 —
validateChallengeForm({ categoryId: '<valid uuid>' })does NOT produce acategoryIderror. - Test 5 —
buildChallengeFormGroupapplies auuidvalidator that rejects'abc','not-a-uuid', and''once the control is touched; accepts a canonical UUID v4. - Test 6 —
firstErrorTab({ categoryId: '...' })returns'general'(so the modal switches tabs to surface the error).
- Reuse the existing
-
Target Integration Test File (backend):
tests/backend/admin-challenges-create-category-job902.spec.ts- Follow the boot pattern from
tests/backend/admin-challenges-api.spec.ts(register first admin, prime CSRF, login). - Test 1 —
POST /api/v1/admin/challengeswithcategoryId: ''and otherwise-valid body returns400 VALIDATION_FAILEDwithdetailscontaining acategoryIdentry; no row is persisted (re-list and assert empty array). - Test 2 —
POST /api/v1/admin/challengeswithcategoryId: 'not-a-uuid'and otherwise-valid body returns400 VALIDATION_FAILEDwith acategoryIddetail. - Test 3 —
POST /api/v1/admin/challengeswith a valid UUID forcategoryId(matching a seeded category) returns201and the row appears in the list.
- Follow the boot pattern from
-
Mocking Strategy:
- Frontend tests: No HTTP/network mocking required — they exercise pure helpers and the in-memory
FormGroupfrom@angular/forms. The existingStubAdminServicepattern fromtests/frontend/admin-challenges-form.spec.tscan be referenced if needed but is not required for the new tests above. - Backend tests: Use the real
AppModulewithprocess.env.DATABASE_PATH = ':memory:', realGlobalExceptionFilter, realCsrfMiddleware, and real admin login flow (same pattern astests/backend/admin-challenges-api.spec.ts). Seed at least one category row through the existing/api/v1/admin/categoriesPOST so the "valid UUID" path can succeed; rely oncategory-repair-migrationto materialize the canonical system rows on fresh DBs.
- Frontend tests: No HTTP/network mocking required — they exercise pure helpers and the in-memory
-
Run command (single, root-level):
npm testThis runs both projects (
backendandfrontend) viatests/jest.config.js. For narrower loops during development:npm run test:backendornpm run test:frontend.