]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(config): add Zod-based simulator configuration syntax validation (#1874)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 27 May 2026 21:30:34 +0000 (23:30 +0200)
committerGitHub <noreply@github.com>
Wed, 27 May 2026 21:30:34 +0000 (23:30 +0200)
* feat(config): scaffold simulator configuration schema and validator skeleton

- Add ConfigurationMigrations.ts: CURRENT_CONFIGURATION_SCHEMA_VERSION=1,
  coerceConfigurationVersion, applyConfigurationMigration, migrateV0ToV1
  with DEPRECATED_KEY_REMAPPINGS (~25 legacy keys)
- Add ConfigurationSchema.ts: strict Zod v4 schema for all config sections
  (log, worker, performanceStorage, uiServer, stationTemplateUrls) with
  deprecated keys as .optional()
- Add ConfigurationValidation.ts: validateConfiguration pipeline
  (guard→clone→coerce→migrate→safeParse→transform) + ConfigurationValidationError
  extends BaseError with structured fieldErrors
- Add $schemaVersion: 1 to config-template.json
- Add ConfigurationFixtures.ts test helpers
- Add ConfigurationSchema.test.ts (63 tests), ConfigurationMigrations.test.ts (44 tests)
- Wire validateConfiguration into Configuration.getConfigurationData() with
  hard-throw at boot (console.error+chalk+process.exit(1))
- Hot-reload snapshot rollback: pre-clear snapshot, restore on failure
- Replace ConfigurationData interface with z.infer<typeof ConfigurationSchema>
- Migrate all consumers atomically (SimulatorState, Configuration, types barrel)
- Delete checkWorkerProcessType and checkWorkerElementsPerWorker (subsumed by schema)
- Delete ConfigurationMigration.ts (logic moved to ConfigurationMigrations.ts)
- Add ConfigurationValidation.test.ts (39 tests), Configuration-hot-reload.test.ts
- Add ConfigurationValidation-perf.test.ts (p99 < 50ms budget)
- Add error message snapshot test
- Update README.md with $schemaVersion documentation

* feat(config): remove legacy ConfigurationMigration.ts and stale call sites

- Delete src/utils/ConfigurationMigration.ts (deprecated-key logic now
  owned by ConfigurationMigrations.ts v0→v1 migration step)
- Remove checkDeprecatedConfigurationKeys import and call site from
  Configuration.getStationTemplateUrls() — validation pipeline in
  getConfigurationData() already handles deprecated key remapping

* chore(config): silence lint warnings on configuration migrations

- Add JSDoc descriptions for setAtPath() params
- Add JSDoc descriptions and @returns for migrateV0ToV1
- Whitelist 'emerg' (syslog level) and 'REMAPPINGS' in cspell dictionary

* refactor(config): rebuild configuration validation pipeline

Address audit findings on PR #1874:

- Extract deprecated-key sweep into pure remapDeprecatedKeys() that
  reports warnings and field errors via return value instead of side
  effects. Runs unconditionally regardless of $schemaVersion so v1
  configs still containing deprecated keys never silently drop user
  values (B3).
- Replace silent-drop setAtPath with collision- and intermediate-aware
  variant: equal-value writes are idempotent no-ops, unequal values
  produce a typed field error (B4), non-object intermediates are
  reported and stop traversal instead of overwriting user data (N7).
- Type DEPRECATED_KEY_REMAPPINGS as Record<string, string | null>;
  null marks deprecated keys with no canonical destination, replacing
  the autoReconnectMaxRetries self-mapping that silently dropped the
  user value (B2). Add 'worker.elementStartDelay' as a dotted source
  key so nested deprecations live in the same single source of truth.
- Refactor ConfigurationValidationError: primary constructor takes
  FieldError[] + a context with explicit phase ('migration' | 'schema');
  static fromZodError() factory wraps Zod failures. Error messages now
  carry the phase in their tag for clearer diagnostics.
- Switch deprecation-warning channel from logger.warn to console.warn
  to break a re-entrant boot path where the Logger proxy lazily
  resolved Configuration.getConfigurationSection('log'), recursing into
  the validation pipeline (B1). transformConfiguration is now a pure
  deep clone — its previous warning loop moved upstream to the sweep.
- Delete dead post-migration remapping in Configuration.ts:
  deprecatedLogKeyMap, deprecatedWorkerKeyMap, the
  delete configurationData.workerPoolStrategy mutation, the cast hiding
  the legacy 'supervisionURLs' read, and the worker.elementStartDelay
  fallback (B6). All deprecation handling now flows through the
  migration's single source of truth.
- Make hot-reload reload loop async end-to-end. Awaits the change
  callback inside the same lock so subsequent reloads cannot interleave
  with an in-flight callback. Adds configurationFileReloadPending so
  events arriving during a reload coalesce into exactly one drain
  reload after the current one completes (N8).
- Polish schema: use z.enum(NativeEnum) directly for ApplicationProtocol
  and ApplicationProtocolVersion to preserve literal-type narrowing on
  UIServerConfiguration (N4); drop the BaseConfigurationSchema alias
  (N5).

* test(config): cover migration edge cases and hot-reload rollback

Tests aligned with the rebuilt validation pipeline:

- Split migration tests into a remapDeprecatedKeys block (per-key sweep,
  null-destination removal for autoReconnectMaxRetries, equal-value
  collision idempotency, unequal-value collision field error, non-object
  intermediate field error, nested worker.elementStartDelay) and a lean
  applyConfigurationMigration block (version-bump only, immutability).
- Replace the empty self-mapping branch with explicit hasOwnProperty
  removal assertions to defeat the previously vacuous test.
- ConfigurationValidation tests: switch deprecation-warning channel
  spies from logger.warn to console.warn (B1 regression also asserts
  logger.warn is never called from the pipeline), tolerate
  schema-incompatible remap targets so the warning fires before the
  downstream throw, add B3 sweep / B6 SSOT / future-version pipeline /
  ConfigurationValidationError shape (FieldError[] + phase, fromZodError
  factory, schema-phase aggregation) tests, and update the error-message
  snapshot to include the new [phase] tag.
- transformConfiguration immutability test verifies a fresh validation
  is unaffected by mutating a prior return value.
- ConfigurationSchema tests: strict parity for performanceStorage and
  uiServer.authentication; StationTemplateUrl entry constraints
  (empty file, negative numberOfStations, deprecated numberOfStation,
  unknown key); worker.elementsPerWorker and poolMaxSize/MinSize
  positive-integer constraints; log.statisticsInterval non-negative
  integer constraints; bidirectional schema/DEPRECATED_KEY_REMAPPINGS
  sync meta-test that walks both top-level and nested @deprecated
  describe markers.
- Hot-reload tests rebuilt around the async runReloadLoop: validation
  failure asserts the logged error is a ConfigurationValidationError;
  JSON parse error path asserts configurationData and section cache are
  fully restored (Gap 7); a sentinel watcher survives a failed reload;
  N8 rapid double-save coalesces into exactly one drain reload reflecting
  the latest content; flag reset is exercised on both paths.
- Configuration validation perf test: relative p99 budget (20× median
  with a 1ms floor) plus an absolute 500ms catastrophic ceiling,
  replacing the flaky absolute 50ms threshold (N1).
- Fixtures: new buildInvalidJsonString, buildV1WithDeprecatedKey
  (handles top-level and dotted source keys), and
  buildV0WithDeprecatedKeyCollision builders for the new test cases.

* docs(config): trim verbose JSDocs and harmonize with Template* conventions

Polish pass after PR audit: bring comments and docstrings in line with
the existing TemplateMigrations / TemplateValidation / TemplateSchema
patterns, drop ceremonial AI-generated narration, and fix two precision
defects.

- ConfigurationMigrations.ts: trim getAtPath / setAtPath / remapDeprecatedKeys /
  migrateV0ToV1 / applyConfigurationMigration JSDocs to match Template*
  density; drop the historical "now handled unconditionally upstream"
  paragraph (AGENTS.md "exclude historical evolution").
- ConfigurationValidation.ts: collapse the duplicated pipeline narration
  (the function-level JSDoc already enumerates stages, the inline
  // Stage N — markers were removing them); shorten the
  ConfigurationValidationError class docstring; tighten transformConfiguration
  JSDoc and drop the aspirational "future cross-field invariants" sentence;
  collapse the 5-line re-entrancy comment to a 2-line rationale.
- ConfigurationSchema.ts: fix two precision defects in JSDoc — stale
  module name (ConfigurationMigration → ConfigurationMigrations) and wrong
  subject (numberOfStation is not in the remap table; nothing auto-migrates
  it). Disambiguate WorkerConfigurationSchema description (resourceLimits
  is bridged, not deprecated; elementStartDelay is the deprecated alias).
- Configuration.ts: shrink runReloadLoop and performReload JSDocs to two-
  line summaries matching the surrounding private-method conventions in
  the same file; collapse the 4-line ESLint-disable rationale to a single
  -- suffix on the disable directive.
- Test fixtures and headers: drop internal review codes (B1/B3/B4/B6/N7/N8/
  Gap-7/RG-4) from JSDocs and test names — they had no lookup table and
  would be opaque to future maintainers; trim test-file headers to the
  one-line Template* shape.

* refactor(config): address post-audit findings on validation pipeline

* fix(config): drop incompatible v0 remaps and tighten validation invariants

Addresses 4 PR review findings cross-validated by 2 oracles.

- ConfigurationMigrations: drop `useWorkerPool` (boolean→enum),
  `distributeStationToTenantEqually` and `distributeStationsToTenantsEqually`
  (boolean→enum), and `uiWebSocketServer` (legacy shape→strict object) to
  `null` (warn-and-delete) — same pattern as `workerPoolStrategy` and
  `autoReconnectMaxRetries`. v0 configurations carrying any of these
  legacy keys would otherwise auto-write a value the strict schema
  rejects, killing the simulator at boot. Migration now warns and asks
  the user to set the canonical key explicitly. Fixes the BLOCKING
  finding from the PR review (regression on 'v0 configs remain valid').
- ConfigurationMigrations: remove redundant `out.$schemaVersion = 1`
  from `migrateV0ToV1`. The `applyConfigurationMigration` loop is the
  single owner of version stamping; per-step writes are silently
  overwritten and become misleading once a second migration is added.
- ConfigurationSchema: tighten `StationTemplateUrlSchema.numberOfStations`
  from `.nonnegative()` to `.positive()` — `0` was schema-valid but
  semantically meaningless (a template entry that spawns no station)
  and conflicts with `isValidNumberOfStations()` which rejects `<= 0`.
  Deprecated `numberOfStation` and `provisionedNumberOfStations` keep
  `.nonnegative()` (back-compat / 'none provisioned' is meaningful).
- ConfigurationValidation: import `isEmpty` directly from
  `utils/Utils.js` instead of the `utils/index.js` barrel, which
  re-exports `Configuration` and creates the cycle
  `ConfigurationValidation → utils/index → Configuration → ConfigurationValidation`.
  Mirrors the existing direct-path `BaseError` import in
  `ConfigurationMigrations.ts` for the same TDZ-cycle-avoidance reason.

* docs(config): fix precision defects in schema JSDocs and README

- ConfigurationSchema.ts: correct StorageConfigurationSchema JSDoc —
  'URI' is accepted but not auto-migrated (not in DEPRECATED_KEY_REMAPPINGS);
  narrow ConfigurationSchema JSDoc meta-test scope claim to 'top-level
  and worker.* keys' to match actual test coverage.
- README.md: clarify that deprecated-key remapping is unconditional
  (runs on every load, not only on v0 migration).

* fix(config): keep numberOfStations as .nonnegative() to preserve disabled-entry convention

Revert the .positive() tightening from 95cd26dd: docker/config.json ships
5 stationTemplateUrls entries with `numberOfStations: 0` (used as a
'keep on disk but disabled' convention), and this file is copied to
src/assets/config.json by docker/Dockerfile at image build time. With
.positive(), Docker images built from this branch would fail to start
on validateConfiguration's strict parse → process.exit(1).

Bootstrap loops already tolerate 0 (no-op, no crash). The original
.nonnegative() correctly models the on-disk convention; isValidNumberOfStations()
in UIServerSecurity.ts is for runtime UI add-station requests, a
separate concern from config-file parsing.

* docs(config): trim re-entrancy and clone rationales to one-liners

21 files changed:
README.md
cspell.config.yaml
src/assets/config-template.json
src/charging-station/ConfigurationMigrations.ts [new file with mode: 0644]
src/charging-station/ConfigurationSchema.ts [new file with mode: 0644]
src/charging-station/ConfigurationValidation.ts [new file with mode: 0644]
src/types/ConfigurationData.ts
src/utils/Configuration.ts
src/utils/ConfigurationMigration.ts [deleted file]
src/utils/ConfigurationUtils.ts
src/worker/WorkerUtils.ts
src/worker/index.ts
tests/charging-station/ConfigurationMigrations.test.ts [new file with mode: 0644]
tests/charging-station/ConfigurationSchema.test.ts [new file with mode: 0644]
tests/charging-station/ConfigurationValidation-perf.test.ts [new file with mode: 0644]
tests/charging-station/ConfigurationValidation.test.ts [new file with mode: 0644]
tests/charging-station/helpers/ConfigurationFixtures.ts [new file with mode: 0644]
tests/utils/Configuration-hot-reload.test.ts [new file with mode: 0644]
tests/utils/Configuration.test.ts
tests/utils/ConfigurationUtils.test.ts
tests/worker/WorkerUtils.test.ts

index 08085ce26a7222f64fc24a633402bee76c1821ec..07951ecd386e2f7fc5a0863001825704e21c9cd3 100644 (file)
--- a/README.md
+++ b/README.md
@@ -175,6 +175,7 @@ But the modifications to test have to be done to the files in the build target d
 
 | Key                        | Value(s)                                     | Default Value                                                                                                                                                                                                                 | Value type                                                                                                                                                                                                                                                                      | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
 | -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| $schemaVersion             | 1                                            | 1                                                                                                                                                                                                                             | integer                                                                                                                                                                                                                                                                         | Configuration schema version. Set to 1. Files without this field are migrated from v0; deprecated keys are remapped on every load.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 | supervisionUrls            |                                              | []                                                                                                                                                                                                                            | string \| string[]                                                                                                                                                                                                                                                              | string or strings array containing global connection URIs to OCPP-J servers                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
 | supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity                                                                                                                                                                                                     | string                                                                                                                                                                                                                                                                          | supervision urls distribution policy to simulated charging stations                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
 | log                        |                                              | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />} | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />}    | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options                                                                                        |
index d9f74befa1297bb987246037f390e66e0c5dcb00..f90e5b26f3b7bb6f99fd6745c4beb27148d09701 100644 (file)
@@ -98,3 +98,5 @@ words:
   - focusables
   - Focusables
   - secret
+  - emerg
+  - REMAPPINGS
index da09c9992c3c84c04c0a9ea7feb731cfef30c9e6..02f759c387e53c005280f780c0eb90794f79ebc1 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrls": ["ws://localhost:8010/OCPP16/5be7fb271014d90008992f06"],
   "supervisionUrlDistribution": "round-robin",
   "log": {
diff --git a/src/charging-station/ConfigurationMigrations.ts b/src/charging-station/ConfigurationMigrations.ts
new file mode 100644 (file)
index 0000000..e4da459
--- /dev/null
@@ -0,0 +1,317 @@
+import { isDeepStrictEqual } from 'node:util'
+
+// Direct path: the `exception/index.js` barrel re-exports OCPPError, causing a TDZ cycle.
+import { BaseError } from '../exception/BaseError.js'
+
+const moduleName = 'ConfigurationMigrations'
+
+/**
+ * Deprecated configuration key → canonical destination mapping.
+ *
+ * - `string` (top-level or dotted path): remap value to that destination.
+ * - `null`: deprecated key with no canonical destination; delete + warn only.
+ *
+ * Source keys may be dotted to express nested deprecations
+ * (e.g. `'worker.elementStartDelay'`). Single source of truth for both the
+ * migration sweep (consumed by `remapDeprecatedKeys`) and the schema's
+ * `@deprecated` `.describe()` strings (sync enforced by
+ * `ConfigurationSchema.test.ts`).
+ */
+export const DEPRECATED_KEY_REMAPPINGS: Readonly<Record<string, null | string>> = {
+  autoReconnectMaxRetries: null,
+  chargingStationsPerWorker: 'worker.elementsPerWorker',
+  distributeStationsToTenantsEqually: null,
+  distributeStationToTenantEqually: null,
+  elementAddDelay: 'worker.elementAddDelay',
+  logConsole: 'log.console',
+  logEnabled: 'log.enabled',
+  logErrorFile: 'log.errorFile',
+  logFile: 'log.file',
+  logFormat: 'log.format',
+  logLevel: 'log.level',
+  logMaxFiles: 'log.maxFiles',
+  logMaxSize: 'log.maxSize',
+  logRotate: 'log.rotate',
+  logStatisticsInterval: 'log.statisticsInterval',
+  stationTemplateURLs: 'stationTemplateUrls',
+  supervisionURLs: 'supervisionUrls',
+  uiWebSocketServer: null,
+  useWorkerPool: null,
+  'worker.elementStartDelay': 'worker.elementAddDelay',
+  workerPoolMaxSize: 'worker.poolMaxSize',
+  workerPoolMinSize: 'worker.poolMinSize',
+  workerPoolSize: 'worker.poolMaxSize',
+  workerPoolStrategy: null,
+  workerProcess: 'worker.processType',
+  workerStartDelay: 'worker.startDelay',
+}
+
+/**
+ * Current schema version for charging station configurations.
+ * Bump only on breaking changes (field rename, removal, type narrowing).
+ * Single authoritative location — concurrent bumps force git merge conflict.
+ */
+export const CURRENT_CONFIGURATION_SCHEMA_VERSION = 1
+
+export interface FieldError {
+  message: string
+  path: string
+}
+
+export type MigrationFn = (
+  config: Record<string, unknown>,
+  filePath: string
+) => Record<string, unknown>
+
+export interface RemapDeprecatedKeysResult {
+  config: Record<string, unknown>
+  fieldErrors: FieldError[]
+  warnings: RemapWarning[]
+}
+
+interface RemapWarning {
+  canonicalDestination: null | string
+  sourceKey: string
+}
+
+/**
+ * Read the value at a dotted `path`. Returns `{ found: false }` if any
+ * intermediate segment is non-object, an array, or missing.
+ * @param target - source object
+ * @param path - dotted path (e.g. `'worker.elementStartDelay'`)
+ * @returns `{ found, value }`
+ */
+const getAtPath = (
+  target: Record<string, unknown>,
+  path: string
+): { found: boolean; value: unknown } => {
+  const parts = path.split('.')
+  let cursor: unknown = target
+  for (const part of parts) {
+    if (cursor == null || typeof cursor !== 'object' || Array.isArray(cursor)) {
+      return { found: false, value: undefined }
+    }
+    const obj = cursor as Record<string, unknown>
+    if (!(part in obj)) {
+      return { found: false, value: undefined }
+    }
+    cursor = obj[part]
+  }
+  return { found: true, value: cursor }
+}
+
+/**
+ * Delete the leaf at a dotted `path`. Intermediate non-object segments
+ * cause the deletion to be a no-op (defensive against external mutation).
+ * @param target - object to mutate in place
+ * @param path - dotted path of the leaf to delete
+ */
+const deleteAtPath = (target: Record<string, unknown>, path: string): void => {
+  const parts = path.split('.')
+  let cursor: Record<string, unknown> = target
+  for (let i = 0; i < parts.length - 1; i++) {
+    const next = cursor[parts[i]]
+    if (next == null || typeof next !== 'object' || Array.isArray(next)) {
+      return
+    }
+    cursor = next as Record<string, unknown>
+  }
+  Reflect.deleteProperty(cursor, parts[parts.length - 1])
+}
+
+/**
+ * Write `value` at dotted `path`, creating intermediate objects as needed.
+ * Records two failure modes via `fieldErrors`: non-object intermediate, and
+ * leaf collision with a non-equal value. Equal-value writes are no-ops.
+ * @param target - object to mutate in place
+ * @param path - dotted destination path
+ * @param value - value to write at the leaf
+ * @param source - originating deprecated key (for error messages)
+ * @param fieldErrors - error accumulator
+ * @returns true on write or no-op, false when an error is recorded
+ */
+const setAtPath = (
+  target: Record<string, unknown>,
+  path: string,
+  value: unknown,
+  source: string,
+  fieldErrors: FieldError[]
+): boolean => {
+  const parts = path.split('.')
+  let cursor: Record<string, unknown> = target
+  for (let i = 0; i < parts.length - 1; i++) {
+    const part = parts[i]
+    const next = cursor[part]
+    if (next == null) {
+      cursor[part] = {}
+      cursor = cursor[part] as Record<string, unknown>
+      continue
+    }
+    if (typeof next !== 'object' || Array.isArray(next)) {
+      fieldErrors.push({
+        message: `cannot migrate deprecated key '${source}' to '${path}': intermediate '${parts
+          .slice(0, i + 1)
+          .join('.')}' is not an object`,
+        path: source,
+      })
+      return false
+    }
+    cursor = next as Record<string, unknown>
+  }
+  const leaf = parts[parts.length - 1]
+  if (leaf in cursor) {
+    if (!isDeepStrictEqual(cursor[leaf], value)) {
+      fieldErrors.push({
+        message: `deprecated key '${source}' value conflicts with existing '${path}' (canonical key or another deprecated alias resolved here)`,
+        path: source,
+      })
+      return false
+    }
+    return true
+  }
+  cursor[leaf] = value
+  return true
+}
+
+/**
+ * Apply the deprecated-key remap table to `config`.
+ *
+ * Pure: returns a new object; `config` is not mutated.
+ * Always safe to call — idempotent when no deprecated keys are present.
+ *
+ * - emits one `warning` entry per deprecated key encountered so the caller
+ *   can route them to the appropriate IO channel
+ * - records collision and non-object-intermediate failures as `fieldErrors`
+ *   for the caller to surface as a typed error
+ * - equal-value collisions are no-ops (tolerates copy-paste between
+ *   deprecated and canonical keys)
+ *
+ * Designed to run unconditionally regardless of `$schemaVersion` so that
+ * v1 configurations still containing deprecated keys never silently drop
+ * user values.
+ * @param config - raw parsed configuration object
+ * @returns `{ config, fieldErrors, warnings }`
+ */
+export const remapDeprecatedKeys = (config: Record<string, unknown>): RemapDeprecatedKeysResult => {
+  const out = structuredClone(config)
+  const fieldErrors: FieldError[] = []
+  const warnings: RemapWarning[] = []
+
+  for (const [sourceKey, canonicalDestination] of Object.entries(DEPRECATED_KEY_REMAPPINGS)) {
+    const { found, value } = getAtPath(out, sourceKey)
+    if (!found) {
+      continue
+    }
+    warnings.push({ canonicalDestination, sourceKey })
+    if (canonicalDestination != null && canonicalDestination !== sourceKey) {
+      const ok = setAtPath(out, canonicalDestination, value, sourceKey, fieldErrors)
+      if (!ok) {
+        // Leave source key in place so its name is referenced in the error.
+        continue
+      }
+    }
+    deleteAtPath(out, sourceKey)
+  }
+
+  return { config: out, fieldErrors, warnings }
+}
+
+/**
+ * v0 → v1: pure version-bump. Deprecated-key remapping happens upstream
+ * in `remapDeprecatedKeys`. `$schemaVersion` is stamped by
+ * `applyConfigurationMigration`.
+ * @param config - source configuration object
+ * @param _filePath - configuration file path (unused)
+ * @returns new configuration object
+ */
+const migrateV0ToV1: MigrationFn = (config, _filePath) => structuredClone(config)
+
+/**
+ * Sequential migration chain. Index `i` migrates a v`i` configuration to v`i+1`.
+ * To add schema version N+1: append one `migrateV{N}ToV{N+1}` function and
+ * bump `CURRENT_CONFIGURATION_SCHEMA_VERSION`.
+ */
+export const migrationChain: readonly MigrationFn[] = [migrateV0ToV1]
+
+/**
+ * Strict integer-string pattern for `$schemaVersion`. Rejects permissive
+ * `Number()` coercions (`'1.0'`, `'0x1'`, `'1e0'`, `' 1 '`, `''`, `'+1'`).
+ */
+const SCHEMA_VERSION_STRING_PATTERN = /^\d+$/
+
+/**
+ * Coerce a raw `$schemaVersion` value to a validated integer.
+ * - Missing → 0 (legacy/pre-versioning configuration — triggers v0→CURRENT migration)
+ * - Non-negative integer (number or canonical decimal string) → parsed integer
+ * - Anything else (negative, float, NaN, Infinity, hex/exponential/whitespace
+ *   string, future version) → fatal error
+ * @param raw - Raw value from parsed JSON
+ * @returns Validated integer version
+ */
+export const coerceConfigurationVersion = (raw: unknown): number => {
+  if (raw == null) {
+    return 0
+  }
+  let parsed: number
+  let rawStr: string
+  if (typeof raw === 'number') {
+    parsed = raw
+    rawStr = String(raw)
+  } else if (typeof raw === 'string' && SCHEMA_VERSION_STRING_PATTERN.test(raw)) {
+    parsed = Number(raw)
+    rawStr = raw
+  } else {
+    if (typeof raw === 'object') {
+      rawStr = JSON.stringify(raw)
+    } else if (typeof raw === 'string' || typeof raw === 'boolean') {
+      rawStr = String(raw)
+    } else if (typeof raw === 'bigint') {
+      rawStr = `${raw.toString()}n`
+    } else if (typeof raw === 'symbol') {
+      rawStr = raw.toString()
+    } else {
+      rawStr = 'unknown'
+    }
+    throw new BaseError(
+      `${moduleName}.coerceConfigurationVersion: Invalid $schemaVersion value '${rawStr}' — must be a non-negative integer`
+    )
+  }
+  if (!Number.isInteger(parsed) || parsed < 0) {
+    throw new BaseError(
+      `${moduleName}.coerceConfigurationVersion: Invalid $schemaVersion value '${rawStr}' — must be a non-negative integer`
+    )
+  }
+  if (parsed > CURRENT_CONFIGURATION_SCHEMA_VERSION) {
+    throw new BaseError(
+      `${moduleName}.coerceConfigurationVersion: Configuration $schemaVersion ${parsed.toString()} is newer than supported version ${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()}. Update the simulator to handle this configuration`
+    )
+  }
+  return parsed
+}
+
+/**
+ * Apply migrations sequentially from the given source version to
+ * `CURRENT_CONFIGURATION_SCHEMA_VERSION`, advancing `$schemaVersion` after each hop.
+ * Returns a new object; the input `config` is not mutated.
+ * @param sourceVersion - Source schema version to migrate from
+ * @param config - Raw parsed configuration object (already remapped)
+ * @param filePath - File path for error messages
+ * @returns Migrated configuration object
+ */
+export const applyConfigurationMigration = (
+  sourceVersion: number,
+  config: Record<string, unknown>,
+  filePath: string
+): Record<string, unknown> => {
+  if (sourceVersion < 0 || sourceVersion >= CURRENT_CONFIGURATION_SCHEMA_VERSION) {
+    throw new BaseError(
+      `${moduleName}.applyConfigurationMigration: No migration defined for $schemaVersion ${sourceVersion.toString()} → ${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()}`
+    )
+  }
+  let migrated = structuredClone(config)
+  for (let v = sourceVersion; v < CURRENT_CONFIGURATION_SCHEMA_VERSION; v++) {
+    migrated = migrationChain[v](migrated, filePath)
+    migrated.$schemaVersion = v + 1
+  }
+  return migrated
+}
diff --git a/src/charging-station/ConfigurationSchema.ts b/src/charging-station/ConfigurationSchema.ts
new file mode 100644 (file)
index 0000000..bade24f
--- /dev/null
@@ -0,0 +1,223 @@
+import type { ListenOptions } from 'node:net'
+import type { ResourceLimits } from 'node:worker_threads'
+
+import { z } from 'zod'
+
+import {
+  ApplicationProtocol,
+  ApplicationProtocolVersion,
+  AuthenticationType,
+  StorageType,
+  SupervisionUrlDistribution,
+} from '../types/index.js'
+import { WorkerProcessType } from '../worker/index.js'
+import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from './ConfigurationMigrations.js'
+
+// ---------------------------------------------------------------
+// Sub-schemas
+// ---------------------------------------------------------------
+
+/**
+ * LogConfiguration — winston logger configuration section.
+ * `maxFiles` and `maxSize` accept `number | string` (winston-daily-rotate-file
+ * units like '14d', '20m').
+ */
+export const LogConfigurationSchema = z
+  .object({
+    console: z.boolean().optional(),
+    enabled: z.boolean().optional(),
+    errorFile: z.string().optional(),
+    file: z.string().optional(),
+    format: z.string().optional(),
+    level: z
+      .enum(['emerg', 'alert', 'crit', 'error', 'warning', 'notice', 'info', 'debug'])
+      .optional(),
+    maxFiles: z.union([z.number(), z.string()]).optional(),
+    maxSize: z.union([z.number(), z.string()]).optional(),
+    rotate: z.boolean().optional(),
+    statisticsInterval: z.number().int().nonnegative().optional(),
+  })
+  .strict()
+
+/**
+ * WorkerConfiguration — worker threads configuration section.
+ * `resourceLimits` is bridged via `z.custom<ResourceLimits>()`;
+ * `elementStartDelay` is preserved as deprecated alias for `elementAddDelay`.
+ */
+export const WorkerConfigurationSchema = z
+  .object({
+    elementAddDelay: z.number().int().nonnegative().optional(),
+    elementsPerWorker: z
+      .union([z.literal('auto'), z.literal('all'), z.number().int().positive()])
+      .optional(),
+    elementStartDelay: z
+      .number()
+      .int()
+      .nonnegative()
+      .optional()
+      .describe("@deprecated: use 'elementAddDelay' instead"),
+    poolMaxSize: z.number().int().positive().optional(),
+    poolMinSize: z.number().int().positive().optional(),
+    processType: z.enum(WorkerProcessType).optional(),
+    resourceLimits: z.custom<ResourceLimits>().optional(),
+    startDelay: z.number().int().nonnegative().optional(),
+  })
+  .strict()
+
+/**
+ * StorageConfiguration — performance storage configuration section.
+ * Legacy `URI` (uppercase) is accepted but not auto-migrated; canonical key is `uri`.
+ */
+export const StorageConfigurationSchema = z
+  .object({
+    enabled: z.boolean().optional(),
+    type: z.enum(StorageType).optional(),
+    uri: z.string().optional(),
+    URI: z.string().optional().describe("@deprecated: use 'uri' instead"),
+  })
+  .strict()
+
+/**
+ * UIServerAuthentication — credentials for the UI server.
+ * `enabled` and `type` are required; `username`/`password` are optional and
+ * depend on the chosen authentication scheme.
+ */
+export const UIServerAuthenticationSchema = z
+  .object({
+    enabled: z.boolean(),
+    password: z.string().optional(),
+    type: z.enum(AuthenticationType),
+    username: z.string().optional(),
+  })
+  .strict()
+
+/**
+ * UIServerConfiguration — UI server configuration section.
+ * `options` is structurally typed as `ListenOptions` from node:net; the schema
+ * uses `z.custom<ListenOptions>()` to bridge the external surface.
+ */
+export const UIServerConfigurationSchema = z
+  .object({
+    authentication: UIServerAuthenticationSchema.optional(),
+    enabled: z.boolean().optional(),
+    options: z.custom<ListenOptions>().optional(),
+    type: z.enum(ApplicationProtocol).optional(),
+    version: z.enum(ApplicationProtocolVersion).optional(),
+  })
+  .strict()
+
+/**
+ * StationTemplateUrl — entry of the `stationTemplateUrls` array.
+ * Legacy `numberOfStation` (singular) is accepted but not auto-migrated;
+ * canonical key is `numberOfStations`.
+ */
+export const StationTemplateUrlSchema = z
+  .object({
+    file: z.string().min(1),
+    numberOfStation: z
+      .number()
+      .int()
+      .nonnegative()
+      .optional()
+      .describe("@deprecated: use 'numberOfStations' instead"),
+    numberOfStations: z.number().int().nonnegative(),
+    provisionedNumberOfStations: z.number().int().nonnegative().optional(),
+  })
+  .strict()
+
+// ---------------------------------------------------------------
+// Top-level configuration schema
+// ---------------------------------------------------------------
+
+/**
+ * ConfigurationSchema — strict schema for the simulator configuration.
+ * Top-level and all sub-sections reject unknown keys (typos must fail at boot).
+ *
+ * Deprecated top-level keys are accepted as `.optional()` with a `.describe()`
+ * marker so existing user configurations continue to parse while
+ * `remapDeprecatedKeys` emits warnings and remaps them to canonical keys.
+ *
+ * The `@deprecated:` describe markers are kept in sync with
+ * `DEPRECATED_KEY_REMAPPINGS` (single source of truth) by a meta-test covering
+ * top-level and `worker.*` keys.
+ */
+export const ConfigurationSchema = z
+  .object({
+    $schemaVersion: z.literal(CURRENT_CONFIGURATION_SCHEMA_VERSION),
+    autoReconnectMaxRetries: z
+      .number()
+      .optional()
+      .describe('@deprecated: moved to charging station template'),
+    chargingStationsPerWorker: z
+      .number()
+      .optional()
+      .describe("@deprecated: use 'worker.elementsPerWorker' instead"),
+    distributeStationsToTenantsEqually: z
+      .boolean()
+      .optional()
+      .describe("@deprecated: use 'supervisionUrlDistribution' instead"),
+    distributeStationToTenantEqually: z
+      .boolean()
+      .optional()
+      .describe("@deprecated: use 'supervisionUrlDistribution' instead"),
+    elementAddDelay: z
+      .number()
+      .optional()
+      .describe("@deprecated: use 'worker.elementAddDelay' instead"),
+    log: LogConfigurationSchema.optional(),
+    logConsole: z.boolean().optional().describe("@deprecated: use 'log.console' instead"),
+    logEnabled: z.boolean().optional().describe("@deprecated: use 'log.enabled' instead"),
+    logErrorFile: z.string().optional().describe("@deprecated: use 'log.errorFile' instead"),
+    logFile: z.string().optional().describe("@deprecated: use 'log.file' instead"),
+    logFormat: z.string().optional().describe("@deprecated: use 'log.format' instead"),
+    logLevel: z.string().optional().describe("@deprecated: use 'log.level' instead"),
+    logMaxFiles: z
+      .union([z.number(), z.string()])
+      .optional()
+      .describe("@deprecated: use 'log.maxFiles' instead"),
+    logMaxSize: z
+      .union([z.number(), z.string()])
+      .optional()
+      .describe("@deprecated: use 'log.maxSize' instead"),
+    logRotate: z.boolean().optional().describe("@deprecated: use 'log.rotate' instead"),
+    logStatisticsInterval: z
+      .number()
+      .optional()
+      .describe("@deprecated: use 'log.statisticsInterval' instead"),
+    performanceStorage: StorageConfigurationSchema.optional(),
+    persistState: z.boolean().optional(),
+    stationTemplateURLs: z
+      .array(z.unknown())
+      .optional()
+      .describe("@deprecated: use 'stationTemplateUrls' instead"),
+    stationTemplateUrls: z.array(StationTemplateUrlSchema).min(1),
+    supervisionUrlDistribution: z.enum(SupervisionUrlDistribution).optional(),
+    supervisionURLs: z
+      .union([z.string(), z.array(z.string())])
+      .optional()
+      .describe("@deprecated: use 'supervisionUrls' instead"),
+    supervisionUrls: z.union([z.string(), z.array(z.string())]).optional(),
+    uiServer: UIServerConfigurationSchema.optional(),
+    uiWebSocketServer: z.unknown().optional().describe("@deprecated: use 'uiServer' instead"),
+    useWorkerPool: z.boolean().optional().describe("@deprecated: use 'worker.processType' instead"),
+    worker: WorkerConfigurationSchema.optional(),
+    workerPoolMaxSize: z
+      .number()
+      .optional()
+      .describe("@deprecated: use 'worker.poolMaxSize' instead"),
+    workerPoolMinSize: z
+      .number()
+      .optional()
+      .describe("@deprecated: use 'worker.poolMinSize' instead"),
+    workerPoolSize: z.number().optional().describe("@deprecated: use 'worker.poolMaxSize' instead"),
+    workerPoolStrategy: z
+      .string()
+      .optional()
+      .describe("@deprecated: use 'worker' configuration section instead"),
+    workerProcess: z.string().optional().describe("@deprecated: use 'worker.processType' instead"),
+    workerStartDelay: z
+      .number()
+      .optional()
+      .describe("@deprecated: use 'worker.startDelay' instead"),
+  })
+  .strict()
diff --git a/src/charging-station/ConfigurationValidation.ts b/src/charging-station/ConfigurationValidation.ts
new file mode 100644 (file)
index 0000000..11dfc5d
--- /dev/null
@@ -0,0 +1,151 @@
+import type { ZodError } from 'zod'
+
+import chalk from 'chalk'
+
+import type { ConfigurationData } from '../types/index.js'
+import type { FieldError } from './ConfigurationMigrations.js'
+
+import { BaseError } from '../exception/index.js'
+import { logPrefix } from '../utils/ConfigurationUtils.js'
+// Direct path: the `utils/index.js` barrel re-exports Configuration, causing a TDZ cycle.
+import { isEmpty } from '../utils/Utils.js'
+import {
+  applyConfigurationMigration,
+  coerceConfigurationVersion,
+  CURRENT_CONFIGURATION_SCHEMA_VERSION,
+  remapDeprecatedKeys,
+} from './ConfigurationMigrations.js'
+import { ConfigurationSchema } from './ConfigurationSchema.js'
+
+const moduleName = 'ConfigurationValidation'
+
+/**
+ * Phase of the validation pipeline that produced a failure.
+ * - `remap`: deprecated-key sweep (collisions, non-object intermediates)
+ * - `schema`: strict Zod parse against `ConfigurationSchema`
+ *
+ * Schema-version migration-chain failures (`coerceConfigurationVersion`,
+ * `applyConfigurationMigration`) propagate as bare `BaseError`, mirroring
+ * `TemplateValidation`.
+ */
+type ValidationPhase = 'remap' | 'schema'
+
+/**
+ * Error thrown when a configuration fails the migration sweep or the strict
+ * schema validation. Carries structured field errors plus migration context.
+ */
+export class ConfigurationValidationError extends BaseError {
+  public readonly fieldErrors: FieldError[]
+  public readonly filePath: string
+  public readonly migratedFrom?: number
+  public readonly phase: ValidationPhase
+
+  public constructor (
+    fieldErrors: FieldError[],
+    context: { filePath: string; migratedFrom?: number; phase: ValidationPhase }
+  ) {
+    const fieldSummary = fieldErrors
+      .map(e => `  - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`)
+      .join('\n')
+    const migrationNote =
+      context.migratedFrom != null
+        ? ` (migrated from v${context.migratedFrom.toString()} → v${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()})`
+        : ''
+    super(
+      `${moduleName}: Configuration validation failed [${context.phase}] for '${context.filePath}'${migrationNote}:\n${fieldSummary}`
+    )
+    this.filePath = context.filePath
+    this.fieldErrors = fieldErrors
+    this.migratedFrom = context.migratedFrom
+    this.phase = context.phase
+  }
+
+  /**
+   * Wrap a Zod validation failure with `phase: 'schema'`.
+   * @param zodError - the underlying Zod error
+   * @param context - file path + optional migration source version
+   * @param context.filePath - configuration file path
+   * @param context.migratedFrom - source schema version when migration ran
+   * @returns a typed validation error ready to throw
+   */
+  public static fromZodError (
+    zodError: ZodError,
+    context: { filePath: string; migratedFrom?: number }
+  ): ConfigurationValidationError {
+    const fieldErrors: FieldError[] = zodError.issues.map(issue => ({
+      message: issue.message,
+      path: issue.path.join('.'),
+    }))
+    return new ConfigurationValidationError(fieldErrors, { ...context, phase: 'schema' })
+  }
+}
+
+/**
+ * Validate a parsed configuration through the
+ * remap → migrate → strict-parse → transform pipeline.
+ * Throws `BaseError` for shape failures, `ConfigurationValidationError`
+ * for migration collisions or schema failures.
+ * @param parsed - Raw parsed JSON value (any type — guarded internally)
+ * @param filePath - Configuration file path (for error messages)
+ * @returns Validated and transformed `ConfigurationData`
+ */
+export const validateConfiguration = (parsed: unknown, filePath: string): ConfigurationData => {
+  if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    throw new BaseError(
+      `${moduleName}.validateConfiguration: Invalid simulator configuration payload (not a JSON object) ${filePath}`
+    )
+  }
+  if (isEmpty(parsed)) {
+    throw new BaseError(
+      `${moduleName}.validateConfiguration: Empty simulator configuration from file ${filePath}`
+    )
+  }
+  // Defensive clone: $schemaVersion is rewritten below.
+  const parsedRecord = structuredClone(parsed) as Record<string, unknown>
+
+  const version = coerceConfigurationVersion(parsedRecord.$schemaVersion)
+  parsedRecord.$schemaVersion = version
+  const migratedFrom = version < CURRENT_CONFIGURATION_SCHEMA_VERSION ? version : undefined
+
+  const { config: swept, fieldErrors: remapErrors, warnings } = remapDeprecatedKeys(parsedRecord)
+  for (const { canonicalDestination, sourceKey } of warnings) {
+    const guidance =
+      canonicalDestination == null
+        ? 'no longer used; remove it from the configuration'
+        : `use '${canonicalDestination}' instead`
+    // console.warn: logger.warn would recurse via Configuration → validateConfiguration.
+    console.warn(
+      `${chalk.green(logPrefix())} ${chalk.yellow(
+        `${moduleName}: deprecated configuration key '${sourceKey}' detected in '${filePath}'; ${guidance}`
+      )}`
+    )
+  }
+  if (remapErrors.length > 0) {
+    throw new ConfigurationValidationError(remapErrors, {
+      filePath,
+      migratedFrom,
+      phase: 'remap',
+    })
+  }
+
+  const migrated =
+    migratedFrom != null ? applyConfigurationMigration(version, swept, filePath) : swept
+
+  const result = ConfigurationSchema.safeParse(migrated)
+  if (!result.success) {
+    throw ConfigurationValidationError.fromZodError(result.error, { filePath, migratedFrom })
+  }
+
+  return transformConfiguration(result.data, filePath)
+}
+
+/**
+ * Post-validation transform: deep clone so callers may mutate the result.
+ * @param validated - schema-validated configuration data
+ * @param _filePath - configuration file path (unused)
+ * @returns deep clone of the validated configuration
+ */
+const transformConfiguration = (
+  validated: ConfigurationData,
+  _filePath: string
+): ConfigurationData => structuredClone(validated)
index 2634567ad1fd68bbd7a7b99a151260b174c40b33..5678fbf3b3ef37626f9971ced67d636c12515c14 100644 (file)
@@ -1,10 +1,13 @@
-import type { ListenOptions } from 'node:net'
-import type { ResourceLimits } from 'node:worker_threads'
-import type { WorkerChoiceStrategy } from 'poolifier'
+import type { z } from 'zod'
 
-import type { WorkerProcessType } from '../worker/index.js'
-import type { StorageType } from './Storage.js'
-import type { ApplicationProtocol, AuthenticationType } from './UIProtocol.js'
+import type {
+  ConfigurationSchema,
+  LogConfigurationSchema,
+  StationTemplateUrlSchema,
+  StorageConfigurationSchema,
+  UIServerConfigurationSchema,
+  WorkerConfigurationSchema,
+} from '../charging-station/ConfigurationSchema.js'
 
 export enum ApplicationProtocolVersion {
   VERSION_11 = '1.1',
@@ -24,103 +27,10 @@ export enum SupervisionUrlDistribution {
   ROUND_ROBIN = 'round-robin',
 }
 
-export interface ConfigurationData {
-  /** @deprecated Moved to charging station template. */
-  autoReconnectMaxRetries?: number
-  /** @deprecated Moved to worker configuration section. */
-  chargingStationsPerWorker?: number
-  /** @deprecated Moved to worker configuration section. */
-  elementAddDelay?: number
-  log?: LogConfiguration
-  /** @deprecated Moved to log configuration section. */
-  logConsole?: boolean
-  /** @deprecated Moved to log configuration section. */
-  logEnabled?: boolean
-  /** @deprecated Moved to log configuration section. */
-  logErrorFile?: string
-  /** @deprecated Moved to log configuration section. */
-  logFile?: string
-  /** @deprecated Moved to log configuration section. */
-  logFormat?: string
-  /** @deprecated Moved to log configuration section. */
-  logLevel?: string
-  /** @deprecated Moved to log configuration section. */
-  logMaxFiles?: number | string
-  /** @deprecated Moved to log configuration section. */
-  logMaxSize?: number | string
-  /** @deprecated Moved to log configuration section. */
-  logRotate?: boolean
-  /** @deprecated Moved to log configuration section. */
-  logStatisticsInterval?: number
-  performanceStorage?: StorageConfiguration
-  persistState?: boolean
-  stationTemplateUrls: StationTemplateUrl[]
-  supervisionUrlDistribution?: SupervisionUrlDistribution
-  supervisionUrls?: string | string[]
-  uiServer?: UIServerConfiguration
-  worker?: WorkerConfiguration
-  /** @deprecated Moved to worker configuration section. */
-  workerPoolMaxSize?: number
-  /** @deprecated Moved to worker configuration section. */
-  workerPoolMinSize?: number
-  /** @deprecated Moved to worker configuration section. */
-  workerPoolStrategy?: WorkerChoiceStrategy
-  /** @deprecated Moved to worker configuration section. */
-  workerProcess?: WorkerProcessType
-  /** @deprecated Moved to worker configuration section. */
-  workerStartDelay?: number
-}
-
-export type ElementsPerWorkerType = 'all' | 'auto' | number
-
-export interface LogConfiguration {
-  console?: boolean
-  enabled?: boolean
-  errorFile?: string
-  file?: string
-  format?: string
-  level?: string
-  maxFiles?: number | string
-  maxSize?: number | string
-  rotate?: boolean
-  statisticsInterval?: number
-}
-
-export interface StationTemplateUrl {
-  file: string
-  numberOfStations: number
-  provisionedNumberOfStations?: number
-}
-
-export interface StorageConfiguration {
-  enabled?: boolean
-  type?: StorageType
-  uri?: string
-}
-
-export interface UIServerConfiguration {
-  authentication?: {
-    enabled: boolean
-    password?: string
-    type: AuthenticationType
-    username?: string
-  }
-  enabled?: boolean
-  options?: ServerOptions
-  type?: ApplicationProtocol
-  version?: ApplicationProtocolVersion
-}
-
-export interface WorkerConfiguration {
-  elementAddDelay?: number
-  elementsPerWorker?: ElementsPerWorkerType
-  /** @deprecated Use `elementAddDelay` instead. */
-  elementStartDelay?: number
-  poolMaxSize?: number
-  poolMinSize?: number
-  processType?: WorkerProcessType
-  resourceLimits?: ResourceLimits
-  startDelay?: number
-}
-
-type ServerOptions = ListenOptions
+export type ConfigurationData = z.infer<typeof ConfigurationSchema>
+export type ElementsPerWorkerType = NonNullable<WorkerConfiguration['elementsPerWorker']>
+export type LogConfiguration = z.infer<typeof LogConfigurationSchema>
+export type StationTemplateUrl = z.infer<typeof StationTemplateUrlSchema>
+export type StorageConfiguration = z.infer<typeof StorageConfigurationSchema>
+export type UIServerConfiguration = z.infer<typeof UIServerConfigurationSchema>
+export type WorkerConfiguration = z.infer<typeof WorkerConfigurationSchema>
index 1b364b16a0c576cd6e92d0c6064e9cc8c2e42ac8..59bd7f5c9dfc5acc2a7341da4c056eb3384ef664 100644 (file)
@@ -4,6 +4,12 @@ import { dirname, join } from 'node:path'
 import { env } from 'node:process'
 import { fileURLToPath } from 'node:url'
 
+import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../charging-station/ConfigurationMigrations.js'
+import {
+  ConfigurationValidationError,
+  validateConfiguration,
+} from '../charging-station/ConfigurationValidation.js'
+import { BaseError } from '../exception/index.js'
 import {
   ApplicationProtocol,
   ApplicationProtocolVersion,
@@ -19,22 +25,20 @@ import {
   type WorkerConfiguration,
 } from '../types/index.js'
 import {
-  checkWorkerProcessType,
   DEFAULT_ELEMENT_ADD_DELAY_MS,
   DEFAULT_POOL_MAX_SIZE,
   DEFAULT_POOL_MIN_SIZE,
   DEFAULT_WORKER_START_DELAY_MS,
   WorkerProcessType,
 } from '../worker/index.js'
-import { checkDeprecatedConfigurationKeys } from './ConfigurationMigration.js'
 import {
   buildPerformanceUriFilePath,
-  checkWorkerElementsPerWorker,
   getDefaultPerformanceStorageUri,
   logPrefix,
 } from './ConfigurationUtils.js'
 import { Constants } from './Constants.js'
 import { ensureError, handleFileException } from './ErrorUtils.js'
+import { logger } from './Logger.js'
 import {
   convertToInt,
   has,
@@ -95,6 +99,7 @@ export class Configuration {
   private static configurationData?: ConfigurationData
   private static configurationFile: string | undefined
   private static configurationFileReloading = false
+  private static configurationFileReloadPending = false
   private static configurationFileWatcher?: FSWatcher
   private static configurationSectionCache: Map<ConfigurationSection, ConfigurationSectionType>
 
@@ -109,6 +114,7 @@ export class Configuration {
         )}`
       )
       Configuration.configurationData = {
+        $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
         log: defaultLogConfiguration,
         performanceStorage: defaultStorageConfiguration,
         stationTemplateUrls: [
@@ -144,9 +150,22 @@ export class Configuration {
       isNotEmptyString(Configuration.configurationFile)
     ) {
       try {
-        Configuration.configurationData = JSON.parse(
-          readFileSync(Configuration.configurationFile, 'utf8')
-        ) as ConfigurationData
+        const parsed: unknown = JSON.parse(readFileSync(Configuration.configurationFile, 'utf8'))
+        try {
+          Configuration.configurationData = validateConfiguration(
+            parsed,
+            Configuration.configurationFile
+          )
+        } catch (validationError) {
+          if (
+            validationError instanceof ConfigurationValidationError ||
+            validationError instanceof BaseError
+          ) {
+            console.error(`${chalk.green(logPrefix())} ${chalk.red(validationError.message)}`)
+            process.exit(1)
+          }
+          throw validationError
+        }
         Configuration.configurationFileWatcher ??= Configuration.getConfigurationFileWatcher()
       } catch (error) {
         handleFileException(
@@ -176,10 +195,6 @@ export class Configuration {
   }
 
   public static getStationTemplateUrls (): StationTemplateUrl[] | undefined {
-    const checkDeprecatedConfigurationKeysOnce = once(() => {
-      checkDeprecatedConfigurationKeys(Configuration.getConfigurationData())
-    })
-    checkDeprecatedConfigurationKeysOnce()
     return Configuration.getConfigurationData()?.stationTemplateUrls
   }
 
@@ -190,16 +205,6 @@ export class Configuration {
   }
 
   public static getSupervisionUrls (): string | string[] | undefined {
-    if (
-      Configuration.getConfigurationData()?.['supervisionURLs' as keyof ConfigurationData] != null
-    ) {
-      const configurationData = Configuration.getConfigurationData()
-      if (configurationData != null) {
-        configurationData.supervisionUrls = configurationData[
-          'supervisionURLs' as keyof ConfigurationData
-        ] as string | string[]
-      }
-    }
     return Configuration.getConfigurationData()?.supervisionUrls
   }
 
@@ -222,30 +227,10 @@ export class Configuration {
 
   private static buildLogSection (): LogConfiguration {
     const configurationData = Configuration.getConfigurationData()
-    const deprecatedLogKeyMap: [keyof ConfigurationData, keyof LogConfiguration][] = [
-      ['logEnabled', 'enabled'],
-      ['logFile', 'file'],
-      ['logErrorFile', 'errorFile'],
-      ['logStatisticsInterval', 'statisticsInterval'],
-      ['logLevel', 'level'],
-      ['logConsole', 'console'],
-      ['logFormat', 'format'],
-      ['logRotate', 'rotate'],
-      ['logMaxFiles', 'maxFiles'],
-      ['logMaxSize', 'maxSize'],
-    ]
-    const deprecatedLogConfiguration: Record<string, unknown> = {}
-    for (const [deprecatedKey, newKey] of deprecatedLogKeyMap) {
-      if (has(deprecatedKey, configurationData)) {
-        deprecatedLogConfiguration[newKey] = configurationData?.[deprecatedKey]
-      }
-    }
-    const logConfiguration: LogConfiguration = {
+    return {
       ...defaultLogConfiguration,
-      ...(deprecatedLogConfiguration as Partial<LogConfiguration>),
       ...(has(ConfigurationSection.log, configurationData) && configurationData?.log),
     }
-    return logConfiguration
   }
 
   private static buildPerformanceStorageSection (): StorageConfiguration {
@@ -306,38 +291,10 @@ export class Configuration {
 
   private static buildWorkerSection (): WorkerConfiguration {
     const configurationData = Configuration.getConfigurationData()
-    const deprecatedWorkerKeyMap: [keyof ConfigurationData, keyof WorkerConfiguration][] = [
-      ['workerProcess', 'processType'],
-      ['workerStartDelay', 'startDelay'],
-      ['chargingStationsPerWorker', 'elementsPerWorker'],
-      ['elementAddDelay', 'elementAddDelay'],
-      ['workerPoolMinSize', 'poolMinSize'],
-      ['workerPoolMaxSize', 'poolMaxSize'],
-    ]
-    const deprecatedWorkerConfiguration: Record<string, unknown> = {}
-    for (const [deprecatedKey, newKey] of deprecatedWorkerKeyMap) {
-      if (has(deprecatedKey, configurationData)) {
-        deprecatedWorkerConfiguration[newKey] = configurationData?.[deprecatedKey]
-      }
-    }
-    if (has('elementStartDelay', configurationData?.worker)) {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional deprecated key migration
-      deprecatedWorkerConfiguration.elementAddDelay = configurationData?.worker?.elementStartDelay
-    }
-    if (configurationData != null) {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional deprecated key removal
-      delete configurationData.workerPoolStrategy
-    }
-    const workerConfiguration: WorkerConfiguration = {
+    return {
       ...defaultWorkerConfiguration,
-      ...(deprecatedWorkerConfiguration as Partial<WorkerConfiguration>),
       ...(has(ConfigurationSection.worker, configurationData) && configurationData?.worker),
     }
-    if (workerConfiguration.processType != null) {
-      checkWorkerProcessType(workerConfiguration.processType)
-    }
-    checkWorkerElementsPerWorker(workerConfiguration.elementsPerWorker)
-    return workerConfiguration
   }
 
   private static cacheConfigurationSection (sectionName: ConfigurationSection): void {
@@ -372,33 +329,27 @@ export class Configuration {
     }
     try {
       return watch(Configuration.configurationFile, (event, filename): void => {
-        if (
-          !Configuration.configurationFileReloading &&
-          (filename?.trim().length ?? 0) > 0 &&
-          event === 'change'
-        ) {
-          Configuration.configurationFileReloading = true
-          const consoleWarnOnce = once(console.warn)
-          consoleWarnOnce(
-            `${chalk.green(logPrefix())} ${chalk.yellow(
-              // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-              `${FileType.Configuration} ${Configuration.configurationFile} file has changed, reload`
-            )}`
-          )
-          delete Configuration.configurationData
-          Configuration.configurationSectionCache.clear()
-          if (Configuration.configurationChangeCallback != null) {
-            Configuration.configurationChangeCallback()
-              .finally(() => {
-                Configuration.configurationFileReloading = false
-              })
-              .catch((error: unknown) => {
-                throw ensureError(error)
-              })
-          } else {
-            Configuration.configurationFileReloading = false
-          }
+        if ((filename?.trim().length ?? 0) === 0 || event !== 'change') {
+          return
         }
+        if (Configuration.configurationFileReloading) {
+          // Coalesce events arriving during an in-flight reload into a single
+          // follow-up reload. N rapid edits collapse into ≤2 reloads
+          // (current + one drained), preserving the latest file content.
+          Configuration.configurationFileReloadPending = true
+          return
+        }
+        Configuration.configurationFileReloading = true
+        const consoleWarnOnce = once(console.warn)
+        consoleWarnOnce(
+          `${chalk.green(logPrefix())} ${chalk.yellow(
+            // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+            `${FileType.Configuration} ${Configuration.configurationFile} file has changed, reload`
+          )}`
+        )
+        Configuration.runReloadLoop().catch((error: unknown) => {
+          logger.error(`${logPrefix()} Configuration reload loop error:`, ensureError(error))
+        })
       })
     } catch (error) {
       handleFileException(
@@ -414,4 +365,74 @@ export class Configuration {
   private static isConfigurationSectionCached (sectionName: ConfigurationSection): boolean {
     return Configuration.configurationSectionCache.has(sectionName)
   }
+
+  /**
+   * Reload the configuration file. On parse or validation failure, restores
+   * the pre-reload `configurationData` and section cache snapshot. Callback
+   * failures are logged via `logger.error` but do not trigger rollback —
+   * the new configuration is already valid; subscriber side-effects are
+   * recoverable on the next reload cycle.
+   */
+  private static async performReload (): Promise<void> {
+    const previousData =
+      Configuration.configurationData != null
+        ? structuredClone(Configuration.configurationData)
+        : undefined
+    const previousCache = new Map(Configuration.configurationSectionCache)
+    let reloadSucceeded = false
+    try {
+      delete Configuration.configurationData
+      Configuration.configurationSectionCache.clear()
+      if (isNotEmptyString(Configuration.configurationFile)) {
+        const parsed: unknown = JSON.parse(readFileSync(Configuration.configurationFile, 'utf8'))
+        Configuration.configurationData = validateConfiguration(
+          parsed,
+          Configuration.configurationFile
+        )
+      }
+      reloadSucceeded = true
+      if (Configuration.configurationChangeCallback != null) {
+        try {
+          await Configuration.configurationChangeCallback()
+        } catch (callbackError) {
+          logger.error(
+            `${logPrefix()} Configuration change callback error:`,
+            ensureError(callbackError)
+          )
+        }
+      }
+    } catch (error) {
+      if (error instanceof ConfigurationValidationError || error instanceof BaseError) {
+        logger.error(
+          `${logPrefix()} Configuration hot-reload failed; rolling back to previous configuration:`,
+          error
+        )
+      } else {
+        logger.error(
+          `${logPrefix()} Configuration hot-reload failed with unexpected error; rolling back:`,
+          ensureError(error)
+        )
+      }
+      if (!reloadSucceeded) {
+        Configuration.configurationData = previousData
+        Configuration.configurationSectionCache = previousCache
+      }
+    }
+  }
+
+  /**
+   * Drive `performReload` until no `configurationFileReloadPending` event
+   * remains. Releases the reloading lock in `finally`.
+   */
+  private static async runReloadLoop (): Promise<void> {
+    try {
+      do {
+        Configuration.configurationFileReloadPending = false
+        await Configuration.performReload()
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated by fs.watch handler during performReload
+      } while (Configuration.configurationFileReloadPending)
+    } finally {
+      Configuration.configurationFileReloading = false
+    }
+  }
 }
diff --git a/src/utils/ConfigurationMigration.ts b/src/utils/ConfigurationMigration.ts
deleted file mode 100644 (file)
index 50dbcd3..0000000
+++ /dev/null
@@ -1,214 +0,0 @@
-import chalk from 'chalk'
-
-import {
-  type ConfigurationData,
-  ConfigurationSection,
-  type StationTemplateUrl,
-} from '../types/index.js'
-import { WorkerProcessType } from '../worker/index.js'
-import { logPrefix } from './ConfigurationUtils.js'
-import { has, isNotEmptyString } from './Utils.js'
-
-/**
- * Check and warn about deprecated configuration keys
- * @param configurationData - The configuration data to check
- */
-export function checkDeprecatedConfigurationKeys (
-  configurationData: ConfigurationData | undefined
-): void {
-  const deprecatedKeys: [string, ConfigurationSection | undefined, string][] = [
-    // connection timeout
-    [
-      'autoReconnectTimeout',
-      undefined,
-      "Use 'ConnectionTimeOut' OCPP parameter in charging station template instead",
-    ],
-    [
-      'connectionTimeout',
-      undefined,
-      "Use 'ConnectionTimeOut' OCPP parameter in charging station template instead",
-    ],
-    // connection retries
-    ['autoReconnectMaxRetries', undefined, 'Use it in charging station template instead'],
-    // station template url(s)
-    ['stationTemplateURLs', undefined, "Use 'stationTemplateUrls' instead"],
-    // supervision url(s)
-    ['supervisionURLs', undefined, "Use 'supervisionUrls' instead"],
-    // supervision urls distribution
-    ['distributeStationToTenantEqually', undefined, "Use 'supervisionUrlDistribution' instead"],
-    ['distributeStationsToTenantsEqually', undefined, "Use 'supervisionUrlDistribution' instead"],
-    // worker section
-    [
-      'useWorkerPool',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the type of worker process model instead`,
-    ],
-    [
-      'workerProcess',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the type of worker process model instead`,
-    ],
-    [
-      'workerStartDelay',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the worker start delay instead`,
-    ],
-    [
-      'chargingStationsPerWorker',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the number of element(s) per worker instead`,
-    ],
-    [
-      'elementAddDelay',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the worker's element add delay instead`,
-    ],
-    [
-      'workerPoolMinSize',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the worker pool minimum size instead`,
-    ],
-    [
-      'workerPoolSize',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the worker pool maximum size instead`,
-    ],
-    [
-      'workerPoolMaxSize',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the worker pool maximum size instead`,
-    ],
-    [
-      'workerPoolStrategy',
-      undefined,
-      `Use '${ConfigurationSection.worker}' section to define the worker pool strategy instead`,
-    ],
-    ['poolStrategy', ConfigurationSection.worker, 'Not publicly exposed to end users'],
-    ['elementStartDelay', ConfigurationSection.worker, "Use 'elementAddDelay' instead"],
-    // log section
-    [
-      'logEnabled',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the logging enablement instead`,
-    ],
-    [
-      'logFile',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log file instead`,
-    ],
-    [
-      'logErrorFile',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log error file instead`,
-    ],
-    [
-      'logConsole',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the console logging enablement instead`,
-    ],
-    [
-      'logStatisticsInterval',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log statistics interval instead`,
-    ],
-    [
-      'logLevel',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log level instead`,
-    ],
-    [
-      'logFormat',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log format instead`,
-    ],
-    [
-      'logRotate',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log rotation enablement instead`,
-    ],
-    [
-      'logMaxFiles',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log maximum files instead`,
-    ],
-    [
-      'logMaxSize',
-      undefined,
-      `Use '${ConfigurationSection.log}' section to define the log maximum size instead`,
-    ],
-    // performanceStorage section
-    ['URI', ConfigurationSection.performanceStorage, "Use 'uri' instead"],
-  ]
-  for (const [key, section, msg] of deprecatedKeys) {
-    warnDeprecatedConfigurationKey(configurationData, key, section, msg)
-  }
-  // station template url(s) remapping
-  if (configurationData?.['stationTemplateURLs' as keyof ConfigurationData] != null) {
-    configurationData.stationTemplateUrls = configurationData[
-      'stationTemplateURLs' as keyof ConfigurationData
-    ] as StationTemplateUrl[]
-  }
-  configurationData?.stationTemplateUrls.forEach((stationTemplateUrl: StationTemplateUrl) => {
-    if (stationTemplateUrl['numberOfStation' as keyof StationTemplateUrl] != null) {
-      console.error(
-        `${chalk.green(logPrefix())} ${chalk.red(
-          `Deprecated configuration key 'numberOfStation' usage for template file '${stationTemplateUrl.file}' in 'stationTemplateUrls'. Use 'numberOfStations' instead`
-        )}`
-      )
-    }
-  })
-  // worker section: staticPool check
-  if (configurationData?.worker?.processType === ('staticPool' as WorkerProcessType)) {
-    console.error(
-      `${chalk.green(logPrefix())} ${chalk.red(
-        `Deprecated configuration 'staticPool' value usage in worker section 'processType' field. Use '${WorkerProcessType.fixedPool}' value instead`
-      )}`
-    )
-  }
-  // uiServer section
-  if (has('uiWebSocketServer', configurationData)) {
-    console.error(
-      `${chalk.green(logPrefix())} ${chalk.red(
-        `Deprecated configuration section 'uiWebSocketServer' usage. Use '${ConfigurationSection.uiServer}' instead`
-      )}`
-    )
-  }
-}
-
-/**
- * Warn about a deprecated configuration key
- * @param configurationData - The configuration data to check
- * @param key - The deprecated key name
- * @param configurationSection - The configuration section containing the key
- * @param logMsgToAppend - Additional message to append to the warning
- */
-function warnDeprecatedConfigurationKey (
-  configurationData: ConfigurationData | undefined,
-  key: string,
-  configurationSection?: ConfigurationSection,
-  logMsgToAppend = ''
-): void {
-  if (
-    configurationSection != null &&
-    configurationData?.[configurationSection as keyof ConfigurationData] != null &&
-    (configurationData[configurationSection as keyof ConfigurationData] as Record<string, unknown>)[
-      key
-    ] != null
-  ) {
-    console.error(
-      `${chalk.green(logPrefix())} ${chalk.red(
-        `Deprecated configuration key '${key}' usage in section '${configurationSection}'${
-          isNotEmptyString(logMsgToAppend) ? `. ${logMsgToAppend}` : ''
-        }`
-      )}`
-    )
-  } else if (configurationData?.[key as keyof ConfigurationData] != null) {
-    console.error(
-      `${chalk.green(logPrefix())} ${chalk.red(
-        `Deprecated configuration key '${key}' usage${
-          isNotEmptyString(logMsgToAppend) ? `. ${logMsgToAppend}` : ''
-        }`
-      )}`
-    )
-  }
-}
index 9cd6a59c5744be51e467eafaa17c69e9afde67e1..2c1839c864b978ed3476020b65072238a5a0876a 100644 (file)
@@ -1,7 +1,7 @@
 import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 
-import { type ElementsPerWorkerType, StorageType } from '../types/index.js'
+import { StorageType } from '../types/index.js'
 import { Constants } from './Constants.js'
 import { logPrefix as utilsLogPrefix } from './Utils.js'
 
@@ -33,21 +33,3 @@ export const getDefaultPerformanceStorageUri = (storageType: StorageType): strin
       throw new Error(`Unsupported storage type '${storageType}'`)
   }
 }
-
-export const checkWorkerElementsPerWorker = (
-  elementsPerWorker: ElementsPerWorkerType | undefined
-): void => {
-  if (elementsPerWorker == null || elementsPerWorker === 'auto' || elementsPerWorker === 'all') {
-    return
-  }
-  if (!Number.isSafeInteger(elementsPerWorker)) {
-    throw new SyntaxError(
-      `Invalid number of elements per worker '${elementsPerWorker.toString()}' defined in configuration`
-    )
-  }
-  if (elementsPerWorker <= 0) {
-    throw new RangeError(
-      `Invalid negative or zero number of elements per worker '${elementsPerWorker.toString()}' defined in configuration`
-    )
-  }
-}
index f10811e181a2be8a36bd089fd9862714c0910f81..0ff59557754e3ff5aadd495edb55d64046351752 100644 (file)
@@ -1,8 +1,6 @@
 import chalk from 'chalk'
 import { getRandomValues } from 'node:crypto'
 
-import { WorkerProcessType } from './WorkerTypes.js'
-
 const isPlainObject = (value: unknown): value is Record<string, unknown> => {
   if (typeof value !== 'object' || value === null) return false
   return Object.prototype.toString.call(value).slice(8, -1) === 'Object'
@@ -55,14 +53,6 @@ export const randomizeDelay = (delay: number): number => {
   return delay + sign * randomSum
 }
 
-export const checkWorkerProcessType = (workerProcessType: WorkerProcessType): void => {
-  if (!Object.values(WorkerProcessType).includes(workerProcessType)) {
-    throw new SyntaxError(
-      `Invalid worker process type '${workerProcessType}' defined in configuration`
-    )
-  }
-}
-
 /**
  * Generates a cryptographically secure random number in the [0,1[ range
  * @returns A number in the [0,1[ range
index 16740ed93c6a57339122aad2ebf489b75a1cb861..310bcb9813d03046b9356f0ffd76c9980a828218 100644 (file)
@@ -15,4 +15,3 @@ export {
   WorkerMessageEvents,
   WorkerProcessType,
 } from './WorkerTypes.js'
-export { checkWorkerProcessType } from './WorkerUtils.js'
diff --git a/tests/charging-station/ConfigurationMigrations.test.ts b/tests/charging-station/ConfigurationMigrations.test.ts
new file mode 100644 (file)
index 0000000..ad865d3
--- /dev/null
@@ -0,0 +1,330 @@
+/**
+ * @file Tests for ConfigurationMigrations
+ * @description Unit tests for schema version coercion, the deprecated-key
+ * sweep (`remapDeprecatedKeys`), and the version-bump migration chain
+ * (`applyConfigurationMigration`).
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+  applyConfigurationMigration,
+  coerceConfigurationVersion,
+  CURRENT_CONFIGURATION_SCHEMA_VERSION,
+  DEPRECATED_KEY_REMAPPINGS,
+  remapDeprecatedKeys,
+} from '../../src/charging-station/ConfigurationMigrations.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  buildLegacyConfiguration,
+  buildV0WithDeprecatedKeyCollision,
+} from './helpers/ConfigurationFixtures.js'
+
+await describe('ConfigurationMigrations', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('CURRENT_CONFIGURATION_SCHEMA_VERSION', async () => {
+    await it('should be a positive integer', () => {
+      assert.ok(Number.isInteger(CURRENT_CONFIGURATION_SCHEMA_VERSION))
+      assert.strictEqual(CURRENT_CONFIGURATION_SCHEMA_VERSION, 1)
+    })
+  })
+
+  await describe('coerceConfigurationVersion', async () => {
+    await it('should return 0 for null or undefined (legacy configs trigger v0 migration)', () => {
+      assert.strictEqual(coerceConfigurationVersion(null), 0)
+      assert.strictEqual(coerceConfigurationVersion(undefined), 0)
+    })
+
+    await it('should return the number for valid integer', () => {
+      assert.strictEqual(coerceConfigurationVersion(1), 1)
+      assert.strictEqual(coerceConfigurationVersion(0), 0)
+    })
+
+    await it('should parse string to number', () => {
+      assert.strictEqual(coerceConfigurationVersion('1'), 1)
+      assert.strictEqual(coerceConfigurationVersion('0'), 0)
+    })
+
+    await it('should throw for non-numeric string', () => {
+      assert.throws(() => coerceConfigurationVersion('abc'), {
+        message: /must be a non-negative integer/,
+      })
+    })
+
+    await it('should throw for negative value', () => {
+      assert.throws(() => coerceConfigurationVersion(-1), {
+        message: /must be a non-negative integer/,
+      })
+    })
+
+    await it('should throw for float value', () => {
+      assert.throws(() => coerceConfigurationVersion(1.5), {
+        message: /must be a non-negative integer/,
+      })
+    })
+
+    await it('should reject permissive numeric string forms', () => {
+      for (const bad of ['1.0', '0x1', ' 1 ', '1e0', '', '01a', '+1', '-1']) {
+        assert.throws(
+          () => coerceConfigurationVersion(bad),
+          { message: /must be a non-negative integer/ },
+          `should reject ${JSON.stringify(bad)}`
+        )
+      }
+    })
+
+    await it('should use harmonized "non-negative integer" wording for all rejection branches', () => {
+      for (const bad of ['abc', Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
+        assert.throws(() => coerceConfigurationVersion(bad), {
+          message: /must be a non-negative integer/,
+        })
+      }
+    })
+
+    await it('should throw for future version', () => {
+      assert.throws(() => coerceConfigurationVersion(CURRENT_CONFIGURATION_SCHEMA_VERSION + 1), {
+        message: /is newer than supported version/,
+      })
+    })
+
+    await it('should throw for object value', () => {
+      assert.throws(() => coerceConfigurationVersion({}), {
+        message: /Invalid \$schemaVersion value/,
+      })
+    })
+
+    await it('should throw for boolean value', () => {
+      assert.throws(() => coerceConfigurationVersion(true), {
+        message: /Invalid \$schemaVersion value/,
+      })
+    })
+  })
+
+  await describe('remapDeprecatedKeys', async () => {
+    await it('should not mutate the input config (immutability boundary)', () => {
+      const input = buildLegacyConfiguration({ logEnabled: true })
+      const before = JSON.stringify(input)
+      remapDeprecatedKeys(input)
+      const after = JSON.stringify(input)
+      assert.strictEqual(before, after)
+    })
+
+    await it('should be a no-op (empty warnings, empty fieldErrors) for a clean v1 config', () => {
+      const result = remapDeprecatedKeys({
+        $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
+        stationTemplateUrls: [{ file: 'clean.json', numberOfStations: 1 }],
+      })
+      assert.deepStrictEqual(result.warnings, [])
+      assert.deepStrictEqual(result.fieldErrors, [])
+    })
+
+    await it('should remap every legacy top-level key in buildLegacyConfiguration', () => {
+      const legacy = buildLegacyConfiguration({
+        logEnabled: true,
+        logFile: '/logs/combined.log',
+        stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }],
+        supervisionURLs: 'ws://localhost:8080',
+        workerProcess: 'workerSet',
+      })
+
+      const { config: result, fieldErrors, warnings } = remapDeprecatedKeys(legacy)
+
+      assert.strictEqual(fieldErrors.length, 0)
+      assert.ok(warnings.length > 0)
+      assert.strictEqual((result.log as Record<string, unknown>).enabled, true)
+      assert.strictEqual((result.log as Record<string, unknown>).file, '/logs/combined.log')
+      assert.strictEqual((result.worker as Record<string, unknown>).processType, 'workerSet')
+      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.ok(Array.isArray(result.stationTemplateUrls))
+      assert.strictEqual(result.logEnabled, undefined)
+      assert.strictEqual(result.logFile, undefined)
+      assert.strictEqual(result.workerProcess, undefined)
+      assert.strictEqual(result.supervisionURLs, undefined)
+      assert.strictEqual(result.stationTemplateURLs, undefined)
+    })
+
+    for (const [deprecated, canonical] of Object.entries(DEPRECATED_KEY_REMAPPINGS)) {
+      await it(`should remap deprecated key '${deprecated}' to ${canonical == null ? 'null (delete-only)' : `'${canonical}'`}`, () => {
+        const sampleValue = deprecated.includes('worker') ? 'workerSet' : 'sample-value'
+        // Build input with the deprecated key. Dotted keys nest into a section.
+        const input: Record<string, unknown> = deprecated.includes('.')
+          ? (() => {
+              const [section, leaf] = deprecated.split('.')
+              return { [section]: { [leaf]: sampleValue } }
+            })()
+          : { [deprecated]: sampleValue }
+
+        const { config: result, fieldErrors, warnings } = remapDeprecatedKeys(input)
+
+        assert.strictEqual(fieldErrors.length, 0)
+        assert.deepStrictEqual(warnings, [
+          { canonicalDestination: canonical, sourceKey: deprecated },
+        ])
+
+        // Source key must be physically removed from its location.
+        if (deprecated.includes('.')) {
+          const [section, leaf] = deprecated.split('.')
+          const sectionObj = result[section] as Record<string, unknown> | undefined
+          assert.strictEqual(
+            sectionObj?.[leaf],
+            undefined,
+            `nested source '${deprecated}' must be removed`
+          )
+        } else {
+          assert.strictEqual(
+            Object.prototype.hasOwnProperty.call(result, deprecated),
+            false,
+            `top-level source '${deprecated}' must be removed`
+          )
+        }
+
+        // Verify canonical destination semantics.
+        if (canonical == null) {
+          // Delete-only destination: value must not appear anywhere obvious.
+          // Specifically, no top-level key carries the same name as `deprecated`.
+          assert.strictEqual(result[deprecated], undefined)
+        } else if (canonical === deprecated) {
+          // Self-mapping: the key remains absent (table entry should normally
+          // use `null` for this case; tolerated here for forward-compat).
+          assert.strictEqual(result[deprecated], undefined)
+        } else if (canonical.includes('.')) {
+          const [section, leaf] = canonical.split('.')
+          const sectionObj = result[section] as Record<string, unknown> | undefined
+          assert.ok(sectionObj != null, `section '${section}' should exist`)
+          assert.strictEqual(sectionObj[leaf], sampleValue)
+        } else {
+          assert.strictEqual(result[canonical], sampleValue)
+        }
+      })
+    }
+
+    await it('should keep canonical key when both deprecated and canonical are present (no overwrite)', () => {
+      const input = {
+        log: { enabled: false },
+        logEnabled: false,
+      }
+      const { config: result, fieldErrors } = remapDeprecatedKeys(input)
+      assert.strictEqual(fieldErrors.length, 0)
+      assert.strictEqual((result.log as Record<string, unknown>).enabled, false)
+      assert.strictEqual(result.logEnabled, undefined)
+    })
+
+    await it('should drop autoReconnectMaxRetries with explicit warning (null destination)', () => {
+      const {
+        config: result,
+        fieldErrors,
+        warnings,
+      } = remapDeprecatedKeys({
+        autoReconnectMaxRetries: 7,
+        stationTemplateURLs: [{ file: 'b2.json', numberOfStations: 1 }],
+      })
+      assert.strictEqual(fieldErrors.length, 0)
+      assert.deepStrictEqual(
+        warnings.find(w => w.sourceKey === 'autoReconnectMaxRetries'),
+        { canonicalDestination: null, sourceKey: 'autoReconnectMaxRetries' }
+      )
+      assert.strictEqual(result.autoReconnectMaxRetries, undefined)
+      assert.strictEqual(
+        Object.prototype.hasOwnProperty.call(result, 'autoReconnectMaxRetries'),
+        false
+      )
+    })
+
+    await it('should treat equal-value collision as idempotent no-op', () => {
+      const input = buildV0WithDeprecatedKeyCollision('workerPoolMaxSize', 16, 'workerPoolSize', 16)
+      const { config: result, fieldErrors, warnings } = remapDeprecatedKeys(input)
+      assert.strictEqual(fieldErrors.length, 0, 'equal values must not produce a fieldError')
+      assert.strictEqual((result.worker as Record<string, unknown>).poolMaxSize, 16)
+      assert.strictEqual(result.workerPoolMaxSize, undefined)
+      assert.strictEqual(result.workerPoolSize, undefined)
+      assert.strictEqual(warnings.length, 2)
+    })
+
+    await it('should record fieldError on unequal-value collision and leave conflicting source in place', () => {
+      const input = buildV0WithDeprecatedKeyCollision('workerPoolMaxSize', 8, 'workerPoolSize', 16)
+      const { config: result, fieldErrors } = remapDeprecatedKeys(input)
+      assert.strictEqual(fieldErrors.length, 1)
+      assert.strictEqual(fieldErrors[0].path, 'workerPoolSize')
+      assert.match(fieldErrors[0].message, /worker\.poolMaxSize/)
+      assert.match(fieldErrors[0].message, /conflicts with existing/)
+      // The first writer wins; the conflicting source stays so its name
+      // remains visible to the user via the error path.
+      assert.strictEqual((result.worker as Record<string, unknown>).poolMaxSize, 8)
+    })
+
+    await it('should record fieldError on non-object intermediate', () => {
+      const input = {
+        log: 'not-an-object',
+        logEnabled: true,
+        stationTemplateURLs: [{ file: 'n7.json', numberOfStations: 1 }],
+      }
+      const { fieldErrors } = remapDeprecatedKeys(input)
+      assert.strictEqual(fieldErrors.length, 1)
+      assert.strictEqual(fieldErrors[0].path, 'logEnabled')
+      assert.match(fieldErrors[0].message, /intermediate 'log' is not an object/)
+    })
+
+    await it('nested — should remap worker.elementStartDelay → worker.elementAddDelay', () => {
+      const {
+        config: result,
+        fieldErrors,
+        warnings,
+      } = remapDeprecatedKeys({
+        stationTemplateURLs: [{ file: 'nested.json', numberOfStations: 1 }],
+        worker: { elementStartDelay: 250 },
+      })
+      assert.strictEqual(fieldErrors.length, 0)
+      assert.deepStrictEqual(
+        warnings.find(w => w.sourceKey === 'worker.elementStartDelay'),
+        { canonicalDestination: 'worker.elementAddDelay', sourceKey: 'worker.elementStartDelay' }
+      )
+      const worker = result.worker as Record<string, unknown>
+      assert.strictEqual(worker.elementAddDelay, 250)
+      assert.strictEqual(worker.elementStartDelay, undefined)
+    })
+
+    await it('nested — should record fieldError on unequal worker.elementStartDelay vs elementAddDelay', () => {
+      const { fieldErrors } = remapDeprecatedKeys({
+        stationTemplateURLs: [{ file: 'nested-conflict.json', numberOfStations: 1 }],
+        worker: { elementAddDelay: 100, elementStartDelay: 250 },
+      })
+      assert.strictEqual(fieldErrors.length, 1)
+      assert.strictEqual(fieldErrors[0].path, 'worker.elementStartDelay')
+    })
+  })
+
+  await describe('applyConfigurationMigration', async () => {
+    await it('should bump $schemaVersion from 0 to CURRENT', () => {
+      const result = applyConfigurationMigration(0, { foo: 'bar' }, 'test.json')
+      assert.strictEqual(result.$schemaVersion, CURRENT_CONFIGURATION_SCHEMA_VERSION)
+      assert.strictEqual(result.foo, 'bar')
+    })
+
+    await it('should not mutate the input config (immutability boundary)', () => {
+      const input = { foo: 'bar' }
+      const before = JSON.stringify(input)
+      applyConfigurationMigration(0, input, 'test.json')
+      const after = JSON.stringify(input)
+      assert.strictEqual(before, after)
+    })
+
+    for (const [label, sourceVersion] of [
+      ['unknown source version', 99],
+      ['source version equal to CURRENT (no-op boundary)', CURRENT_CONFIGURATION_SCHEMA_VERSION],
+      ['negative source version', -1],
+    ] as const) {
+      await it(`should throw for ${label}`, () => {
+        assert.throws(
+          () => applyConfigurationMigration(sourceVersion, { foo: 'bar' }, 'test.json'),
+          {
+            message: /No migration defined/,
+          }
+        )
+      })
+    }
+  })
+})
diff --git a/tests/charging-station/ConfigurationSchema.test.ts b/tests/charging-station/ConfigurationSchema.test.ts
new file mode 100644 (file)
index 0000000..dff29da
--- /dev/null
@@ -0,0 +1,616 @@
+/**
+ * @file Tests for ConfigurationSchema
+ * @description Unit tests for Zod configuration schema validation
+ */
+
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+  CURRENT_CONFIGURATION_SCHEMA_VERSION,
+  DEPRECATED_KEY_REMAPPINGS,
+} from '../../src/charging-station/ConfigurationMigrations.js'
+import {
+  ConfigurationSchema,
+  WorkerConfigurationSchema,
+} from '../../src/charging-station/ConfigurationSchema.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  BAD_FIXTURES,
+  buildFullConfiguration,
+  buildMinimalConfiguration,
+} from './helpers/ConfigurationFixtures.js'
+
+await describe('ConfigurationSchema', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('required fields', async () => {
+    await it('should accept a minimal valid configuration', () => {
+      const result = ConfigurationSchema.safeParse(buildMinimalConfiguration())
+      assert.ok(result.success)
+      assert.strictEqual(result.data.$schemaVersion, CURRENT_CONFIGURATION_SCHEMA_VERSION)
+    })
+
+    await it('should accept a fully-populated configuration', () => {
+      const result = ConfigurationSchema.safeParse(buildFullConfiguration())
+      assert.ok(result.success)
+    })
+
+    for (const [label, value, expectedErrorPath] of BAD_FIXTURES) {
+      await it(`should reject: ${label}`, () => {
+        const result = ConfigurationSchema.safeParse(value)
+        assert.ok(!result.success, `Expected failure for: ${label}`)
+        if (expectedErrorPath !== '') {
+          const paths = result.error.issues.flatMap(i => i.path.join('.'))
+          assert.ok(
+            paths.some(p => p.includes(expectedErrorPath)),
+            `Expected error path '${expectedErrorPath}' in ${JSON.stringify(paths)}`
+          )
+        }
+      })
+    }
+  })
+
+  await describe('$schemaVersion', async () => {
+    await it('should reject missing $schemaVersion', () => {
+      const result = ConfigurationSchema.safeParse({
+        stationTemplateUrls: [{ file: 'a.json', numberOfStations: 1 }],
+      })
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.includes('$schemaVersion')))
+    })
+
+    await it('should accept explicit $schemaVersion equal to CURRENT_CONFIGURATION_SCHEMA_VERSION', () => {
+      const result = ConfigurationSchema.safeParse(buildMinimalConfiguration())
+      assert.ok(result.success)
+      assert.strictEqual(result.data.$schemaVersion, CURRENT_CONFIGURATION_SCHEMA_VERSION)
+    })
+
+    await it('should reject $schemaVersion not equal to CURRENT_CONFIGURATION_SCHEMA_VERSION', () => {
+      const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ $schemaVersion: 0 }))
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.includes('$schemaVersion')))
+    })
+  })
+
+  await describe('strict top-level', async () => {
+    await it('should reject unknown top-level key', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ bogusUnknownKey: 42 })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('deprecated keys accepted', async () => {
+    for (const [key, value] of [
+      ['autoReconnectMaxRetries', -1],
+      ['chargingStationsPerWorker', 2],
+      ['distributeStationToTenantEqually', true],
+      ['distributeStationsToTenantsEqually', false],
+      ['elementAddDelay', 0],
+      ['logConsole', false],
+      ['logEnabled', true],
+      ['logErrorFile', 'logs/error.log'],
+      ['logFile', 'logs/combined.log'],
+      ['logFormat', 'simple'],
+      ['logLevel', 'info'],
+      ['logMaxFiles', 7],
+      ['logMaxSize', '10m'],
+      ['logRotate', true],
+      ['logStatisticsInterval', 60],
+      ['stationTemplateURLs', [{ file: 'a.json', numberOfStations: 1 }]],
+      ['supervisionURLs', 'ws://localhost:8080'],
+      ['uiWebSocketServer', {}],
+      ['useWorkerPool', false],
+      ['workerPoolMaxSize', 16],
+      ['workerPoolMinSize', 4],
+      ['workerPoolSize', 8],
+      ['workerPoolStrategy', 'ROUND_ROBIN'],
+      ['workerProcess', 'workerSet'],
+      ['workerStartDelay', 500],
+    ] as const) {
+      await it(`should accept deprecated key '${key}'`, () => {
+        const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ [key]: value }))
+        assert.ok(
+          result.success,
+          `Expected deprecated key '${key}' to be accepted, got: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+        )
+      })
+    }
+  })
+
+  await describe('sub-section schemas', async () => {
+    await it('should accept valid log section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          log: { enabled: true, file: 'logs/combined.log', level: 'info', rotate: true },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject unknown key in log section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ log: { unknownLogKey: true } })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should accept valid worker section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          worker: { elementsPerWorker: 'auto', processType: 'workerSet', startDelay: 500 },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject invalid worker.processType', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { processType: 'invalid' } })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('worker.processType')))
+    })
+
+    await it('should accept valid performanceStorage section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ performanceStorage: { enabled: true, type: 'none' } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept valid uiServer section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            enabled: false,
+            options: { host: 'localhost', port: 8080 },
+            type: 'ws',
+            version: '1.1',
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept uiServer with authentication', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            authentication: {
+              enabled: true,
+              password: 'admin',
+              type: 'protocol-basic-auth',
+              username: 'admin',
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject unknown key in worker section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { unknownWorkerKey: true } })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('mixed-type fields', async () => {
+    await it('should accept supervisionUrls as string', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ supervisionUrls: 'ws://localhost:8080' })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept supervisionUrls as string array', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          supervisionUrls: ['ws://localhost:8080', 'ws://localhost:8081'],
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept worker.elementsPerWorker as auto', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { elementsPerWorker: 'auto' } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept worker.elementsPerWorker as all', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { elementsPerWorker: 'all' } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept worker.elementsPerWorker as positive integer', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { elementsPerWorker: 4 } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept log.maxFiles as number', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ log: { maxFiles: 7 } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept log.maxFiles as string', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ log: { maxFiles: '14d' } })
+      )
+      assert.ok(result.success)
+    })
+  })
+
+  await describe('enum constraints', async () => {
+    await it('should accept valid supervisionUrlDistribution', () => {
+      for (const val of ['round-robin', 'random', 'charging-station-affinity']) {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({ supervisionUrlDistribution: val })
+        )
+        assert.ok(result.success, `Expected '${val}' to be valid`)
+      }
+    })
+
+    await it('should reject invalid supervisionUrlDistribution', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ supervisionUrlDistribution: 'invalid' })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should accept valid log.level values', () => {
+      for (const level of [
+        'emerg',
+        'alert',
+        'crit',
+        'error',
+        'warning',
+        'notice',
+        'info',
+        'debug',
+      ]) {
+        const result = ConfigurationSchema.safeParse(buildMinimalConfiguration({ log: { level } }))
+        assert.ok(result.success, `Expected log.level '${level}' to be valid`)
+      }
+    })
+
+    await it('should reject invalid log.level', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ log: { level: 'verbose' } })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('external-type bridges', async () => {
+    await it('should accept uiServer.options as arbitrary ListenOptions object (z.custom bridge)', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            enabled: false,
+            options: {
+              backlog: 511,
+              exclusive: true,
+              host: '127.0.0.1',
+              ipv6Only: false,
+              port: 9090,
+            },
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept worker.resourceLimits as arbitrary ResourceLimits object (z.custom bridge)', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          worker: {
+            resourceLimits: {
+              codeRangeSizeMb: 16,
+              maxOldGenerationSizeMb: 256,
+              maxYoungGenerationSizeMb: 64,
+              stackSizeMb: 4,
+            },
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept empty uiServer.options object', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: { enabled: false, options: {}, type: 'ws' },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept worker.elementStartDelay deprecated alias', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { elementStartDelay: 0 } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept performanceStorage.URI deprecated alias', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          performanceStorage: { type: 'jsonfile', URI: 'file:///tmp/perf.json' },
+        })
+      )
+      assert.ok(result.success)
+    })
+  })
+
+  await describe('round-trip on real config-template.json', async () => {
+    await it('should validate src/assets/config-template.json successfully', () => {
+      const raw = JSON.parse(readFileSync('src/assets/config-template.json', 'utf8')) as unknown
+      const result = ConfigurationSchema.safeParse(raw)
+      assert.ok(
+        result.success,
+        `config-template.json failed schema validation: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+      )
+    })
+  })
+
+  await describe('round-trip on hardcoded fallback', async () => {
+    // Mirrors the in-memory fallback in Configuration.ts; guards against drift.
+    await it('should validate the hardcoded Configuration.ts fallback object', () => {
+      const hardcodedFallback = {
+        $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
+        log: {
+          enabled: true,
+          errorFile: 'logs/error.log',
+          file: 'logs/combined.log',
+          format: 'simple',
+          level: 'info',
+          rotate: true,
+          statisticsInterval: 60,
+        },
+        performanceStorage: {
+          enabled: true,
+          type: 'none',
+        },
+        stationTemplateUrls: [
+          {
+            file: 'siemens.station-template.json',
+            numberOfStations: 1,
+          },
+        ],
+        supervisionUrlDistribution: 'round-robin',
+        supervisionUrls: 'ws://localhost:8180/steve/websocket/CentralSystemService',
+        uiServer: {
+          enabled: false,
+          options: {
+            host: 'localhost',
+            port: 8080,
+          },
+          type: 'ws',
+          version: '1.1',
+        },
+        worker: {
+          elementAddDelay: 0,
+          elementsPerWorker: 'auto',
+          poolMaxSize: 16,
+          poolMinSize: 4,
+          processType: 'workerSet',
+          startDelay: 500,
+        },
+      }
+      const result = ConfigurationSchema.safeParse(hardcodedFallback)
+      assert.ok(
+        result.success,
+        `hardcoded fallback failed schema validation: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+      )
+      assert.strictEqual(result.data.$schemaVersion, CURRENT_CONFIGURATION_SCHEMA_VERSION)
+    })
+  })
+
+  await describe('strict sub-section parity', async () => {
+    await it('should reject unknown key in performanceStorage section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ performanceStorage: { bogusStorageKey: true } })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').startsWith('performanceStorage')))
+    })
+
+    await it('should reject unknown key in uiServer.authentication section', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            authentication: {
+              bogusAuthKey: 'x',
+              enabled: true,
+              type: 'protocol-basic-auth',
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('StationTemplateUrl entries', async () => {
+    await it('should reject empty file string', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          stationTemplateUrls: [{ file: '', numberOfStations: 1 }],
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.') === 'stationTemplateUrls.0.file'))
+    })
+
+    await it('should reject negative numberOfStations', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          stationTemplateUrls: [{ file: 'a.json', numberOfStations: -1 }],
+        })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should accept deprecated numberOfStation alias', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          stationTemplateUrls: [{ file: 'a.json', numberOfStation: 1, numberOfStations: 1 }],
+        })
+      )
+      assert.ok(
+        result.success,
+        `Expected deprecated numberOfStation to be accepted, got: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+      )
+    })
+
+    await it('should reject unknown key in stationTemplateUrls entry', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          stationTemplateUrls: [{ bogusEntryKey: 1, file: 'a.json', numberOfStations: 1 }],
+        })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('worker numeric constraints', async () => {
+    for (const invalid of [0, -1] as const) {
+      await it(`should reject worker.elementsPerWorker = ${invalid.toString()}`, () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({ worker: { elementsPerWorker: invalid } })
+        )
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.join('.') === 'worker.elementsPerWorker'))
+      })
+    }
+
+    await it('should reject worker.poolMaxSize = 0', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { poolMaxSize: 0 } })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should reject worker.poolMinSize = -1', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({ worker: { poolMinSize: -1 } })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('log numeric constraints', async () => {
+    for (const invalid of [-1, 1.5] as const) {
+      await it(`should reject log.statisticsInterval = ${invalid.toString()}`, () => {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({ log: { statisticsInterval: invalid } })
+        )
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.join('.') === 'log.statisticsInterval'))
+      })
+    }
+  })
+
+  await describe('schema / DEPRECATED_KEY_REMAPPINGS sync', async () => {
+    interface SchemaShapeEntry {
+      description?: string
+    }
+    interface ZodObjectLike {
+      shape: Record<string, SchemaShapeEntry>
+    }
+
+    await it('should mark every top-level DEPRECATED_KEY_REMAPPINGS entry as @deprecated in the schema', () => {
+      const shape = (ConfigurationSchema as unknown as ZodObjectLike).shape
+      for (const legacy of Object.keys(DEPRECATED_KEY_REMAPPINGS)) {
+        if (legacy.includes('.')) {
+          continue
+        }
+        const entry = shape[legacy]
+        assert.notStrictEqual(entry, undefined, `Schema is missing top-level key '${legacy}'`)
+        assert.match(
+          entry.description ?? '',
+          /@deprecated/,
+          `Schema key '${legacy}' must carry a @deprecated description`
+        )
+      }
+    })
+
+    await it('should mark every nested DEPRECATED_KEY_REMAPPINGS entry as @deprecated in the matching sub-schema', () => {
+      const subSchemas: Record<string, ZodObjectLike> = {
+        worker: WorkerConfigurationSchema,
+      }
+      for (const legacy of Object.keys(DEPRECATED_KEY_REMAPPINGS)) {
+        if (!legacy.includes('.')) {
+          continue
+        }
+        const [section, leaf] = legacy.split('.')
+        const subSchema = subSchemas[section]
+        assert.notStrictEqual(
+          subSchema,
+          undefined,
+          `No sub-schema registered for section '${section}'; extend subSchemas above`
+        )
+        const entry = subSchema.shape[leaf]
+        assert.notStrictEqual(
+          entry,
+          undefined,
+          `Sub-schema '${section}' is missing nested key '${leaf}'`
+        )
+        assert.match(
+          entry.description ?? '',
+          /@deprecated/,
+          `Schema key '${section}.${leaf}' must carry a @deprecated description`
+        )
+      }
+    })
+
+    await it('should list every @deprecated top-level schema key in DEPRECATED_KEY_REMAPPINGS', () => {
+      const shape = (ConfigurationSchema as unknown as ZodObjectLike).shape
+      for (const [fieldName, def] of Object.entries(shape)) {
+        if (def.description?.includes('@deprecated') === true) {
+          assert.ok(
+            fieldName in DEPRECATED_KEY_REMAPPINGS,
+            `Schema field '${fieldName}' marked @deprecated but missing from DEPRECATED_KEY_REMAPPINGS`
+          )
+        }
+      }
+    })
+
+    await it('should list every @deprecated nested sub-schema key in DEPRECATED_KEY_REMAPPINGS', () => {
+      const subSchemas: Record<string, ZodObjectLike> = {
+        worker: WorkerConfigurationSchema,
+      }
+      for (const [section, subSchema] of Object.entries(subSchemas)) {
+        for (const [leaf, def] of Object.entries(subSchema.shape)) {
+          if (def.description?.includes('@deprecated') === true) {
+            const dotted = `${section}.${leaf}`
+            assert.ok(
+              dotted in DEPRECATED_KEY_REMAPPINGS,
+              `Sub-schema field '${dotted}' marked @deprecated but missing from DEPRECATED_KEY_REMAPPINGS`
+            )
+          }
+        }
+      }
+    })
+  })
+})
diff --git a/tests/charging-station/ConfigurationValidation-perf.test.ts b/tests/charging-station/ConfigurationValidation-perf.test.ts
new file mode 100644 (file)
index 0000000..ec521a9
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * @file Performance tests for ConfigurationValidation
+ * @description p99 budget (relative to median) plus an absolute ceiling for catastrophic regressions
+ */
+
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { afterEach, describe, it } from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+import { validateConfiguration } from '../../src/charging-station/ConfigurationValidation.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+/** p99 must stay within Nx of the median (relative threshold absorbs CI noise). */
+const RELATIVE_P99_BUDGET_MULTIPLIER = 20
+/** Hard ceiling: anything past this is a stuck thread or runaway loop, not jitter. */
+const ABSOLUTE_P99_HARD_CEILING_MS = 500
+/** Iterations after warm-up. */
+const TIMING_ITERATIONS = 100
+/** Warm-up iterations to prime JIT and module caches before measurement. */
+const WARMUP_ITERATIONS = 10
+/** Sub-millisecond floor preventing the relative budget from collapsing to 0. */
+const RELATIVE_BUDGET_FLOOR_MS = 1
+
+await describe('ConfigurationValidation performance', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should validate config-template.json within relative p99 budget', () => {
+    const configPath = join(
+      fileURLToPath(new URL('.', import.meta.url)),
+      '../../src/assets/config-template.json'
+    )
+    const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as Record<string, unknown>
+
+    for (let i = 0; i < WARMUP_ITERATIONS; i++) {
+      validateConfiguration(parsed, configPath)
+    }
+
+    const timings: number[] = new Array<number>(TIMING_ITERATIONS)
+    for (let i = 0; i < TIMING_ITERATIONS; i++) {
+      const t0 = performance.now()
+      validateConfiguration(parsed, configPath)
+      timings[i] = performance.now() - t0
+    }
+
+    timings.sort((a, b) => a - b)
+    const median = timings[Math.floor(TIMING_ITERATIONS / 2)]
+    const p99 = timings[Math.floor(TIMING_ITERATIONS * 0.99) - 1]
+    const relativeBudget =
+      Math.max(median, RELATIVE_BUDGET_FLOOR_MS) * RELATIVE_P99_BUDGET_MULTIPLIER
+
+    assert.ok(
+      p99 < relativeBudget,
+      `p99 ${p99.toFixed(2)}ms exceeds relative budget ${relativeBudget.toFixed(2)}ms (${RELATIVE_P99_BUDGET_MULTIPLIER.toString()}× median ${median.toFixed(2)}ms)`
+    )
+    assert.ok(
+      p99 < ABSOLUTE_P99_HARD_CEILING_MS,
+      `p99 ${p99.toFixed(2)}ms exceeds absolute ceiling ${ABSOLUTE_P99_HARD_CEILING_MS.toString()}ms (catastrophic regression)`
+    )
+  })
+})
diff --git a/tests/charging-station/ConfigurationValidation.test.ts b/tests/charging-station/ConfigurationValidation.test.ts
new file mode 100644 (file)
index 0000000..df85ebe
--- /dev/null
@@ -0,0 +1,471 @@
+/**
+ * @file Tests for ConfigurationValidation
+ * @description Unit tests for the validation pipeline, error class shape, immutability, and asset round-trip
+ */
+
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+import { ZodError } from 'zod'
+
+import {
+  CURRENT_CONFIGURATION_SCHEMA_VERSION,
+  DEPRECATED_KEY_REMAPPINGS,
+} from '../../src/charging-station/ConfigurationMigrations.js'
+import {
+  ConfigurationValidationError,
+  validateConfiguration,
+} from '../../src/charging-station/ConfigurationValidation.js'
+import { BaseError } from '../../src/exception/index.js'
+import { logger } from '../../src/utils/index.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  buildLegacyConfiguration,
+  buildMinimalConfiguration,
+  buildV1WithDeprecatedKey,
+} from './helpers/ConfigurationFixtures.js'
+
+/** Expected error message for a v1 config missing `stationTemplateUrls`. */
+const EXPECTED_SNAPSHOT =
+  "ConfigurationValidation: Configuration validation failed [schema] for 'test.json':\n  - stationTemplateUrls: Invalid input: expected array, received undefined"
+
+/**
+ * Schema-valid sample value for each deprecated key in `DEPRECATED_KEY_REMAPPINGS`.
+ * Used to exercise the deprecation-warning channel for every entry.
+ * Dotted source keys (e.g. `'worker.elementStartDelay'`) are nested by
+ * `buildV1WithDeprecatedKey`.
+ */
+const SAMPLE_DEPRECATED_VALUES: Readonly<Record<string, unknown>> = {
+  autoReconnectMaxRetries: 5,
+  chargingStationsPerWorker: 1,
+  distributeStationsToTenantsEqually: true,
+  distributeStationToTenantEqually: true,
+  elementAddDelay: 0,
+  logConsole: false,
+  logEnabled: true,
+  logErrorFile: 'logs/error.log',
+  logFile: 'logs/combined.log',
+  logFormat: 'simple',
+  logLevel: 'info',
+  logMaxFiles: 7,
+  logMaxSize: '10m',
+  logRotate: true,
+  logStatisticsInterval: 60,
+  stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }],
+  supervisionURLs: 'ws://localhost:8080',
+  uiWebSocketServer: {},
+  useWorkerPool: false,
+  'worker.elementStartDelay': 100,
+  workerPoolMaxSize: 16,
+  workerPoolMinSize: 4,
+  workerPoolSize: 16,
+  workerPoolStrategy: 'ROUND_ROBIN',
+  workerProcess: 'workerSet',
+  workerStartDelay: 500,
+}
+
+await describe('ConfigurationValidation', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('validateConfiguration guards', async () => {
+    for (const [label, payload] of [
+      ['null', null],
+      ['string', 'not-an-object'],
+      ['array', [1, 2, 3]],
+    ] as const) {
+      await it(`should throw BaseError 'Invalid' for ${label} payload`, () => {
+        assert.throws(
+          () => validateConfiguration(payload, `${label}.json`),
+          (error: unknown) =>
+            error instanceof BaseError &&
+            error.message.includes('Invalid simulator configuration payload (not a JSON object)')
+        )
+      })
+    }
+
+    await it("should throw BaseError 'Empty' for {}", () => {
+      assert.throws(
+        () => validateConfiguration({}, 'empty.json'),
+        (error: unknown) =>
+          error instanceof BaseError &&
+          error.message.includes('Empty simulator configuration from file')
+      )
+    })
+
+    await it('should throw BaseError for $schemaVersion newer than supported (future-version pipeline)', () => {
+      const future = buildMinimalConfiguration({
+        $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION + 1,
+      })
+      assert.throws(
+        () => validateConfiguration(future, 'future.json'),
+        (error: unknown) =>
+          error instanceof BaseError &&
+          error.message.includes(
+            `is newer than supported version ${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()}`
+          )
+      )
+    })
+  })
+
+  await describe('migration pipeline', async () => {
+    await it('should migrate buildLegacyConfiguration to current schema version', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const parsed = buildLegacyConfiguration()
+
+      const result = validateConfiguration(parsed, 'legacy.json')
+
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+      assert.strictEqual(result.log?.enabled, true)
+      assert.strictEqual(result.worker?.processType, 'workerSet')
+      assert.ok(Array.isArray(result.stationTemplateUrls))
+      assert.strictEqual(result.stationTemplateUrls.length, 1)
+      const raw = result as unknown as Record<string, unknown>
+      assert.strictEqual(raw.logEnabled, undefined)
+      assert.strictEqual(raw.workerProcess, undefined)
+      assert.strictEqual(raw.stationTemplateURLs, undefined)
+    })
+
+    await it('should accept already-current v1 configuration without re-migrating', () => {
+      const parsed = buildMinimalConfiguration()
+
+      const result = validateConfiguration(parsed, 'current.json')
+
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+    })
+
+    await it('should accept string "$schemaVersion": "1" (no-migration path)', () => {
+      const parsed = buildMinimalConfiguration({ $schemaVersion: '1' })
+
+      const result = validateConfiguration(parsed, 'string-version.json')
+
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+    })
+
+    await it('should sweep deprecated keys unconditionally for v1 configs', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const parsed = buildV1WithDeprecatedKey('workerPoolSize', 16)
+
+      const result = validateConfiguration(parsed, 'b3.json')
+
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+      assert.strictEqual(result.worker?.poolMaxSize, 16)
+      assert.strictEqual((result as unknown as Record<string, unknown>).workerPoolSize, undefined)
+    })
+
+    await it('should throw remap-phase ConfigurationValidationError on collision', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const parsed = {
+        $schemaVersion: 1,
+        stationTemplateUrls: [{ file: 'collision.json', numberOfStations: 1 }],
+        workerPoolMaxSize: 8,
+        workerPoolSize: 16,
+      }
+      assert.throws(
+        () => validateConfiguration(parsed, 'collision.json'),
+        (error: unknown) =>
+          error instanceof ConfigurationValidationError &&
+          error.phase === 'remap' &&
+          error.fieldErrors.some(
+            f => f.path === 'workerPoolSize' && f.message.includes('worker.poolMaxSize')
+          )
+      )
+    })
+  })
+
+  await describe('ConfigurationValidationError shape', async () => {
+    await it('should be a BaseError with name, fieldErrors, filePath, and phase set', () => {
+      try {
+        validateConfiguration({ $schemaVersion: 1 }, 'broken.json')
+        assert.fail('Expected ConfigurationValidationError')
+      } catch (error) {
+        assert.ok(error instanceof ConfigurationValidationError)
+        assert.ok(error instanceof BaseError)
+        assert.strictEqual(error.name, 'ConfigurationValidationError')
+        assert.strictEqual(error.filePath, 'broken.json')
+        assert.strictEqual(error.phase, 'schema')
+        assert.ok(Array.isArray(error.fieldErrors))
+        assert.ok(error.fieldErrors.length > 0)
+      }
+    })
+
+    await it('should be constructable directly from FieldError[] with phase=remap', () => {
+      const error = new ConfigurationValidationError(
+        [{ message: 'collision', path: 'workerPoolSize' }],
+        { filePath: 'direct.json', phase: 'remap' }
+      )
+      assert.ok(error instanceof BaseError)
+      assert.strictEqual(error.filePath, 'direct.json')
+      assert.strictEqual(error.phase, 'remap')
+      assert.strictEqual(error.fieldErrors.length, 1)
+      assert.strictEqual(error.fieldErrors[0].path, 'workerPoolSize')
+      assert.strictEqual(error.migratedFrom, undefined)
+      assert.match(error.message, /\[remap\]/)
+    })
+
+    await it('should be constructable from a ZodError via fromZodError factory', () => {
+      const zodError = new ZodError([
+        {
+          code: 'invalid_type',
+          expected: 'array',
+          message: 'Required',
+          path: ['stationTemplateUrls'],
+        },
+      ])
+      const error = ConfigurationValidationError.fromZodError(zodError, { filePath: 'direct.json' })
+
+      assert.ok(error instanceof BaseError)
+      assert.strictEqual(error.filePath, 'direct.json')
+      assert.strictEqual(error.phase, 'schema')
+      assert.strictEqual(error.fieldErrors.length, 1)
+      assert.strictEqual(error.fieldErrors[0].path, 'stationTemplateUrls')
+      assert.strictEqual(error.migratedFrom, undefined)
+      assert.match(error.message, /\[schema\]/)
+    })
+
+    await it('should include migratedFrom note in message when provided', () => {
+      const error = new ConfigurationValidationError([], {
+        filePath: 'migrated.json',
+        migratedFrom: 0,
+        phase: 'schema',
+      })
+
+      assert.strictEqual(error.migratedFrom, 0)
+      assert.match(
+        error.message,
+        new RegExp(`migrated from v0 → v${CURRENT_CONFIGURATION_SCHEMA_VERSION.toString()}`)
+      )
+    })
+
+    await it('should aggregate multiple fieldErrors when several violations are present', () => {
+      try {
+        validateConfiguration(
+          {
+            $schemaVersion: 1,
+            log: 'not-an-object',
+            stationTemplateUrls: 'not-an-array',
+            worker: 123,
+          },
+          'multi.json'
+        )
+        assert.fail('Expected ConfigurationValidationError')
+      } catch (error) {
+        assert.ok(error instanceof ConfigurationValidationError)
+        assert.strictEqual(error.fieldErrors.length, 3)
+        const paths = error.fieldErrors.map(e => e.path)
+        assert.ok(paths.includes('stationTemplateUrls'))
+        assert.ok(paths.includes('log'))
+        assert.ok(paths.includes('worker'))
+      }
+    })
+  })
+
+  await describe('immutability', async () => {
+    await it('should not mutate the caller-supplied parsed object', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const parsed = buildLegacyConfiguration()
+      const before = structuredClone(parsed)
+
+      validateConfiguration(parsed, 'immutable.json')
+
+      assert.deepStrictEqual(parsed, before)
+    })
+
+    await it('should return a deep clone whose mutation does not leak into a subsequent validation', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const parsed = buildMinimalConfiguration({
+        log: { enabled: true },
+        worker: { processType: 'workerSet' },
+      })
+
+      const first = validateConfiguration(parsed, 'mut.json')
+      const second = validateConfiguration(parsed, 'mut.json')
+
+      assert.notStrictEqual(first, second)
+      assert.notStrictEqual(first.log, second.log)
+      assert.notStrictEqual(first.worker, second.worker)
+      assert.notStrictEqual(first.stationTemplateUrls, second.stationTemplateUrls)
+      assert.notStrictEqual(first.stationTemplateUrls[0], second.stationTemplateUrls[0])
+      ;(first as unknown as Record<string, unknown>).bogusMutation = 'mutated'
+      if (first.log != null) {
+        first.log.enabled = false
+      }
+      first.stationTemplateUrls.length = 0
+
+      const third = validateConfiguration(parsed, 'mut.json')
+      assert.strictEqual(third.log?.enabled, true)
+      assert.strictEqual(third.worker?.processType, 'workerSet')
+      assert.strictEqual(third.stationTemplateUrls.length, 1)
+      assert.strictEqual((third as unknown as Record<string, unknown>).bogusMutation, undefined)
+    })
+  })
+
+  await describe('deprecation warnings emitted via console.warn', async () => {
+    for (const legacyKey of Object.keys(DEPRECATED_KEY_REMAPPINGS)) {
+      await it(`should warn about deprecated key '${legacyKey}'`, t => {
+        const warnMock = t.mock.method(console, 'warn', () => undefined)
+        const sampleValue = SAMPLE_DEPRECATED_VALUES[legacyKey]
+        assert.notStrictEqual(
+          sampleValue,
+          undefined,
+          `Missing SAMPLE_DEPRECATED_VALUES entry for ${legacyKey}`
+        )
+        const parsed = buildV1WithDeprecatedKey(legacyKey, sampleValue)
+
+        // Warning must fire before downstream schema validation rejects boolean→enum / array remaps.
+        try {
+          validateConfiguration(parsed, `${legacyKey}.json`)
+        } catch {
+          // schema rejection is expected for these cases
+        }
+
+        const warnMessages = warnMock.mock.calls.map(c =>
+          typeof c.arguments[0] === 'string' ? c.arguments[0] : ''
+        )
+        assert.ok(
+          warnMessages.some(
+            m => m.includes(`'${legacyKey}'`) && m.includes('deprecated configuration key')
+          ),
+          `Expected deprecation warning for '${legacyKey}'; got: ${JSON.stringify(warnMessages)}`
+        )
+      })
+    }
+
+    await it('should emit deprecation warnings via console.warn, never via logger.warn', t => {
+      const consoleMock = t.mock.method(console, 'warn', () => undefined)
+      const loggerMock = t.mock.method(logger, 'warn')
+      const parsed = buildV1WithDeprecatedKey('workerPoolSize', 16)
+
+      const result = validateConfiguration(parsed, 'b1.json')
+
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+      assert.strictEqual(consoleMock.mock.calls.length, 1, 'console.warn should fire exactly once')
+      assert.strictEqual(
+        loggerMock.mock.calls.length,
+        0,
+        'logger.warn must not be called from validateConfiguration (re-entrance regression)'
+      )
+    })
+  })
+
+  await describe('migration is the single source of truth', async () => {
+    await it('should produce equivalent canonical shape from legacy and canonical inputs', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const legacy = buildLegacyConfiguration({ logEnabled: false, workerProcess: 'fixedPool' })
+      const validatedFromLegacy = validateConfiguration(legacy, 'legacy.json')
+
+      const canonical = buildMinimalConfiguration({
+        log: { enabled: false },
+        worker: { processType: 'fixedPool' },
+      })
+      const validatedFromCanonical = validateConfiguration(canonical, 'canonical.json')
+
+      assert.strictEqual(validatedFromLegacy.log?.enabled, validatedFromCanonical.log?.enabled)
+      assert.strictEqual(
+        validatedFromLegacy.worker?.processType,
+        validatedFromCanonical.worker?.processType
+      )
+    })
+  })
+
+  await describe('round-trip on real assets', async () => {
+    await it('should validate src/assets/config-template.json through the pipeline', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const templatePath = join(import.meta.dirname, '../../src/assets/config-template.json')
+      const parsed = JSON.parse(readFileSync(templatePath, 'utf8')) as Record<string, unknown>
+
+      const result = validateConfiguration(parsed, 'config-template.json')
+
+      assert.ok(result, 'config-template.json should validate successfully')
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+      assert.ok(Array.isArray(result.stationTemplateUrls))
+      assert.ok(result.stationTemplateUrls.length > 0)
+    })
+
+    await it('should validate the hardcoded fallback object from Configuration.ts', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      // Mirror of the fallback assigned in src/utils/Configuration.ts when
+      // src/assets/config.json is absent. Built at v0 (no `$schemaVersion`)
+      // so the migration step lifts it to the current schema version.
+      const hardcodedFallback = {
+        log: {
+          enabled: true,
+          errorFile: 'logs/error.log',
+          file: 'logs/combined.log',
+          format: 'simple',
+          level: 'info',
+          rotate: true,
+          statisticsInterval: 60,
+        },
+        performanceStorage: {
+          enabled: true,
+          type: 'none',
+        },
+        stationTemplateUrls: [
+          {
+            file: 'siemens.station-template.json',
+            numberOfStations: 1,
+          },
+        ],
+        supervisionUrlDistribution: 'round-robin',
+        supervisionUrls: 'ws://localhost:8180/steve/websocket/CentralSystemService',
+        uiServer: {
+          enabled: false,
+          options: { host: 'localhost', port: 8080 },
+          type: 'ws',
+          version: '1.1',
+        },
+        worker: {
+          elementAddDelay: 0,
+          elementsPerWorker: 'auto',
+          poolMaxSize: 16,
+          poolMinSize: 4,
+          processType: 'workerSet',
+          startDelay: 500,
+        },
+      }
+
+      const result = validateConfiguration(hardcodedFallback, 'hardcoded-fallback')
+
+      assert.ok(result, 'Hardcoded fallback should validate successfully')
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_CONFIGURATION_SCHEMA_VERSION
+      )
+      assert.strictEqual(result.supervisionUrlDistribution, 'round-robin')
+      assert.strictEqual(result.worker?.processType, 'workerSet')
+    })
+  })
+
+  await describe('error message snapshot', async () => {
+    await it('should match error message snapshot', () => {
+      try {
+        validateConfiguration({ $schemaVersion: 1 }, 'test.json')
+        assert.fail('Expected ConfigurationValidationError')
+      } catch (error) {
+        assert.ok(error instanceof ConfigurationValidationError)
+        assert.strictEqual(error.message, EXPECTED_SNAPSHOT)
+      }
+    })
+  })
+})
diff --git a/tests/charging-station/helpers/ConfigurationFixtures.ts b/tests/charging-station/helpers/ConfigurationFixtures.ts
new file mode 100644 (file)
index 0000000..72692fd
--- /dev/null
@@ -0,0 +1,166 @@
+/**
+ * @file Shared configuration fixtures for schema/migration/validation tests.
+ */
+
+import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../../src/charging-station/ConfigurationMigrations.js'
+
+/**
+ * Build a minimal valid configuration at the current schema version.
+ * @param overrides - Fields to merge into the base configuration
+ * @returns A minimal configuration accepted by `ConfigurationSchema`
+ */
+export const buildMinimalConfiguration = (
+  overrides: Record<string, unknown> = {}
+): Record<string, unknown> => ({
+  $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
+  stationTemplateUrls: [{ file: 'minimal.station-template.json', numberOfStations: 1 }],
+  ...overrides,
+})
+
+/**
+ * Build a legacy (v0) configuration without `$schemaVersion` for migration-path tests.
+ * Uses deprecated top-level keys that were moved to sub-sections.
+ * @param overrides - Fields to merge into the base configuration
+ * @returns A configuration without `$schemaVersion` (pre-versioning / v0)
+ */
+export const buildLegacyConfiguration = (
+  overrides: Record<string, unknown> = {}
+): Record<string, unknown> => ({
+  logEnabled: true,
+  stationTemplateURLs: [{ file: 'legacy.json', numberOfStations: 1 }],
+  workerProcess: 'workerSet',
+  ...overrides,
+})
+
+/**
+ * Build a fully-populated configuration with every section set to valid values.
+ * Suitable for round-trip serialization and schema completeness tests.
+ * @returns A fully-populated configuration at the current schema version
+ */
+export const buildFullConfiguration = (): Record<string, unknown> => ({
+  $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
+  log: {
+    console: false,
+    enabled: true,
+    errorFile: 'logs/error.log',
+    file: 'logs/combined.log',
+    format: 'simple',
+    level: 'info',
+    maxFiles: 7,
+    maxSize: '10m',
+    rotate: true,
+    statisticsInterval: 60,
+  },
+  performanceStorage: {
+    enabled: true,
+    type: 'none',
+  },
+  persistState: false,
+  stationTemplateUrls: [
+    { file: 'full.station-template.json', numberOfStations: 2, provisionedNumberOfStations: 4 },
+  ],
+  supervisionUrlDistribution: 'charging-station-affinity',
+  supervisionUrls: ['ws://localhost:8080/ocpp'],
+  uiServer: {
+    enabled: false,
+    options: { host: 'localhost', port: 8080 },
+    type: 'ws',
+    version: '1.1',
+  },
+  worker: {
+    elementAddDelay: 0,
+    elementsPerWorker: 'auto',
+    poolMaxSize: 16,
+    poolMinSize: 4,
+    processType: 'workerSet',
+    startDelay: 500,
+  },
+})
+
+/**
+ * Build an invalid JSON string for hot-reload parse-error tests.
+ * @returns A string that fails `JSON.parse`
+ */
+export const buildInvalidJsonString = (): string => '{ this is not valid json'
+
+/**
+ * Build a v1 configuration carrying a single deprecated key.
+ * Top-level keys are placed at the root; dotted keys nest into their section.
+ * @param key - Deprecated key name (must be in `DEPRECATED_KEY_REMAPPINGS`)
+ * @param value - Schema-valid sample value
+ * @returns A minimal v1 config carrying the deprecated key
+ */
+export const buildV1WithDeprecatedKey = (key: string, value: unknown): Record<string, unknown> => {
+  if (!key.includes('.')) {
+    return buildMinimalConfiguration({ [key]: value })
+  }
+  const [section, leaf] = key.split('.')
+  return buildMinimalConfiguration({ [section]: { [leaf]: value } })
+}
+
+/**
+ * Build a v0 configuration with two deprecated keys that resolve to the
+ * same canonical destination, for collision-resolution tests.
+ * Equal values resolve as idempotent no-ops; unequal values surface via
+ * `fieldErrors`.
+ * Uses canonical `stationTemplateUrls` so the fixture introduces no extra
+ * deprecation warnings — only the two source keys are part of the sweep.
+ * @param firstKey - First deprecated key
+ * @param firstValue - Value for first key
+ * @param secondKey - Second deprecated key (same canonical destination)
+ * @param secondValue - Value for second key
+ * @returns A configuration triggering only the requested collision branch
+ */
+export const buildV0WithDeprecatedKeyCollision = (
+  firstKey: string,
+  firstValue: unknown,
+  secondKey: string,
+  secondValue: unknown
+): Record<string, unknown> => ({
+  [firstKey]: firstValue,
+  [secondKey]: secondValue,
+  stationTemplateUrls: [{ file: 'collision.json', numberOfStations: 1 }],
+})
+
+/**
+ * Parametric negative-test fixtures: `[label, value, expectedErrorPath]`.
+ * Each entry is expected to fail `ConfigurationSchema.safeParse(value)`.
+ * `expectedErrorPath` is the dot-separated Zod error path (empty string means
+ * the error is at the root / unknown-key level).
+ */
+export const BAD_FIXTURES: [label: string, value: unknown, expectedErrorPath: string][] = [
+  ['missing stationTemplateUrls', { $schemaVersion: 1 }, 'stationTemplateUrls'],
+  [
+    'empty stationTemplateUrls',
+    { $schemaVersion: 1, stationTemplateUrls: [] },
+    'stationTemplateUrls',
+  ],
+  [
+    'missing $schemaVersion',
+    { stationTemplateUrls: [{ file: 'a.json', numberOfStations: 1 }] },
+    '$schemaVersion',
+  ],
+  [
+    'wrong type for stationTemplateUrls',
+    { $schemaVersion: 1, stationTemplateUrls: 'not-array' },
+    'stationTemplateUrls',
+  ],
+  [
+    'unknown top-level key',
+    {
+      $schemaVersion: 1,
+      bogusKey: 42,
+      stationTemplateUrls: [{ file: 'a.json', numberOfStations: 1 }],
+    },
+    '',
+  ],
+  [
+    'invalid worker.processType',
+    {
+      $schemaVersion: 1,
+      stationTemplateUrls: [{ file: 'a.json', numberOfStations: 1 }],
+      worker: { processType: 'invalid' },
+    },
+    'worker.processType',
+  ],
+]
diff --git a/tests/utils/Configuration-hot-reload.test.ts b/tests/utils/Configuration-hot-reload.test.ts
new file mode 100644 (file)
index 0000000..437c85c
--- /dev/null
@@ -0,0 +1,334 @@
+/**
+ * @file Tests for Configuration hot-reload rollback semantics
+ * @description Validates snapshot rollback, callback gating, lock release, and event coalescing
+ */
+import assert from 'node:assert/strict'
+import { type FSWatcher, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ConfigurationData } from '../../src/types/index.js'
+
+import { ConfigurationValidationError } from '../../src/charging-station/ConfigurationValidation.js'
+import { BaseError } from '../../src/exception/index.js'
+import { ConfigurationSection } from '../../src/types/index.js'
+import { Configuration, logger } from '../../src/utils/index.js'
+import {
+  buildInvalidJsonString,
+  buildMinimalConfiguration,
+} from '../charging-station/helpers/ConfigurationFixtures.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+interface ConfigurationInternals {
+  configurationChangeCallback?: () => Promise<void>
+  configurationData: ConfigurationData | undefined
+  configurationFile: string | undefined
+  configurationFileReloading: boolean
+  configurationFileReloadPending: boolean
+  configurationFileWatcher?: FSWatcher
+  configurationSectionCache: Map<ConfigurationSection, unknown>
+  performReload: () => Promise<void>
+  runReloadLoop: () => Promise<void>
+}
+
+const getInternals = (): ConfigurationInternals =>
+  Configuration as unknown as ConfigurationInternals
+
+const createTempConfigDir = (): string => mkdtempSync(join(tmpdir(), 'cfg-hot-reload-'))
+
+const writeConfigFile = (dir: string, contents: unknown): string => {
+  const file = join(dir, 'config.json')
+  writeFileSync(file, typeof contents === 'string' ? contents : JSON.stringify(contents))
+  return file
+}
+
+await describe('Configuration hot-reload', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await it('should replace caches and invoke callback on a valid reload', async t => {
+    t.mock.method(logger, 'error')
+    const internals = getInternals()
+    const tempDir = createTempConfigDir()
+    const originalFile = internals.configurationFile
+    const originalData = internals.configurationData
+    const originalCallback = internals.configurationChangeCallback
+    const originalCache = new Map(internals.configurationSectionCache)
+    const originalReloading = internals.configurationFileReloading
+
+    try {
+      const file = writeConfigFile(tempDir, buildMinimalConfiguration())
+      internals.configurationFile = file
+      internals.configurationData = {
+        stationTemplateUrls: [{ file: 'previous.json', numberOfStations: 1 }],
+      } as unknown as ConfigurationData
+      internals.configurationSectionCache = new Map<ConfigurationSection, unknown>([
+        [ConfigurationSection.log, { sentinel: 'previous-log' }],
+      ])
+      internals.configurationFileReloading = true
+
+      let callbackInvocations = 0
+      internals.configurationChangeCallback = async () => {
+        callbackInvocations += 1
+        return Promise.resolve()
+      }
+
+      await internals.runReloadLoop()
+
+      assert.strictEqual(callbackInvocations, 1)
+      assert.strictEqual(internals.configurationFileReloading, false)
+      assert.notStrictEqual(internals.configurationData, undefined)
+      assert.deepStrictEqual(internals.configurationData.stationTemplateUrls, [
+        { file: 'minimal.station-template.json', numberOfStations: 1 },
+      ])
+      assert.strictEqual(internals.configurationSectionCache.has(ConfigurationSection.log), false)
+    } finally {
+      internals.configurationFile = originalFile
+      internals.configurationData = originalData
+      internals.configurationSectionCache = originalCache
+      internals.configurationFileReloading = originalReloading
+      internals.configurationChangeCallback = originalCallback
+      rmSync(tempDir, { force: true, recursive: true })
+    }
+  })
+
+  await it('should restore caches and skip callback when reload validation fails', async t => {
+    const errorMock = t.mock.method(logger, 'error')
+    const internals = getInternals()
+    const tempDir = createTempConfigDir()
+    const originalFile = internals.configurationFile
+    const originalData = internals.configurationData
+    const originalCallback = internals.configurationChangeCallback
+    const originalCache = new Map(internals.configurationSectionCache)
+    const originalReloading = internals.configurationFileReloading
+
+    try {
+      const file = writeConfigFile(tempDir, { $schemaVersion: 1, bogusKey: true })
+      internals.configurationFile = file
+      const previousData = {
+        stationTemplateUrls: [{ file: 'previous.json', numberOfStations: 1 }],
+      } as unknown as ConfigurationData
+      internals.configurationData = previousData
+      const sentinelSection = { sentinel: 'previous-log' }
+      internals.configurationSectionCache = new Map<ConfigurationSection, unknown>([
+        [ConfigurationSection.log, sentinelSection],
+      ])
+      internals.configurationFileReloading = true
+
+      let callbackInvocations = 0
+      internals.configurationChangeCallback = async () => {
+        callbackInvocations += 1
+        return Promise.resolve()
+      }
+
+      await internals.runReloadLoop()
+
+      assert.strictEqual(callbackInvocations, 0)
+      assert.strictEqual(internals.configurationFileReloading, false)
+      assert.deepStrictEqual(
+        internals.configurationData.stationTemplateUrls,
+        previousData.stationTemplateUrls
+      )
+      assert.strictEqual(
+        internals.configurationSectionCache.get(ConfigurationSection.log),
+        sentinelSection
+      )
+      assert.ok(errorMock.mock.calls.length >= 1)
+      const errorCall = errorMock.mock.calls[errorMock.mock.calls.length - 1]
+      const errorArg = errorCall.arguments.find(arg => arg instanceof Error)
+      assert.ok(errorArg instanceof BaseError, 'Expected logger.error to receive a BaseError')
+      assert.ok(
+        errorArg instanceof ConfigurationValidationError,
+        'Expected schema-phase failure to surface ConfigurationValidationError'
+      )
+    } finally {
+      internals.configurationFile = originalFile
+      internals.configurationData = originalData
+      internals.configurationSectionCache = originalCache
+      internals.configurationFileReloading = originalReloading
+      internals.configurationChangeCallback = originalCallback
+      rmSync(tempDir, { force: true, recursive: true })
+    }
+  })
+
+  await it('should restore configurationData on JSON parse error', async t => {
+    t.mock.method(logger, 'error')
+    const internals = getInternals()
+    const tempDir = createTempConfigDir()
+    const originalFile = internals.configurationFile
+    const originalData = internals.configurationData
+    const originalCallback = internals.configurationChangeCallback
+    const originalCache = new Map(internals.configurationSectionCache)
+    const originalReloading = internals.configurationFileReloading
+
+    try {
+      const previousData = {
+        $schemaVersion: 1,
+        stationTemplateUrls: [{ file: 'previous.json', numberOfStations: 1 }],
+      } as unknown as ConfigurationData
+      const sentinelSection = { sentinel: 'previous-log' }
+      internals.configurationData = previousData
+      internals.configurationSectionCache = new Map<ConfigurationSection, unknown>([
+        [ConfigurationSection.log, sentinelSection],
+      ])
+      internals.configurationChangeCallback = undefined
+
+      const file = join(tempDir, 'malformed.json')
+      writeFileSync(file, buildInvalidJsonString())
+      internals.configurationFile = file
+      internals.configurationFileReloading = true
+
+      await internals.runReloadLoop()
+
+      assert.deepStrictEqual(internals.configurationData, previousData)
+      assert.strictEqual(
+        internals.configurationSectionCache.get(ConfigurationSection.log),
+        sentinelSection
+      )
+      assert.strictEqual(internals.configurationSectionCache.size, 1)
+      assert.strictEqual(internals.configurationFileReloading, false)
+    } finally {
+      internals.configurationFile = originalFile
+      internals.configurationData = originalData
+      internals.configurationSectionCache = originalCache
+      internals.configurationFileReloading = originalReloading
+      internals.configurationChangeCallback = originalCallback
+      rmSync(tempDir, { force: true, recursive: true })
+    }
+  })
+
+  await it('should keep the file watcher active after a failed reload', async t => {
+    t.mock.method(logger, 'error')
+    const internals = getInternals()
+    const tempDir = createTempConfigDir()
+    const originalFile = internals.configurationFile
+    const originalData = internals.configurationData
+    const originalCallback = internals.configurationChangeCallback
+    const originalCache = new Map(internals.configurationSectionCache)
+    const originalReloading = internals.configurationFileReloading
+    const originalWatcher = internals.configurationFileWatcher
+
+    try {
+      const file = writeConfigFile(tempDir, buildMinimalConfiguration())
+      internals.configurationFile = file
+      internals.configurationData = buildMinimalConfiguration() as unknown as ConfigurationData
+      internals.configurationSectionCache = new Map()
+      internals.configurationChangeCallback = undefined
+      // Sentinel watcher must survive across the failing reload.
+      const sentinelWatcher = { close: (): void => undefined } as unknown as FSWatcher
+      internals.configurationFileWatcher = sentinelWatcher
+
+      writeFileSync(file, buildInvalidJsonString())
+      internals.configurationFileReloading = true
+      await internals.runReloadLoop()
+
+      assert.strictEqual(
+        internals.configurationFileWatcher,
+        sentinelWatcher,
+        'Watcher must survive a failed reload'
+      )
+    } finally {
+      internals.configurationFile = originalFile
+      internals.configurationData = originalData
+      internals.configurationSectionCache = originalCache
+      internals.configurationFileReloading = originalReloading
+      internals.configurationChangeCallback = originalCallback
+      internals.configurationFileWatcher = originalWatcher
+      rmSync(tempDir, { force: true, recursive: true })
+    }
+  })
+
+  await it('should drain pending reloads and reflect the latest content under rapid double-save', async t => {
+    t.mock.method(logger, 'error')
+    const internals = getInternals()
+    const tempDir = createTempConfigDir()
+    const originalFile = internals.configurationFile
+    const originalData = internals.configurationData
+    const originalCallback = internals.configurationChangeCallback
+    const originalCache = new Map(internals.configurationSectionCache)
+    const originalReloading = internals.configurationFileReloading
+    const originalPending = internals.configurationFileReloadPending
+
+    try {
+      const file = writeConfigFile(tempDir, buildMinimalConfiguration({ persistState: false }))
+      internals.configurationFile = file
+      internals.configurationData = undefined
+      internals.configurationSectionCache = new Map()
+
+      let callbackInvocations = 0
+      internals.configurationChangeCallback = async () => {
+        callbackInvocations += 1
+        // After the FIRST reload sees `persistState: false`, simulate a
+        // second file save that arrives during the in-flight callback.
+        if (callbackInvocations === 1) {
+          writeFileSync(file, JSON.stringify(buildMinimalConfiguration({ persistState: true })))
+          internals.configurationFileReloadPending = true
+        }
+        return Promise.resolve()
+      }
+
+      internals.configurationFileReloading = true
+      await internals.runReloadLoop()
+
+      // Read through a function to defeat TS's narrowing of `configurationData`
+      // to `undefined` (set explicitly above before the reload).
+      const readConfigurationData = (): ConfigurationData | undefined => internals.configurationData
+      const reloadedData = readConfigurationData()
+      assert.strictEqual(callbackInvocations, 2, 'Pending event must trigger one drain reload')
+      assert.strictEqual(
+        reloadedData?.persistState,
+        true,
+        'Latest write must be reflected in configurationData'
+      )
+      assert.strictEqual(internals.configurationFileReloading, false)
+      assert.strictEqual(internals.configurationFileReloadPending, false)
+    } finally {
+      internals.configurationFile = originalFile
+      internals.configurationData = originalData
+      internals.configurationSectionCache = originalCache
+      internals.configurationFileReloading = originalReloading
+      internals.configurationFileReloadPending = originalPending
+      internals.configurationChangeCallback = originalCallback
+      rmSync(tempDir, { force: true, recursive: true })
+    }
+  })
+
+  await it('should reset configurationFileReloading to false on both success and failure paths', async t => {
+    t.mock.method(logger, 'error')
+    const internals = getInternals()
+    const tempDir = createTempConfigDir()
+    const originalFile = internals.configurationFile
+    const originalData = internals.configurationData
+    const originalCallback = internals.configurationChangeCallback
+    const originalCache = new Map(internals.configurationSectionCache)
+    const originalReloading = internals.configurationFileReloading
+
+    try {
+      const validFile = writeConfigFile(tempDir, buildMinimalConfiguration())
+      internals.configurationChangeCallback = undefined
+      internals.configurationData = undefined
+      internals.configurationSectionCache = new Map()
+
+      internals.configurationFile = validFile
+      internals.configurationFileReloading = true
+      await internals.runReloadLoop()
+      assert.strictEqual(internals.configurationFileReloading, false, 'flag must reset on success')
+
+      const invalidFile = join(tempDir, 'invalid.json')
+      writeFileSync(invalidFile, buildInvalidJsonString())
+      internals.configurationFile = invalidFile
+      internals.configurationFileReloading = true
+      await internals.runReloadLoop()
+      assert.strictEqual(internals.configurationFileReloading, false, 'flag must reset on failure')
+    } finally {
+      internals.configurationFile = originalFile
+      internals.configurationData = originalData
+      internals.configurationSectionCache = originalCache
+      internals.configurationFileReloading = originalReloading
+      internals.configurationChangeCallback = originalCallback
+      rmSync(tempDir, { force: true, recursive: true })
+    }
+  })
+})
index f64cb8ed1a49d700a5dba2fb5d54d10d42a18de0..6b9b93f8926c28ce5ccfa95681b5ff292e7f5b7e 100644 (file)
@@ -21,6 +21,7 @@ import type {
   WorkerConfiguration,
 } from '../../src/types/index.js'
 
+import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../src/charging-station/ConfigurationMigrations.js'
 import {
   ApplicationProtocol,
   ApplicationProtocolVersion,
@@ -149,6 +150,7 @@ await describe('Configuration', async () => {
     const internals = getConfigurationInternals()
     const originalData = internals.configurationData
     internals.configurationData = {
+      $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
       stationTemplateUrls: [],
     }
 
index e217c470920367cce84afbff5702ce39e32744c3..7f0ff5081c73727cdc5f94cb698eb4935af8156c 100644 (file)
@@ -8,7 +8,6 @@ import { afterEach, describe, it } from 'node:test'
 import { StorageType } from '../../src/types/index.js'
 import {
   buildPerformanceUriFilePath,
-  checkWorkerElementsPerWorker,
   getDefaultPerformanceStorageUri,
   logPrefix,
 } from '../../src/utils/ConfigurationUtils.js'
@@ -45,31 +44,4 @@ await describe('ConfigurationUtils', async () => {
       getDefaultPerformanceStorageUri('unsupported' as StorageType)
     }, Error)
   })
-
-  await it('should validate worker elements per worker configuration', () => {
-    // These calls should not throw exceptions
-    assert.doesNotThrow(() => {
-      checkWorkerElementsPerWorker(undefined)
-    })
-    assert.doesNotThrow(() => {
-      checkWorkerElementsPerWorker('auto')
-    })
-    assert.doesNotThrow(() => {
-      checkWorkerElementsPerWorker('all')
-    })
-    assert.doesNotThrow(() => {
-      checkWorkerElementsPerWorker(4)
-    })
-
-    // These calls should throw exceptions
-    assert.throws(() => {
-      checkWorkerElementsPerWorker(0)
-    }, RangeError)
-    assert.throws(() => {
-      checkWorkerElementsPerWorker(-1)
-    }, RangeError)
-    assert.throws(() => {
-      checkWorkerElementsPerWorker(1.5)
-    }, SyntaxError)
-  })
 })
index 4a6da2542c4203f904855ebe6bc6d383678618ac..a4b01cf6c74b0503c4cdd6335d8ba6b20c5c2577 100644 (file)
@@ -5,9 +5,7 @@
 import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
-import { WorkerProcessType } from '../../src/worker/WorkerTypes.js'
 import {
-  checkWorkerProcessType,
   defaultErrorHandler,
   defaultExitHandler,
   randomizeDelay,
@@ -20,24 +18,6 @@ await describe('WorkerUtils', async () => {
     standardCleanup()
   })
 
-  await it('should validate worker process types correctly', () => {
-    // Valid worker process types should not throw
-    assert.doesNotThrow(() => {
-      checkWorkerProcessType(WorkerProcessType.dynamicPool)
-    })
-    assert.doesNotThrow(() => {
-      checkWorkerProcessType(WorkerProcessType.fixedPool)
-    })
-    assert.doesNotThrow(() => {
-      checkWorkerProcessType(WorkerProcessType.workerSet)
-    })
-
-    // Invalid worker process type should throw
-    assert.throws(() => {
-      checkWorkerProcessType('invalidType' as WorkerProcessType)
-    }, SyntaxError)
-  })
-
   await it('should return timeout object after specified delay', async t => {
     await withMockTimers(t, ['setTimeout'], async () => {
       const delay = 10 // 10ms for fast test execution