feat: resolve #314 — Add charging station template Zod validation with schema versioning (#1860)
* 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
- 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.
- 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
- 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