AI Implementation feature(888): Admin Area General Settings and Categories 1.06 #28
@@ -1,173 +0,0 @@
|
||||
# Implementation Plan: Admin General Settings — Event Start/End Validation (Job 887)
|
||||
|
||||
## Status
|
||||
|
||||
NOT already implemented. Two distinct gaps are described in the Job:
|
||||
|
||||
1. **Backend (`PUT /api/v1/admin/general/settings`)** accepts any string for
|
||||
`eventStartUtc` / `eventEndUtc` and persists it. Only the cross-field
|
||||
`end > start` rule is enforced. Garbage payloads like
|
||||
`eventStartUtc='not-a-date'` return HTTP 200 and overwrite the stored
|
||||
schedule.
|
||||
2. **Frontend (`/admin/general`)** has no per-field validation feedback for the
|
||||
two `datetime-local` inputs. The cross-field rule fires correctly
|
||||
(`form.invalid`, Save disabled), but the inputs render no `title`,
|
||||
`aria-invalid`, `aria-describedby`, `validationMessage`, or `[data-testid=...-error]`
|
||||
element when either field is empty/invalid individually.
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:**
|
||||
- Backend: NestJS controllers, Zod schemas in `*.dto.ts`, validated by the
|
||||
shared `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts`)
|
||||
which throws `ApiError.validation('Request validation failed', details)`
|
||||
mapped to HTTP 400 `VALIDATION_FAILED`.
|
||||
- Frontend: Angular 17+ standalone components, signal-based state, `OnPush`,
|
||||
`ReactiveFormsModule`, pure helper module at
|
||||
`frontend/src/app/features/admin/general.pure.ts`. Inline error display
|
||||
pattern is already used for the `pageTitle` field
|
||||
(`general-pageTitle-error` + `pageTitleMessage(...)` computed signal +
|
||||
`showPageTitleError()` computed) — replicate this exact pattern for the
|
||||
two date inputs.
|
||||
- **Data Layer:** SQLite via `better-sqlite3`. `SettingsService` stores every
|
||||
general-settings key as a single string row in the `setting` table. No
|
||||
schema migration is required for this job — we are tightening input
|
||||
validation, not changing storage.
|
||||
- **Test Framework & Structure:**
|
||||
- Jest 29 with `ts-jest`, root config at `tests/jest.config.js`. Two
|
||||
projects: `backend` (`tests/backend/`) and `frontend`
|
||||
(`tests/frontend/`). Tests live ONLY in `/repo/tests/`.
|
||||
- Single command `npm test` runs everything.
|
||||
- **Required Tools & Dependencies:** None. We use the already-installed
|
||||
`zod` (datetime check is built into zod ≥ 3.20) and the existing
|
||||
Angular reactive-forms stack. No `setup.sh` changes are required.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
- **To Modify:**
|
||||
- `backend/src/modules/admin/dto/general.dto.ts` — tighten
|
||||
`eventStartUtc` / `eventEndUtc` to require real ISO-8601 datetimes.
|
||||
- `frontend/src/app/features/admin/general.component.ts` — add per-field
|
||||
datetime error rendering and inline error elements under both inputs.
|
||||
- `frontend/src/app/features/admin/general.pure.ts` — add pure helpers
|
||||
(`isoDatetimeValidator`, `datetimeMessage`) so the same logic is
|
||||
unit-testable in isolation.
|
||||
- `tests/backend/admin-general-service.spec.ts` — add negative cases for
|
||||
non-datetime payloads.
|
||||
- `tests/frontend/admin-general-pure.spec.ts` — add tests for the new
|
||||
validators + message helpers.
|
||||
- **To Create:** None. All changes fit inside existing files.
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### 3.1 Backend — strict ISO-8601 datetime validation
|
||||
|
||||
In `backend/src/modules/admin/dto/general.dto.ts`:
|
||||
|
||||
- Replace the two loose `z.string()` declarations with zod's built-in
|
||||
`.datetime({ offset: false })` check (or
|
||||
`z.string().datetime({ message: '...' })` with explicit messages), so that
|
||||
`GeneralSettingsSchema` rejects non-ISO-8601 strings with a clear
|
||||
`INVALID_DATETIME` message before the request reaches
|
||||
`SettingsService.set(...)`. This guarantees:
|
||||
- The previously valid schedule is unchanged when validation fails (the
|
||||
service is never called).
|
||||
- The error is surfaced via the standard
|
||||
`ApiError.validation('Request validation failed', details)` envelope
|
||||
produced by `ZodValidationPipe` (HTTP 400 `VALIDATION_FAILED`).
|
||||
- Keep the existing `superRefine` end > start rule; it now runs only on
|
||||
values that already passed the per-field datetime check.
|
||||
- Empty string handling: the current UI sends `''` when the user clears an
|
||||
input, and the current code accepts `''`. To avoid regressing the
|
||||
"unconfigured event" state, allow `z.union([z.string().datetime(...), z.literal('')])`
|
||||
(an empty string is a valid "no event scheduled" value). Anything that
|
||||
is neither empty nor a valid ISO datetime is rejected.
|
||||
|
||||
### 3.2 Frontend — per-field datetime error rendering
|
||||
|
||||
In `frontend/src/app/features/admin/general.component.ts`:
|
||||
|
||||
- Apply `isoDatetimeValidator` (new, see 3.3) as a per-control validator
|
||||
on both `eventStartUtc` and `eventEndUtc` (alongside the existing
|
||||
group-level `endAfterStartValidator`).
|
||||
- For each input, add the `aria-describedby` / `aria-invalid` attributes
|
||||
and an inline `<div class="field-error" data-testid="general-eventStart-error">…</div>`
|
||||
(and the matching `general-eventEnd-error`) that renders when the
|
||||
control is touched/dirty AND invalid, driven by a `computed` signal —
|
||||
exactly mirroring the existing `showPageTitleError` /
|
||||
`general-pageTitle-error` pattern in this same file.
|
||||
- The message text comes from the new `datetimeMessage(value, errors)`
|
||||
pure helper (see 3.3). Messages:
|
||||
- non-empty value that fails `Date.parse` →
|
||||
`"Event start must be a valid ISO-8601 datetime."` (or "Event end").
|
||||
- empty value → no message (empty is the intentional "unconfigured"
|
||||
state; the `endBeforeStart` group validator already stays quiet when
|
||||
one side is empty).
|
||||
- The existing `general-endBeforeStart` element stays put; it now
|
||||
coexists with the two new per-field error nodes.
|
||||
|
||||
### 3.3 Frontend pure helpers
|
||||
|
||||
In `frontend/src/app/features/admin/general.pure.ts`, add:
|
||||
|
||||
```ts
|
||||
export function isoDatetimeValidator(ctrl: { value: string | null }) {
|
||||
const v = ctrl?.value ?? '';
|
||||
if (v === '') return null; // unconfigured is allowed
|
||||
const t = Date.parse(v);
|
||||
return Number.isFinite(t) ? null : { invalidDatetime: true };
|
||||
}
|
||||
|
||||
export function datetimeMessage(
|
||||
fieldLabel: string,
|
||||
value: string | null | undefined,
|
||||
controlErrors: { [k: string]: unknown } | null | undefined,
|
||||
): string | null {
|
||||
if (controlErrors?.['invalidDatetime']) {
|
||||
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
|
||||
}
|
||||
// also catch the case where the user typed something unparseable but
|
||||
// the validator did not run yet (defensive)
|
||||
const v = value ?? '';
|
||||
if (v !== '' && !Number.isFinite(Date.parse(v))) {
|
||||
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
These are exported from the existing pure file so the component can call
|
||||
them directly and the spec file can exercise them in isolation.
|
||||
|
||||
### 3.4 No persistence side-effects on rejection
|
||||
|
||||
This is already enforced by the existing `ZodValidationPipe` (the
|
||||
service is never invoked when validation fails), so no further change is
|
||||
needed there.
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- **Target Unit Test Files:**
|
||||
- `tests/backend/admin-general-service.spec.ts` — extend the existing
|
||||
`GeneralSettingsSchema - validation rules` describe block with two
|
||||
cases:
|
||||
- `eventStartUtc = 'not-a-date'` → schema returns `success: false`.
|
||||
- `eventStartUtc = '2026-08-15T10:30:00.000Z'` and
|
||||
`eventEndUtc = 'also-not-a-date'` → schema returns
|
||||
`success: false`, and the issue message includes `'eventEndUtc'`
|
||||
/ contains the word `datetime` (to assert the new error is the
|
||||
source of the rejection, not the cross-field rule).
|
||||
- `tests/frontend/admin-general-pure.spec.ts` — extend with:
|
||||
- `isoDatetimeValidator` returns `null` for empty input.
|
||||
- `isoDatetimeValidator` returns `null` for a valid ISO string.
|
||||
- `isoDatetimeValidator` returns `{ invalidDatetime: true }` for
|
||||
`'not-a-date'`.
|
||||
- `datetimeMessage` returns the correct per-field message for
|
||||
`'Event start'` / `'Event end'` and returns `null` for empty /
|
||||
valid input.
|
||||
- **Mocking Strategy:** Pure-function tests need no mocks. The existing
|
||||
backend spec already constructs the schema in-process; we just feed
|
||||
it bad payloads and inspect `safeParse` results. No DB, no HTTP
|
||||
server, no Angular `TestBed` are required.
|
||||
- **Command:** `npm test` (or the narrower
|
||||
`npm run test:backend` / `npm run test:frontend`).
|
||||
@@ -0,0 +1,83 @@
|
||||
# Implementation Plan: Job 888 — Admin Area General Settings (event window validation)
|
||||
|
||||
## Status
|
||||
NOT YET IMPLEMENTED. Two negative-case defects remain in the admin General settings form:
|
||||
|
||||
1. **Backend (`backend/src/modules/admin/dto/general.dto.ts:10-11`)** — the `eventStartUtc` / `eventEndUtc` fields are typed `z.union([z.literal(''), z.string().datetime({...})])`. Empty strings bypass validation and overwrite a previously valid event schedule with `''`. The docs at `docs/api/admin.md:48-53` and `docs/guides/admin-general-settings.md` describe this empty-allowed behaviour, but the Job explicitly requires the empty case to be rejected so the prior schedule is preserved.
|
||||
2. **Frontend (`frontend/src/app/features/admin/general.component.ts:109-125`)** — the cross-field `endBeforeStart` error and the per-field `general-eventEnd-error` are both gated on `form.controls.eventEndUtc.touched`. When the user simply types a value the field-level @if may not render visibly, and the End input has no `aria-invalid`, `aria-describedby`, `title`, or `validity.validationMessage` binding, so a disabled Save button leaves the user with no visible cue.
|
||||
|
||||
The plan below fixes both defects and re-enables existing positive paths. No schema migration is required.
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:**
|
||||
- **Backend:** NestJS 10, TypeScript strict, zod schemas under `backend/src/modules/<module>/dto/<module>.dto.ts` consumed via `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts`). Errors are raised through `ApiError.validation(...)` returning `{ code: 'VALIDATION_FAILED', message, details: [{path, message}] }` with HTTP 400 (`backend/src/common/errors/api-error.ts`).
|
||||
- **Frontend:** Angular 17+ standalone components with `ChangeDetectionStrategy.OnPush`, reactive forms (`ReactiveFormsModule`), and explicit signal wrappers (`Value`, `Invalid`, `TouchedOrDirty`) for inline error messaging (see `general.component.ts:200-257`). Templates use `@if` blocks with `data-testid` hooks tested in `tests/frontend/admin-general-pure.spec.ts`.
|
||||
- **Pure helpers:** `frontend/src/app/features/admin/general.pure.ts` exports all validation/message helpers and is the only place to mutate when changing field-level rules; the component imports them.
|
||||
|
||||
- **Data Layer:** SQLite (`better-sqlite3`) with a `setting` key/value table (`docs/database/auth-settings.md`). `AdminGeneralService.updateSettings` (`backend/src/modules/admin/general.service.ts:61-74`) writes each `eventStartUtc` / `eventEndUtc` value through `SettingsService.set`. No schema migration is needed for this Job.
|
||||
|
||||
- **Test Framework & Structure:** Jest (`ts-jest`) with two projects (backend / frontend) configured in `tests/jest.config.js`. Tests live under `tests/backend/**` and `tests/frontend/**`. The repo-root `npm test` (alias of `jest --config tests/jest.config.js`) runs everything. Backend tests boot the full Nest app via `Test.createTestingModule({ imports: [AppModule] })` and use `request.agent` + CSRF (`tests/backend/csrf-client.ts`); frontend tests are pure-function specs under `tests/frontend/admin-general-pure.spec.ts` — no DOM, no UI. New tests must follow the same single-command layout.
|
||||
|
||||
- **Required Tools & Dependencies:** No new packages. The required tools (Node 20+, npm workspaces, `jest`, `ts-jest`, `better-sqlite3`) are already declared in `package.json` and bootstrapped by `setup.sh`. The implementer does not need to install anything new.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
- **To Modify:**
|
||||
- `backend/src/modules/admin/dto/general.dto.ts` — tighten the `eventStartUtc` / `eventEndUtc` zod union so empty strings are rejected with the per-field message; leave `superRefine` end-after-start check in place.
|
||||
- `frontend/src/app/features/admin/general.component.ts` — extend the Event end `<input>` bindings to surface `aria-invalid`, `aria-describedby`, `title`, and `validity.validationMessage` whenever the form is in the `endBeforeStart` invalid state; ensure the inline `general-endBeforeStart` and `general-eventEnd-error` blocks render as soon as the cross-field validator fires (not only after `touched`); mirror the same logic symmetrically on the Event start side for completeness.
|
||||
- `frontend/src/app/features/admin/general.pure.ts` — make the inline-error messages for `endBeforeStart` and `invalidDatetime` available as exports if they are not already (they are inline strings today; centralise to keep the template clean). Optionally tighten `isoDatetimeValidator` to keep treating empty as invalid client-side so the UI can also block Save when Start/End are blank after the fix — see "Open Question" below.
|
||||
- `tests/backend/admin-validation.spec.ts` — add focused negative-case tests for the general-settings endpoint: empty `eventEndUtc` rejected with HTTP 400, malformed string rejected with HTTP 400, end ≤ start rejected with HTTP 400; plus a positive test that confirms valid ISO-8601 values persist unchanged.
|
||||
- `tests/frontend/admin-general-pure.spec.ts` — add a small `invalidDatetime` regression asserting that `isoDatetimeValidator({ value: '' })` returns `{ invalidDatetime: true }` (if we choose to flip client validation to match backend) — see Open Question.
|
||||
|
||||
- **To Create:**
|
||||
- `tests/backend/admin-general-event-window.spec.ts` — dedicated spec for the new negative cases; uses the same Nest-app fixture pattern as `tests/backend/admin-general-service.spec.ts` but focuses on PUT validation only.
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Tighten the zod schema** in `backend/src/modules/admin/dto/general.dto.ts`:
|
||||
- Replace each `eventStartUtc` / `eventEndUtc` field from `z.union([z.literal(''), z.string().datetime({ message: '...' })])` to `z.string().datetime({ message: '... must be a valid ISO-8601 datetime' })` directly. The empty-string alternative is what allowed `eventEndUtc=''` to overwrite a valid schedule (Job negative case 1).
|
||||
- Keep the `superRefine` rule that emits `eventEndUtc must be strictly after eventStartUtc` on `path: ['eventEndUtc']` when both values are parseable and `end <= start`.
|
||||
- The error envelope remains `{ code: 'VALIDATION_FAILED', message: 'Request validation failed', details: [{path, message}] }` (HTTP 400), emitted by `ZodValidationPipe` (`backend/src/common/pipes/zod-validation.pipe.ts:9-15`) — no pipe changes needed.
|
||||
|
||||
2. **Persist only on success:** nothing to change in `AdminGeneralService.updateSettings` itself; because `ZodValidationPipe` throws before the handler is called, the prior schedule is preserved automatically on rejected payloads (the SettingsService is never touched).
|
||||
|
||||
3. **Docs reconciliation (non-blocking):** update `docs/api/admin.md:48-53` and `docs/guides/admin-general-settings.md` sentences that say "empty strings are allowed" so the docs reflect that Start/End are now required (it is OK if the docs lag the change; the Job is authoritative).
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Update `general.pure.ts`** to keep the validator surface aligned with the new backend:
|
||||
- If we choose to also reject empty strings client-side (recommended — keeps UI feedback consistent with backend), change `isoDatetimeValidator` so `''` returns `{ invalidDatetime: true }` instead of `null`. If we keep empty-allowed client-side, then `datetimeMessage` must still show a helpful message on `''` so the field stays consistent with the disabled Save button.
|
||||
- Expose a `endBeforeStartMessage` helper that returns `'Event end must be after event start.'` so the template renders it from one source.
|
||||
|
||||
2. **Update `general.component.ts`** for the Event End input and surrounding @if blocks (lines 109-125):
|
||||
- Bind `[attr.aria-invalid]` and `[attr.aria-describedby]` on the End `<input>` to a new `showEventEndCrossFieldError` computed that is true whenever the form has `errors.endBeforeStart`. Also bind `[attr.title]` to the message string and `[attr.validity.validationMessage]` is not directly supported by Angular — instead, surface a `data-error-message` attribute or render a hidden `<span class="sr-only">` so screen readers can announce it.
|
||||
- Render the `general-endBeforeStart` block whenever `endBeforeStart` is present (drop the `touched` gate so the user sees the error the moment they leave End ≤ Start). Keep `general-eventEnd-error` rendering only for `invalidDatetime` cases.
|
||||
- Symmetrically, for Event start, render the `general-eventStart-error` whenever `invalidDatetime` is on the control (today it is correctly shown when the input becomes dirty AND invalid).
|
||||
|
||||
3. **Save gating remains:** `[disabled]="submitting() || form.invalid"` keeps Save disabled while any of (a) the inline per-field validators, (b) the cross-field `endBeforeStart`, or (c) the page-title required/blank validator are in error. No change needed.
|
||||
|
||||
### Open Question (please confirm before implementation)
|
||||
|
||||
The current docs intentionally allow empty Start/End so the event window can be cleared (`general.pure.ts:69-72`, `docs/api/admin.md:48-53`). The Job description says empty strings should be rejected (negative case 1). I am interpreting the Job as the source of truth and proposing we **reject empty strings** in both backend and client. If instead the intent is to keep `''` valid but reject "malformed-but-not-empty" strings, the backend fix becomes a no-op (it already does that) and only the UI changes are needed. Please confirm before implementation begins.
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- **Target backend test file:** new `tests/backend/admin-general-event-window.spec.ts`. Reuses `tests/backend/csrf-client.ts`, follows the exact pattern from `tests/backend/admin-validation.spec.ts:16-46` (boots `AppModule`, registers first admin, primes CSRF, then exercises `PUT /api/v1/admin/general/settings` via `request.agent`).
|
||||
- **Target frontend test file:** `tests/frontend/admin-general-pure.spec.ts`. Add only the helper-level assertions (e.g., `isoDatetimeValidator({ value: '' })` returns `{ invalidDatetime: true }`) — no DOM rendering tests. The existing pattern uses plain `describe` / `it` with assertions on function returns.
|
||||
- **Mocking strategy:** No new mocks. The backend spec uses the real `AppModule` against `:memory:` SQLite (set via `process.env.DATABASE_PATH = ':memory:'` at the top of the file, mirroring `admin-validation.spec.ts:1-3`). Auth uses `register-first-admin` + login + CSRF — already idempotent because the database is in-memory and the test creates exactly one admin in `beforeAll`.
|
||||
- **Cases to cover (minimal & focused):**
|
||||
1. **Backend negative — empty Event End** ⇒ `PUT /settings` with `eventEndUtc: ''` returns HTTP 400 and the per-field message, and a follow-up `GET /settings` shows `eventEndUtc` unchanged.
|
||||
2. **Backend negative — empty Event Start** ⇒ symmetric to (1).
|
||||
3. **Backend negative — malformed datetime** ⇒ `PUT /settings` with `eventEndUtc: 'not-a-date'` returns HTTP 400 and `'eventEndUtc must be a valid ISO-8601 datetime'`.
|
||||
4. **Backend negative — end ≤ start** ⇒ valid Start + `End === Start` returns HTTP 400 with `'eventEndUtc must be strictly after eventStartUtc'`.
|
||||
5. **Backend positive — valid window** ⇒ well-formed `2026-08-15T10:30:00.000Z` / `2026-08-20T18:45:00.000Z` persists and is round-tripped unchanged.
|
||||
6. **Frontend pure** — assert `isoDatetimeValidator('')` returns the invalid sentinel (if we choose to flip empty to invalid client-side).
|
||||
7. **Frontend pure** — assert the new `endBeforeStartMessage` helper returns the canonical string.
|
||||
- **Single command:** `npm test` from `/repo` runs the full suite. Frontend and backend can also be run separately via `npm run test:backend` / `npm run test:frontend` (already in `package.json`).
|
||||
|
||||
## 5. Persistent Project Data (/data)
|
||||
|
||||
Not applicable to this Job. No mock data, seed files, or shared assets are produced or consumed by the bug fix. Tests boot against `:memory:` SQLite, which is sufficient.
|
||||
@@ -7,8 +7,8 @@ export const GeneralSettingsSchema = z
|
||||
logo: z.string().max(2048),
|
||||
welcomeMarkdown: z.string().max(64_000),
|
||||
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
|
||||
eventStartUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' })]),
|
||||
eventEndUtc: z.union([z.literal(''), z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' })]),
|
||||
eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }),
|
||||
eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }),
|
||||
defaultChallengeIp: z.string().min(1).max(255),
|
||||
registrationsEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
+9
-7
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: api
|
||||
title: Admin Endpoints
|
||||
description: Admin-only endpoints for user management, general settings, categories, and supporting uploads.
|
||||
description: Admin-only endpoints for user management, required general settings, categories, and supporting uploads.
|
||||
tags: [api, admin, users, general, categories]
|
||||
timestamp: 2026-07-22T12:00:00Z
|
||||
timestamp: 2026-07-22T14:24:08Z
|
||||
---
|
||||
|
||||
# Endpoints
|
||||
@@ -46,11 +46,13 @@ removes the row.
|
||||
* `logo` up to 2048 chars (a public URL — uploaded separately)
|
||||
* `welcomeMarkdown` up to 64 000 chars
|
||||
* `themeKey` is one of `THEME_IDS`
|
||||
* `eventStartUtc` / `eventEndUtc` are validated as ISO-8601 datetimes
|
||||
(zod `string().datetime(...)`) — empty strings are explicitly allowed
|
||||
to keep the event window unconfigured. `superRefine` then enforces
|
||||
`eventEndUtc > eventStartUtc` and reports the issue on `eventEndUtc`
|
||||
when both fields are non-empty.
|
||||
* `eventStartUtc` / `eventEndUtc` must each be non-empty ISO-8601
|
||||
datetimes. Empty and malformed strings produce a field-specific
|
||||
`400 VALIDATION_FAILED` issue (`eventStartUtc must be a valid ISO-8601
|
||||
datetime` or `eventEndUtc must be a valid ISO-8601 datetime`).
|
||||
`superRefine` additionally requires `eventEndUtc > eventStartUtc` and
|
||||
reports `eventEndUtc must be strictly after eventStartUtc` on the end
|
||||
field when both values parse but are out of order.
|
||||
* `defaultChallengeIp` 1–255 chars
|
||||
* `registrationsEnabled` boolean
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Key Files Index
|
||||
description: One-line responsibility for important source files, including bootstrap payloads, runtime theme application, authenticated event streaming, and validated site-logo uploads.
|
||||
tags: [architecture, index, key-files]
|
||||
timestamp: 2026-07-22T13:40:00Z
|
||||
description: One-line responsibility for important source and contract-test files, including strict event-window validation.
|
||||
tags: [architecture, key-files, event-window, validation]
|
||||
timestamp: 2026-07-22T14:24:08Z
|
||||
---
|
||||
|
||||
# Backend
|
||||
@@ -25,7 +25,9 @@ timestamp: 2026-07-22T13:40:00Z
|
||||
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
|
||||
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
|
||||
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
|
||||
| `backend/src/modules/admin/dto/general.dto.ts` | Zod schema for `PUT /api/v1/admin/general/settings` — string-length rules, theme-key enum, ISO-8601 datetime check on the event-window fields (empty string allowed), and the `endAfterStart` super-refine. |
|
||||
| `backend/src/modules/admin/dto/general.dto.ts` | Zod contract for `PUT /api/v1/admin/general/settings`; both event timestamps are required ISO-8601 values and end must be strictly after start. |
|
||||
| `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. |
|
||||
| `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. |
|
||||
|
||||
# Frontend
|
||||
|
||||
@@ -47,7 +49,8 @@ timestamp: 2026-07-22T13:40:00Z
|
||||
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
|
||||
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
|
||||
| `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. |
|
||||
| `frontend/src/app/features/admin/general.pure.ts` | Pure helpers used by `AdminGeneralComponent` — `deriveEventState`, `endAfterStartValidator`, `normalizePageTitle`, `toIsoUtc`, `toDatetimeLocal`, `pageTitleError` / `pageTitleMessage`, `isoDatetimeValidator`, and `datetimeMessage`. |
|
||||
| `frontend/src/app/features/admin/general.pure.ts` | Pure General Settings helpers, including required datetime validation, field messages, UTC conversion, and end-after-start validation. |
|
||||
| `tests/frontend/admin-general-pure.spec.ts` | Pure client-contract tests for required event timestamps, datetime messaging, UTC conversion, and event-window ordering. |
|
||||
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
|
||||
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: guide
|
||||
title: Admin — General Settings
|
||||
description: How an admin edits global platform settings (page title, logo, theme, event window, default challenge IP, registrations, welcome Markdown) from the /admin/general page.
|
||||
description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page.
|
||||
tags: [guide, admin, settings, general, tester, datetime, validation]
|
||||
timestamp: 2026-07-22T14:07:58Z
|
||||
timestamp: 2026-07-22T14:24:08Z
|
||||
---
|
||||
|
||||
# When this view is available
|
||||
@@ -42,8 +42,8 @@ The page is a single reactive form with these controls (every
|
||||
| Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. |
|
||||
| Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. |
|
||||
| Global theme | `general-themeKey` | `themeKey` | `<select>` populated from `/themes`. Value is one of `THEME_IDS`. |
|
||||
| Event start (UTC) | `general-eventStart` | `eventStartUtc` | `datetime-local` input. Empty string is allowed; any non-empty value must be a valid ISO-8601 datetime — invalid input renders `general-eventStart-error`. |
|
||||
| Event end (UTC) | `general-eventEnd` | `eventEndUtc` | `datetime-local` input. Empty string is allowed; any non-empty value must be a valid ISO-8601 datetime — invalid input renders `general-eventEnd-error`. Must also be strictly after Event start (`general-endBeforeStart`). |
|
||||
| Event start (UTC) | `general-eventStart` | `eventStartUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventStart-error` and disables Save. |
|
||||
| Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventEnd-error`; it must also be strictly after Event start (`general-endBeforeStart`). |
|
||||
| Default challenge IP | `general-defaultIp` | `defaultChallengeIp` | Required, max 255 chars. |
|
||||
| Enable registrations | `general-registrations` | `registrationsEnabled` | Boolean checkbox. When `false`, the public register endpoint returns `REGISTRATIONS_DISABLED`. |
|
||||
| Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. |
|
||||
@@ -74,15 +74,12 @@ The page is a single reactive form with these controls (every
|
||||
strictly after the start, the form becomes invalid and
|
||||
`general-endBeforeStart` appears under the end input. Save remains
|
||||
disabled.
|
||||
* **Per-field datetime validation:** if the user types (or leaves) a value
|
||||
that is not parseable as an ISO-8601 datetime in either the Event start
|
||||
or Event end input, the affected input flips `aria-invalid="true"` and
|
||||
its inline error element (`general-eventStart-error` or
|
||||
`general-eventEnd-error`) renders the message
|
||||
`"<Field> must be a valid ISO-8601 datetime."`. Empty strings are
|
||||
considered valid (the event window may be unconfigured), so clearing
|
||||
both inputs removes the error. Save remains disabled while either input
|
||||
is invalid.
|
||||
* **Per-field datetime validation:** clearing a field or supplying a value
|
||||
that cannot be parsed as an ISO-8601 datetime makes that control invalid.
|
||||
The affected input receives `aria-invalid="true"`, references its inline
|
||||
error through `aria-describedby`, and renders
|
||||
`"<Field> must be a valid ISO-8601 datetime."`. Save remains disabled
|
||||
until both timestamps are valid and ordered correctly.
|
||||
* **Save:** clicking Save sends `PUT /api/v1/admin/general/settings`
|
||||
with all fields. The Page title input is validated client-side for
|
||||
non-blank content; an empty or whitespace-only Page title disables the
|
||||
@@ -134,13 +131,11 @@ two pure helpers exported from
|
||||
`frontend/src/app/features/admin/general.pure.ts`:
|
||||
|
||||
* `isoDatetimeValidator(ctrl)` — an Angular reactive-forms validator
|
||||
that returns `null` for an empty value and `{ invalidDatetime: true }`
|
||||
for any non-empty value that `Date.parse` cannot parse. Empty is
|
||||
intentionally valid so the event window can be cleared.
|
||||
that returns `null` only when `Date.parse` accepts the value. Empty and
|
||||
malformed values return `{ invalidDatetime: true }`.
|
||||
* `datetimeMessage(fieldLabel, value, controlErrors)` — renders the
|
||||
human-readable message `"<Field> must be a valid ISO-8601 datetime."`
|
||||
either when `controlErrors.invalidDatetime` is set, or as a fallback
|
||||
when the raw value itself is non-empty and unparseable.
|
||||
when `invalidDatetime` is set or when the raw value is unparseable.
|
||||
|
||||
The component wires both controls (`eventStartUtc`, `eventEndUtc`) with
|
||||
this validator and keeps three local `signal`s per field — `Value`,
|
||||
@@ -156,19 +151,20 @@ The `computed` signals `showEventStartError` / `showEventEndError` and
|
||||
|
||||
| Condition | Message |
|
||||
|------------------------------------------------------------|--------------------------------------------------------|
|
||||
| Empty value (`''`) | `null` (no error; the event window may be unconfigured). |
|
||||
| Non-empty value that `Date.parse` accepts | `null` (no error). |
|
||||
| Non-empty value that `Date.parse` rejects | `"<Field> must be a valid ISO-8601 datetime."` |
|
||||
| Control carries `invalidDatetime` error | Same message (covered by the validator path). |
|
||||
| Empty value (`''`) | `"<Field> must be a valid ISO-8601 datetime."` |
|
||||
| Value that `Date.parse` accepts | `null` (no error). |
|
||||
| Non-empty value that `Date.parse` rejects | `"<Field> must be a valid ISO-8601 datetime."` |
|
||||
| Control carries `invalidDatetime` error | Same message (covered by the validator path). |
|
||||
|
||||
The error is shown only when the control is both `touched || dirty`
|
||||
AND `invalid`, so the inputs do not flash errors on initial load. After
|
||||
a successful Save, both controls are reset to untouched/pristine via
|
||||
`resetTouchedState()` and their `TouchedOrDirty` signals are cleared.
|
||||
The cross-field `endBeforeStart` error (`general-endBeforeStart`) is
|
||||
still emitted from the form-level `superRefine`/validator chain and is
|
||||
unchanged — it now sits next to the new per-field messages when the
|
||||
user supplies two non-empty values that are out of order.
|
||||
emitted by the form-level validator whenever both valid timestamps are
|
||||
present and end is not strictly after start. The Event end input also
|
||||
receives `aria-invalid="true"`, references both possible error elements,
|
||||
and exposes `Event end must be after event start.` as its title.
|
||||
|
||||
# Visual elements
|
||||
|
||||
@@ -193,7 +189,7 @@ user supplies two non-empty values that are out of order.
|
||||
| 3 | `frontend/src/app/core/services/admin.service.ts` | `getGeneralSettings()` → `GET /api/v1/admin/general/settings`; `listAdminThemes()` → `GET /api/v1/admin/general/themes`; `updateGeneralSettings()` → `PUT .../settings`; `uploadLogo()` → `POST /api/v1/uploads/logo`. |
|
||||
| 4 | `backend/src/modules/admin/admin-general.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
|
||||
| 5 | `backend/src/modules/admin/general.service.ts` | `getSettings` reads 8 keys via `SettingsService`; `updateSettings` writes all 8, emits `{ topic: 'general', themeKey }` via `SseHubService`. |
|
||||
| 6 | `backend/src/modules/admin/dto/general.dto.ts` | `GeneralSettingsSchema` enforces string lengths, `themeKey` enum, per-field ISO-8601 datetime check on `eventStartUtc`/`eventEndUtc` (empty string allowed), and `eventEndUtc > eventStartUtc` via `superRefine`. |
|
||||
| 6 | `backend/src/modules/admin/dto/general.dto.ts` | `GeneralSettingsSchema` requires ISO-8601 values for both event-window fields and enforces `eventEndUtc > eventStartUtc` via `superRefine`. |
|
||||
|
||||
# Notes
|
||||
|
||||
@@ -202,8 +198,11 @@ user supplies two non-empty values that are out of order.
|
||||
without polling. Other tabs do not auto-refresh settings values.
|
||||
* The "Event controls" toggle is intentionally a derived display, not
|
||||
an editable control — adjust the UTC timestamps to change state.
|
||||
* All timestamps are stored as ISO-8601 UTC strings in the `setting`
|
||||
* All valid timestamps are stored as ISO-8601 UTC strings in the `setting`
|
||||
table; the UI converts to/from `datetime-local` for display.
|
||||
* Both timestamps are required by the General Settings form and API.
|
||||
The `UNCONFIGURED` derived display remains a defensive state for missing
|
||||
or invalid data, but clearing either input makes the form unsavable.
|
||||
* Saving the form requires the page title and default challenge IP to
|
||||
be non-empty; the form is `invalid` and Save stays disabled until
|
||||
they are.
|
||||
|
||||
+3
-4
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
||||
controls.
|
||||
|
||||
The docs below are organized by purpose so agents can pull just the slice
|
||||
they need. Last regenerated 2026-07-22T13:40:00Z.
|
||||
they need. Last regenerated 2026-07-22T14:24:08Z.
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -62,9 +62,8 @@ they need. Last regenerated 2026-07-22T13:40:00Z.
|
||||
navigate the post-login admin area side-nav (General, Challenges,
|
||||
Players, Blog, System) and reach the enabled child pages.
|
||||
* [Admin — General Settings](/guides/admin-general-settings.md) - How an
|
||||
admin edits platform-wide settings (page title, logo, theme, event
|
||||
window, default challenge IP, registrations, welcome Markdown) at
|
||||
`/admin/general`.
|
||||
admin edits platform-wide settings, including the required and strictly
|
||||
ordered event window, at `/admin/general`.
|
||||
* [Admin — Categories](/guides/admin-categories.md) - How an admin lists,
|
||||
creates, edits, and deletes challenge categories at `/admin/categories`,
|
||||
including system-row and challenge-attached protection.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
datetimeMessage,
|
||||
deriveEventState,
|
||||
endAfterStartValidator,
|
||||
endBeforeStartMessage,
|
||||
isoDatetimeValidator,
|
||||
normalizePageTitle,
|
||||
pageTitleMessage,
|
||||
@@ -113,14 +114,17 @@ import {
|
||||
type="datetime-local"
|
||||
formControlName="eventEndUtc"
|
||||
data-testid="general-eventEnd"
|
||||
[attr.aria-invalid]="showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart']) ? 'true' : null"
|
||||
[attr.aria-describedby]="(showEventEndError() || (form.controls.eventEndUtc.touched && !!form.error?.['endBeforeStart'])) ? 'general-eventEnd-error general-endBeforeStart' : null"
|
||||
[attr.aria-invalid]="showEventEndError() || eventEndCrossFieldError() ? 'true' : null"
|
||||
[attr.aria-describedby]="(showEventEndError() || eventEndCrossFieldError())
|
||||
? 'general-eventEnd-error general-endBeforeStart'
|
||||
: null"
|
||||
[attr.title]="(showEventEndError() ? eventEndMessage() : eventEndCrossFieldError() ? endBeforeStartMessage : null)"
|
||||
/>
|
||||
@if (showEventEndError()) {
|
||||
<div class="field-error" id="general-eventEnd-error" data-testid="general-eventEnd-error">{{ eventEndMessage() }}</div>
|
||||
}
|
||||
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) {
|
||||
<div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">Event end must be after event start.</div>
|
||||
@if (eventEndCrossFieldError()) {
|
||||
<div class="field-error" id="general-endBeforeStart" data-testid="general-endBeforeStart">{{ endBeforeStartMessage }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -256,6 +260,10 @@ export class AdminGeneralComponent implements OnInit {
|
||||
datetimeMessage('Event end', this.eventEndValue(), this.form.controls.eventEndUtc.errors),
|
||||
);
|
||||
|
||||
readonly eventEndCrossFieldError = computed(
|
||||
() => !!this.form.errors?.['endBeforeStart'],
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.form.controls.welcomeMarkdown.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
|
||||
@@ -67,7 +67,6 @@ export function toIsoUtc(local: string): string {
|
||||
|
||||
export function isoDatetimeValidator(ctrl: { value: string | null | undefined }): { invalidDatetime: true } | null {
|
||||
const v = ctrl?.value ?? '';
|
||||
if (v === '') return null;
|
||||
const t = Date.parse(v);
|
||||
return Number.isFinite(t) ? null : { invalidDatetime: true };
|
||||
}
|
||||
@@ -81,8 +80,10 @@ export function datetimeMessage(
|
||||
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
|
||||
}
|
||||
const v = value ?? '';
|
||||
if (v !== '' && !Number.isFinite(Date.parse(v))) {
|
||||
if (!Number.isFinite(Date.parse(v))) {
|
||||
return `${fieldLabel} must be a valid ISO-8601 datetime.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const endBeforeStartMessage = 'Event end must be after event start.';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { GeneralSettingsSchema } from '../../backend/src/modules/admin/dto/general.dto';
|
||||
|
||||
const baseValid = {
|
||||
pageTitle: 'OpenVelo',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'classic',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
};
|
||||
|
||||
function withEvent(start: string, end: string) {
|
||||
return { ...baseValid, eventStartUtc: start, eventEndUtc: end };
|
||||
}
|
||||
|
||||
describe('Admin general settings - event window validation (Job 888)', () => {
|
||||
it('accepts a valid event window', () => {
|
||||
const r = GeneralSettingsSchema.safeParse(
|
||||
withEvent('2026-08-15T10:30:00.000Z', '2026-08-20T18:45:00.000Z'),
|
||||
);
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty eventEndUtc with a per-field message', () => {
|
||||
const r = GeneralSettingsSchema.safeParse(withEvent('2026-08-15T10:30:00.000Z', ''));
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) return;
|
||||
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
|
||||
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
|
||||
});
|
||||
|
||||
it('rejects an empty eventStartUtc with a per-field message', () => {
|
||||
const r = GeneralSettingsSchema.safeParse(withEvent('', '2026-08-20T18:45:00.000Z'));
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) return;
|
||||
const startIssue = r.error.issues.find((i) => i.path.join('.') === 'eventStartUtc');
|
||||
expect(startIssue?.message).toBe('eventStartUtc must be a valid ISO-8601 datetime');
|
||||
});
|
||||
|
||||
it('rejects a non-empty malformed eventEndUtc', () => {
|
||||
const r = GeneralSettingsSchema.safeParse(withEvent('2026-08-15T10:30:00.000Z', 'not-a-date'));
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) return;
|
||||
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
|
||||
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
|
||||
});
|
||||
|
||||
it('rejects end equal to start', () => {
|
||||
const ts = '2026-08-15T10:30:00.000Z';
|
||||
const r = GeneralSettingsSchema.safeParse(withEvent(ts, ts));
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) return;
|
||||
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
|
||||
expect(endIssue?.message).toBe('eventEndUtc must be strictly after eventStartUtc');
|
||||
});
|
||||
|
||||
it('rejects end before start', () => {
|
||||
const r = GeneralSettingsSchema.safeParse(
|
||||
withEvent('2026-08-20T18:45:00.000Z', '2026-08-15T10:30:00.000Z'),
|
||||
);
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) return;
|
||||
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
|
||||
expect(endIssue?.message).toBe('eventEndUtc must be strictly after eventStartUtc');
|
||||
});
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe('GeneralSettingsSchema - validation rules', () => {
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts empty strings (unconfigured event window) for both datetime fields', () => {
|
||||
it('rejects empty strings for event datetime fields with per-field messages (Job 888)', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'T',
|
||||
logo: '',
|
||||
@@ -152,7 +152,12 @@ describe('GeneralSettingsSchema - validation rules', () => {
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.success).toBe(false);
|
||||
if (r.success) return;
|
||||
const startIssue = r.error.issues.find((i) => i.path.join('.') === 'eventStartUtc');
|
||||
const endIssue = r.error.issues.find((i) => i.path.join('.') === 'eventEndUtc');
|
||||
expect(startIssue?.message).toBe('eventStartUtc must be a valid ISO-8601 datetime');
|
||||
expect(endIssue?.message).toBe('eventEndUtc must be a valid ISO-8601 datetime');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -165,10 +165,10 @@ describe('pageTitleMessage', () => {
|
||||
});
|
||||
|
||||
describe('isoDatetimeValidator', () => {
|
||||
it('returns null for an empty value (unconfigured is allowed)', () => {
|
||||
expect(isoDatetimeValidator({ value: '' })).toBeNull();
|
||||
expect(isoDatetimeValidator({ value: null })).toBeNull();
|
||||
expect(isoDatetimeValidator({ value: undefined })).toBeNull();
|
||||
it('returns { invalidDatetime: true } for an empty value (empty string no longer accepted)', () => {
|
||||
expect(isoDatetimeValidator({ value: '' })).toEqual({ invalidDatetime: true });
|
||||
expect(isoDatetimeValidator({ value: null })).toEqual({ invalidDatetime: true });
|
||||
expect(isoDatetimeValidator({ value: undefined })).toEqual({ invalidDatetime: true });
|
||||
});
|
||||
|
||||
it('returns null for a valid ISO-8601 datetime', () => {
|
||||
@@ -194,9 +194,9 @@ describe('datetimeMessage', () => {
|
||||
.toBe('Event start must be a valid ISO-8601 datetime.');
|
||||
});
|
||||
|
||||
it('returns null for an empty value', () => {
|
||||
expect(datetimeMessage('Event start', '', null)).toBeNull();
|
||||
expect(datetimeMessage('Event start', '', { invalidDatetime: false as any })).toBeNull();
|
||||
it('returns the per-field error for an empty value (empty is now invalid)', () => {
|
||||
expect(datetimeMessage('Event start', '', null)).toBe('Event start must be a valid ISO-8601 datetime.');
|
||||
expect(datetimeMessage('Event end', '', { invalidDatetime: true })).toBe('Event end must be a valid ISO-8601 datetime.');
|
||||
});
|
||||
|
||||
it('returns null for a valid ISO-8601 datetime', () => {
|
||||
|
||||
Reference in New Issue
Block a user