--- /dev/null
+/**
+ * @file Tests for ChargingStation Configuration Management
+ * @description Unit tests for boot notification, config persistence, and WebSocket handling
+ */
+import { expect } from '@std/expect'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
+import { AvailabilityType, RegistrationStatusEnumType } from '../../src/types/index.js'
+import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
+
+// Alias for tests that reference createRealChargingStation
+const createRealChargingStation = createMockChargingStation
+
+await describe('ChargingStation Configuration Management', async () => {
+ // ===== B02/B03 BOOT NOTIFICATION BEHAVIOR TESTS =====
+ // These tests verify behavioral requirements, not just state detection
+ await describe('B02 - Pending Boot Notification Behavior', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ // B02.FR.01: Station stores currentTime and interval from Pending response
+ await it('should store interval from Pending response for retry scheduling', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+
+ // Assert - Pending response should have interval for retry
+ expect(station.bootNotificationResponse).toBeDefined()
+ expect(station.bootNotificationResponse?.interval).toBeGreaterThan(0)
+ expect(station.inPendingState()).toBe(true)
+ })
+
+ // B02.FR.02: Station should be able to transition out of Pending via new response
+ await it('should transition from Pending to Accepted when receiving new response', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+ expect(station.inPendingState()).toBe(true)
+
+ // Act - Simulate receiving Accepted response (as would happen after retry)
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 300,
+ status: RegistrationStatusEnumType.ACCEPTED,
+ }
+
+ // Assert - Should now be in Accepted state
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ })
+
+ // B02.FR.03: Pending station should have valid heartbeat interval for operation
+ await it('should use interval from response as heartbeat interval when Pending', () => {
+ // Arrange - Create station with specific interval
+ const customInterval = 120 // seconds
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ heartbeatInterval: customInterval,
+ })
+ station = result.station
+
+ // Assert - Heartbeat interval should match response interval
+ expect(station.getHeartbeatInterval()).toBe(customInterval * 1000)
+ expect(station.inPendingState()).toBe(true)
+ })
+
+ // B02.FR.06: Station should handle clock synchronization from response
+ await it('should have currentTime in Pending response for clock sync', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+
+ // Assert - currentTime should be present for clock synchronization
+ expect(station.bootNotificationResponse?.currentTime).toBeDefined()
+ expect(station.bootNotificationResponse?.currentTime instanceof Date).toBe(true)
+ })
+
+ // B02.FR.04/05: Station should be able to transition to Rejected from Pending
+ await it('should transition from Pending to Rejected when receiving rejection', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+ expect(station.inPendingState()).toBe(true)
+
+ // Act - Simulate receiving Rejected response
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 3600, // Longer interval for rejected state
+ status: RegistrationStatusEnumType.REJECTED,
+ }
+
+ // Assert - Should now be in Rejected state
+ expect(station.inRejectedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ })
+ })
+
+ await describe('B03 - Rejected Boot Notification Behavior', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ // B03.FR.01: Station stores currentTime and interval from Rejected response
+ await it('should store interval from Rejected response for retry scheduling', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+
+ // Assert - Rejected response should have interval for retry (typically longer)
+ expect(station.bootNotificationResponse).toBeDefined()
+ expect(station.bootNotificationResponse?.interval).toBeGreaterThan(0)
+ expect(station.inRejectedState()).toBe(true)
+ })
+
+ // B03.FR.03: Station should NOT initiate non-boot messages when Rejected
+ await it('should not initiate messages when in Rejected state (B03.FR.03)', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ connectorsCount: 2,
+ })
+ station = result.station
+ const mocks = result.mocks
+
+ // Clear any setup messages
+ mocks.webSocket.clearMessages()
+
+ // Assert - Station is in rejected state
+ expect(station.inRejectedState()).toBe(true)
+
+ // Assert - No messages should have been sent (station should be silent)
+ // Per B03.FR.03: CS SHALL NOT send any OCPP message until interval expires
+ expect(mocks.webSocket.sentMessages.length).toBe(0)
+ })
+
+ // B03.FR.04: Station should transition from Rejected to Accepted
+ await it('should transition from Rejected to Accepted when receiving acceptance', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+ expect(station.inRejectedState()).toBe(true)
+
+ // Act - Simulate receiving Accepted response after retry
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 60,
+ status: RegistrationStatusEnumType.ACCEPTED,
+ }
+
+ // Assert - Should now be in Accepted state
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inRejectedState()).toBe(false)
+ })
+
+ // B03.FR.05: Station should have currentTime for clock synchronization
+ await it('should have currentTime in Rejected response for clock sync', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+
+ // Assert - currentTime should be present
+ expect(station.bootNotificationResponse?.currentTime).toBeDefined()
+ expect(station.bootNotificationResponse?.currentTime instanceof Date).toBe(true)
+ })
+
+ // B03.FR.02: Rejected state should use different (typically longer) retry interval
+ await it('should support configurable retry interval for Rejected state', () => {
+ // Arrange - Create two stations: one pending, one rejected
+ const pendingStation = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ heartbeatInterval: 60, // Normal interval
+ })
+
+ const rejectedStation = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ heartbeatInterval: 3600, // Longer interval for rejected
+ })
+
+ // Assert - Both should have their respective intervals
+ expect(pendingStation.station.inPendingState()).toBe(true)
+ expect(rejectedStation.station.inRejectedState()).toBe(true)
+ expect(pendingStation.station.getHeartbeatInterval()).toBe(60000)
+ expect(rejectedStation.station.getHeartbeatInterval()).toBe(3600000)
+
+ // Cleanup
+ cleanupChargingStation(pendingStation.station)
+ cleanupChargingStation(rejectedStation.station)
+ })
+
+ // B03.FR.04 + state preservation: Connectors should maintain state during rejection
+ await it('should preserve connector states during Rejected state', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ connectorsCount: 2,
+ })
+ station = result.station
+
+ // Set up connector state
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.availability = AvailabilityType.Operative
+ }
+
+ // Assert - Connector state should be preserved even in Rejected state
+ expect(station.getConnectorStatus(1)?.availability).toBe(AvailabilityType.Operative)
+ expect(station.hasConnector(1)).toBe(true)
+ })
+ })
+
+ await describe('Configuration Persistence', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ // === OCPP Configuration Getters ===
+
+ await it('should return heartbeat interval in milliseconds', () => {
+ // Arrange - create station with 60 second heartbeat
+ const result = createRealChargingStation({ heartbeatInterval: 60 })
+ station = result.station
+
+ // Act & Assert - should convert seconds to milliseconds
+ expect(station.getHeartbeatInterval()).toBe(60000)
+ })
+
+ await it('should return default heartbeat interval when not explicitly configured', () => {
+ // Arrange - use default heartbeat interval (TEST_HEARTBEAT_INTERVAL_SECONDS = 60)
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - default 60s * 1000 = 60000ms
+ expect(station.getHeartbeatInterval()).toBe(60000)
+ })
+
+ await it('should return connection timeout in milliseconds', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - default connection timeout is 30 seconds
+ expect(station.getConnectionTimeout()).toBe(30000)
+ })
+
+ await it('should return authorize remote TX requests as boolean', () => {
+ // Arrange - create station which defaults to false for AuthorizeRemoteTxRequests
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - getAuthorizeRemoteTxRequests returns boolean
+ const authorizeRemoteTx = station.getAuthorizeRemoteTxRequests()
+ expect(typeof authorizeRemoteTx).toBe('boolean')
+ })
+
+ await it('should return local auth list enabled as boolean', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - getLocalAuthListEnabled returns boolean
+ const localAuthEnabled = station.getLocalAuthListEnabled()
+ expect(typeof localAuthEnabled).toBe('boolean')
+ })
+
+ // === Configuration Save Operations ===
+
+ await it('should call saveOcppConfiguration without throwing', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - should not throw
+ expect(() => station?.saveOcppConfiguration()).not.toThrow()
+ })
+
+ await it('should have ocppConfiguration object with configurationKey array', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - configuration structure should be present
+ expect(station.ocppConfiguration).toBeDefined()
+ expect(station.ocppConfiguration?.configurationKey).toBeDefined()
+ expect(Array.isArray(station.ocppConfiguration?.configurationKey)).toBe(true)
+ })
+
+ // === Configuration Mutation ===
+
+ await it('should allow updating heartbeat interval', () => {
+ // Arrange - create with 60 second interval
+ const result = createRealChargingStation({ heartbeatInterval: 60 })
+ station = result.station
+ const initialInterval = station.getHeartbeatInterval()
+ expect(initialInterval).toBe(60000)
+
+ // Act - simulate configuration change by creating new station with different interval
+ const result2 = createRealChargingStation({ heartbeatInterval: 120 })
+ const station2 = result2.station
+
+ // Assert - different configurations have different intervals
+ expect(station2.getHeartbeatInterval()).toBe(120000)
+ expect(station.getHeartbeatInterval()).toBe(60000) // Original unchanged
+
+ // Cleanup second station
+ cleanupChargingStation(station2)
+ })
+
+ await it('should support setSupervisionUrl method if available', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - setSupervisionUrl should be a function if available
+ if ('setSupervisionUrl' in station && typeof station.setSupervisionUrl === 'function') {
+ expect(() => station?.setSupervisionUrl('ws://new-server:8080')).not.toThrow()
+ } else {
+ // Mock station may not have setSupervisionUrl, which is expected
+ expect(station).toBeDefined()
+ }
+ })
+
+ // === Configuration Loading & Persistence ===
+
+ await it('should have template file reference', () => {
+ // Arrange
+ const result = createRealChargingStation({ templateFile: 'custom-template.json' })
+ station = result.station
+
+ // Act & Assert - station info should have template reference
+ expect(station.stationInfo?.templateName).toBe('custom-template.json')
+ })
+
+ await it('should have hashId for configuration persistence', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - hashId is used for configuration file naming
+ expect(station.stationInfo?.hashId).toBeDefined()
+ expect(typeof station.stationInfo?.hashId).toBe('string')
+ })
+
+ await it('should preserve station info properties for persistence', () => {
+ // Arrange
+ const result = createRealChargingStation({
+ baseName: 'PERSIST-CS',
+ index: 5,
+ })
+ station = result.station
+
+ // Act & Assert - station info should have all properties for persistence
+ expect(station.stationInfo).toBeDefined()
+ expect(station.stationInfo?.baseName).toBe('PERSIST-CS')
+ expect(station.stationInfo?.chargingStationId).toContain('PERSIST-CS')
+ expect(station.stationInfo?.templateIndex).toBe(5)
+ })
+
+ await it('should track configuration file path via templateFile', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+
+ // Act & Assert - templateFile is used to track configuration source
+ expect(station.templateFile).toBeDefined()
+ expect(typeof station.templateFile).toBe('string')
+ })
+
+ await it('should use mocked file system without real file writes', () => {
+ // Arrange
+ const result = createRealChargingStation()
+ station = result.station
+ const mocks = result.mocks
+
+ // Act - perform save operation (mocked to no-op)
+ station.saveOcppConfiguration()
+
+ // Assert - mock file system is available for tracking (no real writes)
+ expect(mocks.fileSystem).toBeDefined()
+ expect(mocks.fileSystem.writtenFiles).toBeInstanceOf(Map)
+ // In mock mode, saveOcppConfiguration is a no-op, so no files are written
+ expect(mocks.fileSystem.writtenFiles.size).toBe(0)
+ })
+ })
+
+ await describe('WebSocket Message Handling', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ // === Connection Management Tests ===
+
+ await it('should report WebSocket connection state via isWebSocketConnectionOpened()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Assert - connection is open by default
+ expect(station.isWebSocketConnectionOpened()).toBe(true)
+
+ // Act - change ready state to CLOSED
+ mocks.webSocket.readyState = 3 // WebSocketReadyState.CLOSED
+
+ // Assert
+ expect(station.isWebSocketConnectionOpened()).toBe(false)
+ })
+
+ await it('should return false when WebSocket is CONNECTING', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Act
+ mocks.webSocket.readyState = 0 // WebSocketReadyState.CONNECTING
+
+ // Assert
+ expect(station.isWebSocketConnectionOpened()).toBe(false)
+ })
+
+ await it('should return false when WebSocket is CLOSING', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Act
+ mocks.webSocket.readyState = 2 // WebSocketReadyState.CLOSING
+
+ // Assert
+ expect(station.isWebSocketConnectionOpened()).toBe(false)
+ })
+
+ await it('should close WebSocket connection via closeWSConnection()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+
+ // Assert - connection exists initially
+ expect(station.wsConnection).not.toBeNull()
+
+ // Act
+ station.closeWSConnection()
+
+ // Assert - connection is nullified
+ expect(station.wsConnection).toBeNull()
+ })
+
+ await it('should handle closeWSConnection() when already closed', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+
+ // Act - close twice (idempotent)
+ station.closeWSConnection()
+ station.closeWSConnection()
+
+ // Assert - no error, connection remains null
+ expect(station.wsConnection).toBeNull()
+ })
+
+ // === Message Capture Tests ===
+
+ await it('should capture sent messages in sentMessages array', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Act - send messages via mock WebSocket
+ mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
+ mocks.webSocket.send('["2","uuid-2","StatusNotification",{"connectorId":1}]')
+
+ // Assert
+ expect(mocks.webSocket.sentMessages.length).toBe(2)
+ expect(mocks.webSocket.sentMessages[0]).toBe('["2","uuid-1","Heartbeat",{}]')
+ expect(mocks.webSocket.sentMessages[1]).toBe(
+ '["2","uuid-2","StatusNotification",{"connectorId":1}]'
+ )
+ })
+
+ await it('should return last sent message via getLastSentMessage()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Act
+ mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
+ mocks.webSocket.send('["2","uuid-2","BootNotification",{}]')
+
+ // Assert
+ expect(mocks.webSocket.getLastSentMessage()).toBe('["2","uuid-2","BootNotification",{}]')
+ })
+
+ await it('should return undefined for getLastSentMessage() when no messages sent', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Assert
+ expect(mocks.webSocket.getLastSentMessage()).toBeUndefined()
+ })
+
+ await it('should parse sent messages as JSON via getSentMessagesAsJson()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Act
+ mocks.webSocket.send('[2,"uuid-1","Heartbeat",{}]')
+
+ // Assert
+ const parsed = mocks.webSocket.getSentMessagesAsJson()
+ expect(parsed.length).toBe(1)
+ expect(parsed[0]).toEqual([2, 'uuid-1', 'Heartbeat', {}])
+ })
+
+ await it('should clear captured messages via clearMessages()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Populate messages
+ mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
+ mocks.webSocket.send('["2","uuid-2","Heartbeat",{}]')
+ expect(mocks.webSocket.sentMessages.length).toBe(2)
+
+ // Act
+ mocks.webSocket.clearMessages()
+
+ // Assert
+ expect(mocks.webSocket.sentMessages.length).toBe(0)
+ expect(mocks.webSocket.sentBinaryMessages.length).toBe(0)
+ })
+
+ // === Event Simulation Tests ===
+
+ await it('should emit message event via simulateMessage()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ let receivedData: unknown
+
+ // Set up listener
+ mocks.webSocket.on('message', (data: unknown) => {
+ receivedData = data
+ })
+
+ // Act
+ mocks.webSocket.simulateMessage('[3,"uuid-1",{}]')
+
+ // Assert
+ expect(receivedData).toBeDefined()
+ expect(Buffer.isBuffer(receivedData)).toBe(true)
+ })
+
+ await it('should emit open event and set readyState via simulateOpen()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ let openEventFired = false
+
+ // First close the connection to test opening
+ mocks.webSocket.readyState = 3 // CLOSED
+
+ // Set up listener
+ mocks.webSocket.on('open', () => {
+ openEventFired = true
+ })
+
+ // Act
+ mocks.webSocket.simulateOpen()
+
+ // Assert
+ expect(openEventFired).toBe(true)
+ expect(mocks.webSocket.readyState).toBe(1) // WebSocketReadyState.OPEN
+ })
+
+ await it('should emit close event and set readyState via simulateClose()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ let closeCode: number | undefined
+
+ // Set up listener
+ mocks.webSocket.on('close', (code: number) => {
+ closeCode = code
+ })
+
+ // Act
+ mocks.webSocket.simulateClose(1001, 'Going away')
+
+ // Assert
+ expect(closeCode).toBe(1001)
+ expect(mocks.webSocket.readyState).toBe(3) // WebSocketReadyState.CLOSED
+ })
+
+ await it('should emit error event via simulateError()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ let receivedError: Error | undefined
+
+ // Set up listener
+ mocks.webSocket.on('error', (error: Error) => {
+ receivedError = error
+ })
+
+ // Act
+ const testError = new Error('Connection refused')
+ mocks.webSocket.simulateError(testError)
+
+ // Assert
+ expect(receivedError).toBe(testError)
+ expect(receivedError?.message).toBe('Connection refused')
+ })
+
+ await it('should emit ping event via simulatePing()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ let pingReceived = false
+ let pingData: Buffer | undefined
+
+ // Set up listener
+ mocks.webSocket.on('ping', (data: Buffer) => {
+ pingReceived = true
+ pingData = data
+ })
+
+ // Act
+ mocks.webSocket.simulatePing(Buffer.from('ping-data'))
+
+ // Assert
+ expect(pingReceived).toBe(true)
+ expect(pingData?.toString()).toBe('ping-data')
+ })
+
+ await it('should emit pong event via simulatePong()', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ let pongReceived = false
+ let pongData: Buffer | undefined
+
+ // Set up listener
+ mocks.webSocket.on('pong', (data: Buffer) => {
+ pongReceived = true
+ pongData = data
+ })
+
+ // Act
+ mocks.webSocket.simulatePong(Buffer.from('pong-data'))
+
+ // Assert
+ expect(pongReceived).toBe(true)
+ expect(pongData?.toString()).toBe('pong-data')
+ })
+
+ // === Edge Case Tests ===
+
+ await it('should throw error when sending on closed WebSocket', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Close the WebSocket
+ mocks.webSocket.readyState = 3 // WebSocketReadyState.CLOSED
+
+ // Act & Assert
+ expect(() => {
+ mocks.webSocket.send('["2","uuid","Heartbeat",{}]')
+ }).toThrow('WebSocket is not open')
+ })
+
+ await it('should capture URL from WebSocket connection', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Assert
+ expect(mocks.webSocket.url).toBeDefined()
+ expect(typeof mocks.webSocket.url).toBe('string')
+ expect(mocks.webSocket.url.length).toBeGreaterThan(0)
+ })
+ })
+
+ await describe('WebSocket Ping Interval', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should return valid WebSocket ping interval from getWebSocketPingInterval()', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+
+ // Act
+ const pingInterval = station.getWebSocketPingInterval()
+
+ // Assert - should return a valid interval value
+ expect(pingInterval).toBeGreaterThanOrEqual(0)
+ expect(typeof pingInterval).toBe('number')
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should restart WebSocket ping when restartWebSocketPing() is called', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+
+ // Act - restartWebSocketPing will stop and restart
+ station.restartWebSocketPing()
+
+ // Assert - should complete without error
+ expect(station).toBeDefined()
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for ChargingStation Connector and EVSE Operations
+ * @description Unit tests for connector queries, EVSE management, and availability
+ */
+import { expect } from '@std/expect'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
+import { AvailabilityType, RegistrationStatusEnumType } from '../../src/types/index.js'
+import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
+
+// Alias for tests that reference createRealChargingStation
+const createRealChargingStation = createMockChargingStation
+
+await describe('ChargingStation Connector and EVSE State', async () => {
+ await describe('Connector Query Tests', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should return true for hasConnector() with existing connector IDs', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ expect(station.hasConnector(0)).toBe(true)
+ expect(station.hasConnector(1)).toBe(true)
+ expect(station.hasConnector(2)).toBe(true)
+ })
+
+ await it('should return false for hasConnector() with non-existing connector IDs', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ expect(station.hasConnector(3)).toBe(false)
+ expect(station.hasConnector(999)).toBe(false)
+ expect(station.hasConnector(-1)).toBe(false)
+ })
+
+ await it('should return connector status for valid connector IDs', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ const status1 = station.getConnectorStatus(1)
+ const status2 = station.getConnectorStatus(2)
+
+ expect(status1).toBeDefined()
+ expect(status2).toBeDefined()
+ })
+
+ await it('should return undefined for getConnectorStatus() with invalid connector IDs', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ expect(station.getConnectorStatus(999)).toBeUndefined()
+ expect(station.getConnectorStatus(-1)).toBeUndefined()
+ })
+
+ await it('should correctly count connectors via getNumberOfConnectors()', () => {
+ const result = createMockChargingStation({ connectorsCount: 3 })
+ station = result.station
+
+ // Should return 3, not 4 (connector 0 is excluded from count)
+ expect(station.getNumberOfConnectors()).toBe(3)
+ })
+
+ await it('should return true for isConnectorAvailable() on operative connectors', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ expect(station.isConnectorAvailable(1)).toBe(true)
+ expect(station.isConnectorAvailable(2)).toBe(true)
+ })
+
+ await it('should return false for isConnectorAvailable() on connector 0', () => {
+ // Connector 0 is never "available" per isConnectorAvailable() logic (connectorId > 0)
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ expect(station.isConnectorAvailable(0)).toBe(false)
+ })
+
+ await it('should return false for isConnectorAvailable() on non-existing connector', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ expect(station.isConnectorAvailable(999)).toBe(false)
+ })
+ })
+
+ await describe('Connector 0 (Shared Power) Tests', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should include connector 0 for shared power configuration', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Connector 0 always exists and represents the charging station itself
+ expect(station.hasConnector(0)).toBe(true)
+ expect(station.getConnectorStatus(0)).toBeDefined()
+ })
+
+ await it('should determine station availability via connector 0 status', () => {
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Initially connector 0 is operative
+ expect(station.isChargingStationAvailable()).toBe(true)
+ })
+ })
+
+ await describe('EVSE Query Tests (non-EVSE mode)', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should return 0 for getNumberOfEvses() in non-EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 0 })
+ station = result.station
+
+ expect(station.hasEvses).toBe(false)
+ expect(station.getNumberOfEvses()).toBe(0)
+ })
+
+ await it('should return undefined for getEvseIdByConnectorId() in non-EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 0 })
+ station = result.station
+
+ expect(station.getEvseIdByConnectorId(1)).toBeUndefined()
+ expect(station.getEvseIdByConnectorId(2)).toBeUndefined()
+ })
+ })
+
+ await describe('EVSE Mode Tests', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should enable hasEvses flag in EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ expect(station.hasEvses).toBe(true)
+ })
+
+ await it('should return correct EVSE count via getNumberOfEvses() in EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ expect(station.getNumberOfEvses()).toBe(1)
+ })
+
+ await it('should return connector status via getConnectorStatus() in EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ // Connectors are nested under EVSEs in EVSE mode
+ const status1 = station.getConnectorStatus(1)
+ const status2 = station.getConnectorStatus(2)
+
+ expect(status1).toBeDefined()
+ expect(status2).toBeDefined()
+ })
+
+ await it('should map connector IDs to EVSE IDs via getEvseIdByConnectorId()', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ // In single-EVSE mode, both connectors should map to EVSE 1
+ expect(station.getEvseIdByConnectorId(1)).toBe(1)
+ expect(station.getEvseIdByConnectorId(2)).toBe(1)
+ })
+
+ await it('should return undefined for getEvseIdByConnectorId() with invalid connector', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ expect(station.getEvseIdByConnectorId(999)).toBeUndefined()
+ })
+
+ await it('should return EVSE status via getEvseStatus() for valid EVSE IDs', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ const evseStatus = station.getEvseStatus(1)
+
+ expect(evseStatus).toBeDefined()
+ expect(evseStatus?.connectors).toBeDefined()
+ expect(evseStatus?.connectors.size).toBeGreaterThan(0)
+ })
+
+ await it('should return undefined for getEvseStatus() with invalid EVSE IDs', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ expect(station.getEvseStatus(999)).toBeUndefined()
+ })
+
+ await it('should return true for hasConnector() with connectors in EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ expect(station.hasConnector(1)).toBe(true)
+ expect(station.hasConnector(2)).toBe(true)
+ })
+
+ await it('should return false for hasConnector() with non-existing connector in EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
+ station = result.station
+
+ expect(station.hasConnector(999)).toBe(false)
+ })
+
+ await it('should correctly count connectors in EVSE mode via getNumberOfConnectors()', () => {
+ const result = createMockChargingStation({ connectorsCount: 4, evsesCount: 2 })
+ station = result.station
+
+ // Should return total connectors across all EVSEs
+ expect(station.getNumberOfConnectors()).toBe(4)
+ })
+ })
+
+ await describe('Boot Notification State', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should return true for inAcceptedState when boot status is ACCEPTED', () => {
+ // Arrange
+ const result = createRealChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.ACCEPTED,
+ })
+ station = result.station
+
+ // Act & Assert
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ expect(station.inRejectedState()).toBe(false)
+ expect(station.inUnknownState()).toBe(false)
+ })
+
+ await it('should return true for inPendingState when boot status is PENDING', () => {
+ // Arrange
+ const result = createRealChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+
+ // Act & Assert
+ expect(station.inPendingState()).toBe(true)
+ expect(station.inAcceptedState()).toBe(false)
+ expect(station.inRejectedState()).toBe(false)
+ expect(station.inUnknownState()).toBe(false)
+ })
+
+ await it('should return true for inRejectedState when boot status is REJECTED', () => {
+ // Arrange
+ const result = createRealChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+
+ // Act & Assert
+ expect(station.inRejectedState()).toBe(true)
+ expect(station.inAcceptedState()).toBe(false)
+ expect(station.inPendingState()).toBe(false)
+ expect(station.inUnknownState()).toBe(false)
+ })
+
+ await it('should return true for inUnknownState when boot notification response is null', () => {
+ // Arrange - create station with default accepted status, then delete the response
+ const result = createRealChargingStation({ connectorsCount: 1 })
+ station = result.station
+
+ // Act - simulate unknown state by clearing boot notification response
+ if (station.bootNotificationResponse != null) {
+ // Delete the boot notification response to simulate unknown state
+ delete station.bootNotificationResponse
+ }
+
+ // Assert - only check inUnknownState
+ expect(station.inUnknownState()).toBe(true)
+ })
+
+ await it('should allow state transitions from PENDING to ACCEPTED', () => {
+ // Arrange
+ const result = createRealChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+ expect(station.inPendingState()).toBe(true)
+
+ // Act - transition from PENDING to ACCEPTED
+ station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED
+ station.bootNotificationResponse.currentTime = new Date()
+
+ // Assert
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ })
+
+ await it('should allow state transitions from PENDING to REJECTED', () => {
+ // Arrange
+ const result = createRealChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+ expect(station.inPendingState()).toBe(true)
+
+ // Act - transition from PENDING to REJECTED
+ station.bootNotificationResponse.status = RegistrationStatusEnumType.REJECTED
+ station.bootNotificationResponse.currentTime = new Date()
+
+ // Assert
+ expect(station.inRejectedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ })
+ })
+
+ await describe('Reservation Management', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should add reservation successfully to connector', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const reservation = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000), // 1 hour from now
+ idTag: 'test-tag-1',
+ reservationId: 101,
+ }
+
+ // Act
+ await station.addReservation(reservation)
+
+ // Assert
+ const found = station.getReservationBy('reservationId', 101)
+ expect(found).toBeDefined()
+ expect(found?.idTag).toBe('test-tag-1')
+ expect(found?.connectorId).toBe(1)
+ })
+
+ await it('should replace existing reservation with new one', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const firstReservation = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'tag-1',
+ reservationId: 201,
+ }
+ const secondReservation = {
+ connectorId: 2,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'tag-2',
+ reservationId: 201, // Same ID
+ }
+
+ // Act
+ await station.addReservation(firstReservation)
+ await station.addReservation(secondReservation)
+
+ // Assert - Only second reservation should exist with same ID
+ const found = station.getReservationBy('reservationId', 201)
+ expect(found).toBeDefined()
+ expect(found?.idTag).toBe('tag-2')
+ expect(found?.connectorId).toBe(2)
+ })
+
+ await it('should remove reservation with EXPIRED reason', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const reservation = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'test-tag-expired',
+ reservationId: 301,
+ }
+ await station.addReservation(reservation)
+
+ // Act
+ const { ReservationTerminationReason } = await import('../../src/types/ocpp/Reservation.js')
+ await station.removeReservation(reservation, ReservationTerminationReason.EXPIRED)
+
+ // Assert
+ const found = station.getReservationBy('reservationId', 301)
+ expect(found).toBeUndefined()
+ })
+
+ await it('should remove reservation with REPLACE_EXISTING reason', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const reservation = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'test-tag-replace',
+ reservationId: 401,
+ }
+ await station.addReservation(reservation)
+
+ // Act
+ const { ReservationTerminationReason } = await import('../../src/types/ocpp/Reservation.js')
+ await station.removeReservation(reservation, ReservationTerminationReason.REPLACE_EXISTING)
+
+ // Assert
+ const found = station.getReservationBy('reservationId', 401)
+ expect(found).toBeUndefined()
+ })
+
+ await it('should query reservation by reservationId', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const reservation = {
+ connectorId: 2,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'query-test-id',
+ reservationId: 501,
+ }
+ await station.addReservation(reservation)
+
+ // Act
+ const found = station.getReservationBy('reservationId', 501)
+
+ // Assert
+ expect(found).toBeDefined()
+ expect(found?.connectorId).toBe(2)
+ expect(found?.idTag).toBe('query-test-id')
+ })
+
+ await it('should query reservation by idTag', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const reservation = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'search-by-tag',
+ reservationId: 601,
+ }
+ await station.addReservation(reservation)
+
+ // Act
+ const found = station.getReservationBy('idTag', 'search-by-tag')
+
+ // Assert
+ expect(found).toBeDefined()
+ expect(found?.reservationId).toBe(601)
+ expect(found?.connectorId).toBe(1)
+ })
+
+ await it('should query reservation by connectorId', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 3 })
+ station = result.station
+ const reservation = {
+ connectorId: 2,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'connector-search',
+ reservationId: 701,
+ }
+ await station.addReservation(reservation)
+
+ // Act
+ const found = station.getReservationBy('connectorId', 2)
+
+ // Assert
+ expect(found).toBeDefined()
+ expect(found?.reservationId).toBe(701)
+ expect(found?.idTag).toBe('connector-search')
+ })
+
+ await it('should handle isConnectorReservable check with valid reservationId', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const reservation = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'reservable-check',
+ reservationId: 801,
+ }
+ await station.addReservation(reservation)
+
+ // Act
+ const isReservable = station.isConnectorReservable(801)
+
+ // Assert - Should return false since reservation exists
+ expect(isReservable).toBe(false)
+ })
+
+ await it('should handle isConnectorReservable check with non-existent reservationId', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const isReservable = station.isConnectorReservable(999)
+
+ // Assert - Should return true since reservation does not exist
+ expect(isReservable).toBe(true)
+ })
+
+ await it('should not allow reservation on connector 0 via isConnectorReservable', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const isReservable = station.isConnectorReservable(901, 'test-tag', 0)
+
+ // Assert - Connector 0 should not be reservable
+ expect(isReservable).toBe(false)
+ })
+
+ await it('should handle multiple reservations on different connectors', async () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 4 })
+ station = result.station
+ const reservation1 = {
+ connectorId: 1,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'multi-test-1',
+ reservationId: 1001,
+ }
+ const reservation2 = {
+ connectorId: 2,
+ expiryDate: new Date(Date.now() + 3600000),
+ idTag: 'multi-test-2',
+ reservationId: 1002,
+ }
+
+ // Act
+ await station.addReservation(reservation1)
+ await station.addReservation(reservation2)
+
+ // Assert
+ const found1 = station.getReservationBy('reservationId', 1001)
+ const found2 = station.getReservationBy('reservationId', 1002)
+ expect(found1).toBeDefined()
+ expect(found2).toBeDefined()
+ expect(found1?.connectorId).toBe(1)
+ expect(found2?.connectorId).toBe(2)
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for ChargingStation Transaction Management
+ * @description Unit tests for transaction queries, energy meters, and concurrent transaction handling
+ */
+import { expect } from '@std/expect'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
+import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
+
+await describe('ChargingStation Transaction Management', async () => {
+ await describe('Transaction Query Tests', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should return undefined for getConnectorIdByTransactionId with no active transactions', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const connectorId = station.getConnectorIdByTransactionId(12345)
+
+ // Assert
+ expect(connectorId).toBeUndefined()
+ })
+
+ await it('should return connector id for getConnectorIdByTransactionId with active transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ connector1.transactionIdTag = 'TEST-TAG-001'
+ }
+
+ // Act
+ const connectorId = station.getConnectorIdByTransactionId(100)
+
+ // Assert
+ expect(connectorId).toBe(1)
+ })
+
+ await it('should return undefined for getConnectorIdByTransactionId with undefined transactionId', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const connectorId = station.getConnectorIdByTransactionId(undefined)
+
+ // Assert
+ expect(connectorId).toBeUndefined()
+ })
+
+ await it('should return undefined for getEvseIdByTransactionId in non-EVSE mode', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 0 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+
+ // Act
+ const evseId = station.getEvseIdByTransactionId(100)
+
+ // Assert
+ expect(evseId).toBeUndefined()
+ })
+
+ await it('should return EVSE id for getEvseIdByTransactionId in EVSE mode with active transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 2 })
+ station = result.station
+ // Get connector in EVSE 1
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 200
+ connector1.transactionIdTag = 'TEST-TAG-002'
+ }
+
+ // Act
+ const evseId = station.getEvseIdByTransactionId(200)
+
+ // Assert
+ expect(evseId).toBe(1)
+ })
+
+ await it('should return idTag for getTransactionIdTag with active transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 300
+ connector1.transactionIdTag = 'MY-TAG-123'
+ }
+
+ // Act
+ const idTag = station.getTransactionIdTag(300)
+
+ // Assert
+ expect(idTag).toBe('MY-TAG-123')
+ })
+
+ await it('should return undefined for getTransactionIdTag with no matching transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const idTag = station.getTransactionIdTag(999)
+
+ // Assert
+ expect(idTag).toBeUndefined()
+ })
+
+ await it('should return zero for getNumberOfRunningTransactions with no transactions', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 3 })
+ station = result.station
+
+ // Act
+ const count = station.getNumberOfRunningTransactions()
+
+ // Assert
+ expect(count).toBe(0)
+ })
+
+ await it('should return correct count for getNumberOfRunningTransactions with active transactions', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 3 })
+ station = result.station
+ // Set up transactions on connectors 1 and 2
+ const connector1 = station.getConnectorStatus(1)
+ const connector2 = station.getConnectorStatus(2)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+ if (connector2 != null) {
+ connector2.transactionStarted = true
+ connector2.transactionId = 101
+ }
+
+ // Act
+ const count = station.getNumberOfRunningTransactions()
+
+ // Assert
+ expect(count).toBe(2)
+ })
+ })
+
+ await describe('Energy Meter Tests', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should return 0 for getEnergyActiveImportRegisterByConnectorId with no transaction energy', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const energy = station.getEnergyActiveImportRegisterByConnectorId(1)
+
+ // Assert
+ expect(energy).toBe(0)
+ })
+
+ await it('should return energy value for getEnergyActiveImportRegisterByConnectorId with active transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ connector1.transactionEnergyActiveImportRegisterValue = 12500
+ }
+
+ // Act
+ const energy = station.getEnergyActiveImportRegisterByConnectorId(1)
+
+ // Assert
+ expect(energy).toBe(12500)
+ })
+
+ await it('should return rounded energy value when rounded=true', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ connector1.transactionEnergyActiveImportRegisterValue = 12345.678
+ }
+
+ // Act
+ const energy = station.getEnergyActiveImportRegisterByConnectorId(1, true)
+
+ // Assert
+ expect(energy).toBe(12346)
+ })
+
+ await it('should return 0 for getEnergyActiveImportRegisterByConnectorId with invalid connector', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const energy = station.getEnergyActiveImportRegisterByConnectorId(99)
+
+ // Assert
+ expect(energy).toBe(0)
+ })
+
+ await it('should return 0 for getEnergyActiveImportRegisterByTransactionId with no matching transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Act
+ const energy = station.getEnergyActiveImportRegisterByTransactionId(999)
+
+ // Assert
+ expect(energy).toBe(0)
+ })
+
+ await it('should return energy for getEnergyActiveImportRegisterByTransactionId with active transaction', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 400
+ connector1.transactionEnergyActiveImportRegisterValue = 25000
+ }
+
+ // Act
+ const energy = station.getEnergyActiveImportRegisterByTransactionId(400)
+
+ // Assert
+ expect(energy).toBe(25000)
+ })
+ })
+
+ await describe('Concurrent Transaction Scenarios', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should handle multiple transactions on different connectors', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 3 })
+ station = result.station
+
+ // Set up transactions on connectors 1, 2, and 3
+ const connector1 = station.getConnectorStatus(1)
+ const connector2 = station.getConnectorStatus(2)
+ const connector3 = station.getConnectorStatus(3)
+
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ connector1.transactionIdTag = 'TAG-A'
+ connector1.transactionEnergyActiveImportRegisterValue = 10000
+ }
+ if (connector2 != null) {
+ connector2.transactionStarted = true
+ connector2.transactionId = 101
+ connector2.transactionIdTag = 'TAG-B'
+ connector2.transactionEnergyActiveImportRegisterValue = 20000
+ }
+ if (connector3 != null) {
+ connector3.transactionStarted = true
+ connector3.transactionId = 102
+ connector3.transactionIdTag = 'TAG-C'
+ connector3.transactionEnergyActiveImportRegisterValue = 30000
+ }
+
+ // Act & Assert - Running transactions count
+ expect(station.getNumberOfRunningTransactions()).toBe(3)
+
+ // Act & Assert - Transaction queries
+ expect(station.getConnectorIdByTransactionId(100)).toBe(1)
+ expect(station.getConnectorIdByTransactionId(101)).toBe(2)
+ expect(station.getConnectorIdByTransactionId(102)).toBe(3)
+
+ // Act & Assert - Energy meters
+ expect(station.getEnergyActiveImportRegisterByTransactionId(100)).toBe(10000)
+ expect(station.getEnergyActiveImportRegisterByTransactionId(101)).toBe(20000)
+ expect(station.getEnergyActiveImportRegisterByTransactionId(102)).toBe(30000)
+
+ // Act & Assert - Id tags
+ expect(station.getTransactionIdTag(100)).toBe('TAG-A')
+ expect(station.getTransactionIdTag(101)).toBe('TAG-B')
+ expect(station.getTransactionIdTag(102)).toBe('TAG-C')
+ })
+
+ await it('should handle transactions across multiple EVSEs', () => {
+ // Arrange - 4 connectors across 2 EVSEs
+ const result = createMockChargingStation({ connectorsCount: 4, evsesCount: 2 })
+ station = result.station
+
+ // Set up transaction on connector 1 (EVSE 1) and connector 3 (EVSE 2)
+ const connector1 = station.getConnectorStatus(1)
+ const connector3 = station.getConnectorStatus(3)
+
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 500
+ connector1.transactionIdTag = 'EVSE1-TAG'
+ connector1.transactionEnergyActiveImportRegisterValue = 15000
+ }
+ if (connector3 != null) {
+ connector3.transactionStarted = true
+ connector3.transactionId = 501
+ connector3.transactionIdTag = 'EVSE2-TAG'
+ connector3.transactionEnergyActiveImportRegisterValue = 18000
+ }
+
+ // Act & Assert - Running transactions count
+ expect(station.getNumberOfRunningTransactions()).toBe(2)
+
+ // Act & Assert - EVSE queries
+ expect(station.getEvseIdByTransactionId(500)).toBe(1)
+ expect(station.getEvseIdByTransactionId(501)).toBe(2)
+
+ // Act & Assert - Connector queries
+ expect(station.getConnectorIdByTransactionId(500)).toBe(1)
+ expect(station.getConnectorIdByTransactionId(501)).toBe(3)
+
+ // Act & Assert - Energy meters
+ expect(station.getEnergyActiveImportRegisterByTransactionId(500)).toBe(15000)
+ expect(station.getEnergyActiveImportRegisterByTransactionId(501)).toBe(18000)
+ })
+
+ await it('should correctly count transactions only on connectors > 0', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+
+ // Connector 0 should not count (station-level)
+ const connector0 = station.getConnectorStatus(0)
+ const connector1 = station.getConnectorStatus(1)
+
+ if (connector0 != null) {
+ // This shouldn't happen in real usage but test robustness
+ connector0.transactionStarted = true
+ connector0.transactionId = 999
+ }
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+
+ // Act
+ const count = station.getNumberOfRunningTransactions()
+
+ // Assert - Only connector 1 should count
+ expect(count).toBe(1)
+ })
+
+ await it('should return idTag in EVSE mode for getTransactionIdTag', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 2 })
+ station = result.station
+
+ const connector2 = station.getConnectorStatus(2)
+ if (connector2 != null) {
+ connector2.transactionStarted = true
+ connector2.transactionId = 600
+ connector2.transactionIdTag = 'EVSE-MODE-TAG'
+ }
+
+ // Act
+ const idTag = station.getTransactionIdTag(600)
+
+ // Assert
+ expect(idTag).toBe('EVSE-MODE-TAG')
+ })
+
+ await it('should handle rounded energy values for getEnergyActiveImportRegisterByTransactionId', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 700
+ connector1.transactionEnergyActiveImportRegisterValue = 12345.5
+ }
+
+ // Act
+ const unrounded = station.getEnergyActiveImportRegisterByTransactionId(700, false)
+ const rounded = station.getEnergyActiveImportRegisterByTransactionId(700, true)
+
+ // Assert
+ expect(unrounded).toBe(12345.5)
+ expect(rounded).toBe(12346)
+ })
+ })
+
+ await describe('Heartbeat and Meter Intervals', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ await it('should create interval when startHeartbeat() is called with valid interval', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1, heartbeatInterval: 30000 })
+ station = result.station
+
+ // Act
+ station.startHeartbeat()
+
+ // Assert - heartbeat interval should be created
+ expect(station.heartbeatSetInterval).toBeDefined()
+ expect(typeof station.heartbeatSetInterval).toBe('object')
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should restart heartbeat interval when restartHeartbeat() is called', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1, heartbeatInterval: 30000 })
+ station = result.station
+ station.startHeartbeat()
+ const firstInterval = station.heartbeatSetInterval
+
+ // Act
+ station.restartHeartbeat()
+ const secondInterval = station.heartbeatSetInterval
+
+ // Assert - interval should be different (old cleared, new created)
+ expect(secondInterval).toBeDefined()
+ expect(typeof secondInterval).toBe('object')
+ expect(firstInterval !== secondInterval).toBe(true)
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should not create heartbeat interval if already started', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1, heartbeatInterval: 30000 })
+ station = result.station
+ station.startHeartbeat()
+ const firstInterval = station.heartbeatSetInterval
+
+ // Act - call startHeartbeat again
+ station.startHeartbeat()
+ const secondInterval = station.heartbeatSetInterval
+
+ // Assert - interval should be same (not restarted)
+ expect(firstInterval).toBe(secondInterval)
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should create meter values interval when startMeterValues() is called for active transaction', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+
+ // Act
+ station.startMeterValues(1, 10000)
+
+ // Assert - meter values interval should be created
+ if (connector1 != null) {
+ expect(connector1.transactionSetInterval).toBeDefined()
+ expect(typeof connector1.transactionSetInterval).toBe('object')
+ }
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should restart meter values interval when restartMeterValues() is called', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+ station.startMeterValues(1, 10000)
+ const firstInterval = connector1?.transactionSetInterval
+
+ // Act
+ station.restartMeterValues(1, 15000)
+ const secondInterval = connector1?.transactionSetInterval
+
+ // Assert - interval should be different
+ expect(secondInterval).toBeDefined()
+ expect(typeof secondInterval).toBe('object')
+ expect(firstInterval !== secondInterval).toBe(true)
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should clear meter values interval when stopMeterValues() is called', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+ station.startMeterValues(1, 10000)
+
+ // Act
+ station.stopMeterValues(1)
+
+ // Assert - interval should be cleared
+ expect(connector1?.transactionSetInterval).toBeUndefined()
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should create transaction updated interval when startTxUpdatedInterval() is called for OCPP 2.0', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+
+ // Act
+ station.startTxUpdatedInterval(1, 5000)
+
+ // Assert - transaction updated interval should be created
+ if (connector1 != null) {
+ expect(connector1.transactionTxUpdatedSetInterval).toBeDefined()
+ expect(typeof connector1.transactionTxUpdatedSetInterval).toBe('object')
+ }
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+
+ await it('should clear transaction updated interval when stopTxUpdatedInterval() is called', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ try {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
+ station = result.station
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 100
+ }
+ station.startTxUpdatedInterval(1, 5000)
+
+ // Act
+ station.stopTxUpdatedInterval(1)
+
+ // Assert - interval should be cleared
+ expect(connector1?.transactionTxUpdatedSetInterval).toBeUndefined()
+ } finally {
+ t.mock.timers.reset()
+ }
+ })
+ })
+})
/**
- * @file Tests for ChargingStation
- * @description Unit tests for charging station lifecycle, state management, and connector operations
+ * @file ChargingStation Integration Tests
+ * @description Integration test suite that verifies all ChargingStation test modules work together.
+ *
+ * Domain-specific tests have been split into separate files:
+ * - ChargingStation-Lifecycle.test.ts: start/stop/restart operations
+ * - ChargingStation-Connectors.test.ts: connector and EVSE operations, reservations
+ * - ChargingStation-Transactions.test.ts: transaction handling and energy meters
+ * - ChargingStation-Configuration.test.ts: boot notification, config persistence, WebSocket, error handling
*/
import { expect } from '@std/expect'
-import { afterEach, describe, it } from 'node:test'
+import { describe, it } from 'node:test'
import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
-import { AvailabilityType, RegistrationStatusEnumType } from '../../src/types/index.js'
-import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
-
-// Alias for tests that reference createRealChargingStation
-const createRealChargingStation = createMockChargingStation
-
-await describe('ChargingStation', async () => {
- await describe('Lifecycle', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- await it('should transition from stopped to started on start()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act
- const initialStarted = station.started
- station.start()
- const finalStarted = station.started
-
- // Assert
- expect(initialStarted).toBe(false)
- expect(finalStarted).toBe(true)
- })
-
- await it('should not restart when already started', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act
- station.start()
- const firstStarted = station.started
- station.start() // Try to start again (idempotent)
- const stillStarted = station.started
-
- // Assert
- expect(firstStarted).toBe(true)
- expect(stillStarted).toBe(true)
- })
-
- await it('should set starting flag during start()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act & Assert
- const initialStarting = station.starting
- expect(initialStarting).toBe(false)
- // After start() completes, starting should be false
- station.start()
- expect(station.starting).toBe(false)
- expect(station.started).toBe(true)
- })
-
- await it('should transition from started to stopped on stop()', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- station.start()
- expect(station.started).toBe(true)
-
- // Act
- await station.stop()
-
- // Assert
- expect(station.started).toBe(false)
- })
-
- await it('should be idempotent when calling stop() on already stopped station', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- // Station starts in stopped state
- expect(station.started).toBe(false)
-
- // Act - call stop on already stopped station
- await station.stop()
-
- // Assert - should remain stopped without error
- expect(station.started).toBe(false)
- })
-
- await it('should set stopping flag during stop()', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- station.start()
-
- // Assert initial state
- expect(station.stopping).toBe(false)
-
- // Act
- await station.stop()
-
- // Assert - after stop() completes, stopping should be false
- expect(station.stopping).toBe(false)
- expect(station.started).toBe(false)
- })
-
- await it('should clear bootNotificationResponse on stop()', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- station.start()
- expect(station.bootNotificationResponse).toBeDefined()
-
- // Act
- await station.stop()
-
- // Assert - bootNotificationResponse should be deleted
- expect(station.bootNotificationResponse).toBeUndefined()
- })
-
- await it('should be restartable after stop()', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- station.start()
- expect(station.started).toBe(true)
-
- // Act - stop then start again
- await station.stop()
- expect(station.started).toBe(false)
- station.start()
-
- // Assert - should be started again
- expect(station.started).toBe(true)
- })
-
- await it('should handle delete() on stopped station', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- expect(station.started).toBe(false)
-
- // Act - delete while stopped (deleteConfiguration = false to skip file ops)
- await station.delete(false)
-
- // Assert - connectors and evses should be cleared
- expect(station.connectors.size).toBe(0)
- expect(station.evses.size).toBe(0)
- expect(station.requests.size).toBe(0)
- })
-
- await it('should stop station before delete() if running', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- station.start()
- expect(station.started).toBe(true)
-
- // Act - delete calls stop internally
- await station.delete(false)
-
- // Assert - station should be stopped and cleared
- expect(station.started).toBe(false)
- expect(station.connectors.size).toBe(0)
- })
-
- await it('should guard against concurrent start operations', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Simulate starting state manually to test guard
- const stationAny = station as unknown as { started: boolean; starting: boolean }
- stationAny.starting = true
- stationAny.started = false
-
- // Act - attempt to start while already starting should be guarded
- // The mock start() method resets starting, but this tests the initial state
- expect(station.starting).toBe(true)
-
- // Assert - the real ChargingStation guards against this
- // (mock implementation doesn't fully replicate guard, but state is verified)
- })
- })
-
- await describe('Connector and EVSE State', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // === Connector Query Tests ===
-
- await it('should return true for hasConnector() with existing connector IDs', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- expect(station.hasConnector(0)).toBe(true)
- expect(station.hasConnector(1)).toBe(true)
- expect(station.hasConnector(2)).toBe(true)
- })
-
- await it('should return false for hasConnector() with non-existing connector IDs', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- expect(station.hasConnector(3)).toBe(false)
- expect(station.hasConnector(999)).toBe(false)
- expect(station.hasConnector(-1)).toBe(false)
- })
-
- await it('should return connector status for valid connector IDs', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- const status1 = station.getConnectorStatus(1)
- const status2 = station.getConnectorStatus(2)
-
- expect(status1).toBeDefined()
- expect(status2).toBeDefined()
- })
-
- await it('should return undefined for getConnectorStatus() with invalid connector IDs', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- expect(station.getConnectorStatus(999)).toBeUndefined()
- expect(station.getConnectorStatus(-1)).toBeUndefined()
- })
-
- await it('should correctly count connectors via getNumberOfConnectors()', () => {
- const result = createMockChargingStation({ connectorsCount: 3 })
- station = result.station
-
- // Should return 3, not 4 (connector 0 is excluded from count)
- expect(station.getNumberOfConnectors()).toBe(3)
- })
-
- await it('should return true for isConnectorAvailable() on operative connectors', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- expect(station.isConnectorAvailable(1)).toBe(true)
- expect(station.isConnectorAvailable(2)).toBe(true)
- })
-
- await it('should return false for isConnectorAvailable() on connector 0', () => {
- // Connector 0 is never "available" per isConnectorAvailable() logic (connectorId > 0)
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- expect(station.isConnectorAvailable(0)).toBe(false)
- })
-
- await it('should return false for isConnectorAvailable() on non-existing connector', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- expect(station.isConnectorAvailable(999)).toBe(false)
- })
-
- // === Connector 0 (shared power) Tests ===
-
- await it('should include connector 0 for shared power configuration', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Connector 0 always exists and represents the charging station itself
- expect(station.hasConnector(0)).toBe(true)
- expect(station.getConnectorStatus(0)).toBeDefined()
- })
-
- await it('should determine station availability via connector 0 status', () => {
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Initially connector 0 is operative
- expect(station.isChargingStationAvailable()).toBe(true)
- })
-
- // === EVSE Query Tests (non-EVSE mode) ===
-
- await it('should return 0 for getNumberOfEvses() in non-EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 0 })
- station = result.station
-
- expect(station.hasEvses).toBe(false)
- expect(station.getNumberOfEvses()).toBe(0)
- })
-
- await it('should return undefined for getEvseIdByConnectorId() in non-EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 0 })
- station = result.station
-
- expect(station.getEvseIdByConnectorId(1)).toBeUndefined()
- expect(station.getEvseIdByConnectorId(2)).toBeUndefined()
- })
-
- // === EVSE Mode Tests ===
-
- await it('should enable hasEvses flag in EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- expect(station.hasEvses).toBe(true)
- })
-
- await it('should return correct EVSE count via getNumberOfEvses() in EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- expect(station.getNumberOfEvses()).toBe(1)
- })
-
- await it('should return connector status via getConnectorStatus() in EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- // Connectors are nested under EVSEs in EVSE mode
- const status1 = station.getConnectorStatus(1)
- const status2 = station.getConnectorStatus(2)
-
- expect(status1).toBeDefined()
- expect(status2).toBeDefined()
- })
-
- await it('should map connector IDs to EVSE IDs via getEvseIdByConnectorId()', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- // In single-EVSE mode, both connectors should map to EVSE 1
- expect(station.getEvseIdByConnectorId(1)).toBe(1)
- expect(station.getEvseIdByConnectorId(2)).toBe(1)
- })
-
- await it('should return undefined for getEvseIdByConnectorId() with invalid connector', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- expect(station.getEvseIdByConnectorId(999)).toBeUndefined()
- })
-
- await it('should return EVSE status via getEvseStatus() for valid EVSE IDs', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- const evseStatus = station.getEvseStatus(1)
-
- expect(evseStatus).toBeDefined()
- expect(evseStatus?.connectors).toBeDefined()
- expect(evseStatus?.connectors.size).toBeGreaterThan(0)
- })
-
- await it('should return undefined for getEvseStatus() with invalid EVSE IDs', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- expect(station.getEvseStatus(999)).toBeUndefined()
- })
-
- await it('should return true for hasConnector() with connectors in EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- expect(station.hasConnector(1)).toBe(true)
- expect(station.hasConnector(2)).toBe(true)
- })
-
- await it('should return false for hasConnector() with non-existing connector in EVSE mode', () => {
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
- station = result.station
-
- expect(station.hasConnector(999)).toBe(false)
- })
-
- await it('should correctly count connectors in EVSE mode via getNumberOfConnectors()', () => {
- const result = createMockChargingStation({ connectorsCount: 4, evsesCount: 2 })
- station = result.station
-
- // Should return total connectors across all EVSEs
- expect(station.getNumberOfConnectors()).toBe(4)
- })
- })
-
- await describe('Boot Notification State', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- await it('should return true for inAcceptedState when boot status is ACCEPTED', () => {
- // Arrange
- const result = createRealChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.ACCEPTED,
- })
- station = result.station
-
- // Act & Assert
- expect(station.inAcceptedState()).toBe(true)
- expect(station.inPendingState()).toBe(false)
- expect(station.inRejectedState()).toBe(false)
- expect(station.inUnknownState()).toBe(false)
- })
-
- await it('should return true for inPendingState when boot status is PENDING', () => {
- // Arrange
- const result = createRealChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
-
- // Act & Assert
- expect(station.inPendingState()).toBe(true)
- expect(station.inAcceptedState()).toBe(false)
- expect(station.inRejectedState()).toBe(false)
- expect(station.inUnknownState()).toBe(false)
- })
-
- await it('should return true for inRejectedState when boot status is REJECTED', () => {
- // Arrange
- const result = createRealChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- })
- station = result.station
-
- // Act & Assert
- expect(station.inRejectedState()).toBe(true)
- expect(station.inAcceptedState()).toBe(false)
- expect(station.inPendingState()).toBe(false)
- expect(station.inUnknownState()).toBe(false)
- })
-
- await it('should return true for inUnknownState when boot notification response is null', () => {
- // Arrange - create station with default accepted status, then delete the response
- const result = createRealChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act - simulate unknown state by clearing boot notification response
- if (station.bootNotificationResponse != null) {
- // Delete the boot notification response to simulate unknown state
- delete station.bootNotificationResponse
- }
-
- // Assert - only check inUnknownState
- expect(station.inUnknownState()).toBe(true)
- })
-
- await it('should allow state transitions from PENDING to ACCEPTED', () => {
- // Arrange
- const result = createRealChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
- expect(station.inPendingState()).toBe(true)
-
- // Act - transition from PENDING to ACCEPTED
- station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED
- station.bootNotificationResponse.currentTime = new Date()
-
- // Assert
- expect(station.inAcceptedState()).toBe(true)
- expect(station.inPendingState()).toBe(false)
- })
-
- await it('should allow state transitions from PENDING to REJECTED', () => {
- // Arrange
- const result = createRealChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
- expect(station.inPendingState()).toBe(true)
-
- // Act - transition from PENDING to REJECTED
- station.bootNotificationResponse.status = RegistrationStatusEnumType.REJECTED
- station.bootNotificationResponse.currentTime = new Date()
-
- // Assert
- expect(station.inRejectedState()).toBe(true)
- expect(station.inPendingState()).toBe(false)
- })
- })
-
- // ===== B02/B03 BOOT NOTIFICATION BEHAVIOR TESTS =====
- // These tests verify behavioral requirements, not just state detection (which is tested above)
- await describe('B02 - Pending Boot Notification Behavior', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // B02.FR.01: Station stores currentTime and interval from Pending response
- await it('should store interval from Pending response for retry scheduling', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
-
- // Assert - Pending response should have interval for retry
- expect(station.bootNotificationResponse).toBeDefined()
- expect(station.bootNotificationResponse?.interval).toBeGreaterThan(0)
- expect(station.inPendingState()).toBe(true)
- })
-
- // B02.FR.02: Station should be able to transition out of Pending via new response
- await it('should transition from Pending to Accepted when receiving new response', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
- expect(station.inPendingState()).toBe(true)
-
- // Act - Simulate receiving Accepted response (as would happen after retry)
- station.bootNotificationResponse = {
- currentTime: new Date(),
- interval: 300,
- status: RegistrationStatusEnumType.ACCEPTED,
- }
-
- // Assert - Should now be in Accepted state
- expect(station.inAcceptedState()).toBe(true)
- expect(station.inPendingState()).toBe(false)
- })
-
- // B02.FR.03: Pending station should have valid heartbeat interval for operation
- await it('should use interval from response as heartbeat interval when Pending', () => {
- // Arrange - Create station with specific interval
- const customInterval = 120 // seconds
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- heartbeatInterval: customInterval,
- })
- station = result.station
-
- // Assert - Heartbeat interval should match response interval
- expect(station.getHeartbeatInterval()).toBe(customInterval * 1000)
- expect(station.inPendingState()).toBe(true)
- })
-
- // B02.FR.06: Station should handle clock synchronization from response
- await it('should have currentTime in Pending response for clock sync', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
-
- // Assert - currentTime should be present for clock synchronization
- expect(station.bootNotificationResponse?.currentTime).toBeDefined()
- expect(station.bootNotificationResponse?.currentTime instanceof Date).toBe(true)
- })
-
- // B02.FR.04/05: Station should be able to transition to Rejected from Pending
- await it('should transition from Pending to Rejected when receiving rejection', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- })
- station = result.station
- expect(station.inPendingState()).toBe(true)
-
- // Act - Simulate receiving Rejected response
- station.bootNotificationResponse = {
- currentTime: new Date(),
- interval: 3600, // Longer interval for rejected state
- status: RegistrationStatusEnumType.REJECTED,
- }
-
- // Assert - Should now be in Rejected state
- expect(station.inRejectedState()).toBe(true)
- expect(station.inPendingState()).toBe(false)
- })
- })
-
- await describe('B03 - Rejected Boot Notification Behavior', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // B03.FR.01: Station stores currentTime and interval from Rejected response
- await it('should store interval from Rejected response for retry scheduling', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- })
- station = result.station
-
- // Assert - Rejected response should have interval for retry (typically longer)
- expect(station.bootNotificationResponse).toBeDefined()
- expect(station.bootNotificationResponse?.interval).toBeGreaterThan(0)
- expect(station.inRejectedState()).toBe(true)
- })
-
- // B03.FR.03: Station should NOT initiate non-boot messages when Rejected
- await it('should not initiate messages when in Rejected state (B03.FR.03)', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- connectorsCount: 2,
- })
- station = result.station
- const mocks = result.mocks
-
- // Clear any setup messages
- mocks.webSocket.clearMessages()
-
- // Assert - Station is in rejected state
- expect(station.inRejectedState()).toBe(true)
-
- // Assert - No messages should have been sent (station should be silent)
- // Per B03.FR.03: CS SHALL NOT send any OCPP message until interval expires
- expect(mocks.webSocket.sentMessages.length).toBe(0)
- })
-
- // B03.FR.04: Station should transition from Rejected to Accepted
- await it('should transition from Rejected to Accepted when receiving acceptance', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- })
- station = result.station
- expect(station.inRejectedState()).toBe(true)
-
- // Act - Simulate receiving Accepted response after retry
- station.bootNotificationResponse = {
- currentTime: new Date(),
- interval: 60,
- status: RegistrationStatusEnumType.ACCEPTED,
- }
-
- // Assert - Should now be in Accepted state
- expect(station.inAcceptedState()).toBe(true)
- expect(station.inRejectedState()).toBe(false)
- })
-
- // B03.FR.05: Station should have currentTime for clock synchronization
- await it('should have currentTime in Rejected response for clock sync', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- })
- station = result.station
-
- // Assert - currentTime should be present
- expect(station.bootNotificationResponse?.currentTime).toBeDefined()
- expect(station.bootNotificationResponse?.currentTime instanceof Date).toBe(true)
- })
-
- // B03.FR.02: Rejected state should use different (typically longer) retry interval
- await it('should support configurable retry interval for Rejected state', () => {
- // Arrange - Create two stations: one pending, one rejected
- const pendingStation = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.PENDING,
- heartbeatInterval: 60, // Normal interval
- })
-
- const rejectedStation = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- heartbeatInterval: 3600, // Longer interval for rejected
- })
-
- // Assert - Both should have their respective intervals
- expect(pendingStation.station.inPendingState()).toBe(true)
- expect(rejectedStation.station.inRejectedState()).toBe(true)
- expect(pendingStation.station.getHeartbeatInterval()).toBe(60000)
- expect(rejectedStation.station.getHeartbeatInterval()).toBe(3600000)
-
- // Cleanup
- cleanupChargingStation(pendingStation.station)
- cleanupChargingStation(rejectedStation.station)
- })
-
- // B03.FR.04 + state preservation: Connectors should maintain state during rejection
- await it('should preserve connector states during Rejected state', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- connectorsCount: 2,
- })
- station = result.station
-
- // Set up connector state
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.availability = AvailabilityType.Operative
- }
-
- // Assert - Connector state should be preserved even in Rejected state
- expect(station.getConnectorStatus(1)?.availability).toBe(AvailabilityType.Operative)
- expect(station.hasConnector(1)).toBe(true)
- })
- })
-
- await describe('Configuration Persistence', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // === OCPP Configuration Getters ===
-
- await it('should return heartbeat interval in milliseconds', () => {
- // Arrange - create station with 60 second heartbeat
- const result = createRealChargingStation({ heartbeatInterval: 60 })
- station = result.station
-
- // Act & Assert - should convert seconds to milliseconds
- expect(station.getHeartbeatInterval()).toBe(60000)
- })
-
- await it('should return default heartbeat interval when not explicitly configured', () => {
- // Arrange - use default heartbeat interval (TEST_HEARTBEAT_INTERVAL_SECONDS = 60)
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - default 60s * 1000 = 60000ms
- expect(station.getHeartbeatInterval()).toBe(60000)
- })
-
- await it('should return connection timeout in milliseconds', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - default connection timeout is 30 seconds
- expect(station.getConnectionTimeout()).toBe(30000)
- })
-
- await it('should return authorize remote TX requests as boolean', () => {
- // Arrange - create station which defaults to false for AuthorizeRemoteTxRequests
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - getAuthorizeRemoteTxRequests returns boolean
- const authorizeRemoteTx = station.getAuthorizeRemoteTxRequests()
- expect(typeof authorizeRemoteTx).toBe('boolean')
- })
-
- await it('should return local auth list enabled as boolean', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - getLocalAuthListEnabled returns boolean
- const localAuthEnabled = station.getLocalAuthListEnabled()
- expect(typeof localAuthEnabled).toBe('boolean')
- })
-
- // === Configuration Save Operations ===
-
- await it('should call saveOcppConfiguration without throwing', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - should not throw
- expect(() => station?.saveOcppConfiguration()).not.toThrow()
- })
-
- await it('should have ocppConfiguration object with configurationKey array', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - configuration structure should be present
- expect(station.ocppConfiguration).toBeDefined()
- expect(station.ocppConfiguration?.configurationKey).toBeDefined()
- expect(Array.isArray(station.ocppConfiguration?.configurationKey)).toBe(true)
- })
-
- // === Configuration Mutation ===
-
- await it('should allow updating heartbeat interval', () => {
- // Arrange - create with 60 second interval
- const result = createRealChargingStation({ heartbeatInterval: 60 })
- station = result.station
- const initialInterval = station.getHeartbeatInterval()
- expect(initialInterval).toBe(60000)
-
- // Act - simulate configuration change by creating new station with different interval
- const result2 = createRealChargingStation({ heartbeatInterval: 120 })
- const station2 = result2.station
-
- // Assert - different configurations have different intervals
- expect(station2.getHeartbeatInterval()).toBe(120000)
- expect(station.getHeartbeatInterval()).toBe(60000) // Original unchanged
-
- // Cleanup second station
- cleanupChargingStation(station2)
- })
-
- await it('should support setSupervisionUrl method if available', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - setSupervisionUrl should be a function if available
- if ('setSupervisionUrl' in station && typeof station.setSupervisionUrl === 'function') {
- expect(() => station?.setSupervisionUrl('ws://new-server:8080')).not.toThrow()
- } else {
- // Mock station may not have setSupervisionUrl, which is expected
- expect(station).toBeDefined()
- }
- })
-
- // === Configuration Loading & Persistence ===
-
- await it('should have template file reference', () => {
- // Arrange
- const result = createRealChargingStation({ templateFile: 'custom-template.json' })
- station = result.station
-
- // Act & Assert - station info should have template reference
- expect(station.stationInfo?.templateName).toBe('custom-template.json')
- })
-
- await it('should have hashId for configuration persistence', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - hashId is used for configuration file naming
- expect(station.stationInfo?.hashId).toBeDefined()
- expect(typeof station.stationInfo?.hashId).toBe('string')
- })
-
- await it('should preserve station info properties for persistence', () => {
- // Arrange
- const result = createRealChargingStation({
- baseName: 'PERSIST-CS',
- index: 5,
- })
- station = result.station
-
- // Act & Assert - station info should have all properties for persistence
- expect(station.stationInfo).toBeDefined()
- expect(station.stationInfo?.baseName).toBe('PERSIST-CS')
- expect(station.stationInfo?.chargingStationId).toContain('PERSIST-CS')
- expect(station.stationInfo?.templateIndex).toBe(5)
- })
-
- await it('should track configuration file path via templateFile', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
-
- // Act & Assert - templateFile is used to track configuration source
- expect(station.templateFile).toBeDefined()
- expect(typeof station.templateFile).toBe('string')
- })
-
- await it('should use mocked file system without real file writes', () => {
- // Arrange
- const result = createRealChargingStation()
- station = result.station
- const mocks = result.mocks
-
- // Act - perform save operation (mocked to no-op)
- station.saveOcppConfiguration()
-
- // Assert - mock file system is available for tracking (no real writes)
- expect(mocks.fileSystem).toBeDefined()
- expect(mocks.fileSystem.writtenFiles).toBeInstanceOf(Map)
- // In mock mode, saveOcppConfiguration is a no-op, so no files are written
- expect(mocks.fileSystem.writtenFiles.size).toBe(0)
- })
- })
-
- await describe('WebSocket Message Handling', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // === Connection Management Tests ===
-
- await it('should report WebSocket connection state via isWebSocketConnectionOpened()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Assert - connection is open by default
- expect(station.isWebSocketConnectionOpened()).toBe(true)
-
- // Act - change ready state to CLOSED
- mocks.webSocket.readyState = 3 // WebSocketReadyState.CLOSED
-
- // Assert
- expect(station.isWebSocketConnectionOpened()).toBe(false)
- })
-
- await it('should return false when WebSocket is CONNECTING', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Act
- mocks.webSocket.readyState = 0 // WebSocketReadyState.CONNECTING
-
- // Assert
- expect(station.isWebSocketConnectionOpened()).toBe(false)
- })
-
- await it('should return false when WebSocket is CLOSING', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Act
- mocks.webSocket.readyState = 2 // WebSocketReadyState.CLOSING
-
- // Assert
- expect(station.isWebSocketConnectionOpened()).toBe(false)
- })
-
- await it('should close WebSocket connection via closeWSConnection()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Assert - connection exists initially
- expect(station.wsConnection).not.toBeNull()
-
- // Act
- station.closeWSConnection()
-
- // Assert - connection is nullified
- expect(station.wsConnection).toBeNull()
- })
-
- await it('should handle closeWSConnection() when already closed', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act - close twice (idempotent)
- station.closeWSConnection()
- station.closeWSConnection()
-
- // Assert - no error, connection remains null
- expect(station.wsConnection).toBeNull()
- })
-
- // === Message Capture Tests ===
-
- await it('should capture sent messages in sentMessages array', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Act - send messages via mock WebSocket
- mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
- mocks.webSocket.send('["2","uuid-2","StatusNotification",{"connectorId":1}]')
-
- // Assert
- expect(mocks.webSocket.sentMessages.length).toBe(2)
- expect(mocks.webSocket.sentMessages[0]).toBe('["2","uuid-1","Heartbeat",{}]')
- expect(mocks.webSocket.sentMessages[1]).toBe(
- '["2","uuid-2","StatusNotification",{"connectorId":1}]'
- )
- })
-
- await it('should return last sent message via getLastSentMessage()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Act
- mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
- mocks.webSocket.send('["2","uuid-2","BootNotification",{}]')
-
- // Assert
- expect(mocks.webSocket.getLastSentMessage()).toBe('["2","uuid-2","BootNotification",{}]')
- })
-
- await it('should return undefined for getLastSentMessage() when no messages sent', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Assert
- expect(mocks.webSocket.getLastSentMessage()).toBeUndefined()
- })
-
- await it('should parse sent messages as JSON via getSentMessagesAsJson()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Act
- mocks.webSocket.send('[2,"uuid-1","Heartbeat",{}]')
-
- // Assert
- const parsed = mocks.webSocket.getSentMessagesAsJson()
- expect(parsed.length).toBe(1)
- expect(parsed[0]).toEqual([2, 'uuid-1', 'Heartbeat', {}])
- })
-
- await it('should clear captured messages via clearMessages()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Populate messages
- mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
- mocks.webSocket.send('["2","uuid-2","Heartbeat",{}]')
- expect(mocks.webSocket.sentMessages.length).toBe(2)
-
- // Act
- mocks.webSocket.clearMessages()
-
- // Assert
- expect(mocks.webSocket.sentMessages.length).toBe(0)
- expect(mocks.webSocket.sentBinaryMessages.length).toBe(0)
- })
-
- // === Event Simulation Tests ===
-
- await it('should emit message event via simulateMessage()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- let receivedData: unknown
-
- // Set up listener
- mocks.webSocket.on('message', (data: unknown) => {
- receivedData = data
- })
-
- // Act
- mocks.webSocket.simulateMessage('[3,"uuid-1",{}]')
-
- // Assert
- expect(receivedData).toBeDefined()
- expect(Buffer.isBuffer(receivedData)).toBe(true)
- })
-
- await it('should emit open event and set readyState via simulateOpen()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- let openEventFired = false
-
- // First close the connection to test opening
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Set up listener
- mocks.webSocket.on('open', () => {
- openEventFired = true
- })
-
- // Act
- mocks.webSocket.simulateOpen()
-
- // Assert
- expect(openEventFired).toBe(true)
- expect(mocks.webSocket.readyState).toBe(1) // WebSocketReadyState.OPEN
- })
-
- await it('should emit close event and set readyState via simulateClose()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- let closeCode: number | undefined
-
- // Set up listener
- mocks.webSocket.on('close', (code: number) => {
- closeCode = code
- })
-
- // Act
- mocks.webSocket.simulateClose(1001, 'Going away')
-
- // Assert
- expect(closeCode).toBe(1001)
- expect(mocks.webSocket.readyState).toBe(3) // WebSocketReadyState.CLOSED
- })
-
- await it('should emit error event via simulateError()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- let receivedError: Error | undefined
-
- // Set up listener
- mocks.webSocket.on('error', (error: Error) => {
- receivedError = error
- })
-
- // Act
- const testError = new Error('Connection refused')
- mocks.webSocket.simulateError(testError)
-
- // Assert
- expect(receivedError).toBe(testError)
- expect(receivedError?.message).toBe('Connection refused')
- })
-
- await it('should emit ping event via simulatePing()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- let pingReceived = false
- let pingData: Buffer | undefined
-
- // Set up listener
- mocks.webSocket.on('ping', (data: Buffer) => {
- pingReceived = true
- pingData = data
- })
-
- // Act
- mocks.webSocket.simulatePing(Buffer.from('ping-data'))
-
- // Assert
- expect(pingReceived).toBe(true)
- expect(pingData?.toString()).toBe('ping-data')
- })
-
- await it('should emit pong event via simulatePong()', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- let pongReceived = false
- let pongData: Buffer | undefined
-
- // Set up listener
- mocks.webSocket.on('pong', (data: Buffer) => {
- pongReceived = true
- pongData = data
- })
-
- // Act
- mocks.webSocket.simulatePong(Buffer.from('pong-data'))
-
- // Assert
- expect(pongReceived).toBe(true)
- expect(pongData?.toString()).toBe('pong-data')
- })
-
- // === Edge Case Tests ===
-
- await it('should throw error when sending on closed WebSocket', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Close the WebSocket
- mocks.webSocket.readyState = 3 // WebSocketReadyState.CLOSED
-
- // Act & Assert
- expect(() => {
- mocks.webSocket.send('["2","uuid","Heartbeat",{}]')
- }).toThrow('WebSocket is not open')
- })
-
- await it('should capture URL from WebSocket connection', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Assert
- expect(mocks.webSocket.url).toBeDefined()
- expect(typeof mocks.webSocket.url).toBe('string')
- expect(mocks.webSocket.url.length).toBeGreaterThan(0)
- })
- })
-
- // ===== TRANSACTION MANAGEMENT TESTS =====
- await describe('Transaction Management', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // === Transaction Query Tests ===
-
- await it('should return undefined for getConnectorIdByTransactionId with no active transactions', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const connectorId = station.getConnectorIdByTransactionId(12345)
-
- // Assert
- expect(connectorId).toBeUndefined()
- })
-
- await it('should return connector id for getConnectorIdByTransactionId with active transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- connector1.transactionIdTag = 'TEST-TAG-001'
- }
-
- // Act
- const connectorId = station.getConnectorIdByTransactionId(100)
-
- // Assert
- expect(connectorId).toBe(1)
- })
-
- await it('should return undefined for getConnectorIdByTransactionId with undefined transactionId', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const connectorId = station.getConnectorIdByTransactionId(undefined)
-
- // Assert
- expect(connectorId).toBeUndefined()
- })
-
- await it('should return undefined for getEvseIdByTransactionId in non-EVSE mode', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 0 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
-
- // Act
- const evseId = station.getEvseIdByTransactionId(100)
-
- // Assert
- expect(evseId).toBeUndefined()
- })
-
- await it('should return EVSE id for getEvseIdByTransactionId in EVSE mode with active transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 2 })
- station = result.station
- // Get connector in EVSE 1
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 200
- connector1.transactionIdTag = 'TEST-TAG-002'
- }
-
- // Act
- const evseId = station.getEvseIdByTransactionId(200)
-
- // Assert
- expect(evseId).toBe(1)
- })
-
- await it('should return idTag for getTransactionIdTag with active transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 300
- connector1.transactionIdTag = 'MY-TAG-123'
- }
-
- // Act
- const idTag = station.getTransactionIdTag(300)
-
- // Assert
- expect(idTag).toBe('MY-TAG-123')
- })
-
- await it('should return undefined for getTransactionIdTag with no matching transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const idTag = station.getTransactionIdTag(999)
-
- // Assert
- expect(idTag).toBeUndefined()
- })
-
- await it('should return zero for getNumberOfRunningTransactions with no transactions', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 3 })
- station = result.station
-
- // Act
- const count = station.getNumberOfRunningTransactions()
-
- // Assert
- expect(count).toBe(0)
- })
-
- await it('should return correct count for getNumberOfRunningTransactions with active transactions', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 3 })
- station = result.station
- // Set up transactions on connectors 1 and 2
- const connector1 = station.getConnectorStatus(1)
- const connector2 = station.getConnectorStatus(2)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
- if (connector2 != null) {
- connector2.transactionStarted = true
- connector2.transactionId = 101
- }
-
- // Act
- const count = station.getNumberOfRunningTransactions()
-
- // Assert
- expect(count).toBe(2)
- })
-
- // === Energy Meter Tests ===
-
- await it('should return 0 for getEnergyActiveImportRegisterByConnectorId with no transaction energy', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const energy = station.getEnergyActiveImportRegisterByConnectorId(1)
-
- // Assert
- expect(energy).toBe(0)
- })
-
- await it('should return energy value for getEnergyActiveImportRegisterByConnectorId with active transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- connector1.transactionEnergyActiveImportRegisterValue = 12500
- }
-
- // Act
- const energy = station.getEnergyActiveImportRegisterByConnectorId(1)
-
- // Assert
- expect(energy).toBe(12500)
- })
-
- await it('should return rounded energy value when rounded=true', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- connector1.transactionEnergyActiveImportRegisterValue = 12345.678
- }
-
- // Act
- const energy = station.getEnergyActiveImportRegisterByConnectorId(1, true)
-
- // Assert
- expect(energy).toBe(12346)
- })
-
- await it('should return 0 for getEnergyActiveImportRegisterByConnectorId with invalid connector', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const energy = station.getEnergyActiveImportRegisterByConnectorId(99)
-
- // Assert
- expect(energy).toBe(0)
- })
-
- await it('should return 0 for getEnergyActiveImportRegisterByTransactionId with no matching transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const energy = station.getEnergyActiveImportRegisterByTransactionId(999)
-
- // Assert
- expect(energy).toBe(0)
- })
-
- await it('should return energy for getEnergyActiveImportRegisterByTransactionId with active transaction', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 400
- connector1.transactionEnergyActiveImportRegisterValue = 25000
- }
-
- // Act
- const energy = station.getEnergyActiveImportRegisterByTransactionId(400)
-
- // Assert
- expect(energy).toBe(25000)
- })
-
- // === Concurrent Transaction Scenarios ===
-
- await it('should handle multiple transactions on different connectors', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 3 })
- station = result.station
-
- // Set up transactions on connectors 1, 2, and 3
- const connector1 = station.getConnectorStatus(1)
- const connector2 = station.getConnectorStatus(2)
- const connector3 = station.getConnectorStatus(3)
-
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- connector1.transactionIdTag = 'TAG-A'
- connector1.transactionEnergyActiveImportRegisterValue = 10000
- }
- if (connector2 != null) {
- connector2.transactionStarted = true
- connector2.transactionId = 101
- connector2.transactionIdTag = 'TAG-B'
- connector2.transactionEnergyActiveImportRegisterValue = 20000
- }
- if (connector3 != null) {
- connector3.transactionStarted = true
- connector3.transactionId = 102
- connector3.transactionIdTag = 'TAG-C'
- connector3.transactionEnergyActiveImportRegisterValue = 30000
- }
-
- // Act & Assert - Running transactions count
- expect(station.getNumberOfRunningTransactions()).toBe(3)
-
- // Act & Assert - Transaction queries
- expect(station.getConnectorIdByTransactionId(100)).toBe(1)
- expect(station.getConnectorIdByTransactionId(101)).toBe(2)
- expect(station.getConnectorIdByTransactionId(102)).toBe(3)
-
- // Act & Assert - Energy meters
- expect(station.getEnergyActiveImportRegisterByTransactionId(100)).toBe(10000)
- expect(station.getEnergyActiveImportRegisterByTransactionId(101)).toBe(20000)
- expect(station.getEnergyActiveImportRegisterByTransactionId(102)).toBe(30000)
-
- // Act & Assert - Id tags
- expect(station.getTransactionIdTag(100)).toBe('TAG-A')
- expect(station.getTransactionIdTag(101)).toBe('TAG-B')
- expect(station.getTransactionIdTag(102)).toBe('TAG-C')
- })
-
- await it('should handle transactions across multiple EVSEs', () => {
- // Arrange - 4 connectors across 2 EVSEs
- const result = createMockChargingStation({ connectorsCount: 4, evsesCount: 2 })
- station = result.station
-
- // Set up transaction on connector 1 (EVSE 1) and connector 3 (EVSE 2)
- const connector1 = station.getConnectorStatus(1)
- const connector3 = station.getConnectorStatus(3)
-
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 500
- connector1.transactionIdTag = 'EVSE1-TAG'
- connector1.transactionEnergyActiveImportRegisterValue = 15000
- }
- if (connector3 != null) {
- connector3.transactionStarted = true
- connector3.transactionId = 501
- connector3.transactionIdTag = 'EVSE2-TAG'
- connector3.transactionEnergyActiveImportRegisterValue = 18000
- }
-
- // Act & Assert - Running transactions count
- expect(station.getNumberOfRunningTransactions()).toBe(2)
-
- // Act & Assert - EVSE queries
- expect(station.getEvseIdByTransactionId(500)).toBe(1)
- expect(station.getEvseIdByTransactionId(501)).toBe(2)
-
- // Act & Assert - Connector queries
- expect(station.getConnectorIdByTransactionId(500)).toBe(1)
- expect(station.getConnectorIdByTransactionId(501)).toBe(3)
-
- // Act & Assert - Energy meters
- expect(station.getEnergyActiveImportRegisterByTransactionId(500)).toBe(15000)
- expect(station.getEnergyActiveImportRegisterByTransactionId(501)).toBe(18000)
- })
-
- await it('should correctly count transactions only on connectors > 0', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Connector 0 should not count (station-level)
- const connector0 = station.getConnectorStatus(0)
- const connector1 = station.getConnectorStatus(1)
-
- if (connector0 != null) {
- // This shouldn't happen in real usage but test robustness
- connector0.transactionStarted = true
- connector0.transactionId = 999
- }
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
-
- // Act
- const count = station.getNumberOfRunningTransactions()
-
- // Assert - Only connector 1 should count
- expect(count).toBe(1)
- })
-
- await it('should return idTag in EVSE mode for getTransactionIdTag', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 2 })
- station = result.station
-
- const connector2 = station.getConnectorStatus(2)
- if (connector2 != null) {
- connector2.transactionStarted = true
- connector2.transactionId = 600
- connector2.transactionIdTag = 'EVSE-MODE-TAG'
- }
-
- // Act
- const idTag = station.getTransactionIdTag(600)
-
- // Assert
- expect(idTag).toBe('EVSE-MODE-TAG')
- })
-
- await it('should handle rounded energy values for getEnergyActiveImportRegisterByTransactionId', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 700
- connector1.transactionEnergyActiveImportRegisterValue = 12345.5
- }
-
- // Act
- const unrounded = station.getEnergyActiveImportRegisterByTransactionId(700, false)
- const rounded = station.getEnergyActiveImportRegisterByTransactionId(700, true)
-
- // Assert
- expect(unrounded).toBe(12345.5)
- expect(rounded).toBe(12346)
- })
- })
-
- // ===== HEARTBEAT AND PING INTERVALS TESTS =====
- await describe('Heartbeat and Ping Intervals', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // === Heartbeat Interval Tests ===
-
- await it('should create interval when startHeartbeat() is called with valid interval', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1, heartbeatInterval: 30000 })
- station = result.station
-
- // Act
- station.startHeartbeat()
-
- // Assert - heartbeat interval should be created
- expect(station.heartbeatSetInterval).toBeDefined()
- expect(typeof station.heartbeatSetInterval).toBe('object')
- } finally {
- t.mock.timers.reset()
- }
- })
-
- await it('should restart heartbeat interval when restartHeartbeat() is called', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1, heartbeatInterval: 30000 })
- station = result.station
- station.startHeartbeat()
- const firstInterval = station.heartbeatSetInterval
-
- // Act
- station.restartHeartbeat()
- const secondInterval = station.heartbeatSetInterval
-
- // Assert - interval should be different (old cleared, new created)
- expect(secondInterval).toBeDefined()
- expect(typeof secondInterval).toBe('object')
- expect(firstInterval !== secondInterval).toBe(true)
- } finally {
- t.mock.timers.reset()
- }
- })
-
- await it('should not create heartbeat interval if already started', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1, heartbeatInterval: 30000 })
- station = result.station
- station.startHeartbeat()
- const firstInterval = station.heartbeatSetInterval
-
- // Act - call startHeartbeat again
- station.startHeartbeat()
- const secondInterval = station.heartbeatSetInterval
-
- // Assert - interval should be same (not restarted)
- expect(firstInterval).toBe(secondInterval)
- } finally {
- t.mock.timers.reset()
- }
- })
-
- // === WebSocket Ping Interval Tests ===
-
- await it('should return valid WebSocket ping interval from getWebSocketPingInterval()', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act
- const pingInterval = station.getWebSocketPingInterval()
-
- // Assert - should return a valid interval value
- expect(pingInterval).toBeGreaterThanOrEqual(0)
- expect(typeof pingInterval).toBe('number')
- } finally {
- t.mock.timers.reset()
- }
- })
-
- await it('should restart WebSocket ping when restartWebSocketPing() is called', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Act - restartWebSocketPing will stop and restart
- station.restartWebSocketPing()
-
- // Assert - should complete without error
- expect(station).toBeDefined()
- } finally {
- t.mock.timers.reset()
- }
- })
-
- // === Meter Values Interval Tests ===
-
- await it('should create meter values interval when startMeterValues() is called for active transaction', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
-
- // Act
- station.startMeterValues(1, 10000)
-
- // Assert - meter values interval should be created
- if (connector1 != null) {
- expect(connector1.transactionSetInterval).toBeDefined()
- expect(typeof connector1.transactionSetInterval).toBe('object')
- }
- } finally {
- t.mock.timers.reset()
- }
- })
-
- await it('should restart meter values interval when restartMeterValues() is called', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
- station.startMeterValues(1, 10000)
- const firstInterval = connector1?.transactionSetInterval
-
- // Act
- station.restartMeterValues(1, 15000)
- const secondInterval = connector1?.transactionSetInterval
-
- // Assert - interval should be different
- expect(secondInterval).toBeDefined()
- expect(typeof secondInterval).toBe('object')
- expect(firstInterval !== secondInterval).toBe(true)
- } finally {
- t.mock.timers.reset()
- }
- })
-
- await it('should clear meter values interval when stopMeterValues() is called', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
- station.startMeterValues(1, 10000)
-
- // Act
- station.stopMeterValues(1)
-
- // Assert - interval should be cleared
- expect(connector1?.transactionSetInterval).toBeUndefined()
- } finally {
- t.mock.timers.reset()
- }
- })
-
- // === OCPP 2.0 Transaction Updated Interval Tests ===
-
- await it('should create transaction updated interval when startTxUpdatedInterval() is called for OCPP 2.0', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
-
- // Act
- station.startTxUpdatedInterval(1, 5000)
-
- // Assert - transaction updated interval should be created
- if (connector1 != null) {
- expect(connector1.transactionTxUpdatedSetInterval).toBeDefined()
- expect(typeof connector1.transactionTxUpdatedSetInterval).toBe('object')
- }
- } finally {
- t.mock.timers.reset()
- }
- })
-
- await it('should clear transaction updated interval when stopTxUpdatedInterval() is called', t => {
- t.mock.timers.enable({ apis: ['setInterval'] })
- try {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2, ocppVersion: '2.0' })
- station = result.station
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 100
- }
- station.startTxUpdatedInterval(1, 5000)
-
- // Act
- station.stopTxUpdatedInterval(1)
-
- // Assert - interval should be cleared
- expect(connector1?.transactionTxUpdatedSetInterval).toBeUndefined()
- } finally {
- t.mock.timers.reset()
- }
- })
- })
-
- // ===========================================================================
- // ERROR RECOVERY AND RESILIENCE TESTS (Task 11)
- // ===========================================================================
- await describe('Error Recovery and Resilience', async () => {
- let station: ChargingStation
-
- afterEach(() => {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // -------------------------------------------------------------------------
- // Reconnection Logic Tests
- // -------------------------------------------------------------------------
-
- await it('should trigger reconnection on abnormal WebSocket close', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Station must be started for reconnection to trigger
- station.started = true
-
- // Act - Simulate abnormal close (code 1006 = abnormal closure)
- mocks.webSocket.simulateClose(1006, 'Connection lost')
-
- // Assert - WebSocket should be in CLOSED state
- expect(mocks.webSocket.readyState).toBe(3) // CLOSED
- })
-
- await it('should not reconnect on normal WebSocket close', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- station.started = true
-
- // Act - Simulate normal close (code 1000 = normal closure)
- mocks.webSocket.simulateClose(1000, 'Normal closure')
-
- // Assert - WebSocket should be closed
- expect(mocks.webSocket.readyState).toBe(3) // CLOSED
- })
-
- await it('should track connection retry count', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Assert - Initial retry count should be 0
- expect(station.wsConnectionRetryCount).toBe(0)
-
- // Act - Increment retry count manually (simulating reconnection attempt)
- station.wsConnectionRetryCount = 1
-
- // Assert - Count should be incremented
- expect(station.wsConnectionRetryCount).toBe(1)
- })
-
- await it('should support exponential backoff configuration', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Assert - stationInfo should have reconnect configuration options
- expect(station.stationInfo).toBeDefined()
- // The actual implementation uses stationInfo.reconnectExponentialDelay
- // and stationInfo.autoReconnectMaxRetries for reconnection logic
- })
-
- await it('should reset retry count on successful connection', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- station.wsConnectionRetryCount = 5 // Simulate some retries
-
- // Act - Reset retry count (as would happen on successful reconnection)
- station.wsConnectionRetryCount = 0
-
- // Assert
- expect(station.wsConnectionRetryCount).toBe(0)
- })
-
- // -------------------------------------------------------------------------
- // Error Handling Tests
- // -------------------------------------------------------------------------
-
- await it('should handle invalid OCPP message format gracefully', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Act - Simulate invalid message (not valid JSON array)
- // The mock emits message event - actual station would parse and handle error
- mocks.webSocket.simulateMessage('invalid json')
-
- // Assert - Station should still be operational (not crashed)
+import { RegistrationStatusEnumType } from '../../src/types/index.js'
+import {
+ cleanupChargingStation,
+ createMockChargingStation,
+ MockIdTagsCache,
+ MockSharedLRUCache,
+ MockWebSocket,
+ WebSocketReadyState,
+} from './ChargingStationTestUtils.js'
+
+await describe('ChargingStation Integration Tests', async () => {
+ await describe('Test Utilities Verification', async () => {
+ await it('should create mock charging station with default options', () => {
+ const result = createMockChargingStation()
+ const station = result.station
+
+ expect(station).toBeDefined()
expect(station.connectors.size).toBeGreaterThan(0)
- })
-
- await it('should handle WebSocket error event gracefully', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
-
- // Set up error listener to track that error was emitted
- let errorEventReceived = false
- mocks.webSocket.on('error', () => {
- errorEventReceived = true
- })
-
- // Act - Simulate error event
- mocks.webSocket.simulateError(new Error('Connection refused'))
+ expect(station.stationInfo).toBeDefined()
- // Assert - Error event should have been emitted and received
- expect(errorEventReceived).toBe(true)
+ cleanupChargingStation(station)
})
- await it('should reject duplicate message IDs for incoming messages', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Add a request with specific message ID to simulate duplicate
- const messageId = 'duplicate-uuid-123'
- station.requests.set(messageId, ['callback', 'errorCallback', 'TestCommand'])
+ await it('should create mock charging station with custom connector count', () => {
+ const result = createMockChargingStation({ connectorsCount: 5 })
+ const station = result.station
- // Assert - Request with duplicate ID exists
- expect(station.requests.has(messageId)).toBe(true)
+ // 5 connectors + connector 0 = 6 total
+ expect(station.connectors.size).toBe(6)
+ expect(station.hasConnector(5)).toBe(true)
- // The actual implementation throws OCPPError with SECURITY_ERROR
- // when receiving an incoming message with duplicate message ID
- // (see ChargingStation.ts:handleIncomingMessage)
+ cleanupChargingStation(station)
})
- await it('should handle response for unknown message ID', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Ensure requests map is empty
- station.requests.clear()
+ await it('should create mock charging station with EVSE mode', () => {
+ const result = createMockChargingStation({ connectorsCount: 4, evsesCount: 2 })
+ const station = result.station
- // Assert - No pending request exists
- expect(station.requests.size).toBe(0)
+ expect(station.hasEvses).toBe(true)
+ expect(station.getNumberOfEvses()).toBe(2)
- // The actual implementation throws OCPPError with INTERNAL_ERROR
- // when receiving a response for unknown message ID
- // (see ChargingStation.ts:handleResponseMessage)
+ cleanupChargingStation(station)
})
- // -------------------------------------------------------------------------
- // Graceful Degradation Tests
- // -------------------------------------------------------------------------
-
- await it('should handle server unreachable state', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
+ await it('should provide mock WebSocket with message capture', () => {
+ const result = createMockChargingStation()
const mocks = result.mocks
- // Act - Close WebSocket to simulate server unreachable
- mocks.webSocket.simulateClose(1006, 'Server unreachable')
-
- // Assert - Station should remain in valid state
- expect(station.connectors.size).toBeGreaterThan(0)
- expect(mocks.webSocket.readyState).toBe(3) // CLOSED
- })
-
- await it('should handle boot notification rejected state', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- connectorsCount: 1,
- })
- station = result.station
-
- // Assert - Station should report rejected state
- expect(station.inRejectedState()).toBe(true)
- expect(station.inAcceptedState()).toBe(false)
+ expect(mocks.webSocket).toBeDefined()
+ expect(mocks.webSocket).toBeInstanceOf(MockWebSocket)
+ expect(mocks.webSocket.readyState).toBe(WebSocketReadyState.OPEN)
- // Station in rejected state should not initiate messages per OCPP spec (B03.FR.03)
- expect(station.bootNotificationResponse?.status).toBe(RegistrationStatusEnumType.REJECTED)
+ cleanupChargingStation(result.station)
})
- await it('should maintain connector states during connection failure', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
+ await it('should provide mock caches', () => {
+ const result = createMockChargingStation()
const mocks = result.mocks
- // Set up connector state before failure
- const connector1 = station.getConnectorStatus(1)
- if (connector1 != null) {
- connector1.transactionStarted = true
- connector1.transactionId = 999
- }
-
- // Act - Simulate connection failure
- mocks.webSocket.simulateClose(1006, 'Connection lost')
-
- // Assert - Connector state should be preserved
- const connector1After = station.getConnectorStatus(1)
- expect(connector1After?.transactionStarted).toBe(true)
- expect(connector1After?.transactionId).toBe(999)
- })
-
- // -------------------------------------------------------------------------
- // Cleanup on Errors Tests
- // -------------------------------------------------------------------------
-
- await it('should clear event listeners on cleanup', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // The cleanup function should clear all listeners
- // Act
- cleanupChargingStation(station)
+ expect(mocks.sharedLRUCache).toBeDefined()
+ expect(mocks.sharedLRUCache).toBeInstanceOf(MockSharedLRUCache)
+ expect(mocks.idTagsCache).toBeDefined()
+ expect(mocks.idTagsCache).toBeInstanceOf(MockIdTagsCache)
- // Assert - Station should be properly cleaned up
- // (listenerCount returns 0 in mock implementation)
- expect(station.listenerCount('someEvent')).toBe(0)
+ cleanupChargingStation(result.station)
})
- await it('should clear timers on cleanup', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Set up a heartbeat timer (simulated)
- station.heartbeatSetInterval = setInterval(() => {
- /* empty */
- }, 30000) as unknown as NodeJS.Timeout
+ await it('should support boot notification status configuration', () => {
+ // Test ACCEPTED state
+ const acceptedResult = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.ACCEPTED,
+ })
+ expect(acceptedResult.station.inAcceptedState()).toBe(true)
+ cleanupChargingStation(acceptedResult.station)
- // Act - Cleanup station
- cleanupChargingStation(station)
+ // Test PENDING state
+ const pendingResult = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ expect(pendingResult.station.inPendingState()).toBe(true)
+ cleanupChargingStation(pendingResult.station)
- // Assert - Timer should be cleared
- expect(station.heartbeatSetInterval).toBeUndefined()
+ // Test REJECTED state
+ const rejectedResult = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ expect(rejectedResult.station.inRejectedState()).toBe(true)
+ cleanupChargingStation(rejectedResult.station)
})
+ })
- await it('should clear pending requests on cleanup', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
-
- // Add some pending requests
- station.requests.set('req-1', ['callback1', 'errorCallback1', 'Command1'])
- station.requests.set('req-2', ['callback2', 'errorCallback2', 'Command2'])
-
- // Act - Cleanup station
- cleanupChargingStation(station)
-
- // Assert - Requests should be cleared
- expect(station.requests.size).toBe(0)
- })
+ await describe('Cross-Domain Integration', async () => {
+ let station: ChargingStation | undefined
- await it('should handle delete operation with pending transactions', async () => {
- // Arrange
+ await it('should support full lifecycle with transactions', async () => {
+ // Create station
const result = createMockChargingStation({ connectorsCount: 2 })
station = result.station
- // Set up a running transaction
+ // Start station
+ station.start()
+ expect(station.started).toBe(true)
+
+ // Set up transaction
const connector1 = station.getConnectorStatus(1)
if (connector1 != null) {
connector1.transactionStarted = true
- connector1.transactionId = 1001
+ connector1.transactionId = 1
+ connector1.transactionIdTag = 'TEST-TAG'
+ connector1.transactionEnergyActiveImportRegisterValue = 5000
}
- // Start the station
- station.start()
- expect(station.started).toBe(true)
+ // Verify transaction
+ expect(station.getNumberOfRunningTransactions()).toBe(1)
+ expect(station.getTransactionIdTag(1)).toBe('TEST-TAG')
+ expect(station.getEnergyActiveImportRegisterByTransactionId(1)).toBe(5000)
- // Act - Delete station (should stop first)
- await station.delete()
-
- // Assert - Station should be stopped and resources cleared
+ // Stop station
+ await station.stop()
expect(station.started).toBe(false)
- expect(station.connectors.size).toBe(0)
- expect(station.evses.size).toBe(0)
- })
- })
-
- await describe('Message Buffering', async () => {
- let station: ChargingStation
-
- afterEach(() => {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- // -------------------------------------------------------------------------
- // Buffer Operations Tests
- // -------------------------------------------------------------------------
-
- await it('should buffer message when WebSocket is closed', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const testMessage = '[2,"test-msg-1","BootNotification",{}]'
-
- // Ensure WebSocket is closed
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Act - Buffer a message
- station.bufferMessage(testMessage)
-
- // Assert - Message should be queued but not sent
- expect(station.messageQueue.length).toBe(1)
- expect(station.messageQueue[0]).toBe(testMessage)
- expect(mocks.webSocket.sentMessages.length).toBe(0)
- })
-
- await it('should send message immediately when WebSocket is open', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const testMessage = '[2,"test-msg-2","Heartbeat",{}]'
- // Ensure WebSocket is open
- mocks.webSocket.readyState = 1 // OPEN
- mocks.webSocket.simulateOpen()
-
- // Act - Send message
- station.bufferMessage(testMessage)
-
- // Note: Due to async nature, the message may be sent or buffered depending on timing
- // This test verifies the message is queued at minimum
- expect(station.messageQueue.length).toBeGreaterThanOrEqual(0)
- })
-
- await it('should flush messages in FIFO order when connection restored', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const msg1 = '[2,"msg-1","BootNotification",{}]'
- const msg2 = '[2,"msg-2","Heartbeat",{}]'
- const msg3 = '[2,"msg-3","StatusNotification",{}]'
-
- // Simulate offline: close the connection
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Act - Buffer multiple messages
- station.bufferMessage(msg1)
- station.bufferMessage(msg2)
- station.bufferMessage(msg3)
-
- // Assert - All messages should be buffered
- expect(station.messageQueue.length).toBe(3)
- expect(station.messageQueue[0]).toBe(msg1)
- expect(station.messageQueue[1]).toBe(msg2)
- expect(station.messageQueue[2]).toBe(msg3)
- expect(mocks.webSocket.sentMessages.length).toBe(0)
- })
-
- await it('should preserve message order across multiple buffer operations', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const messages = [
- '[2,"m1","Cmd1",{}]',
- '[2,"m2","Cmd2",{}]',
- '[2,"m3","Cmd3",{}]',
- '[2,"m4","Cmd4",{}]',
- '[2,"m5","Cmd5",{}]',
- ]
-
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Act - Buffer all messages
- for (const msg of messages) {
- station.bufferMessage(msg)
- }
-
- // Assert - Verify FIFO order
- expect(station.messageQueue.length).toBe(5)
- for (let i = 0; i < messages.length; i++) {
- expect(station.messageQueue[i]).toBe(messages[i])
- }
- })
-
- await it('should handle buffer full scenario (stress test with many messages)', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const messageCount = 100
-
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Act - Buffer many messages
- for (let i = 0; i < messageCount; i++) {
- const msg = `[2,"msg-${i.toString()}","Command",{"data":"${i.toString()}"}]`
- station.bufferMessage(msg)
- }
-
- // Assert - All messages should be buffered
- expect(station.messageQueue.length).toBe(messageCount)
- expect(mocks.webSocket.sentMessages.length).toBe(0)
-
- // Verify first and last message are in correct positions
- expect(station.messageQueue[0]).toContain('msg-0')
- expect(station.messageQueue[messageCount - 1]).toContain(
- `msg-${(messageCount - 1).toString()}`
- )
+ cleanupChargingStation(station)
+ station = undefined
})
- // -------------------------------------------------------------------------
- // Flush Behavior Tests
- // -------------------------------------------------------------------------
-
- await it('should not send buffered messages while disconnected', () => {
- // Arrange
+ await it('should support WebSocket operations with state management', () => {
+ // Create station
const result = createMockChargingStation({ connectorsCount: 1 })
station = result.station
const mocks = result.mocks
- const testMessage = '[2,"offline-msg","Test",{}]'
- mocks.webSocket.readyState = 3 // CLOSED
+ // Start station
+ station.start()
- // Act - Buffer message
- station.bufferMessage(testMessage)
+ // Send WebSocket messages
+ mocks.webSocket.send('["2","uuid-1","Heartbeat",{}]')
+ expect(mocks.webSocket.sentMessages.length).toBe(1)
- // Small delay to ensure no async flush attempts
- const initialSentCount = mocks.webSocket.sentMessages.length
+ // Simulate connection close
+ mocks.webSocket.simulateClose(1006, 'Connection lost')
+ expect(mocks.webSocket.readyState).toBe(WebSocketReadyState.CLOSED)
- // Assert - Message should remain buffered
+ // Buffer messages while disconnected
+ station.bufferMessage('["2","uuid-2","StatusNotification",{}]')
expect(station.messageQueue.length).toBe(1)
- expect(mocks.webSocket.sentMessages.length).toBe(initialSentCount)
- })
-
- await it('should clear buffer after successful message transmission', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const testMessage = '[2,"clear-test","Command",{}]'
-
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Act - Buffer message
- station.bufferMessage(testMessage)
- const bufferedCount = station.messageQueue.length
-
- // Assert - Message is buffered
- expect(bufferedCount).toBe(1)
-
- // Now simulate successful send by manually removing (simulating what sendMessageBuffer does)
- if (station.messageQueue.length > 0) {
- station.messageQueue.shift()
- }
-
- // Assert - Buffer should be cleared
- expect(station.messageQueue.length).toBe(0)
- })
-
- await it('should handle rapid buffer/reconnect cycles without message loss', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const cycleCount = 3
- const messagesPerCycle = 2
- let totalExpectedMessages = 0
-
- // Act - Perform multiple buffer/disconnect cycles
- for (let cycle = 0; cycle < cycleCount; cycle++) {
- // Simulate disconnection
- mocks.webSocket.readyState = 3 // CLOSED
-
- // Buffer messages in this cycle
- for (let i = 0; i < messagesPerCycle; i++) {
- const msg = `[2,"cycle-${cycle.toString()}-msg-${i.toString()}","Cmd",{}]`
- station.bufferMessage(msg)
- totalExpectedMessages++
- }
- }
-
- // Assert - All messages from all cycles should be buffered in order
- expect(station.messageQueue.length).toBe(totalExpectedMessages)
- expect(station.messageQueue[0]).toContain('cycle-0-msg-0')
- expect(station.messageQueue[totalExpectedMessages - 1]).toContain(
- `cycle-${(cycleCount - 1).toString()}-msg-${(messagesPerCycle - 1).toString()}`
- )
- })
- })
-
- await describe('Reservation Management', async () => {
- let station: ChargingStation | undefined
-
- afterEach(() => {
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
-
- await it('should add reservation successfully to connector', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const reservation = {
- connectorId: 1,
- expiryDate: new Date(Date.now() + 3600000), // 1 hour from now
- idTag: 'test-tag-1',
- reservationId: 101,
- }
-
- // Act
- await station.addReservation(reservation)
-
- // Assert
- const found = station.getReservationBy('reservationId', 101)
- expect(found).toBeDefined()
- expect(found?.idTag).toBe('test-tag-1')
- expect(found?.connectorId).toBe(1)
- })
-
- await it('should replace existing reservation with new one', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const firstReservation = {
- connectorId: 1,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'tag-1',
- reservationId: 201,
- }
- const secondReservation = {
- connectorId: 2,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'tag-2',
- reservationId: 201, // Same ID
- }
-
- // Act
- await station.addReservation(firstReservation)
- await station.addReservation(secondReservation)
- // Assert - Only second reservation should exist with same ID
- const found = station.getReservationBy('reservationId', 201)
- expect(found).toBeDefined()
- expect(found?.idTag).toBe('tag-2')
- expect(found?.connectorId).toBe(2)
+ cleanupChargingStation(station)
+ station = undefined
})
- await it('should remove reservation with EXPIRED reason', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
+ await it('should support EVSE mode with reservations', async () => {
+ // Create station with EVSEs
+ const result = createMockChargingStation({ connectorsCount: 2, evsesCount: 1 })
station = result.station
- const reservation = {
- connectorId: 1,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'test-tag-expired',
- reservationId: 301,
- }
- await station.addReservation(reservation)
-
- // Act
- const { ReservationTerminationReason } = await import('../../src/types/ocpp/Reservation.js')
- await station.removeReservation(reservation, ReservationTerminationReason.EXPIRED)
- // Assert
- const found = station.getReservationBy('reservationId', 301)
- expect(found).toBeUndefined()
- })
+ // Verify EVSE structure
+ expect(station.hasEvses).toBe(true)
+ expect(station.getEvseIdByConnectorId(1)).toBe(1)
- await it('should remove reservation with REPLACE_EXISTING reason', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
+ // Add reservation
const reservation = {
connectorId: 1,
expiryDate: new Date(Date.now() + 3600000),
- idTag: 'test-tag-replace',
- reservationId: 401,
- }
- await station.addReservation(reservation)
-
- // Act
- const { ReservationTerminationReason } = await import('../../src/types/ocpp/Reservation.js')
- await station.removeReservation(reservation, ReservationTerminationReason.REPLACE_EXISTING)
-
- // Assert
- const found = station.getReservationBy('reservationId', 401)
- expect(found).toBeUndefined()
- })
-
- await it('should query reservation by reservationId', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const reservation = {
- connectorId: 2,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'query-test-id',
- reservationId: 501,
+ idTag: 'RESERVATION-TAG',
+ reservationId: 1,
}
await station.addReservation(reservation)
- // Act
- const found = station.getReservationBy('reservationId', 501)
-
- // Assert
+ // Verify reservation
+ const found = station.getReservationBy('reservationId', 1)
expect(found).toBeDefined()
- expect(found?.connectorId).toBe(2)
- expect(found?.idTag).toBe('query-test-id')
- })
-
- await it('should query reservation by idTag', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const reservation = {
- connectorId: 1,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'search-by-tag',
- reservationId: 601,
- }
- await station.addReservation(reservation)
+ expect(found?.idTag).toBe('RESERVATION-TAG')
- // Act
- const found = station.getReservationBy('idTag', 'search-by-tag')
+ // Check reservability
+ expect(station.isConnectorReservable(1)).toBe(false)
+ expect(station.isConnectorReservable(999)).toBe(true)
- // Assert
- expect(found).toBeDefined()
- expect(found?.reservationId).toBe(601)
- expect(found?.connectorId).toBe(1)
+ cleanupChargingStation(station)
+ station = undefined
})
- await it('should query reservation by connectorId', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 3 })
+ await it('should handle configuration with boot notification states', () => {
+ // Create station in pending state
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ connectorsCount: 1,
+ heartbeatInterval: 120,
+ })
station = result.station
- const reservation = {
- connectorId: 2,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'connector-search',
- reservationId: 701,
- }
- await station.addReservation(reservation)
-
- // Act
- const found = station.getReservationBy('connectorId', 2)
- // Assert
- expect(found).toBeDefined()
- expect(found?.reservationId).toBe(701)
- expect(found?.idTag).toBe('connector-search')
- })
+ // Verify initial state
+ expect(station.inPendingState()).toBe(true)
+ expect(station.getHeartbeatInterval()).toBe(120000)
- await it('should handle isConnectorReservable check with valid reservationId', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- const reservation = {
- connectorId: 1,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'reservable-check',
- reservationId: 801,
+ // Transition to accepted
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 60,
+ status: RegistrationStatusEnumType.ACCEPTED,
}
- await station.addReservation(reservation)
-
- // Act
- const isReservable = station.isConnectorReservable(801)
-
- // Assert - Should return false since reservation exists
- expect(isReservable).toBe(false)
- })
- await it('should handle isConnectorReservable check with non-existent reservationId', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
-
- // Act
- const isReservable = station.isConnectorReservable(999)
+ // Verify state change
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
- // Assert - Should return true since reservation does not exist
- expect(isReservable).toBe(true)
+ cleanupChargingStation(station)
+ station = undefined
})
+ })
- await it('should not allow reservation on connector 0 via isConnectorReservable', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
+ await describe('Mock Reset Verification', async () => {
+ await it('should reset singleton mocks between tests', () => {
+ // First test - create and use mocks
+ const result1 = createMockChargingStation()
+ const mocks1 = result1.mocks
- // Act
- const isReservable = station.isConnectorReservable(901, 'test-tag', 0)
+ // Store some data in cache
+ mocks1.idTagsCache.setIdTags('test-file.json', ['tag1', 'tag2'])
+ expect(mocks1.idTagsCache.getIdTags('test-file.json')).toEqual(['tag1', 'tag2'])
- // Assert - Connector 0 should not be reservable
- expect(isReservable).toBe(false)
- })
+ // Cleanup first station
+ cleanupChargingStation(result1.station)
- await it('should handle multiple reservations on different connectors', async () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 4 })
- station = result.station
- const reservation1 = {
- connectorId: 1,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'multi-test-1',
- reservationId: 1001,
- }
- const reservation2 = {
- connectorId: 2,
- expiryDate: new Date(Date.now() + 3600000),
- idTag: 'multi-test-2',
- reservationId: 1002,
- }
+ // Second test - should have fresh mocks
+ const result2 = createMockChargingStation()
+ const mocks2 = result2.mocks
- // Act
- await station.addReservation(reservation1)
- await station.addReservation(reservation2)
+ // Cache should be fresh (singletons reset in cleanup)
+ // The resetInstance is called in cleanup, so new getInstance creates fresh instance
+ expect(mocks2.idTagsCache.getIdTags('test-file.json')).toBeUndefined()
- // Assert
- const found1 = station.getReservationBy('reservationId', 1001)
- const found2 = station.getReservationBy('reservationId', 1002)
- expect(found1).toBeDefined()
- expect(found2).toBeDefined()
- expect(found1?.connectorId).toBe(1)
- expect(found2?.connectorId).toBe(2)
+ cleanupChargingStation(result2.station)
})
})
})