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==}
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'}
'@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
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
'@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':
'@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)
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
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
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-ABB",
"chargePointModel": "MD_TERRA_53",
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-ABB",
"chargePointModel": "MD_TERRA_53",
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-CHARGEX",
"chargePointModel": "Aqueduct 1.0",
{
+ "$schemaVersion": 1,
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "ocppcentraladdress",
"idTagsFile": "idtags.json",
{
+ "$schemaVersion": 1,
"supervisionUrls": ["ws://localhost:9000"],
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "CentralSystemAddress",
{
+ "$schemaVersion": 1,
"supervisionUrls": ["ws://localhost:9000"],
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "CentralSystemAddress",
{
+ "$schemaVersion": 1,
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "CentralSystemAddress",
"idTagsFile": "idtags.json",
{
+ "$schemaVersion": 1,
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "ocppcentraladdress",
"idTagsFile": "idtags.json",
{
+ "$schemaVersion": 1,
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "ocppcentraladdress",
"idTagsFile": "idtags.json",
{
+ "$schemaVersion": 1,
"supervisionUrlOcppConfiguration": true,
"supervisionUrlOcppKey": "ocppcentraladdress",
"idTagsFile": "idtags.json",
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-SIEMENS",
"fixedName": true,
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-BASIC",
"chargePointModel": "Simulator simple",
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-BASIC-SIGNED",
"chargePointModel": "Simulator simple",
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-BASIC",
"chargePointModel": "Simulator simple",
{
+ "$schemaVersion": 1,
"idTagsFile": "idtags.json",
"baseName": "CS-SIMU",
"chargePointModel": "Simulator connectors",
logPrefix,
mergeDeepRight,
min,
- once,
promiseWithTimeout,
secureRandom,
sleep,
buildTemplateName,
checkChargingStationState,
checkConfiguration,
- checkConnectorsConfiguration,
- checkEvsesConfiguration,
checkStationInfoConnectorStatus,
- checkTemplate,
createSerialNumber,
getAmperageLimitationUnitDivider,
getBootConnectorStatus,
getChargingStationChargingProfilesLimit,
getChargingStationId,
+ getConfiguredMaxNumberOfConnectors,
getConnectorChargingProfilesLimit,
getDefaultConnectorMaximumPower,
getDefaultVoltageOut,
getHashId,
getIdTagsFile,
+ getMaxNumberOfConnectors,
getMaxNumberOfEvses,
getPhaseRotationValue,
hasFeatureProfile,
setChargingStationOptions,
stationTemplateToStationInfo,
validateStationInfo,
- warnTemplateKeysDeprecation,
} from './Helpers.js'
import { IdTagsCache } from './IdTagsCache.js'
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'
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)
} 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
}
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`
)
}
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()}`,
return staticCount > 0 ? maximumPower / staticCount : undefined
}
-const getMaxNumberOfConnectors = (
+export const getMaxNumberOfConnectors = (
connectors: Record<string, ConnectorStatus> | undefined
): number => {
if (connectors == null) {
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,
}
}
-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,
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 => {
})
}
-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
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 = (
}
}
-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
--- /dev/null
+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
+}
--- /dev/null
+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 }
--- /dev/null
+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
+}
/**
* 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.
checkChargingStationState,
checkConfiguration,
checkStationInfoConnectorStatus,
- checkTemplate,
getBootConnectorStatus,
getChargingStationId,
getHashId,
+ getMaxConfiguredNumberOfConnectors,
getMaxNumberOfEvses,
getPhaseRotationValue,
hasPendingReservation,
hasPendingReservations,
hasReservationExpired,
+ pickConfiguredNumberOfConnectors,
resetConnectorStatus,
setChargingStationOptions,
validateStationInfo,
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')
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))
+ }
+ })
+ })
})
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})
--- /dev/null
+/**
+ * @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`
+ )
+ }
+ })
+ })
+})
--- /dev/null
+/**
+ * @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,
+})
}
}
+/**
+ * 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
*