| 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 |
- focusables
- Focusables
- secret
+ - emerg
+ - REMAPPINGS
{
+ "$schemaVersion": 1,
"supervisionUrls": ["ws://localhost:8010/OCPP16/5be7fb271014d90008992f06"],
"supervisionUrlDistribution": "round-robin",
"log": {
--- /dev/null
+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
+}
--- /dev/null
+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()
--- /dev/null
+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)
-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',
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>
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,
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,
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>
)}`
)
Configuration.configurationData = {
+ $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
log: defaultLogConfiguration,
performanceStorage: defaultStorageConfiguration,
stationTemplateUrls: [
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(
}
public static getStationTemplateUrls (): StationTemplateUrl[] | undefined {
- const checkDeprecatedConfigurationKeysOnce = once(() => {
- checkDeprecatedConfigurationKeys(Configuration.getConfigurationData())
- })
- checkDeprecatedConfigurationKeysOnce()
return Configuration.getConfigurationData()?.stationTemplateUrls
}
}
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
}
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 {
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 {
}
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(
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
+ }
+ }
}
+++ /dev/null
-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}` : ''
- }`
- )}`
- )
- }
-}
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'
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`
- )
- }
-}
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'
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
WorkerMessageEvents,
WorkerProcessType,
} from './WorkerTypes.js'
-export { checkWorkerProcessType } from './WorkerUtils.js'
--- /dev/null
+/**
+ * @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/,
+ }
+ )
+ })
+ }
+ })
+})
--- /dev/null
+/**
+ * @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`
+ )
+ }
+ }
+ }
+ })
+ })
+})
--- /dev/null
+/**
+ * @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)`
+ )
+ })
+})
--- /dev/null
+/**
+ * @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)
+ }
+ })
+ })
+})
--- /dev/null
+/**
+ * @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',
+ ],
+]
--- /dev/null
+/**
+ * @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 })
+ }
+ })
+})
WorkerConfiguration,
} from '../../src/types/index.js'
+import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../src/charging-station/ConfigurationMigrations.js'
import {
ApplicationProtocol,
ApplicationProtocolVersion,
const internals = getConfigurationInternals()
const originalData = internals.configurationData
internals.configurationData = {
+ $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION,
stationTemplateUrls: [],
}
import { StorageType } from '../../src/types/index.js'
import {
buildPerformanceUriFilePath,
- checkWorkerElementsPerWorker,
getDefaultPerformanceStorageUri,
logPrefix,
} from '../../src/utils/ConfigurationUtils.js'
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)
- })
})
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,
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