94 lines
9.3 KiB
Markdown
94 lines
9.3 KiB
Markdown
# 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 `OnPush` change detection, Angular Signals + `effect()`, typed reactive forms (`FormBuilder.nonNullable`), RxJS for streams.
|
|
- Stylistic choices: pure helper functions live in `*.pure.ts` next to the components; component classes only orchestrate state and templates.
|
|
- Error envelopes use stable codes (`VALIDATION_FAILED`, `CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`) returned from `ApiError.badRequest(...)` with a `details[]` of `{ path, message }`.
|
|
|
|
- **Data Layer:** SQLite via TypeORM (`better-sqlite3`). `challenge.category_id` is `TEXT NOT NULL` with `ON DELETE RESTRICT` FK to `category.id`. The `category.id` column is TEXT UUID v4 (see `database/challenges.md`).
|
|
|
|
- **Test Framework & Structure:**
|
|
- **Jest 29** with `ts-jest`. Multi-project config in `tests/jest.config.js` with two projects: `backend` (node env) and `frontend` (jsdom env).
|
|
- Frontend tests live in `tests/frontend/*.spec.ts`, backend tests in `tests/backend/*.spec.ts`. Tests NEVER live next to source.
|
|
- Single command from the repo root: `npm test` (or `npm 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.ts` already exercises `validateChallengeForm`, `buildChallengeFormGroup`, `syncChallengeForm`, etc.
|
|
|
|
- **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.sh` requires 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 empty `categoryId` and a UUID-format validator on the control; tighten `onOk()`.
|
|
- `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` — add a `categoryUuidPattern` constant and a `CATEGORY_ID_REQUIRED` helper constant; tighten `validateChallengeForm` to flag any non-UUID value as a category error (defense in depth).
|
|
- `backend/src/modules/admin/dto/challenges.dto.ts` — change `categoryId` in `baseShape` from `z.string().min(1).max(64)` to `z.string().uuid()` so an empty string, whitespace, or non-UUID payload is rejected at the validation pipe with a `400 VALIDATION_FAILED` and a `categoryId` detail.
|
|
|
|
- **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-UUID `categoryId` on `POST /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.categoryId` from `z.string().min(1).max(64)` to `z.string().uuid({ message: 'Category is required' })`.
|
|
- This single change makes the backend reject:
|
|
- Empty string (`""`) → caught at the Zod pipe → `400 VALIDATION_FAILED` with `details: [{ 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.
|
|
- `CategoryIdParamSchema` for `GET/PUT/DELETE /:id` already uses `z.string().min(1).max(64)`; for consistency, also tighten it to `z.string().uuid()` so that a missing/invalid id route produces a uniform 400.
|
|
- The `ImportChallengeSchema` (used by import payloads) already passes a `categorySystemKey`, not `categoryId`, so it is unaffected.
|
|
- `mapServerFieldErrors` in `challenge-form.pure.ts` already maps `path === 'categoryId'` to the per-field error, so the inline `cf-error-category` will render automatically once the backend returns a `categoryId` detail.
|
|
|
|
### 3. Frontend UI Integration (`frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts`)
|
|
- In `buildChallengeFormGroup`, replace the bare `Validators.required` on `categoryId` with a stronger validator set that:
|
|
1. Rejects empty strings (`Validators.required`).
|
|
2. 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`) so `showError('categoryId')` can render a meaningful message.
|
|
- In `onOk()`:
|
|
- Read `categoryId = this.form.controls.categoryId.value` BEFORE calling `validateChallengeForm`.
|
|
- 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'` via `firstErrorTab`, and does NOT call `submit.emit(...)`. This is in addition to `validateChallengeForm` (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.
|
|
- 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:
|
|
```ts
|
|
if (!values.categoryId) errs.categoryId = 'Category is required';
|
|
else if (!CATEGORY_ID_PATTERN.test(values.categoryId))
|
|
errs.categoryId = 'Category is required';
|
|
```
|
|
(Same message on purpose: do not leak internal format expectations to the user; both states mean "you have not selected a valid category".)
|
|
- Add `firstErrorTab` already returns `'general'` for `errors.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 from `tests/frontend/admin-challenges-form.spec.ts`.
|
|
- Test 1 — `validateChallengeForm({ categoryId: '' })` produces `errs.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 a `categoryId` error.
|
|
- Test 5 — `buildChallengeFormGroup` applies a `uuid` validator 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).
|
|
|
|
- **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/challenges` with `categoryId: ''` and otherwise-valid body returns `400 VALIDATION_FAILED` with `details` containing a `categoryId` entry; no row is persisted (re-list and assert empty array).
|
|
- Test 2 — `POST /api/v1/admin/challenges` with `categoryId: 'not-a-uuid'` and otherwise-valid body returns `400 VALIDATION_FAILED` with a `categoryId` detail.
|
|
- Test 3 — `POST /api/v1/admin/challenges` with a valid UUID for `categoryId` (matching a seeded category) returns `201` and the row appears in the list.
|
|
|
|
- **Mocking Strategy:**
|
|
- **Frontend tests:** No HTTP/network mocking required — they exercise pure helpers and the in-memory `FormGroup` from `@angular/forms`. The existing `StubAdminService` pattern from `tests/frontend/admin-challenges-form.spec.ts` can be referenced if needed but is not required for the new tests above.
|
|
- **Backend tests:** Use the real `AppModule` with `process.env.DATABASE_PATH = ':memory:'`, real `GlobalExceptionFilter`, real `CsrfMiddleware`, and real admin login flow (same pattern as `tests/backend/admin-challenges-api.spec.ts`). Seed at least one category row through the existing `/api/v1/admin/categories` POST so the "valid UUID" path can succeed; rely on `category-repair-migration` to materialize the canonical system rows on fresh DBs.
|
|
|
|
- **Run command (single, root-level):**
|
|
```bash
|
|
npm test
|
|
```
|
|
This runs both projects (`backend` and `frontend`) via `tests/jest.config.js`. For narrower loops during development: `npm run test:backend` or `npm run test:frontend`.
|