feat: Admin Area General Settings and Categories 1.01
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
# Implementation Plan: Admin Area — General Settings and Categories (Job 882)
|
||||
|
||||
**[ALREADY_IMPLEMENTED]**
|
||||
|
||||
This Job describes the Admin Area shell, the General settings page, the Categories management list/create/edit/delete, the username-menu "Admin area" entry, the admin navigation guard, and the "Invalid username or password" login feedback. Every functional surface and acceptance criterion listed in the Job Description is already present in the repository on the `main` branch. No code, schema, configuration, or test changes are required.
|
||||
|
||||
## 1. Why this Job is already implemented
|
||||
|
||||
I walked the data flow end-to-end (browser → frontend `AuthService` → `POST /api/v1/auth/login` → backend `AuthService.login` → `ApiError` envelope → frontend `LandingComponent` → username menu → admin nav guard → admin shell routes) and confirmed every piece the Job calls out is wired up and unit/integration tested.
|
||||
|
||||
## 2. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:**
|
||||
- Backend: NestJS 10, TypeORM with `better-sqlite3`, controllers/services/modules, zod-validated DTOs via `ZodValidationPipe`, errors funneled through `ApiError` / `GlobalExceptionFilter`, Argon2id for password hashing.
|
||||
- Frontend: Angular 17 standalone components, OnPush change detection, signal-based state, reactive forms, route-level guards (`CanActivateFn`), CSRF + auth HTTP interceptors.
|
||||
- Auth: `JwtAuthGuard` (global) + `AdminGuard` (per-handler) + `@Roles('admin')` metadata → `RolesGuard`.
|
||||
- Cross-tab session invalidation: `BroadcastChannel` + `localStorage` fallback in `AuthService`.
|
||||
|
||||
- **Data Layer:** TypeORM entities on SQLite (`backend/src/database/entities/user.entity.ts`, `category.entity.ts`, `challenge.entity.ts`, `setting.entity.ts`, `refresh-token.entity.ts`). Settings live in the `setting` table by key; the `category` table has `id`, `name`, `abbreviation`, `description`, `iconPath`, `systemKey`, `createdAt`, `updatedAt`. Users have `role` (`'admin' | 'player'`) and `status` (`'enabled' | 'disabled'`).
|
||||
|
||||
- **Test Framework & Structure:** Jest via `npm test` (root), configured at `tests/jest.config.js` with two projects (`backend`, `frontend`). Tests live exclusively under `tests/backend/*.spec.ts` and `tests/frontend/*.spec.ts`. Backend tests use `supertest` against an in-memory Nest app; frontend tests are pure-TS spec files against the libraries/services.
|
||||
|
||||
- **Required Tools & Dependencies:** None new — backend `argon2`, `zod`, `@nestjs/typeorm`, `better-sqlite3`, `better-sqlite3` native rebuild in `setup.sh`. Frontend: `@angular/core`, `@angular/router`, `@angular/forms`, `@angular/common/http`. Everything is already pinned in `package.json` / workspaces.
|
||||
|
||||
## 3. Impacted Files (existing — all already implemented)
|
||||
|
||||
### Backend
|
||||
- `backend/src/modules/admin/admin.module.ts` — registers `AdminController`, `AdminGeneralController`, `AdminCategoriesController`, `AdminService`, `AdminGeneralService`, `AdminCategoriesService`; imports `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule`, and `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`.
|
||||
- `backend/src/modules/admin/admin-general.controller.ts` — `@Controller('api/v1/admin/general')`, `@UseGuards(AdminGuard)` + `@Roles('admin')`; exposes `GET/PUT /settings`, `GET /themes`.
|
||||
- `backend/src/modules/admin/general.service.ts` — `AdminGeneralService.getSettings / updateSettings / listThemes`. Reads/writes all general settings via `SettingsService` (`PAGE_TITLE`, `LOGO`, `WELCOME_MARKDOWN`, `THEME_KEY`, `EVENT_START_UTC`, `EVENT_END_UTC`, `DEFAULT_CHALLENGE_IP`, `REGISTRATIONS_ENABLED`); emits an SSE `general` event after update via `SseHubService`.
|
||||
- `backend/src/modules/admin/dto/general.dto.ts` — `GeneralSettingsSchema` (zod) with `superRefine` enforcing `eventEndUtc > eventStartUtc` and `THEME_IDS` enum on `themeKey`.
|
||||
- `backend/src/modules/admin/admin-categories.controller.ts` — `@Controller('api/v1/admin/categories')`, admin-only; `GET / POST / PUT :id / DELETE :id`.
|
||||
- `backend/src/modules/admin/categories.service.ts` — `AdminCategoriesService.list / create / update / remove` with **uppercased abbreviation**, **duplicate-abbreviation → 409**, **system-category abbreviation immutable → 409 SYSTEM_PROTECTED**, **delete system category → 403 SYSTEM_PROTECTED**, **delete category with challenges → 409 CATEGORY_HAS_CHALLENGES**.
|
||||
- `backend/src/modules/admin/dto/categories.dto.ts` — `CreateCategorySchema`, `UpdateCategorySchema`, `CategoryIdParamSchema`.
|
||||
- `backend/src/modules/auth/auth.service.ts` — `login()` throws `ApiError(ERROR_CODES.INVALID_CREDENTIALS, 'Invalid credentials', 401)` on missing/disabled user *or* argon2 verify mismatch. The controller (`auth.controller.ts`) emits the standard envelope to the SPA.
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/app/app.routes.ts` — `/admin` mounted under the auth-guarded `''` (home) parent with child paths `general` (default) and `categories`. `adminGuard` protects the whole subtree.
|
||||
- `frontend/src/app/core/guards/admin.guard.ts` + `admin.guard.decision.ts` — Pure `decideAdminGuard({ initialized, isAuthenticated, role })`: not-init → `/bootstrap`, not-auth → `/login`, auth-but-not-admin → `/`, admin → `allow`.
|
||||
- `frontend/src/app/core/services/auth.service.ts` — `login()` returns a discriminated `LoginResult`; errors flow through `mapAuthError()`.
|
||||
- `frontend/src/app/features/landing/login-modal.service.ts` — `buildLoginFailureMessage()` maps `INVALID_CREDENTIALS` → `'Invalid username or password.'` (the exact text the Job cites).
|
||||
- `frontend/src/app/features/landing/landing.component.ts` + `landing.component.html` — surfaces `[data-testid="login-server-error"]` with the mapped text and a `RATE_LIMITED` warn variant; closes modal + navigates to `/` on success.
|
||||
- `frontend/src/app/features/shell/header/shell-header.component.ts` — username `user-menu-trigger` toggles `[data-testid="user-menu"]`. When `canAccessAdmin` is true (computed by `shouldShowAdminNav({ isAuthenticated, role })`), the "Admin area" `[data-testid="user-menu-admin"]` entry is rendered.
|
||||
- `frontend/src/app/features/home/home.component.ts` — wires the `adminClick` output to `goAdmin()` → `router.navigateByUrl('/admin')`.
|
||||
- `frontend/src/app/features/home/home.shell.ts` — exports `shouldShowAdminNav` (pure, unit-tested).
|
||||
- `frontend/src/app/features/admin/admin-shell.component.ts` — admin shell template with the side nav `ENTRIES = [General, Challenges, Players, Blog, System]`. `data-testid="admin-aside"`, `data-testid="admin-nav"`, `data-testid="admin-nav-general"`, `<router-outlet />` for child pages. General is `enabled: true`; Challenges/Players/Blog/System render greyed-out (placeholders, per the Job wording — the Job requires the menu items to be present).
|
||||
- `frontend/src/app/features/admin/general.component.ts` + `general.pure.ts` — full General Settings page: page title, logo (file upload), global theme (select), event start/end, default challenge IP, registrations toggle, welcome Markdown with live preview, event-state derived display, save with success/error states. All `data-testid`s match the existing tests.
|
||||
- `frontend/src/app/features/admin/categories/categories.component.ts` + `category-form-modal.component.ts` + `category-delete-modal.component.ts` — list sorted alphabetically by abbreviation, create/edit/delete modals, icon upload, deletion shows the friendly "Cannot delete: category has N challenge(s) attached" message and a "System categories cannot be deleted" message.
|
||||
- `frontend/src/app/core/services/admin.service.ts` — typed wrappers for all the admin endpoints used by the components above.
|
||||
|
||||
### Tests (existing — all passing)
|
||||
- `tests/backend/admin-general-service.spec.ts` — covers `getSettings`, `updateSettings` (settings persisted, SSE `general` event emitted), theme listing intersection.
|
||||
- `tests/backend/admin-categories-service.spec.ts` — abbreviation uppercasing, duplicate-abbreviation 409, system abbreviation immutable, system name/description editable, system delete blocked, delete with attached challenges 409, sort by lowercase abbreviation.
|
||||
- `tests/backend/admin-guard.spec.ts`, `tests/backend/admin-validation.spec.ts` — guard chain + zod validation.
|
||||
- `tests/frontend/admin-shell.spec.ts`, `tests/frontend/admin-navigation.spec.ts` — pure predicate and guard-decision coverage.
|
||||
- `tests/frontend/admin-general-pure.spec.ts` — `deriveEventState`, `endAfterStartValidator`, datetime helpers.
|
||||
- `tests/frontend/landing-modal.spec.ts` — covers `buildLoginFailureMessage({ code: 'INVALID_CREDENTIALS', ... })` → `'Invalid username or password.'`.
|
||||
- `tests/backend/login-shell-smoke.spec.ts` — exercise `POST /api/v1/auth/register-first-admin` → login → `/me` end-to-end.
|
||||
|
||||
## 4. Job requirement → existing implementation map
|
||||
|
||||
| Job-stated requirement | Where it lives today |
|
||||
|---|---|
|
||||
| Admin login can be authenticated (POST /api/v1/auth/login admin-creds 201) | `backend/src/modules/auth/auth.service.ts:51-73` + `auth.controller.ts`; covered by `tests/backend/login-shell-smoke.spec.ts`. |
|
||||
| Newly-registered non-admin player username menu exposes no "Admin area" entry | `frontend/src/app/features/shell/header/shell-header.component.ts:76-85` (gated by `canAccessAdmin()`); predicate at `frontend/src/app/features/home/home.shell.ts:9-13`; covered by `tests/frontend/admin-shell.spec.ts`. |
|
||||
| Direct navigation to /admin by non-admin redirects | `frontend/src/app/core/guards/admin.guard.ts` + `admin.guard.decision.ts:11-13`; covered by `tests/frontend/admin-navigation.spec.ts`. |
|
||||
| Admin opens username menu → "Admin area" → /admin reachable | `home.component.ts:154-157 goAdmin()` + `app.routes.ts` `/admin` lazy-loads `AdminShellComponent` which redirects to `/admin/general`. |
|
||||
| Admin area shows side menu: General, Challenges, Players, Blog, System | `frontend/src/app/features/admin/admin-shell.component.ts:12-18 ENTRIES`. General enabled, others greyed-out placeholders (matches Job: "with the General, Challenges, Players, Blog, and System menu"). |
|
||||
| General section loads + saves settings | `AdminGeneralComponent.ngOnInit / onSubmit` + `AdminService.getGeneralSettings / updateGeneralSettings / listAdminThemes / uploadLogo`. |
|
||||
| Categories management data exposed (list/create/update/delete with validation + system protection + challenge-attached protection) | `AdminCategoriesComponent` + `CategoryFormModal` + `CategoryDeleteModal` consuming `AdminService.listCategories / createCategory / updateCategory / deleteCategory / uploadCategoryIcon` against `AdminCategoriesService` + DTOs. All error codes (`SYSTEM_PROTECTED`, `CATEGORY_HAS_CHALLENGES`, conflict) rendered into user-facing messages. |
|
||||
| Login with admin credentials returning HTTP 401 with visible "Invalid username or password" | Backend returns `401 { code: 'INVALID_CREDENTIALS', message: 'Invalid credentials' }` from `auth.service.ts:61,67`; frontend maps via `buildLoginFailureMessage` (`login-modal.service.ts:21-22`) → `'Invalid username or password.'`, rendered at `[data-testid="login-server-error"]`. |
|
||||
|
||||
## 5. Conclusion
|
||||
|
||||
Per the project's "Already Implemented Check" rule, since the entire Job scope — admin shell + General + Categories + admin guard + admin nav predicate + login error mapping — is present and exercised by the existing test suite, the implementation phase should perform **no application edits and no test creation**. The only legitimate action items, if any successor job wanted to extend coverage, would be out of scope for this Job:
|
||||
|
||||
- Optionally enable the still-disabled `Challenges`, `Players`, `Blog`, `System` nav entries (separate future jobs).
|
||||
- Optionally surface a more verbose login-failure message (e.g. account-locked distinct from invalid credentials) — also a separate concern.
|
||||
|
||||
### Files NOT to Modify
|
||||
None — the Job is fully complete.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Implementation Plan: Admin Area General Settings and Categories 1.01
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
- **Codebase style & conventions:** TypeScript monorepo with a NestJS REST API and an Angular 17 standalone-component SPA. The General page is an OnPush standalone component using a non-nullable reactive form, signals for request state, and a typed `AdminService`; server request validation uses Zod through `ZodValidationPipe`, while controllers delegate persistence to services. The affected flow is `frontend/src/app/features/admin/general.component.ts` → `AdminService.updateGeneralSettings()` → `AdminGeneralController.updateSettings()` → `GeneralSettingsSchema` → `AdminGeneralService.updateSettings()` → `SettingsService`, with `/api/v1/bootstrap` subsequently exposing the stored `pageTitle` to `HomeComponent` and `ShellHeaderComponent`.
|
||||
- **Data Layer:** SQLite via TypeORM. General settings are key/value rows in the `setting` table; `pageTitle` is stored under `SETTINGS_KEYS.PAGE_TITLE`. No schema or migration change is required. Invalid requests must be rejected before `AdminGeneralService.updateSettings()` executes, ensuring the existing row remains unchanged.
|
||||
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`: Node for `tests/backend/**/*.spec.ts` and jsdom for `tests/frontend/**/*.spec.ts`. Tests are kept in the dedicated `/repo/tests` tree and all run from the repository root with `npm test`; focused projects can run with `npm run test:frontend` and `npm run test:backend`. Add minimal pure frontend validation coverage and extend the existing backend schema/service test rather than introducing browser/UI infrastructure.
|
||||
- **Required Tools & Dependencies:** No new system tools, global CLIs, package dependencies, persistent `/data` fixtures, or `setup.sh` changes are required. Existing Angular Forms, Zod, Jest, and ts-jest cover the implementation and tests. Verification should use `npm test` and `npm run build`; the repository has no lint script or dedicated typecheck script, and the build scripts perform TypeScript/Angular compilation.
|
||||
|
||||
## 2. Impacted Files
|
||||
- **To Modify:**
|
||||
- `frontend/src/app/features/admin/general.pure.ts` — add a small typed whitespace-aware page-title validator/helper that can be shared by the reactive form and tested without Angular UI setup.
|
||||
- `frontend/src/app/features/admin/general.component.ts` — apply the page-title validator, render actionable field-level feedback, normalize the submitted title, and keep invalid forms from invoking the API.
|
||||
- `backend/src/modules/admin/dto/general.dto.ts` — trim `pageTitle` before enforcing the existing 1–120 character contract so whitespace-only input is rejected and valid surrounding whitespace is not persisted.
|
||||
- `tests/frontend/admin-general-pure.spec.ts` — cover empty/whitespace rejection and valid-title normalization/acceptance using the pure helper.
|
||||
- `tests/backend/admin-general-service.spec.ts` — cover schema rejection of whitespace-only titles and schema output trimming for a valid title, proving invalid payloads cannot reach persistence.
|
||||
- `docs/guides/admin-general-settings.md` — update the Page Title contract and expected save behavior to document trimming, inline validation feedback, and preservation of the stored value on invalid input.
|
||||
- `docs/api/admin.md` — document the server contract as a trimmed, non-empty 1–120-character page title.
|
||||
- **To Create:** None.
|
||||
|
||||
## 3. Proposed Changes
|
||||
1. **Database / Schema Migration:** No migration is needed. Continue using the existing `setting` row and `SettingsService.set`; rely on validation occurring before the service call so empty or whitespace-only requests perform no writes and the prior `pageTitle` remains available from both admin settings and `/api/v1/bootstrap`.
|
||||
2. **Backend Logic & APIs:** Change `GeneralSettingsSchema.pageTitle` from a raw `z.string().min(1).max(120)` to a trimmed string contract with the same bounds. Because `ZodValidationPipe` passes `parsed.data` onward, valid values such as `' OpenVelo '` will reach `AdminGeneralService.updateSettings()` as `'OpenVelo'`, while `''` and `' '` will produce the existing `400 VALIDATION_FAILED` envelope before any `Promise.all` persistence or SSE emission. Keep endpoint paths, response types, error envelope, settings service, and bootstrap logic unchanged.
|
||||
3. **Frontend UI Integration:** Add a standalone page-title validator/helper in `general.pure.ts` that treats `value.trim().length === 0` as invalid while preserving Angular's max-length validation. Attach it to the non-nullable `pageTitle` control in `AdminGeneralComponent`, show an inline field error beneath `general-pageTitle` when the control is touched/dirty and either required/whitespace-invalid, and use an actionable message such as “Page title is required and cannot contain only whitespace.” Keep the existing disabled condition (`form.invalid`) and `onSubmit()` invalid guard/`markAllAsTouched()` so no PUT is issued for invalid values. For valid submissions, trim `v.pageTitle` before calling `AdminService.updateGeneralSettings()` so the immediate SPA request agrees with the server normalization. Clear stale save success/error state when a new submission begins as already done; only display `Saved.` after a successful response and patch the normalized response back into the form. No shell-header fallback change is needed because invalid titles will no longer be persisted.
|
||||
|
||||
## 4. Test Strategy
|
||||
- **Target Unit Test File:** Extend `tests/frontend/admin-general-pure.spec.ts` with minimal tests for the page-title helper/validator: whitespace-only input is invalid, a normal title is valid, and valid surrounding whitespace normalizes to the intended persisted value. Extend `tests/backend/admin-general-service.spec.ts` under `GeneralSettingsSchema - validation rules` with one rejection assertion for `' '` and one successful parse assertion proving `' OpenVelo '` becomes `'OpenVelo'`. These tests directly guard both client pre-validation and the authoritative server boundary without a visual/browser test.
|
||||
- **Mocking Strategy:** Frontend tests invoke only the pure helper with simple typed control-like values, so no TestBed, DOM interaction, or HTTP mock is needed. Backend schema tests use `safeParse` directly, so no Nest application or database is needed; the existing `AdminGeneralService` fake settings/hub test remains unchanged. This keeps tests fast and isolated while proving that invalid input never produces data suitable for persistence. Implement in TDD order: add the focused failing tests, verify the expected failures, make the minimal production changes, then run `npm test` and `npm run build` from `/repo`.
|
||||
@@ -3,7 +3,7 @@ import { THEME_IDS } from '../../../common/types/theme-ids';
|
||||
|
||||
export const GeneralSettingsSchema = z
|
||||
.object({
|
||||
pageTitle: z.string().min(1).max(120),
|
||||
pageTitle: z.string().trim().min(1).max(120),
|
||||
logo: z.string().max(2048),
|
||||
welcomeMarkdown: z.string().max(64_000),
|
||||
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
|
||||
|
||||
@@ -5,7 +5,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AdminService, GeneralSettings, ThemeView } from '../../core/services/admin.service';
|
||||
import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { AdminCategoriesComponent } from './categories/categories.component';
|
||||
import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from './general.pure';
|
||||
import {
|
||||
deriveEventState,
|
||||
endAfterStartValidator,
|
||||
normalizePageTitle,
|
||||
pageTitleError,
|
||||
toDatetimeLocal,
|
||||
toIsoUtc,
|
||||
} from './general.pure';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-general',
|
||||
@@ -42,6 +49,9 @@ import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } f
|
||||
<label for="pageTitle">Page title</label>
|
||||
<div>
|
||||
<input id="pageTitle" type="text" formControlName="pageTitle" data-testid="general-pageTitle" />
|
||||
@if (showPageTitleError()) {
|
||||
<div class="field-error" data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label for="logo">Logo</label>
|
||||
@@ -165,7 +175,7 @@ export class AdminGeneralComponent implements OnInit {
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]),
|
||||
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
|
||||
logo: this.fb.nonNullable.control(''),
|
||||
welcomeMarkdown: this.fb.nonNullable.control(''),
|
||||
themeKey: this.fb.nonNullable.control('classic'),
|
||||
@@ -186,6 +196,26 @@ export class AdminGeneralComponent implements OnInit {
|
||||
return state.toUpperCase();
|
||||
});
|
||||
|
||||
readonly showPageTitleError = computed(() => {
|
||||
const c = this.form.controls.pageTitle;
|
||||
return (c.touched || c.dirty) && c.invalid;
|
||||
});
|
||||
|
||||
readonly pageTitleMessage = computed(() => {
|
||||
const err = pageTitleError(this.form.controls.pageTitle.value);
|
||||
if (err === 'required') {
|
||||
return 'Page title is required and cannot contain only whitespace.';
|
||||
}
|
||||
if (err === 'maxlength') {
|
||||
return 'Page title must be 120 characters or fewer.';
|
||||
}
|
||||
const controlErrors = this.form.controls.pageTitle.errors;
|
||||
if (controlErrors?.['required']) return 'Page title is required and cannot contain only whitespace.';
|
||||
if (controlErrors?.['maxlength']) return 'Page title must be 120 characters or fewer.';
|
||||
if (controlErrors?.['whitespace']) return 'Page title is required and cannot contain only whitespace.';
|
||||
return null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.form.controls.welcomeMarkdown.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
@@ -208,6 +238,15 @@ export class AdminGeneralComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private pageTitleNotBlankValidator(): (ctrl: { value: string | null }) => { whitespace: true } | null {
|
||||
return (ctrl) => {
|
||||
const value = ctrl?.value ?? '';
|
||||
if (value.length === 0) return null;
|
||||
if (value.trim().length === 0) return { whitespace: true };
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
private applySettings(s: GeneralSettings): void {
|
||||
this.form.patchValue({
|
||||
pageTitle: s.pageTitle,
|
||||
@@ -232,7 +271,7 @@ export class AdminGeneralComponent implements OnInit {
|
||||
try {
|
||||
const v = this.form.getRawValue();
|
||||
const updated = await this.admin.updateGeneralSettings({
|
||||
pageTitle: v.pageTitle,
|
||||
pageTitle: normalizePageTitle(v.pageTitle),
|
||||
logo: v.logo,
|
||||
welcomeMarkdown: v.welcomeMarkdown,
|
||||
themeKey: v.themeKey,
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
export type PageTitleError = 'required' | 'maxlength' | null;
|
||||
|
||||
export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError {
|
||||
const v = value ?? '';
|
||||
if (v.trim().length === 0) return 'required';
|
||||
if (v.length > maxLength) return 'maxlength';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizePageTitle(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export type EventDerivedState = 'running' | 'countdown' | 'stopped' | 'unconfigured';
|
||||
|
||||
export function deriveEventState(start: string, end: string): EventDerivedState {
|
||||
|
||||
@@ -56,6 +56,37 @@ describe('GeneralSettingsSchema - validation rules', () => {
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only pageTitle', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: ' ',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from a valid pageTitle', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: ' OpenVelo ',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.pageTitle).toBe('OpenVelo');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGeneralService.updateSettings - happy path', () => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from '../../frontend/src/app/features/admin/general.pure';
|
||||
import {
|
||||
deriveEventState,
|
||||
endAfterStartValidator,
|
||||
normalizePageTitle,
|
||||
pageTitleError,
|
||||
toDatetimeLocal,
|
||||
toIsoUtc,
|
||||
} from '../../frontend/src/app/features/admin/general.pure';
|
||||
|
||||
describe('deriveEventState', () => {
|
||||
const start = '2026-01-01T00:00:00Z';
|
||||
@@ -80,3 +87,42 @@ describe('datetime helpers', () => {
|
||||
expect(toIsoUtc('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageTitleError', () => {
|
||||
it('returns "required" for empty input', () => {
|
||||
expect(pageTitleError('')).toBe('required');
|
||||
expect(pageTitleError(null)).toBe('required');
|
||||
expect(pageTitleError(undefined)).toBe('required');
|
||||
});
|
||||
|
||||
it('returns "required" for whitespace-only input', () => {
|
||||
expect(pageTitleError(' ')).toBe('required');
|
||||
expect(pageTitleError('\t\n ')).toBe('required');
|
||||
});
|
||||
|
||||
it('returns null for a valid title', () => {
|
||||
expect(pageTitleError('OpenVelo')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "maxlength" when the raw string exceeds 120 characters', () => {
|
||||
expect(pageTitleError('a'.repeat(121))).toBe('maxlength');
|
||||
});
|
||||
|
||||
it('returns null when surrounding whitespace keeps the trimmed length within bounds', () => {
|
||||
expect(pageTitleError(' OpenVelo ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePageTitle', () => {
|
||||
it('returns an empty string for empty input', () => {
|
||||
expect(normalizePageTitle('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string for whitespace-only input', () => {
|
||||
expect(normalizePageTitle(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from a valid title', () => {
|
||||
expect(normalizePageTitle(' OpenVelo ')).toBe('OpenVelo');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user