}
private getOcppConfigurationFromTemplate (): ChargingStationOcppConfiguration | undefined {
- return this.getTemplateFromFile()?.Configuration
+ return clone(this.getTemplateFromFile()?.Configuration)
}
private getPowerDivider (): number {
return false
}
- if (chargingStation.hasEvses && evseId > chargingStation.getNumberOfEvses()) {
+ if (chargingStation.hasEvses && !chargingStation.hasEvse(evseId)) {
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: EVSE ${evseId.toString()} exceeds available EVSEs (${chargingStation.getNumberOfEvses().toString()})`
+ `${chargingStation.logPrefix()} ${moduleName}.validateChargingProfile: EVSE ${evseId.toString()} does not exist`
)
return false
}
--- /dev/null
+/**
+ * @file Tests that a non-persistent charging station owns an independent OCPP Configuration.
+ * @description getOcppConfigurationFromTemplate() must clone the cached template's
+ * Configuration so a runtime configuration-key change on a non-persistent station stays
+ * local and does not mutate the shared template held in the SharedLRUCache.
+ */
+import assert from 'node:assert/strict'
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ChargingStationTemplate } from '../../src/types/index.js'
+
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import {
+ getConfigurationKey,
+ setConfigurationKeyValue,
+} from '../../src/charging-station/ConfigurationKeyUtils.js'
+import { SharedLRUCache } from '../../src/charging-station/SharedLRUCache.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+// templateFileHash is the SharedLRUCache key; reached via a typed boundary cast (no `as any`).
+const templateHashOf = (station: ChargingStation): string =>
+ (station as unknown as { templateFileHash: string }).templateFileHash
+
+const configValue = (template: ChargingStationTemplate, key: string): string | undefined =>
+ template.Configuration?.configurationKey?.find(configKey => configKey.key === key)?.value
+
+// A pre-existing, mutable key shipped by the virtual-simple template.
+const CONFIG_KEY = 'MeterValueSampleInterval'
+const TEMPLATE_VALUE = '30'
+
+const tmpRoots: string[] = []
+
+// Fresh template in its own temp station-templates dir. A station caches its parsed template
+// in the SharedLRUCache under a content-derived key; templateHashOf() fetches that exact entry
+// so the test can assert the station's Configuration is an independent copy of it.
+const makeTemplate = (): string => {
+ const root = mkdtempSync(join(tmpdir(), 'cs-config-isolation-'))
+ tmpRoots.push(root)
+ mkdirSync(join(root, 'station-templates'), { recursive: true })
+ const file = join(root, 'station-templates', 'virtual-simple.station-template.json')
+ copyFileSync(
+ join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'),
+ file
+ )
+ return file
+}
+
+await describe('ChargingStation OCPP Configuration isolation', async () => {
+ afterEach(() => {
+ standardCleanup()
+ for (const root of tmpRoots.splice(0)) {
+ rmSync(root, { force: true, recursive: true })
+ }
+ })
+
+ await it('should not mutate the shared cached template when a non-persistent station changes a configuration key', () => {
+ const templateFile = makeTemplate()
+ const station = new ChargingStation(1, templateFile, {
+ autoStart: false,
+ persistentConfiguration: false,
+ supervisionUrls: 'ws://localhost:9999/',
+ })
+
+ // The exact cached template the station parsed and read its Configuration from.
+ const cachedTemplate = SharedLRUCache.getInstance().getChargingStationTemplate(
+ templateHashOf(station)
+ )
+ assert.strictEqual(configValue(cachedTemplate, CONFIG_KEY), TEMPLATE_VALUE)
+
+ // A non-persistent station must own an independent copy, not the cached template's array.
+ // Assert both operands are present first so the reference check cannot pass vacuously.
+ assert.ok(
+ station.ocppConfiguration?.configurationKey != null,
+ 'station ocppConfiguration must be initialized'
+ )
+ assert.ok(
+ cachedTemplate.Configuration?.configurationKey != null,
+ 'cached template Configuration must be present'
+ )
+ assert.notStrictEqual(
+ station.ocppConfiguration.configurationKey,
+ cachedTemplate.Configuration.configurationKey
+ )
+
+ setConfigurationKeyValue(station, CONFIG_KEY, '999')
+
+ assert.strictEqual(getConfigurationKey(station, CONFIG_KEY)?.value, '999')
+ // The shared cached template is untouched.
+ assert.strictEqual(configValue(cachedTemplate, CONFIG_KEY), TEMPLATE_VALUE)
+ })
+})
await describe('ChargingStation keeps its identity across a reset', async () => {
afterEach(() => {
standardCleanup()
- // The real ChargingStation uses the real SharedLRUCache singleton (not the
- // mock standardCleanup resets); clear it so a cached template cannot bleed
- // between tests.
- SharedLRUCache.getInstance().clear()
for (const root of tmpRoots.splice(0)) {
rmSync(root, { force: true, recursive: true })
}
await station.reset()
- // A plain reset() reuses the warm template cache, whose in-place OCPP-key
- // mutation still carries the URL; the retained-options mirror is not exercised
- // on this path (see the cache-cold reload test below).
+ // A plain reset() reuses the warm template cache. The station's OCPP
+ // Configuration is an independent clone, so setSupervisionUrl's key mutation no longer
+ // persists in the cached template; on reinitialization the URL is re-seeded from the
+ // retained-options mirror (as in the cache-cold reload test below).
assert.strictEqual(station.wsConnectionUrl.host, 'localhost:7777')
})
// Invalidate the cached template exactly as the template-reload path does (the
// watcher calls deleteChargingStationTemplate before initialize), forcing the
// OCPP key absent on reinitialization so it is re-seeded from configuredSupervisionUrl.
- // This is the only path where the retained-options mirror is load-bearing: a
- // warm-cache reset passes via the cached mutation and would not guard it.
+ // The warm-cache reset (above) also re-seeds from the retained-options
+ // mirror; this test additionally covers the explicit template-reload (cache-cold) path.
SharedLRUCache.getInstance().clear()
internalsOf(station).initialize(internalsOf(station).creationOptions)
import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type {
+ EvseStatus,
OCPP20ChargingProfileType,
OCPP20ChargingRateUnitEnumType,
OCPP20RequestStartTransactionRequest,
resetReportingValueSize,
} from './OCPP20TestUtils.js'
+// EVSE map access to build a non-contiguous EVSE layout; typed boundary cast (no `as any`).
+const evsesOf = (station: ChargingStation): Map<number, EvseStatus> =>
+ (station as unknown as { evses: Map<number, EvseStatus> }).evses
+
await describe('F01 & F02 - Remote Start Transaction', async () => {
let mockStation: ChargingStation
let incomingRequestService: OCPP20IncomingRequestService
)
})
+ // RequestStartTransaction may carry a TxProfile for the target EVSE (OCPP 2.0.1
+ // part 2 F01.FR.08-10 / F02.FR.16-18). validateChargingProfile must reject it by EVSE
+ // existence, not by count: getNumberOfEvses() counts EVSEs (excluding EVSE 0) and is not
+ // a maximum id, so a count-based guard false-rejects existing but non-contiguous EVSEs.
+ // Existence-based rejection mirrors K01.FR.28 (conformance test TC_K_14_CS).
+ await it('should accept a charging profile for an existing non-contiguous EVSE', async () => {
+ // Arrange: remove EVSE 2 so EVSE ids are non-contiguous {0, 1, 3}. getNumberOfEvses()
+ // then reports 2 while the existing target id is 3 — the exact shape a count-based guard
+ // wrongly rejects but an existence check accepts.
+ evsesOf(mockStation).delete(2)
+ assert.strictEqual(mockStation.getNumberOfEvses(), 2)
+ assert.strictEqual(mockStation.hasEvse(3), true)
+
+ const chargingProfile: OCPP20ChargingProfileType = {
+ chargingProfileKind: OCPP20ChargingProfileKindEnumType.Relative,
+ chargingProfilePurpose: OCPP20ChargingProfilePurposeEnumType.TxProfile,
+ chargingSchedule: [
+ {
+ chargingRateUnit: 'A' as OCPP20ChargingRateUnitEnumType,
+ chargingSchedulePeriod: [
+ {
+ limit: 30,
+ startPeriod: 0,
+ },
+ ],
+ id: 1,
+ },
+ ],
+ id: 1,
+ stackLevel: 0,
+ }
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ chargingProfile,
+ evseId: 3,
+ idToken: {
+ idToken: 'NON_CONTIGUOUS_EVSE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 322,
+ })
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ assert.notStrictEqual(response.transactionId, undefined)
+ })
+
// FR: F01.FR.09, F01.FR.10
await it('should reject RequestStartTransaction when connector is already occupied', async () => {
// First, start a transaction to occupy the connector