]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat: resolve #314 — Add charging station template Zod validation with schema version...
authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sun, 17 May 2026 22:38:50 +0000 (00:38 +0200)
committerGitHub <noreply@github.com>
Sun, 17 May 2026 22:38:50 +0000 (00:38 +0200)
* feat: add charging station template Zod validation with schema versioning

Add Zod v4 runtime validation for charging station templates with
schema versioning support. This replaces scattered imperative checks
with a declarative schema pipeline that validates, migrates, and
transforms templates at parse time.

New files:
- TemplateMigrations.ts: CURRENT_SCHEMA_VERSION, coerceVersion(),
  migration registry with migrateV0ToV1()
- TemplateSchema.ts: Zod schemas with topology union, loose + strict
  variants, MeterValues value coercion (number -> string)
- TemplateValidation.ts: validateTemplate() pipeline,
  transformTemplate(), TemplateValidationError

Integration:
- getTemplateFromFile() now calls validateTemplate() instead of bare
  JSON.parse(...) as ChargingStationTemplate
- Cache key updated to hash:vN format incorporating schema version
- Removed checkTemplate(), checkConnectorsConfiguration(),
  checkEvsesConfiguration(), warnTemplateKeysDeprecation() from
  Helpers.ts (logic absorbed into schema/pipeline)
- Exported getConfiguredMaxNumberOfConnectors and
  getMaxNumberOfConnectors from Helpers.ts for connector setup

Template changes:
- Added "$schemaVersion": 1 to all 15 template JSON files

Tests:
- TemplateMigrations.test.ts: version coercion, migration, error cases
- TemplateSchema.test.ts: required fields, topology, EVSE validation,
  MeterValues coercion, all 15 templates pass
- TemplateValidation.test.ts: pipeline, transforms, error class, round-trip

Closes #314

* fix(template): address PR #1860 review findings

- coerceVersion(null|undefined) now returns 0 so legacy templates
  without $schemaVersion go through v0->v1 migration; deprecated keys
  (supervisionUrl, authorizationFile, payloadSchemaValidation,
  mustAuthorizeAtRemoteStart) are renamed instead of being silently
  swallowed by looseObject [HIGH]
- transformTemplate uses Math.max(numberOfConnectors[]) as the
  worst-case bound for the randomConnectors auto-trigger; new
  Helpers.ts pair getMaxConfiguredNumberOfConnectors (deterministic
  upper bound) and pickConfiguredNumberOfConnectors (random pick)
  centralize array semantics; getConfiguredMaxNumberOfConnectors now
  delegates to pickConfiguredNumberOfConnectors [HIGH]
- BaseTemplateSchema.$schemaVersion is now z.literal(CURRENT_SCHEMA_VERSION)
  (post-migration assertion); deprecated keys removed from the v1
  schema and explicitly rejected by a superRefine block with a clear
  diagnostic [MEDIUM]
- transformTemplate drops the unreachable templateMaxConnectors < 0
  branch [LOW]
- validateTemplate widens to accept unknown and rejects null,
  non-plain-object and array payloads with a clear BaseError instead
  of crashing later in coerceVersion [LOW]
- SignedMeterValueSchema, UnitOfMeasureSchema and WsOptionsSchema
  replace z.looseObject({}) with typed shapes plus .catchall(z.unknown())
  for forward-compatible vendor extensions [LOW]
- TemplateValidationError now surfaces '(migrated from vX -> vY)' in
  its message so post-migration validation failures are visible to
  operators

Tests: update coerceVersion(null|undefined) expectation to 0; add
end-to-end legacy migration, deprecated-key rejection (Schema and
Validation layers), null/string/array payload guards, array-form
numberOfConnectors auto-trigger regression, dead-branch regression,
migratedFrom message presence, and unit tests for the two new
Helpers.ts helpers.

* fix(template): address second-pass PR #1860 review findings

- normalize $schemaVersion to coerced integer in validateTemplate so the
  no-migration path (when version === CURRENT) also satisfies the strict
  z.literal(CURRENT_SCHEMA_VERSION) check; was failing for user-authored
  templates with string "$schemaVersion": "1" [F1]
- harmonize coerceVersion error wording on "non-negative integer" across
  all rejection branches; the previous "positive integer" message was
  contradictory since 0 is a valid input (legacy/pre-versioning sentinel
  triggering v0 migration) [F2]
- introduce SCHEMA_VERSION_STRING_PATTERN (^\\d+$) gate in coerceVersion
  to reject permissive Number() coercions ('1.0', '0x1', '1e0', ' 1 ',
  '', '+1', '01a'); pattern mirrors the canonical non-negative-integer
  string pattern used in TemplateSchema for connector/EVSE keys [F6]
- replace for...in with Object.entries destructuring in Evses topology
  validation, harmonizing with the prior-art pattern in Helpers.ts and
  removing the redundant template.Evses[evseKey] re-lookup [F3]
- tighten getMaxConfiguredNumberOfConnectors cast to readonly number[]
  matching the helper signature [F5/F7]
- document the cache-bound warning emission frequency in
  transformTemplate's JSDoc: warnings fire once per (templateFile,
  schemaVersion) cache miss rather than per station instance, which is
  template-scoped on purpose [F4]

Tests: assert string "$schemaVersion": "1" is accepted and normalized
to numeric 1; battery of 8 permissive numeric strings rejected with
harmonized wording; coerceVersion rejection branches all use the same
"non-negative integer" diagnostic.

* fix(template): broaden wsOptions.headers and harden template pipeline

- WsOptionsSchema.headers accepts string | number | string[] values,
  matching Node's OutgoingHttpHeader runtime contract; field-names
  enforced non-empty per RFC 9110 §5.1.

- templateHash uses SRI-style `${algorithm}:${schemaVersion}:${contentHash}`
  computed over the validated template, restoring whitespace-insensitive
  cache semantics from pre-PR main while keeping schema-version-bump
  invalidation deterministic.

- Migration registry refactored to sequential n→n+1 chain so future
  schema versions append one function instead of rewriting prior
  migrations (no behavior change at v1).

- validateTemplate clones its input via structuredClone at the
  validation boundary, honoring the repository immutability convention.

BREAKING CHANGE: external log monitors keyed on the literal string
"Failed to read charging station template file" must also match
"Invalid charging station template payload (not a JSON object)" for
JSON-shape errors; file I/O errors retain their previous wording via
handleFileException.

* test: extract mockLoggerWarnDebug helper to dedupe migration logger spies

Replaces 8 occurrences of the warn+debug t.mock.method pair across
TemplateMigrations.test.ts and TemplateValidation.test.ts with a typed
helper sibling to createLoggerMocks.

* chore(deps): deduplicate pnpm-lock entries for @rolldown/pluginutils and ws

---------

Co-authored-by: Coding Agent <agent@e-mobility-simulator.dev>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
28 files changed:
pnpm-lock.yaml
src/assets/station-templates/abb-atg.station-template.json
src/assets/station-templates/abb.station-template.json
src/assets/station-templates/chargex.station-template.json
src/assets/station-templates/evlink.station-template.json
src/assets/station-templates/keba-ocpp2-signed.station-template.json
src/assets/station-templates/keba-ocpp2.station-template.json
src/assets/station-templates/keba.station-template.json
src/assets/station-templates/schneider-evses.station-template.json
src/assets/station-templates/schneider-imredd.station-template.json
src/assets/station-templates/schneider.station-template.json
src/assets/station-templates/siemens.station-template.json
src/assets/station-templates/virtual-simple-atg.station-template.json
src/assets/station-templates/virtual-simple-signed.station-template.json
src/assets/station-templates/virtual-simple.station-template.json
src/assets/station-templates/virtual.station-template.json
src/charging-station/ChargingStation.ts
src/charging-station/Helpers.ts
src/charging-station/TemplateMigrations.ts [new file with mode: 0644]
src/charging-station/TemplateSchema.ts [new file with mode: 0644]
src/charging-station/TemplateValidation.ts [new file with mode: 0644]
src/utils/Utils.ts
tests/charging-station/Helpers.test.ts
tests/charging-station/TemplateMigrations.test.ts [new file with mode: 0644]
tests/charging-station/TemplateSchema.test.ts [new file with mode: 0644]
tests/charging-station/TemplateValidation.test.ts [new file with mode: 0644]
tests/charging-station/helpers/TemplateFixtures.ts [new file with mode: 0644]
tests/helpers/TestLifecycleHelpers.ts

index dfb6a14fa231b9b60756da0c3076f469c53bdf66..38a92699f7c083f889fd1133e0fa48d36ab00aff 100644 (file)
@@ -1487,9 +1487,6 @@ packages:
     cpu: [x64]
     os: [win32]
 
-  '@rolldown/pluginutils@1.0.0':
-    resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==}
-
   '@rolldown/pluginutils@1.0.1':
     resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
 
@@ -4917,18 +4914,6 @@ packages:
   wrappy@1.0.2:
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
 
-  ws@8.20.0:
-    resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
-    engines: {node: '>=10.0.0'}
-    peerDependencies:
-      bufferutil: ^4.0.1
-      utf-8-validate: '>=5.0.2'
-    peerDependenciesMeta:
-      bufferutil:
-        optional: true
-      utf-8-validate:
-        optional: true
-
   ws@8.20.1:
     resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==}
     engines: {node: '>=10.0.0'}
@@ -5683,7 +5668,7 @@ snapshots:
       '@parcel/watcher': 2.5.6
       effect: 3.21.2
       multipasta: 0.2.7
-      ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+      ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)
     transitivePeerDependencies:
       - bufferutil
       - utf-8-validate
@@ -5698,7 +5683,7 @@ snapshots:
       effect: 3.21.2
       mime: 3.0.0
       undici: 7.25.0
-      ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+      ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)
     transitivePeerDependencies:
       - bufferutil
       - utf-8-validate
@@ -6241,8 +6226,6 @@ snapshots:
   '@rolldown/binding-win32-x64-msvc@1.0.1':
     optional: true
 
-  '@rolldown/pluginutils@1.0.0': {}
-
   '@rolldown/pluginutils@1.0.1': {}
 
   '@simple-libs/child-process-utils@1.0.2':
@@ -6465,7 +6448,7 @@ snapshots:
       '@babel/core': 7.29.0
       '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
       '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
-      '@rolldown/pluginutils': 1.0.0
+      '@rolldown/pluginutils': 1.0.1
       '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0)
       vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.1)(yaml@2.9.0)
       vue: 3.5.34(typescript@6.0.3)
@@ -9227,7 +9210,7 @@ snapshots:
   rolldown@1.0.1:
     dependencies:
       '@oxc-project/types': 0.130.0
-      '@rolldown/pluginutils': 1.0.0
+      '@rolldown/pluginutils': 1.0.1
     optionalDependencies:
       '@rolldown/binding-android-arm64': 1.0.1
       '@rolldown/binding-darwin-arm64': 1.0.1
@@ -10070,11 +10053,6 @@ snapshots:
 
   wrappy@1.0.2: {}
 
-  ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6):
-    optionalDependencies:
-      bufferutil: 4.1.0
-      utf-8-validate: 6.0.6
-
   ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6):
     optionalDependencies:
       bufferutil: 4.1.0
index b9c9aaa6ac079def46c5b1c9cf0535ee5fc7487e..835333ca189d328af7f32ed4ba497f90030aa9f1 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-ABB",
   "chargePointModel": "MD_TERRA_53",
index 9a12fc329adf9e57538a1e390484f143b9b4e736..d8d1523b4ebaf856159866563a2132b32c4c174e 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-ABB",
   "chargePointModel": "MD_TERRA_53",
index b3122496a987f66f19f7ce245ca82f676be0ffbe..95875f2172915b2c669d691ebd2cde4041129d7b 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-CHARGEX",
   "chargePointModel": "Aqueduct 1.0",
index 920d5cc1f9ac2e3aef3f86680ea95305db58f50a..1e72929b71dcc8ee46dccf5c5b4510a2c083d328 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index c19d9babc286828f67bc668d0231e85a149dd5c6..f9bed001e7c0f1142a16c126e4dac02d7a03c3b8 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrls": ["ws://localhost:9000"],
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "CentralSystemAddress",
index 7ee304d3f6018c81d79ac25a4d148ff71194e0a3..71c3c9d40eafd41001d1a1ebfd5e61a79e23c732 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrls": ["ws://localhost:9000"],
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "CentralSystemAddress",
index d51cc7423a99ae2d568e2ff0a79218ff6816ee85..a306612a88a045222f503a911e1edaaf5cd7e70b 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "CentralSystemAddress",
   "idTagsFile": "idtags.json",
index aceb747c69c4a31fb6db47e7cd7395871ed7609b..9962b9220348d93658ff41d5fa031f5eba7abc15 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index 029fb214d04223bb66c984ed9487737af8e84f18..586183b83764045ffda39008db2efbcbd03f4f1d 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index 5d3d798e343900663a2c4355710d52f6e6ca909d..05983b9d9a12980bdc6d07321634542e03132666 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index 5a51acb0e347735bbca327ad7e1553afc199ef86..ddd5a30a65714176ac872c470ebd29ad2ca4ed39 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-SIEMENS",
   "fixedName": true,
index 8f5276853ba5f86e8e437201e826ab46ac232f40..2b0017edeae0139e24c71323a78c0f6d2eb88680 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-BASIC",
   "chargePointModel": "Simulator simple",
index 2b50af934f07131a080e97c357e0fcc292330aa0..f04c99119baa04aa9caf78221aa7ab4f30d6a4a3 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-BASIC-SIGNED",
   "chargePointModel": "Simulator simple",
index c8a737dc2db2b30cb9e13352f4fde723bd2d9919..74f1026565e6a2d5632a86d3dee3f7470644eeac 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-BASIC",
   "chargePointModel": "Simulator simple",
index 46f26238c28a4f35db1e405bb7b8492c1b2c0e94..27cd659e3029f676250e850a03f221e647077f1c 100644 (file)
@@ -1,4 +1,5 @@
 {
+  "$schemaVersion": 1,
   "idTagsFile": "idtags.json",
   "baseName": "CS-SIMU",
   "chargePointModel": "Simulator connectors",
index e9d86a7c261bd286a3a756819e62dbf0001ae25a..69e46b37ce998e59470565d499931fddd83ca15c 100644 (file)
@@ -100,7 +100,6 @@ import {
   logPrefix,
   mergeDeepRight,
   min,
-  once,
   promiseWithTimeout,
   secureRandom,
   sleep,
@@ -120,20 +119,19 @@ import {
   buildTemplateName,
   checkChargingStationState,
   checkConfiguration,
-  checkConnectorsConfiguration,
-  checkEvsesConfiguration,
   checkStationInfoConnectorStatus,
-  checkTemplate,
   createSerialNumber,
   getAmperageLimitationUnitDivider,
   getBootConnectorStatus,
   getChargingStationChargingProfilesLimit,
   getChargingStationId,
+  getConfiguredMaxNumberOfConnectors,
   getConnectorChargingProfilesLimit,
   getDefaultConnectorMaximumPower,
   getDefaultVoltageOut,
   getHashId,
   getIdTagsFile,
+  getMaxNumberOfConnectors,
   getMaxNumberOfEvses,
   getPhaseRotationValue,
   hasFeatureProfile,
@@ -144,7 +142,6 @@ import {
   setChargingStationOptions,
   stationTemplateToStationInfo,
   validateStationInfo,
-  warnTemplateKeysDeprecation,
 } from './Helpers.js'
 import { IdTagsCache } from './IdTagsCache.js'
 import {
@@ -160,6 +157,8 @@ import {
   stopRunningTransactions,
 } from './ocpp/index.js'
 import { SharedLRUCache } from './SharedLRUCache.js'
+import { CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
+import { validateTemplate } from './TemplateValidation.js'
 
 const moduleName = 'ChargingStation'
 
@@ -1603,15 +1602,6 @@ export class ChargingStation extends EventEmitter {
       logger.error(`${this.logPrefix()} ${moduleName}.getStationInfoFromTemplate: ${errorMsg}`)
       throw new BaseError(errorMsg)
     }
-    checkTemplate(stationTemplate, this.logPrefix(), this.templateFile)
-    const warnTemplateKeysDeprecationOnce = once(warnTemplateKeysDeprecation)
-    warnTemplateKeysDeprecationOnce(stationTemplate, this.logPrefix(), this.templateFile)
-    if (stationTemplate.Connectors != null) {
-      checkConnectorsConfiguration(stationTemplate, this.logPrefix(), this.templateFile)
-    }
-    if (stationTemplate.Evses != null) {
-      checkEvsesConfiguration(stationTemplate, this.logPrefix(), this.templateFile)
-    }
     const stationInfo = stationTemplateToStationInfo(stationTemplate)
     stationInfo.templateIndex = this.index
     stationInfo.templateName = buildTemplateName(this.templateFile)
@@ -1655,13 +1645,16 @@ export class ChargingStation extends EventEmitter {
       } else {
         const measureId = `${FileType.ChargingStationTemplate} read`
         const beginId = PerformanceStatistics.beginMeasure(measureId)
-        template = JSON.parse(readFileSync(this.templateFile, 'utf8')) as ChargingStationTemplate
+        const rawContent = readFileSync(this.templateFile, 'utf8')
+        const parsed = JSON.parse(rawContent) as Record<string, unknown>
+        template = validateTemplate(parsed, this.templateFile)
         PerformanceStatistics.endMeasure(measureId, beginId)
-        template.templateHash = hash(
-          Constants.DEFAULT_HASH_ALGORITHM,
-          JSON.stringify(template),
-          'hex'
-        )
+        // SRI-style key `${algorithm}:${schemaVersion}:${contentHash}`.
+        // Hashing the validated template (not the raw file) keeps the key
+        // stable across cosmetic whitespace edits; the algorithm and version
+        // prefixes ensure cache entries are invalidated when either bumps.
+        const contentHash = hash(Constants.DEFAULT_HASH_ALGORITHM, JSON.stringify(template), 'hex')
+        template.templateHash = `${Constants.DEFAULT_HASH_ALGORITHM}:${CURRENT_SCHEMA_VERSION.toString()}:${contentHash}`
         this.sharedLRUCache.setChargingStationTemplate(template)
         this.templateFileHash = template.templateHash
       }
@@ -1783,7 +1776,6 @@ export class ChargingStation extends EventEmitter {
       logger.error(`${this.logPrefix()} ${moduleName}.initialize: ${errorMsg}`)
       throw new BaseError(errorMsg)
     }
-    checkTemplate(stationTemplate, this.logPrefix(), this.templateFile)
     this.configurationFile = join(
       dirname(this.templateFile.replace('station-templates', 'configurations')),
       `${getHashId(this.index, stationTemplate)}.json`
@@ -1867,8 +1859,11 @@ export class ChargingStation extends EventEmitter {
       )
     }
     if (stationTemplate.Connectors != null) {
-      const { configuredMaxConnectors, templateMaxAvailableConnectors, templateMaxConnectors } =
-        checkConnectorsConfiguration(stationTemplate, this.logPrefix(), this.templateFile)
+      const configuredMaxConnectors = getConfiguredMaxNumberOfConnectors(stationTemplate)
+      const templateMaxConnectors = getMaxNumberOfConnectors(stationTemplate.Connectors)
+      const templateMaxAvailableConnectors =
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        stationTemplate.Connectors[0] != null ? templateMaxConnectors - 1 : templateMaxConnectors
       const connectorsConfigHash = hash(
         Constants.DEFAULT_HASH_ALGORITHM,
         `${JSON.stringify(stationTemplate.Connectors)}${configuredMaxConnectors.toString()}`,
index b92f58ab3789836781a84791242ed79d1f8ea348..c98239ce619f03f7068486418f80d8eb19fb9607 100644 (file)
@@ -322,7 +322,7 @@ export const getDefaultConnectorMaximumPower = (
   return staticCount > 0 ? maximumPower / staticCount : undefined
 }
 
-const getMaxNumberOfConnectors = (
+export const getMaxNumberOfConnectors = (
   connectors: Record<string, ConnectorStatus> | undefined
 ): number => {
   if (connectors == null) {
@@ -351,28 +351,6 @@ export const getBootConnectorStatus = (
   return ConnectorStatusEnum.Available
 }
 
-export const checkTemplate = (
-  stationTemplate: ChargingStationTemplate | undefined,
-  logPrefix: string,
-  templateFile: string
-): void => {
-  if (stationTemplate == null) {
-    const errorMsg = `Failed to read charging station template file ${templateFile}`
-    logger.error(`${logPrefix} ${moduleName}.checkTemplate: ${errorMsg}`)
-    throw new BaseError(errorMsg)
-  }
-  if (isEmpty(stationTemplate)) {
-    const errorMsg = `Empty charging station information from template file ${templateFile}`
-    logger.error(`${logPrefix} ${moduleName}.checkTemplate: ${errorMsg}`)
-    throw new BaseError(errorMsg)
-  }
-  if (stationTemplate.idTagsFile == null || isEmpty(stationTemplate.idTagsFile)) {
-    logger.warn(
-      `${logPrefix} ${moduleName}.checkTemplate: Missing id tags file in template file ${templateFile}. That can lead to issues with the Automatic Transaction Generator`
-    )
-  }
-}
-
 export const checkConfiguration = (
   stationConfiguration: ChargingStationConfiguration | undefined,
   logPrefix: string,
@@ -390,68 +368,6 @@ export const checkConfiguration = (
   }
 }
 
-export const checkConnectorsConfiguration = (
-  stationTemplate: ChargingStationTemplate,
-  logPrefix: string,
-  templateFile: string
-): {
-  configuredMaxConnectors: number
-  templateMaxAvailableConnectors: number
-  templateMaxConnectors: number
-} => {
-  const configuredMaxConnectors = getConfiguredMaxNumberOfConnectors(stationTemplate)
-  checkConfiguredMaxConnectors(configuredMaxConnectors, logPrefix, templateFile)
-  const templateMaxConnectors = getMaxNumberOfConnectors(stationTemplate.Connectors)
-  checkTemplateMaxConnectors(templateMaxConnectors, logPrefix, templateFile)
-  const templateMaxAvailableConnectors =
-    stationTemplate.Connectors?.[0] != null ? templateMaxConnectors - 1 : templateMaxConnectors
-  if (
-    configuredMaxConnectors > templateMaxAvailableConnectors &&
-    stationTemplate.randomConnectors !== true
-  ) {
-    logger.warn(
-      `${logPrefix} ${moduleName}.checkConnectorsConfiguration: Number of connectors exceeds the number of connector configurations in template ${templateFile}, forcing random connector configurations affectation`
-    )
-    stationTemplate.randomConnectors = true
-  }
-  return {
-    configuredMaxConnectors,
-    templateMaxAvailableConnectors,
-    templateMaxConnectors,
-  }
-}
-
-export const checkEvsesConfiguration = (
-  stationTemplate: ChargingStationTemplate,
-  logPrefix: string,
-  templateFile: string
-): void => {
-  if (stationTemplate.Evses == null) {
-    return
-  }
-  for (const evseKey in stationTemplate.Evses) {
-    const evseId = convertToInt(evseKey)
-    const connectorIds = Object.keys(stationTemplate.Evses[evseKey].Connectors).map(convertToInt)
-    if (evseId === 0) {
-      for (const connectorId of connectorIds) {
-        if (connectorId !== 0) {
-          throw new BaseError(
-            `${logPrefix} Template ${templateFile} EVSE 0 has invalid connector id ${connectorId.toString()}, only connector id 0 is allowed (OCPP 2.0.1 §7.2)`
-          )
-        }
-      }
-    } else if (evseId > 0) {
-      for (const connectorId of connectorIds) {
-        if (connectorId < 1) {
-          throw new BaseError(
-            `${logPrefix} Template ${templateFile} EVSE ${evseId.toString()} has invalid connector id ${connectorId.toString()}, connector ids must start at 1 (OCPP 2.0.1 §7.2)`
-          )
-        }
-      }
-    }
-  }
-}
-
 export const checkStationInfoConnectorStatus = (
   connectorId: number,
   connectorStatus: ConnectorStatus,
@@ -636,29 +552,6 @@ export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): Connec
   return connectorStatus
 }
 
-export const warnTemplateKeysDeprecation = (
-  stationTemplate: ChargingStationTemplate,
-  logPrefix: string,
-  templateFile: string
-): void => {
-  const templateKeys: { deprecatedKey: string; key?: string }[] = [
-    { deprecatedKey: 'supervisionUrl', key: 'supervisionUrls' },
-    { deprecatedKey: 'authorizationFile', key: 'idTagsFile' },
-    { deprecatedKey: 'payloadSchemaValidation', key: 'ocppStrictCompliance' },
-    { deprecatedKey: 'mustAuthorizeAtRemoteStart', key: 'remoteAuthorization' },
-  ]
-  for (const templateKey of templateKeys) {
-    warnDeprecatedTemplateKey(
-      stationTemplate,
-      templateKey.deprecatedKey,
-      logPrefix,
-      templateFile,
-      templateKey.key != null ? `Use '${templateKey.key}' instead` : undefined
-    )
-    convertDeprecatedTemplateKey(stationTemplate, templateKey.deprecatedKey, templateKey.key)
-  }
-}
-
 export const stationTemplateToStationInfo = (
   stationTemplate: ChargingStationTemplate
 ): ChargingStationInfo => {
@@ -941,15 +834,15 @@ export const waitChargingStationEvents = async (
   })
 }
 
-const getConfiguredMaxNumberOfConnectors = (stationTemplate: ChargingStationTemplate): number => {
+export const getConfiguredMaxNumberOfConnectors = (
+  stationTemplate: ChargingStationTemplate
+): number => {
+  const picked = pickConfiguredNumberOfConnectors(stationTemplate.numberOfConnectors)
+  if (picked != null) {
+    return picked
+  }
   let configuredMaxNumberOfConnectors = 0
-  if (isNotEmptyArray<number>(stationTemplate.numberOfConnectors)) {
-    const numberOfConnectors = stationTemplate.numberOfConnectors
-    configuredMaxNumberOfConnectors =
-      numberOfConnectors[Math.floor(secureRandom() * numberOfConnectors.length)]
-  } else if (typeof stationTemplate.numberOfConnectors === 'number') {
-    configuredMaxNumberOfConnectors = stationTemplate.numberOfConnectors
-  } else if (stationTemplate.Connectors != null && stationTemplate.Evses == null) {
+  if (stationTemplate.Connectors != null && stationTemplate.Evses == null) {
     configuredMaxNumberOfConnectors =
       // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
       stationTemplate.Connectors[0] != null
@@ -964,32 +857,43 @@ const getConfiguredMaxNumberOfConnectors = (stationTemplate: ChargingStationTemp
   return configuredMaxNumberOfConnectors
 }
 
-const checkConfiguredMaxConnectors = (
-  configuredMaxConnectors: number,
-  logPrefix: string,
-  templateFile: string
-): void => {
-  if (configuredMaxConnectors <= 0) {
-    logger.warn(
-      `${logPrefix} ${moduleName}.checkConfiguredMaxConnectors: Charging station information from template ${templateFile} with ${configuredMaxConnectors.toString()} connectors`
-    )
+/**
+ * Worst-case upper bound on the configured connector count from the
+ * `numberOfConnectors` template field. Used at validation time to decide
+ * whether `randomConnectors` must be auto-enabled (i.e. whether *any*
+ * runtime random pick could exceed the connector definitions).
+ * @param numberOfConnectors - Template `numberOfConnectors` field value
+ * @returns Upper bound, or `undefined` when the field is not set
+ */
+export const getMaxConfiguredNumberOfConnectors = (
+  numberOfConnectors: number | readonly number[] | undefined
+): number | undefined => {
+  if (isNotEmptyArray<number>(numberOfConnectors)) {
+    return Math.max(...numberOfConnectors)
+  }
+  if (typeof numberOfConnectors === 'number') {
+    return numberOfConnectors
   }
+  return undefined
 }
 
-const checkTemplateMaxConnectors = (
-  templateMaxConnectors: number,
-  logPrefix: string,
-  templateFile: string
-): void => {
-  if (templateMaxConnectors === 0) {
-    logger.warn(
-      `${logPrefix} ${moduleName}.checkTemplateMaxConnectors: Charging station information from template ${templateFile} with empty connectors configuration`
-    )
-  } else if (templateMaxConnectors < 0) {
-    logger.error(
-      `${logPrefix} ${moduleName}.checkTemplateMaxConnectors: Charging station information from template ${templateFile} with no connectors configuration defined`
-    )
+/**
+ * Random pick from the `numberOfConnectors` template field. Used at
+ * runtime to materialize the actual connector count for one station
+ * instance.
+ * @param numberOfConnectors - Template `numberOfConnectors` field value
+ * @returns Picked count, or `undefined` when the field is not set
+ */
+export const pickConfiguredNumberOfConnectors = (
+  numberOfConnectors: number | readonly number[] | undefined
+): number | undefined => {
+  if (isNotEmptyArray<number>(numberOfConnectors)) {
+    return numberOfConnectors[Math.floor(secureRandom() * numberOfConnectors.length)]
   }
+  if (typeof numberOfConnectors === 'number') {
+    return numberOfConnectors
+  }
+  return undefined
 }
 
 const initializeConnectorStatus = (
@@ -1009,36 +913,6 @@ const initializeConnectorStatus = (
   }
 }
 
-const warnDeprecatedTemplateKey = (
-  template: ChargingStationTemplate,
-  key: string,
-  logPrefix: string,
-  templateFile: string,
-  logMsgToAppend = ''
-): void => {
-  if (template[key as keyof ChargingStationTemplate] != null) {
-    const logMsg = `Deprecated template key '${key}' usage in file '${templateFile}'${
-      isNotEmptyString(logMsgToAppend) ? `. ${logMsgToAppend}` : ''
-    }`
-    logger.warn(`${logPrefix} ${moduleName}.warnDeprecatedTemplateKey: ${logMsg}`)
-  }
-}
-
-const convertDeprecatedTemplateKey = (
-  template: ChargingStationTemplate,
-  deprecatedKey: string,
-  key?: string
-): void => {
-  const templateRecord = template as unknown as Record<string, unknown>
-  if (templateRecord[deprecatedKey] != null) {
-    if (key != null) {
-      templateRecord[key] = templateRecord[deprecatedKey]
-    }
-    // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
-    delete templateRecord[deprecatedKey]
-  }
-}
-
 interface ChargingProfilesLimit {
   chargingProfile: ChargingProfile
   limit: number
diff --git a/src/charging-station/TemplateMigrations.ts b/src/charging-station/TemplateMigrations.ts
new file mode 100644 (file)
index 0000000..0654c04
--- /dev/null
@@ -0,0 +1,138 @@
+import { BaseError } from '../exception/index.js'
+import { isNotEmptyString, logger } from '../utils/index.js'
+
+const moduleName = 'TemplateMigrations'
+
+/**
+ * Current schema version for charging station templates.
+ * Bump only on breaking changes (field rename, removal, type narrowing).
+ * Single authoritative location — concurrent bumps force git merge conflict.
+ */
+export const CURRENT_SCHEMA_VERSION = 1
+
+type MigrationFn = (template: Record<string, unknown>) => Record<string, unknown>
+
+/**
+ * Sequential migration chain. Index `i` migrates a v`i` template to v`i+1`.
+ * To add schema version N+1: append one `migrateV{N}ToV{N+1}` function and
+ * bump `CURRENT_SCHEMA_VERSION`.
+ */
+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 template — triggers v0→CURRENT migration
+ *   so deprecated keys (`supervisionUrl`, `authorizationFile`,
+ *   `payloadSchemaValidation`, `mustAuthorizeAtRemoteStart`) are renamed
+ *   before strict schema validation)
+ * - 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 coerceVersion = (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 === 'number' || 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}.coerceVersion: Invalid $schemaVersion value '${rawStr}' — must be a non-negative integer`
+    )
+  }
+  if (!Number.isInteger(parsed) || parsed < 0) {
+    throw new BaseError(
+      `${moduleName}.coerceVersion: Invalid $schemaVersion value '${rawStr}' — must be a non-negative integer`
+    )
+  }
+  if (parsed > CURRENT_SCHEMA_VERSION) {
+    throw new BaseError(
+      `${moduleName}.coerceVersion: Template $schemaVersion ${parsed.toString()} is newer than supported version ${CURRENT_SCHEMA_VERSION.toString()}. Update the simulator to handle this template`
+    )
+  }
+  return parsed
+}
+
+/**
+ * Apply migrations sequentially from the given source version to
+ * `CURRENT_SCHEMA_VERSION`, advancing `$schemaVersion` after each hop.
+ * Mutates `template` in place and returns the same reference. Callers that
+ * need to preserve their input must clone before invocation.
+ * @param sourceVersion - Source schema version to migrate from
+ * @param template - Raw parsed template object
+ * @param filePath - Optional file path for log messages
+ * @returns Migrated template object
+ */
+export const applyMigration = (
+  sourceVersion: number,
+  template: Record<string, unknown>,
+  filePath?: string
+): Record<string, unknown> => {
+  if (sourceVersion < 0 || sourceVersion >= CURRENT_SCHEMA_VERSION) {
+    throw new BaseError(
+      `${moduleName}.applyMigration: No migration defined for $schemaVersion ${sourceVersion.toString()} → ${CURRENT_SCHEMA_VERSION.toString()}`
+    )
+  }
+  logger.debug(
+    `${moduleName}.applyMigration: Migrating template${filePath != null ? ` '${filePath}'` : ''} from v${sourceVersion.toString()} to v${CURRENT_SCHEMA_VERSION.toString()}`
+  )
+  let migrated = template
+  for (let v = sourceVersion; v < CURRENT_SCHEMA_VERSION; v++) {
+    migrated = migrationChain[v](migrated)
+    migrated.$schemaVersion = v + 1
+  }
+  return migrated
+}
+
+/**
+ * Migrate a v0 template to v1 by renaming deprecated keys to their v1 equivalents.
+ * @param template - Pre-migration template object
+ * @returns Same reference with deprecated keys renamed
+ */
+function migrateV0ToV1 (template: Record<string, unknown>): Record<string, unknown> {
+  const deprecatedKeys: { deprecatedKey: string; key?: string }[] = [
+    { deprecatedKey: 'supervisionUrl', key: 'supervisionUrls' },
+    { deprecatedKey: 'authorizationFile', key: 'idTagsFile' },
+    { deprecatedKey: 'payloadSchemaValidation', key: 'ocppStrictCompliance' },
+    { deprecatedKey: 'mustAuthorizeAtRemoteStart', key: 'remoteAuthorization' },
+  ]
+  for (const { deprecatedKey, key } of deprecatedKeys) {
+    if (template[deprecatedKey] != null) {
+      const logMsg = `Deprecated template key '${deprecatedKey}' found${
+        isNotEmptyString(key) ? `. Use '${key}' instead` : ''
+      }`
+      logger.warn(`${moduleName}.migrateV0ToV1: ${logMsg}`)
+      if (key != null) {
+        template[key] = template[deprecatedKey]
+      }
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete template[deprecatedKey]
+    }
+  }
+  return template
+}
diff --git a/src/charging-station/TemplateSchema.ts b/src/charging-station/TemplateSchema.ts
new file mode 100644 (file)
index 0000000..ea85e50
--- /dev/null
@@ -0,0 +1,302 @@
+import { z } from 'zod'
+
+import { CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
+
+// ---------------------------------------------------------------
+// Sub-schemas
+// ---------------------------------------------------------------
+
+/**
+ * SignedMeterValue — OCPP signed meter value envelope.
+ * `.catchall(z.unknown())` preserves forward-compatibility for
+ * vendor-specific extensions.
+ */
+const SignedMeterValueSchema = z
+  .object({
+    encodingMethod: z.string().optional(),
+    publicKey: z.string().optional(),
+    signedMeterData: z.string().optional(),
+    signingMethod: z.string().optional(),
+  })
+  .catchall(z.unknown())
+
+/**
+ * UnitOfMeasure — OCPP 2.0 unit-of-measure descriptor.
+ */
+const UnitOfMeasureSchema = z
+  .object({
+    multiplier: z.number().int().optional(),
+    unit: z.string().optional(),
+  })
+  .catchall(z.unknown())
+
+/**
+ * SampledValueTemplate — MeterValues entries in connectors.
+ * Accepts both string and number for `value` and normalizes to string,
+ * covering the type mismatch in templates like `evlink` (`"value": 0`). // cspell:ignore evlink
+ */
+const SampledValueTemplateSchema = z.looseObject({
+  context: z.string().optional(),
+  fluctuationPercent: z.number().optional(),
+  format: z.string().optional(),
+  location: z.string().optional(),
+  measurand: z.string().optional(),
+  minimumValue: z.number().optional(),
+  phase: z.string().optional(),
+  signedMeterValue: SignedMeterValueSchema.optional(),
+  unit: z.string().optional(),
+  unitOfMeasure: UnitOfMeasureSchema.optional(),
+  value: z.union([z.string(), z.number()]).pipe(z.coerce.string()).optional(),
+})
+
+/**
+ * WsOptions — `ws.ClientOptions & ClientRequestArgs` intersection.
+ * The full surface is large (~60 fields) and external; the schema types the
+ * commonly used fields and preserves the rest via `.catchall(z.unknown())`.
+ */
+const WsOptionsSchema = z
+  .object({
+    handshakeTimeout: z.number().optional(),
+    // Node's OutgoingHttpHeader at runtime accepts string | number | string[],
+    // broader than the strict ws.ClientOptions intersection. Field-names must
+    // be non-empty per RFC 9110 §5.1.
+    headers: z
+      .record(z.string().min(1), z.union([z.string(), z.number(), z.array(z.string())]))
+      .optional(),
+    maxPayload: z.number().optional(),
+    perMessageDeflate: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(),
+    protocolVersion: z.number().optional(),
+    rejectUnauthorized: z.boolean().optional(),
+    skipUTF8Validation: z.boolean().optional(),
+  })
+  .catchall(z.unknown())
+
+/**
+ * ConnectorStatus — individual connector configuration within a template.
+ * Uses looseObject to tolerate runtime-only fields added by the simulator.
+ */
+const ConnectorStatusSchema = z.looseObject({
+  bootStatus: z.string().optional(),
+  maximumPower: z.number().optional(),
+  MeterValues: z.array(SampledValueTemplateSchema).optional(),
+})
+
+/**
+ * EvseTemplate — EVSE entry containing its connectors.
+ */
+const EvseTemplateSchema = z.looseObject({
+  Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema),
+  MeterValues: z.array(SampledValueTemplateSchema).optional(),
+})
+
+/**
+ * ConfigurationKey — OCPP configuration key entry.
+ * `key` is z.string() (open set: vendor-specific and OCPP 2.0 namespaced keys are valid).
+ */
+const ConfigurationKeySchema = z.looseObject({
+  key: z.string(),
+  readonly: z.boolean(),
+  reboot: z.boolean().optional(),
+  value: z.string().optional(),
+  visible: z.boolean().optional(),
+})
+
+/**
+ * ChargingStationOcppConfiguration — the Configuration section.
+ */
+const OcppConfigurationSchema = z.looseObject({
+  configurationKey: z.array(ConfigurationKeySchema).optional(),
+})
+
+/**
+ * AutomaticTransactionGeneratorConfiguration — ATG section.
+ * `stopAbsoluteDuration` is typed as required in the interface but absent from all templates,
+ * so it is optional in the schema (templates omit it; the runtime provides the default).
+ */
+const AutomaticTransactionGeneratorSchema = z.looseObject({
+  enable: z.boolean(),
+  idTagDistribution: z.string().optional(),
+  maxDelayBetweenTwoTransactions: z.number(),
+  maxDuration: z.number(),
+  minDelayBetweenTwoTransactions: z.number(),
+  minDuration: z.number(),
+  probabilityOfStart: z.number(),
+  requireAuthorize: z.boolean().optional(),
+  stopAbsoluteDuration: z.boolean().optional(),
+  stopAfterHours: z.number(),
+})
+
+/**
+ * FirmwareUpgrade sub-schema.
+ */
+const FirmwareUpgradeSchema = z.looseObject({
+  failureStatus: z.string().optional(),
+  reset: z.boolean().optional(),
+  versionUpgrade: z
+    .looseObject({
+      patternGroup: z.number().optional(),
+      step: z.number().optional(),
+    })
+    .optional(),
+})
+
+/**
+ * CommandsSupport sub-schema.
+ */
+const CommandsSupportSchema = z.looseObject({
+  incomingCommands: z.record(z.string(), z.boolean()),
+  outgoingCommands: z.record(z.string(), z.boolean()).optional(),
+})
+
+// ---------------------------------------------------------------
+// Connectors vs Evses topology variants
+// ---------------------------------------------------------------
+
+const ConnectorsVariant = z.looseObject({
+  Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema),
+})
+
+const EvsesVariant = z.looseObject({
+  Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema),
+})
+
+// ---------------------------------------------------------------
+// Main template schema (loose — tolerates unknown keys)
+// ---------------------------------------------------------------
+
+const BaseTemplateSchema = z.looseObject({
+  $schemaVersion: z.literal(CURRENT_SCHEMA_VERSION),
+  amperageLimitationOcppKey: z.string().optional(),
+  amperageLimitationUnit: z.string().optional(),
+  AutomaticTransactionGenerator: AutomaticTransactionGeneratorSchema.optional(),
+  automaticTransactionGeneratorPersistentConfiguration: z.boolean().optional(),
+  autoReconnectMaxRetries: z.number().optional(),
+  autoRegister: z.boolean().optional(),
+  autoStart: z.boolean().optional(),
+  baseName: z.string().min(1),
+  beginEndMeterValues: z.boolean().optional(),
+  chargeBoxSerialNumberPrefix: z.string().optional(),
+  chargePointModel: z.string().min(1),
+  chargePointSerialNumberPrefix: z.string().optional(),
+  chargePointVendor: z.string().min(1),
+  commandsSupport: CommandsSupportSchema.optional(),
+  Configuration: OcppConfigurationSchema.optional(),
+  Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(),
+  currentOutType: z.string().optional(),
+  customValueLimitationMeterValues: z.boolean().optional(),
+  enableStatistics: z.boolean().optional(),
+  Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema).optional(),
+  firmwareUpgrade: FirmwareUpgradeSchema.optional(),
+  firmwareVersion: z.string().optional(),
+  firmwareVersionPattern: z.string().optional(),
+  fixedName: z.boolean().optional(),
+  iccid: z.string().optional(),
+  idTagsFile: z.string().optional(),
+  imsi: z.string().optional(),
+  mainVoltageMeterValues: z.boolean().optional(),
+  messageTriggerSupport: z.record(z.string(), z.boolean()).optional(),
+  meteringPerTransaction: z.boolean().optional(),
+  meterSerialNumberPrefix: z.string().optional(),
+  meterType: z.string().optional(),
+  nameSuffix: z.string().optional(),
+  numberOfConnectors: z.union([z.number(), z.array(z.number())]).optional(),
+  numberOfPhases: z.number().optional(),
+  ocppPersistentConfiguration: z.boolean().optional(),
+  ocppProtocol: z.string().optional(),
+  ocppStrictCompliance: z.boolean().optional(),
+  ocppVersion: z.string().optional(),
+  outOfOrderEndMeterValues: z.boolean().optional(),
+  phaseLineToLineVoltageMeterValues: z.boolean().optional(),
+  postTransactionDelay: z.number().optional(),
+  power: z.union([z.number(), z.array(z.number())]).optional(),
+  powerSharedByConnectors: z.boolean().optional(),
+  powerUnit: z.string().optional(),
+  randomConnectors: z.boolean().optional(),
+  reconnectExponentialDelay: z.boolean().optional(),
+  registrationMaxRetries: z.number().optional(),
+  remoteAuthorization: z.boolean().optional(),
+  resetTime: z.number().optional(),
+  stationInfoPersistentConfiguration: z.boolean().optional(),
+  stopTransactionsOnStopped: z.boolean().optional(),
+  supervisionPassword: z.string().optional(),
+  supervisionUrlOcppConfiguration: z.boolean().optional(),
+  supervisionUrlOcppKey: z.string().optional(),
+  supervisionUrls: z.union([z.string(), z.array(z.string())]).optional(),
+  supervisionUser: z.string().optional(),
+  templateHash: z.string().optional(),
+  transactionDataMeterValues: z.boolean().optional(),
+  useConnectorId0: z.boolean().optional(),
+  voltageOut: z.number().optional(),
+  wsOptions: WsOptionsSchema.optional(),
+  x509Certificates: z.record(z.string(), z.string()).optional(),
+})
+
+const LEGACY_KEYS = [
+  'authorizationFile',
+  'mustAuthorizeAtRemoteStart',
+  'payloadSchemaValidation',
+  'supervisionUrl',
+] as const
+
+/**
+ * TemplateSchema — validates that the template has valid structure and
+ * defines either Connectors OR Evses (not both, not neither).
+ */
+export const TemplateSchema = BaseTemplateSchema.superRefine((template, ctx) => {
+  for (const legacyKey of LEGACY_KEYS) {
+    if ((template as Record<string, unknown>)[legacyKey] !== undefined) {
+      ctx.addIssue({
+        code: 'custom',
+        message: `Deprecated template key '${legacyKey}' is not allowed at $schemaVersion ${CURRENT_SCHEMA_VERSION.toString()}. Remove '$schemaVersion' to trigger automatic v0 migration, or replace the key with its v1 equivalent`,
+        path: [legacyKey],
+      })
+    }
+  }
+  const hasConnectors = template.Connectors != null
+  const hasEvses = template.Evses != null
+  if (hasConnectors && hasEvses) {
+    ctx.addIssue({
+      code: 'custom',
+      message: 'Template must define Connectors OR Evses, not both',
+      path: ['Connectors'],
+    })
+  }
+  // Validate Evses topology (OCPP 2.0.1 §7.2 constraints)
+  if (hasEvses && template.Evses != null) {
+    for (const [evseKey, evse] of Object.entries(template.Evses)) {
+      const evseId = Number(evseKey)
+      const connectorIds = Object.keys(evse.Connectors).map(Number)
+      if (evseId === 0) {
+        for (const connectorId of connectorIds) {
+          if (connectorId !== 0) {
+            ctx.addIssue({
+              code: 'custom',
+              message: `EVSE 0 has invalid connector id ${connectorId.toString()}, only connector id 0 is allowed (OCPP 2.0.1 §7.2)`,
+              path: ['Evses', evseKey, 'Connectors', connectorId.toString()],
+            })
+          }
+        }
+      } else if (evseId > 0) {
+        for (const connectorId of connectorIds) {
+          if (connectorId < 1) {
+            ctx.addIssue({
+              code: 'custom',
+              message: `EVSE ${evseId.toString()} has invalid connector id ${connectorId.toString()}, connector ids must start at 1 (OCPP 2.0.1 §7.2)`,
+              path: ['Evses', evseKey, 'Connectors', connectorId.toString()],
+            })
+          }
+        }
+      }
+    }
+  }
+})
+
+/**
+ * StrictTemplateSchema — rejects unknown keys. For CI strict mode.
+ */
+export const StrictTemplateSchema = BaseTemplateSchema.strict()
+
+// ---------------------------------------------------------------
+// Exported sub-schemas for reuse
+// ---------------------------------------------------------------
+export { ConnectorsVariant, EvsesVariant }
diff --git a/src/charging-station/TemplateValidation.ts b/src/charging-station/TemplateValidation.ts
new file mode 100644 (file)
index 0000000..1dc2d17
--- /dev/null
@@ -0,0 +1,146 @@
+import type { ZodError } from 'zod'
+
+import type { ChargingStationTemplate } from '../types/index.js'
+
+import { BaseError } from '../exception/index.js'
+import { isEmpty, isNotEmptyString, logger } from '../utils/index.js'
+import { getMaxConfiguredNumberOfConnectors } from './Helpers.js'
+import { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
+import { TemplateSchema } from './TemplateSchema.js'
+
+const moduleName = 'TemplateValidation'
+
+/**
+ * Error thrown when a charging station template fails Zod validation.
+ * Includes structured field errors and migration context for diagnostics.
+ */
+export class TemplateValidationError extends BaseError {
+  public readonly fieldErrors: { message: string; path: string }[]
+  public readonly filePath: string
+  public readonly migratedFrom?: number
+
+  public constructor (zodError: ZodError, context: { filePath: string; migratedFrom?: number }) {
+    const fieldErrors = zodError.issues.map(issue => ({
+      message: issue.message,
+      path: issue.path.join('.'),
+    }))
+    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_SCHEMA_VERSION.toString()})`
+        : ''
+    super(
+      `${moduleName}: Template validation failed for '${context.filePath}'${migrationNote}:\n${fieldSummary}`
+    )
+    this.filePath = context.filePath
+    this.fieldErrors = fieldErrors
+    this.migratedFrom = context.migratedFrom
+  }
+}
+
+/**
+ * Validate a parsed template object through the migration → validation → transform pipeline.
+ * @param parsed - Raw parsed JSON value (any type — guarded internally)
+ * @param filePath - Template file path (for error messages)
+ * @returns Validated and transformed ChargingStationTemplate
+ */
+export const validateTemplate = (parsed: unknown, filePath: string): ChargingStationTemplate => {
+  if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    throw new BaseError(
+      `${moduleName}.validateTemplate: Invalid charging station template payload (not a JSON object) in template file ${filePath}`
+    )
+  }
+  if (isEmpty(parsed)) {
+    throw new BaseError(
+      `${moduleName}.validateTemplate: Empty charging station information from template file ${filePath}`
+    )
+  }
+  // Clone before mutating $schemaVersion below and inside applyMigration,
+  // so the caller's parsed JSON stays untouched.
+  const parsedRecord = structuredClone(parsed) as Record<string, unknown>
+
+  const version = coerceVersion(parsedRecord.$schemaVersion)
+  parsedRecord.$schemaVersion = version
+  const migratedFrom = version < CURRENT_SCHEMA_VERSION ? version : undefined
+  const migrated =
+    migratedFrom != null ? applyMigration(version, parsedRecord, filePath) : parsedRecord
+
+  const result = TemplateSchema.safeParse(migrated)
+  if (!result.success) {
+    throw new TemplateValidationError(result.error, { filePath, migratedFrom })
+  }
+
+  return transformTemplate(result.data, filePath)
+}
+
+/**
+ * Post-validation transform.
+ *
+ * Forces `randomConnectors=true` when the worst-case configured connector
+ * count (max of `numberOfConnectors[]`, or its scalar value) exceeds the
+ * available connector definitions in the template — runtime random pick
+ * can hit any value of the array, so any value above the available count
+ * requires `randomConnectors=true` to be safe under any pick.
+ *
+ * Warns about missing `idTagsFile` (advisory, non-fatal).
+ *
+ * Warnings fire once per `(templateFile, schemaVersion)` cache miss,
+ * not per station instance.
+ *
+ * Connector-count diagnostics fire only for the `Connectors` topology;
+ * the `Evses` topology does not currently emit equivalent warnings.
+ * @param validated - Schema-validated template data
+ * @param filePath - Template file path (for log messages)
+ * @returns Transformed ChargingStationTemplate
+ */
+function transformTemplate (
+  validated: Record<string, unknown>,
+  filePath: string
+): ChargingStationTemplate {
+  if (
+    validated.idTagsFile == null ||
+    (typeof validated.idTagsFile === 'string' && !isNotEmptyString(validated.idTagsFile))
+  ) {
+    logger.warn(
+      `${moduleName}.transformTemplate: Missing id tags file in template file ${filePath}. That can lead to issues with the Automatic Transaction Generator`
+    )
+  }
+
+  if (validated.Connectors != null && typeof validated.Connectors === 'object') {
+    const connectors = validated.Connectors as Record<string, unknown>
+    const templateMaxConnectors = Object.keys(connectors).length
+    const templateMaxAvailableConnectors =
+      connectors['0'] != null ? templateMaxConnectors - 1 : templateMaxConnectors
+
+    const configuredMaxConnectors =
+      getMaxConfiguredNumberOfConnectors(
+        validated.numberOfConnectors as number | readonly number[] | undefined
+      ) ?? templateMaxAvailableConnectors
+
+    if (
+      configuredMaxConnectors > templateMaxAvailableConnectors &&
+      validated.randomConnectors !== true
+    ) {
+      logger.warn(
+        `${moduleName}.transformTemplate: Number of connectors (${configuredMaxConnectors.toString()}) exceeds the number of connector configurations (${templateMaxAvailableConnectors.toString()}) in template ${filePath}, forcing random connector configurations affectation`
+      )
+      validated.randomConnectors = true
+    }
+
+    if (templateMaxConnectors === 0) {
+      logger.warn(
+        `${moduleName}.transformTemplate: Charging station information from template ${filePath} with empty connectors configuration`
+      )
+    }
+
+    if (configuredMaxConnectors <= 0) {
+      logger.warn(
+        `${moduleName}.transformTemplate: Charging station information from template ${filePath} with ${configuredMaxConnectors.toString()} connectors`
+      )
+    }
+  }
+
+  return validated as unknown as ChargingStationTemplate
+}
index 98f706ab2878e3fc508c121aeace11ab18e6d73f..0cc36082f108044e78a8ea74de6787862a942aa8 100644 (file)
@@ -34,7 +34,7 @@ export const logPrefix = (prefixString = ''): string => {
 
 /**
  * Formats a log prefix for direct concatenation with a module/method tag.
- * @param logPrefixFn - Prefix-producing function. Defaults to `logPrefix` (timestamp only) so callers without a
+ * @param logPrefixFn - Prefix-producing function. Defaults to `logPrefix` so callers without a
  *                     module-specific prefix still emit a timestamped log line.
  * @returns The prefix followed by a single trailing space (e.g. `"<prefix> "`). The trailing space is part of the
  *          contract: call sites concatenate the result directly with the message body, e.g.
index 9c5b8001de2d84833b95e93438fdb6c4d5215506..668de1d315ef6798c29db41cf7148b13c121f757 100644 (file)
@@ -10,15 +10,16 @@ import {
   checkChargingStationState,
   checkConfiguration,
   checkStationInfoConnectorStatus,
-  checkTemplate,
   getBootConnectorStatus,
   getChargingStationId,
   getHashId,
+  getMaxConfiguredNumberOfConnectors,
   getMaxNumberOfEvses,
   getPhaseRotationValue,
   hasPendingReservation,
   hasPendingReservations,
   hasReservationExpired,
+  pickConfiguredNumberOfConnectors,
   resetConnectorStatus,
   setChargingStationOptions,
   validateStationInfo,
@@ -627,30 +628,6 @@ await describe('Helpers', async () => {
     assert.strictEqual(getMaxNumberOfEvses({}), 0)
   })
 
-  await it('should throw for undefined or empty template', t => {
-    // Arrange
-    const warnMock = t.mock.method(logger, 'warn')
-    const errorMock = t.mock.method(logger, 'error')
-
-    // Act & Assert
-    assert.throws(
-      () => {
-        checkTemplate(undefined, 'log prefix |', 'test-template.json')
-      },
-      { message: /Failed to read charging station template file test-template\.json/ }
-    )
-    assert.strictEqual(errorMock.mock.calls.length, 1)
-    assert.throws(
-      () => {
-        checkTemplate({} as ChargingStationTemplate, 'log prefix |', 'test-template.json')
-      },
-      { message: /Empty charging station information from template file test-template\.json/ }
-    )
-    assert.strictEqual(errorMock.mock.calls.length, 2)
-    checkTemplate(chargingStationTemplate, 'log prefix |', 'test-template.json')
-    assert.strictEqual(warnMock.mock.calls.length, 1)
-  })
-
   await it('should throw for undefined or empty configuration', t => {
     // Arrange
     const errorMock = t.mock.method(logger, 'error')
@@ -987,4 +964,46 @@ await describe('Helpers', async () => {
       assert.strictEqual(connectorStatus.MeterValues.length, 1)
     })
   })
+
+  await describe('getMaxConfiguredNumberOfConnectors', async () => {
+    await it('should return undefined for undefined input', () => {
+      assert.strictEqual(getMaxConfiguredNumberOfConnectors(undefined), undefined)
+    })
+
+    await it('should return undefined for empty array', () => {
+      assert.strictEqual(getMaxConfiguredNumberOfConnectors([]), undefined)
+    })
+
+    await it('should return the number itself for scalar input', () => {
+      assert.strictEqual(getMaxConfiguredNumberOfConnectors(3), 3)
+    })
+
+    await it('should return the worst-case (max) of a non-empty array', () => {
+      assert.strictEqual(getMaxConfiguredNumberOfConnectors([2, 4, 6]), 6)
+      assert.strictEqual(getMaxConfiguredNumberOfConnectors([1]), 1)
+      assert.strictEqual(getMaxConfiguredNumberOfConnectors([5, 1, 3]), 5)
+    })
+  })
+
+  await describe('pickConfiguredNumberOfConnectors', async () => {
+    await it('should return undefined for undefined input', () => {
+      assert.strictEqual(pickConfiguredNumberOfConnectors(undefined), undefined)
+    })
+
+    await it('should return undefined for empty array', () => {
+      assert.strictEqual(pickConfiguredNumberOfConnectors([]), undefined)
+    })
+
+    await it('should return the number itself for scalar input', () => {
+      assert.strictEqual(pickConfiguredNumberOfConnectors(3), 3)
+    })
+
+    await it('should return one of the array elements for non-empty array', () => {
+      const candidates = [2, 4, 6]
+      for (let i = 0; i < 50; i++) {
+        const picked = pickConfiguredNumberOfConnectors(candidates)
+        assert.ok(picked != null && candidates.includes(picked))
+      }
+    })
+  })
 })
diff --git a/tests/charging-station/TemplateMigrations.test.ts b/tests/charging-station/TemplateMigrations.test.ts
new file mode 100644 (file)
index 0000000..bd3d9b8
--- /dev/null
@@ -0,0 +1,149 @@
+/**
+ * @file Tests for TemplateMigrations
+ * @description Unit tests for schema version coercion and migration functions
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+  applyMigration,
+  coerceVersion,
+  CURRENT_SCHEMA_VERSION,
+} from '../../src/charging-station/TemplateMigrations.js'
+import { logger } from '../../src/utils/index.js'
+import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { buildLegacyTemplate } from './helpers/TemplateFixtures.js'
+
+await describe('TemplateMigrations', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('CURRENT_SCHEMA_VERSION', async () => {
+    await it('should be a positive integer', () => {
+      assert.ok(Number.isInteger(CURRENT_SCHEMA_VERSION))
+      assert.strictEqual(CURRENT_SCHEMA_VERSION, 1)
+    })
+  })
+
+  await describe('coerceVersion', async () => {
+    await it('should return 0 for null or undefined (legacy templates trigger v0 migration)', () => {
+      assert.strictEqual(coerceVersion(null), 0)
+      assert.strictEqual(coerceVersion(undefined), 0)
+    })
+
+    await it('should return the number for valid integer', () => {
+      assert.strictEqual(coerceVersion(1), 1)
+      assert.strictEqual(coerceVersion(0), 0)
+    })
+
+    await it('should parse string to number', () => {
+      assert.strictEqual(coerceVersion('1'), 1)
+      assert.strictEqual(coerceVersion('0'), 0)
+    })
+
+    await it('should throw for non-numeric string', () => {
+      assert.throws(() => coerceVersion('abc'), { message: /must be a non-negative integer/ })
+    })
+
+    await it('should throw for negative value', () => {
+      assert.throws(() => coerceVersion(-1), { message: /must be a non-negative integer/ })
+    })
+
+    await it('should throw for float value', () => {
+      assert.throws(() => coerceVersion(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(
+          () => coerceVersion(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(() => coerceVersion(bad), { message: /must be a non-negative integer/ })
+      }
+    })
+
+    await it('should throw for future version', () => {
+      assert.throws(() => coerceVersion(CURRENT_SCHEMA_VERSION + 1), {
+        message: /is newer than supported version/,
+      })
+    })
+
+    await it('should throw for object value', () => {
+      assert.throws(() => coerceVersion({}), { message: /Invalid \$schemaVersion value/ })
+    })
+
+    await it('should throw for boolean value', () => {
+      assert.throws(() => coerceVersion(true), { message: /Invalid \$schemaVersion value/ })
+    })
+  })
+
+  await describe('applyMigration', async () => {
+    await it('should migrate v0 to v1 renaming all deprecated keys at once', t => {
+      mockLoggerWarnDebug(t, logger)
+      const template = buildLegacyTemplate({
+        authorizationFile: 'tags.json',
+        mustAuthorizeAtRemoteStart: true,
+        payloadSchemaValidation: false,
+        supervisionUrl: 'ws://localhost:8080',
+      })
+
+      const result = applyMigration(0, template)
+
+      assert.strictEqual(result.$schemaVersion, CURRENT_SCHEMA_VERSION)
+      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.strictEqual(result.idTagsFile, 'tags.json')
+      assert.strictEqual(result.remoteAuthorization, true)
+      assert.strictEqual(result.ocppStrictCompliance, false)
+      assert.strictEqual(result.supervisionUrl, undefined)
+      assert.strictEqual(result.authorizationFile, undefined)
+      assert.strictEqual(result.mustAuthorizeAtRemoteStart, undefined)
+      assert.strictEqual(result.payloadSchemaValidation, undefined)
+    })
+
+    for (const [deprecated, replacement, value] of [
+      ['supervisionUrl', 'supervisionUrls', 'ws://localhost:8080'],
+      ['authorizationFile', 'idTagsFile', 'tags.json'],
+      ['payloadSchemaValidation', 'ocppStrictCompliance', false],
+      ['mustAuthorizeAtRemoteStart', 'remoteAuthorization', true],
+    ] as const) {
+      await it(`should migrate v0 renaming ${deprecated} to ${replacement}`, t => {
+        mockLoggerWarnDebug(t, logger)
+        const template = buildLegacyTemplate({ [deprecated]: value })
+
+        const result = applyMigration(0, template)
+
+        assert.strictEqual(result[replacement], value)
+        assert.strictEqual(result[deprecated], undefined)
+      })
+    }
+
+    for (const [label, sourceVersion] of [
+      ['unknown source version', 99],
+      ['source version equal to CURRENT_SCHEMA_VERSION (no-op boundary)', CURRENT_SCHEMA_VERSION],
+      ['negative source version', -1],
+    ] as const) {
+      await it(`should throw for ${label}`, () => {
+        assert.throws(() => applyMigration(sourceVersion, buildLegacyTemplate()), {
+          message: /No migration defined/,
+        })
+      })
+    }
+
+    await it('should set $schemaVersion to CURRENT_SCHEMA_VERSION after migration', t => {
+      mockLoggerWarnDebug(t, logger)
+
+      const result = applyMigration(0, buildLegacyTemplate())
+
+      assert.strictEqual(result.$schemaVersion, CURRENT_SCHEMA_VERSION)
+    })
+  })
+})
diff --git a/tests/charging-station/TemplateSchema.test.ts b/tests/charging-station/TemplateSchema.test.ts
new file mode 100644 (file)
index 0000000..3ba1e7c
--- /dev/null
@@ -0,0 +1,324 @@
+/**
+ * @file Tests for TemplateSchema
+ * @description Unit tests for Zod template schema validation
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/TemplateMigrations.js'
+import { TemplateSchema } from '../../src/charging-station/TemplateSchema.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
+import { buildMinimalTemplate } from './helpers/TemplateFixtures.js'
+
+await describe('TemplateSchema', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('required fields', async () => {
+    await it('should accept a minimal valid template with Connectors', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Connectors: {
+            0: {},
+            1: { MeterValues: [] },
+          },
+        })
+      )
+      assert.ok(result.success)
+      assert.strictEqual(result.data.baseName, TEST_CHARGING_STATION_BASE_NAME)
+    })
+
+    for (const requiredField of ['baseName', 'chargePointModel', 'chargePointVendor'] as const) {
+      await it(`should reject missing ${requiredField}`, () => {
+        const template = buildMinimalTemplate()
+        // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+        delete template[requiredField]
+        const result = TemplateSchema.safeParse(template)
+        assert.ok(!result.success)
+        assert.ok(result.error.issues.some(i => i.path.includes(requiredField)))
+      })
+    }
+
+    await it('should reject empty baseName', () => {
+      const result = TemplateSchema.safeParse(buildMinimalTemplate({ baseName: '' }))
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('$schemaVersion', async () => {
+    await it('should reject template missing $schemaVersion (post-migration schema is strict)', () => {
+      const template = buildMinimalTemplate()
+
+      delete template.$schemaVersion
+      const result = TemplateSchema.safeParse(template)
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.includes('$schemaVersion')))
+    })
+
+    await it('should accept explicit $schemaVersion equal to CURRENT_SCHEMA_VERSION', () => {
+      const result = TemplateSchema.safeParse(buildMinimalTemplate())
+      assert.ok(result.success)
+      assert.strictEqual(result.data.$schemaVersion, CURRENT_SCHEMA_VERSION)
+    })
+
+    await it('should reject $schemaVersion not equal to CURRENT_SCHEMA_VERSION', () => {
+      const result = TemplateSchema.safeParse(buildMinimalTemplate({ $schemaVersion: 0 }))
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.includes('$schemaVersion')))
+    })
+  })
+
+  await describe('deprecated keys rejection', async () => {
+    for (const [legacyKey, legacyValue] of [
+      ['supervisionUrl', 'ws://localhost:8080'],
+      ['authorizationFile', 'tags.json'],
+      ['mustAuthorizeAtRemoteStart', true],
+      ['payloadSchemaValidation', false],
+    ] as const) {
+      await it(`should reject template containing legacy ${legacyKey} key`, () => {
+        const result = TemplateSchema.safeParse(buildMinimalTemplate({ [legacyKey]: legacyValue }))
+        assert.ok(!result.success)
+        assert.ok(
+          result.error.issues.some(
+            i => i.path.includes(legacyKey) && i.message.includes('Deprecated')
+          )
+        )
+      })
+    }
+  })
+
+  await describe('typed sub-schemas', async () => {
+    await it('should accept structured signedMeterValue with vendor extensions', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Connectors: {
+            0: {},
+            1: {
+              MeterValues: [
+                {
+                  signedMeterValue: {
+                    encodingMethod: 'Other',
+                    publicKey: 'pk',
+                    signedMeterData: 'data',
+                    signingMethod: 'Other',
+                    vendorField: 'preserved',
+                  },
+                  unit: 'Wh',
+                },
+              ],
+            },
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject signedMeterValue with non-string encodingMethod', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Connectors: {
+            0: {},
+            1: {
+              MeterValues: [
+                {
+                  signedMeterValue: { encodingMethod: 42 },
+                  unit: 'Wh',
+                },
+              ],
+            },
+          },
+        })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should accept structured wsOptions with known fields', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          wsOptions: {
+            handshakeTimeout: 5000,
+            headers: { 'X-Custom': 'value' },
+            rejectUnauthorized: false,
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject wsOptions with non-object headers', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ wsOptions: { headers: 'not an object' } })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should accept numeric wsOptions header value (Node OutgoingHttpHeader)', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ wsOptions: { headers: { 'X-Forwarded-Port': 8080 } } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept multi-value wsOptions header (string array)', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          wsOptions: { headers: { Accept: ['application/json', 'text/plain'] } },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject wsOptions header array with non-string element', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          wsOptions: { headers: { Accept: ['application/json', 42] } },
+        })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should reject wsOptions header with empty field-name (RFC 9110 §5.1)', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ wsOptions: { headers: { '': 'value' } } })
+      )
+      assert.ok(!result.success)
+    })
+
+    await it('should reject wsOptions header with boolean value', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ wsOptions: { headers: { 'X-Foo': true } } })
+      )
+      assert.ok(!result.success)
+    })
+  })
+
+  await describe('topology discrimination', async () => {
+    await it('should reject template with both Connectors and Evses', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Connectors: { 0: {} },
+          Evses: { 0: { Connectors: { 0: {} } } },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.message.includes('Connectors OR Evses, not both')))
+    })
+
+    await it('should accept template with only Connectors', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ Connectors: { 0: {}, 1: {} } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept template with only Evses', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Evses: {
+            0: { Connectors: { 0: {} } },
+            1: { Connectors: { 1: {} } },
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should accept template with neither Connectors nor Evses', () => {
+      const result = TemplateSchema.safeParse(buildMinimalTemplate())
+      assert.ok(result.success)
+    })
+  })
+
+  await describe('Evses validation (OCPP 2.0.1 §7.2)', async () => {
+    await it('should reject EVSE 0 with non-zero connector id', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ Evses: { 0: { Connectors: { 1: {} } } } })
+      )
+      assert.ok(!result.success)
+      assert.ok(
+        result.error.issues.some(i => i.message.includes('EVSE 0 has invalid connector id'))
+      )
+    })
+
+    await it('should reject EVSE > 0 with connector id 0', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ Evses: { 1: { Connectors: { 0: {} } } } })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.message.includes('connector ids must start at 1')))
+    })
+
+    await it('should accept valid EVSE configuration', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Evses: {
+            0: { Connectors: { 0: {} } },
+            1: { Connectors: { 1: {} } },
+            2: { Connectors: { 2: {} } },
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+  })
+
+  await describe('MeterValues normalization', async () => {
+    await it('should accept string value in MeterValues', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Connectors: {
+            0: {},
+            1: { MeterValues: [{ unit: 'Wh', value: '42' }] },
+          },
+        })
+      )
+      assert.ok(result.success)
+      const mv = result.data.Connectors?.['1']?.MeterValues?.[0]
+      assert.strictEqual(mv?.value, '42')
+    })
+
+    await it('should coerce number value to string in MeterValues', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({
+          Connectors: {
+            0: {},
+            1: { MeterValues: [{ unit: 'Wh', value: 0 }] },
+          },
+        })
+      )
+      assert.ok(result.success)
+      const mv = result.data.Connectors?.['1']?.MeterValues?.[0]
+      assert.strictEqual(mv?.value, '0')
+    })
+  })
+
+  await describe('looseObject behavior', async () => {
+    await it('should tolerate unknown top-level keys', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ unknownField: 'should be preserved' })
+      )
+      assert.ok(result.success)
+      assert.strictEqual(
+        (result.data as Record<string, unknown>).unknownField,
+        'should be preserved'
+      )
+    })
+  })
+
+  await describe('connector key validation', async () => {
+    await it('should accept numeric string keys in Connectors', () => {
+      const result = TemplateSchema.safeParse(
+        buildMinimalTemplate({ Connectors: { 0: {}, 1: {}, 2: {} } })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject non-numeric keys in Connectors', () => {
+      const result = TemplateSchema.safeParse(buildMinimalTemplate({ Connectors: { abc: {} } }))
+      assert.ok(!result.success)
+    })
+  })
+})
diff --git a/tests/charging-station/TemplateValidation.test.ts b/tests/charging-station/TemplateValidation.test.ts
new file mode 100644 (file)
index 0000000..ab7397f
--- /dev/null
@@ -0,0 +1,305 @@
+/**
+ * @file Tests for TemplateValidation
+ * @description Unit tests for template validation pipeline, transforms, and error handling
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+import { ZodError } from 'zod'
+
+import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/TemplateMigrations.js'
+import {
+  TemplateValidationError,
+  validateTemplate,
+} from '../../src/charging-station/TemplateValidation.js'
+import { BaseError } from '../../src/exception/index.js'
+import { logger } from '../../src/utils/index.js'
+import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
+import { buildLegacyTemplate, buildMinimalTemplate } from './helpers/TemplateFixtures.js'
+
+await describe('TemplateValidation', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('validateTemplate', async () => {
+    await it('should validate a minimal valid template', t => {
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({ Connectors: { 0: {}, 1: {} } })
+
+      const result = validateTemplate(parsed, 'test.json')
+
+      assert.strictEqual(result.baseName, TEST_CHARGING_STATION_BASE_NAME)
+    })
+
+    await it('should accept string "$schemaVersion": "1" at current version (no-migration path)', t => {
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({
+        $schemaVersion: '1',
+        Connectors: { 0: {}, 1: {} },
+      })
+
+      const result = validateTemplate(parsed, 'string-version.json')
+
+      assert.strictEqual(result.baseName, TEST_CHARGING_STATION_BASE_NAME)
+      assert.strictEqual(
+        (result as unknown as Record<string, unknown>).$schemaVersion,
+        CURRENT_SCHEMA_VERSION,
+        '$schemaVersion should be normalized to numeric CURRENT_SCHEMA_VERSION'
+      )
+    })
+
+    await it('should not mutate the caller-supplied parsed object (immutability boundary)', t => {
+      mockLoggerWarnDebug(t, logger)
+      const parsed = buildLegacyTemplate({
+        Connectors: { 0: {}, 1: {} },
+        supervisionUrl: 'ws://localhost:8080',
+      })
+      const before = structuredClone(parsed)
+
+      validateTemplate(parsed, 'immutable.json')
+
+      assert.deepStrictEqual(parsed, before)
+    })
+
+    await it('should throw BaseError for empty template', () => {
+      assert.throws(
+        () => validateTemplate({}, 'test.json'),
+        (error: unknown) =>
+          error instanceof BaseError &&
+          error.message.includes('Empty charging station information from template file')
+      )
+    })
+
+    await it('should throw TemplateValidationError for invalid template', () => {
+      assert.throws(
+        () => validateTemplate({ baseName: '' }, 'test.json'),
+        (error: unknown) => error instanceof TemplateValidationError
+      )
+    })
+
+    await it('should include filePath and fieldErrors in TemplateValidationError', () => {
+      try {
+        validateTemplate(
+          { baseName: '', chargePointModel: 'X', chargePointVendor: 'Y' },
+          'my-template.json'
+        )
+        assert.fail('Expected TemplateValidationError')
+      } catch (error) {
+        assert.ok(error instanceof TemplateValidationError)
+        assert.strictEqual(error.filePath, 'my-template.json')
+        assert.ok(Array.isArray(error.fieldErrors))
+        assert.ok(error.fieldErrors.length > 0)
+      }
+    })
+
+    await it('should apply migration for v0 templates', t => {
+      mockLoggerWarnDebug(t, logger)
+      const parsed = buildLegacyTemplate({
+        $schemaVersion: 0,
+        Connectors: { 0: {}, 1: {} },
+        supervisionUrl: 'ws://localhost:8080',
+      })
+
+      const result = validateTemplate(parsed, 'test.json')
+
+      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+    })
+
+    await it('should auto-migrate template missing $schemaVersion (legacy v0 default)', t => {
+      mockLoggerWarnDebug(t, logger)
+      const parsed = buildLegacyTemplate({
+        authorizationFile: 'tags.json',
+        Connectors: { 0: {}, 1: {} },
+        mustAuthorizeAtRemoteStart: true,
+        payloadSchemaValidation: false,
+        supervisionUrl: 'ws://localhost:8080',
+      })
+
+      const result = validateTemplate(parsed, 'legacy.json')
+
+      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.strictEqual(result.idTagsFile, 'tags.json')
+      assert.strictEqual(result.remoteAuthorization, true)
+      assert.strictEqual(result.ocppStrictCompliance, false)
+      const raw = result as unknown as Record<string, unknown>
+      assert.strictEqual(raw.supervisionUrl, undefined)
+      assert.strictEqual(raw.authorizationFile, undefined)
+      assert.strictEqual(raw.mustAuthorizeAtRemoteStart, undefined)
+      assert.strictEqual(raw.payloadSchemaValidation, undefined)
+    })
+
+    for (const [label, payload] of [
+      ['null', null],
+      ['string', 'a string'],
+      ['array', [1, 2, 3]],
+    ] as const) {
+      await it(`should throw BaseError for ${label} parsed payload`, () => {
+        assert.throws(
+          () => validateTemplate(payload, `${label}.json`),
+          (error: unknown) =>
+            error instanceof BaseError &&
+            error.message.includes('Invalid charging station template payload (not a JSON object)')
+        )
+      })
+    }
+
+    for (const [legacyKey, legacyValue] of [
+      ['supervisionUrl', 'ws://localhost:8080'],
+      ['mustAuthorizeAtRemoteStart', true],
+      ['payloadSchemaValidation', false],
+    ] as const) {
+      await it(`should reject v1 template containing legacy ${legacyKey} key`, t => {
+        t.mock.method(logger, 'warn')
+        assert.throws(
+          () =>
+            validateTemplate(buildMinimalTemplate({ [legacyKey]: legacyValue }), 'v1-legacy.json'),
+          (error: unknown) =>
+            error instanceof TemplateValidationError &&
+            error.fieldErrors.some(e => e.path === legacyKey && e.message.includes('Deprecated'))
+        )
+      })
+    }
+
+    await it('should include "(migrated from vX → vY)" note in TemplateValidationError message', t => {
+      mockLoggerWarnDebug(t, logger)
+      try {
+        validateTemplate(buildLegacyTemplate({ $schemaVersion: 0, baseName: '' }), 'broken.json')
+        assert.fail('Expected TemplateValidationError')
+      } catch (error) {
+        assert.ok(error instanceof TemplateValidationError)
+        assert.strictEqual(error.migratedFrom, 0)
+        assert.match(error.message, /migrated from v0 → v1/)
+      }
+    })
+  })
+
+  await describe('transformTemplate', async () => {
+    await it('should warn about missing idTagsFile', t => {
+      const warnMock = t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate()
+
+      validateTemplate(parsed, 'test.json')
+
+      const warnMessages = warnMock.mock.calls.map(c =>
+        typeof c.arguments[0] === 'string' ? c.arguments[0] : ''
+      )
+      assert.ok(warnMessages.some(m => m.includes('Missing id tags file')))
+    })
+
+    await it('should force randomConnectors when scalar numberOfConnectors exceeds defined connectors', t => {
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({
+        Connectors: { 0: {}, 1: {} },
+        numberOfConnectors: 5,
+        randomConnectors: false,
+      })
+
+      const result = validateTemplate(parsed, 'test.json')
+
+      assert.strictEqual(result.randomConnectors, true)
+    })
+
+    await it('should force randomConnectors when max(numberOfConnectors[]) exceeds defined connectors', t => {
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({
+        Connectors: { 0: {}, 1: {}, 2: {} },
+        numberOfConnectors: [2, 4, 6],
+        randomConnectors: false,
+      })
+
+      const result = validateTemplate(parsed, 'test.json')
+
+      assert.strictEqual(result.randomConnectors, true)
+    })
+
+    await it('should not force randomConnectors when max(numberOfConnectors[]) does not exceed defined connectors', t => {
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({
+        Connectors: { 0: {}, 1: {}, 2: {}, 3: {}, 4: {} },
+        numberOfConnectors: [1, 2, 3, 4],
+      })
+
+      const result = validateTemplate(parsed, 'test.json')
+
+      assert.notStrictEqual(result.randomConnectors, true)
+    })
+
+    await it('should not force randomConnectors when already true', t => {
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({
+        Connectors: { 0: {}, 1: {} },
+        numberOfConnectors: 5,
+        randomConnectors: true,
+      })
+
+      const result = validateTemplate(parsed, 'test.json')
+
+      assert.strictEqual(result.randomConnectors, true)
+    })
+
+    await it('should not log error for empty Connectors map', t => {
+      const errorMock = t.mock.method(logger, 'error')
+      t.mock.method(logger, 'warn')
+      const parsed = buildMinimalTemplate({ Connectors: {} })
+
+      validateTemplate(parsed, 'test.json')
+
+      const errorMessages = errorMock.mock.calls.map(c =>
+        typeof c.arguments[0] === 'string' ? c.arguments[0] : ''
+      )
+      assert.ok(!errorMessages.some(m => m.includes('no connectors configuration defined')))
+    })
+  })
+
+  await describe('TemplateValidationError', async () => {
+    await it('should be an instance of BaseError', () => {
+      const zodError = new ZodError([
+        {
+          code: 'invalid_type',
+          expected: 'string',
+          message: 'Required',
+          path: ['baseName'],
+        },
+      ])
+      const error = new TemplateValidationError(zodError, { filePath: 'test.json' })
+      assert.ok(error instanceof BaseError)
+      assert.strictEqual(error.filePath, 'test.json')
+      assert.strictEqual(error.fieldErrors.length, 1)
+      assert.strictEqual(error.fieldErrors[0].path, 'baseName')
+    })
+
+    await it('should include migratedFrom when provided', () => {
+      const zodError = new ZodError([])
+      const error = new TemplateValidationError(zodError, {
+        filePath: 'test.json',
+        migratedFrom: 0,
+      })
+      assert.strictEqual(error.migratedFrom, 0)
+    })
+  })
+
+  await describe('all template files round-trip', async () => {
+    await it('should validate all 15 station template files through the pipeline', async t => {
+      mockLoggerWarnDebug(t, logger)
+      const fs = await import('node:fs')
+      const path = await import('node:path')
+      const templateDir = path.join(import.meta.dirname, '../../src/assets/station-templates')
+      const files = fs.readdirSync(templateDir).filter(f => f.endsWith('.json'))
+      assert.strictEqual(files.length, 15)
+
+      for (const file of files) {
+        const content = fs.readFileSync(path.join(templateDir, file), 'utf8')
+        const parsed = JSON.parse(content) as Record<string, unknown>
+        const result = validateTemplate(parsed, file)
+        assert.ok(result, `Template ${file} should validate successfully`)
+        assert.strictEqual(
+          result.baseName.length > 0,
+          true,
+          `Template ${file} should have baseName`
+        )
+      }
+    })
+  })
+})
diff --git a/tests/charging-station/helpers/TemplateFixtures.ts b/tests/charging-station/helpers/TemplateFixtures.ts
new file mode 100644 (file)
index 0000000..575d331
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * @file Shared template fixtures for schema/migration/validation tests.
+ */
+
+import { CURRENT_SCHEMA_VERSION } from '../../../src/charging-station/TemplateMigrations.js'
+import {
+  TEST_CHARGE_POINT_MODEL,
+  TEST_CHARGE_POINT_VENDOR,
+  TEST_CHARGING_STATION_BASE_NAME,
+} from '../ChargingStationTestConstants.js'
+
+/**
+ * Build a minimal valid template at the current schema version.
+ * @param overrides - Fields to merge into the base template
+ * @returns A minimal template accepted by `TemplateSchema`
+ */
+export const buildMinimalTemplate = (
+  overrides: Record<string, unknown> = {}
+): Record<string, unknown> => ({
+  $schemaVersion: CURRENT_SCHEMA_VERSION,
+  baseName: TEST_CHARGING_STATION_BASE_NAME,
+  chargePointModel: TEST_CHARGE_POINT_MODEL,
+  chargePointVendor: TEST_CHARGE_POINT_VENDOR,
+  ...overrides,
+})
+
+/**
+ * Build a minimal pre-current-version template for migration-path tests.
+ * @param overrides - Fields to merge into the base template
+ * @returns A minimal template without `$schemaVersion`
+ */
+export const buildLegacyTemplate = (
+  overrides: Record<string, unknown> = {}
+): Record<string, unknown> => ({
+  baseName: TEST_CHARGING_STATION_BASE_NAME,
+  chargePointModel: TEST_CHARGE_POINT_MODEL,
+  chargePointVendor: TEST_CHARGE_POINT_VENDOR,
+  ...overrides,
+})
index e3a5f8b571d922fd6fff25ce3d2eb0f7302d1b04..5d4820de37bd64546041d96828d1e6ab43d4d359 100644 (file)
@@ -256,6 +256,21 @@ export function createTimerScope (
   }
 }
 
+/**
+ * Install no-op spies on the logger warn and debug methods.
+ * @param t - Test context from node:test.
+ * @param logger - Logger instance to spy on.
+ * @param logger.debug - Logger debug method
+ * @param logger.warn - Logger warn method
+ */
+export function mockLoggerWarnDebug (
+  t: MockContext,
+  logger: { debug: unknown; warn: unknown }
+): void {
+  t.mock.method(logger, 'warn')
+  t.mock.method(logger, 'debug')
+}
+
 /**
  * Setup a connector with an active transaction
  *