import { TEST_HEARTBEAT_INTERVAL_MS } from './ChargingStationTestConstants.js'
import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
-await describe('ChargingStation Error Recovery and Resilience', async () => {
- let station: ChargingStation
+await describe('ChargingStation Resilience', async () => {
+ await describe('Error Recovery and Resilience', async () => {
+ let station: ChargingStation
- beforeEach(() => {
- station = undefined
- })
+ beforeEach(() => {
+ station = undefined
+ })
- afterEach(() => {
- standardCleanup()
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
+ afterEach(() => {
+ standardCleanup()
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
- // -------------------------------------------------------------------------
- // Reconnection Logic Tests
- // -------------------------------------------------------------------------
+ // -------------------------------------------------------------------------
+ // 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
+ 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
+ // 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')
+ // 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
- })
+ // 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
+ 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')
+ // 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
- })
+ // 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
+ 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)
+ // Assert - Initial retry count should be 0
+ expect(station.wsConnectionRetryCount).toBe(0)
- // Act - Increment retry count manually (simulating reconnection attempt)
- station.wsConnectionRetryCount = 1
+ // Act - Increment retry count manually (simulating reconnection attempt)
+ station.wsConnectionRetryCount = 1
- // Assert - Count should be incremented
- expect(station.wsConnectionRetryCount).toBe(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
+ 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
- })
+ // 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
+ 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
+ // Act - Reset retry count (as would happen on successful reconnection)
+ station.wsConnectionRetryCount = 0
- // Assert
- expect(station.wsConnectionRetryCount).toBe(0)
- })
+ // Assert
+ expect(station.wsConnectionRetryCount).toBe(0)
+ })
- // -------------------------------------------------------------------------
- // Error Handling Tests
- // -------------------------------------------------------------------------
+ // -------------------------------------------------------------------------
+ // 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
+ 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')
+ // 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)
- expect(station.connectors.size).toBeGreaterThan(0)
- })
+ // Assert - Station should still be operational (not crashed)
+ 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
+ })
- await it('should handle WebSocket error event gracefully', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
+ // Act - Simulate error event
+ mocks.webSocket.simulateError(new Error('Connection refused'))
- // Set up error listener to track that error was emitted
- let errorEventReceived = false
- mocks.webSocket.on('error', () => {
- errorEventReceived = true
+ // Assert - Error event should have been emitted and received
+ expect(errorEventReceived).toBe(true)
})
- // Act - Simulate error event
- mocks.webSocket.simulateError(new Error('Connection refused'))
+ await it('should reject duplicate message IDs for incoming messages', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
- // Assert - Error event should have been emitted and received
- expect(errorEventReceived).toBe(true)
- })
+ // 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 reject duplicate message IDs for incoming messages', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
+ // Assert - Request with duplicate ID exists
+ expect(station.requests.has(messageId)).toBe(true)
- // Add a request with specific message ID to simulate duplicate
- const messageId = 'duplicate-uuid-123'
- station.requests.set(messageId, ['callback', 'errorCallback', 'TestCommand'])
+ // The actual implementation throws OCPPError with SECURITY_ERROR
+ // when receiving an incoming message with duplicate message ID
+ // (see ChargingStation.ts:handleIncomingMessage)
+ })
- // Assert - Request with duplicate ID exists
- expect(station.requests.has(messageId)).toBe(true)
+ await it('should handle response for unknown message ID', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
- // The actual implementation throws OCPPError with SECURITY_ERROR
- // when receiving an incoming message with duplicate message ID
- // (see ChargingStation.ts:handleIncomingMessage)
- })
+ // Ensure requests map is empty
+ station.requests.clear()
- await it('should handle response for unknown message ID', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
+ // Assert - No pending request exists
+ expect(station.requests.size).toBe(0)
- // Ensure requests map is empty
- station.requests.clear()
+ // The actual implementation throws OCPPError with INTERNAL_ERROR
+ // when receiving a response for unknown message ID
+ // (see ChargingStation.ts:handleResponseMessage)
+ })
- // Assert - No pending request exists
- expect(station.requests.size).toBe(0)
+ // -------------------------------------------------------------------------
+ // Graceful Degradation Tests
+ // -------------------------------------------------------------------------
- // The actual implementation throws OCPPError with INTERNAL_ERROR
- // when receiving a response for unknown message ID
- // (see ChargingStation.ts:handleResponseMessage)
- })
+ await it('should handle server unreachable state', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
- // -------------------------------------------------------------------------
- // Graceful Degradation Tests
- // -------------------------------------------------------------------------
+ // Act - Close WebSocket to simulate server unreachable
+ mocks.webSocket.simulateClose(1006, 'Server unreachable')
- await it('should handle server unreachable state', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
+ // Assert - Station should remain in valid state
+ expect(station.connectors.size).toBeGreaterThan(0)
+ expect(mocks.webSocket.readyState).toBe(3) // CLOSED
+ })
- // Act - Close WebSocket to simulate server unreachable
- mocks.webSocket.simulateClose(1006, 'Server unreachable')
+ await it('should handle boot notification rejected state', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ connectorsCount: 1,
+ })
+ station = result.station
- // Assert - Station should remain in valid state
- expect(station.connectors.size).toBeGreaterThan(0)
- expect(mocks.webSocket.readyState).toBe(3) // CLOSED
- })
+ // Assert - Station should report rejected state
+ expect(station.inRejectedState()).toBe(true)
+ expect(station.inAcceptedState()).toBe(false)
- await it('should handle boot notification rejected state', () => {
- // Arrange
- const result = createMockChargingStation({
- bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
- connectorsCount: 1,
+ // Station in rejected state should not initiate messages per OCPP spec (B03.FR.03)
+ expect(station.bootNotificationResponse?.status).toBe(RegistrationStatusEnumType.REJECTED)
})
- station = result.station
- // Assert - Station should report rejected state
- expect(station.inRejectedState()).toBe(true)
- expect(station.inAcceptedState()).toBe(false)
+ await it('should maintain connector states during connection failure', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 2 })
+ station = result.station
+ const mocks = result.mocks
+
+ // Set up connector state before failure
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.transactionStarted = true
+ connector1.transactionId = 999
+ }
- // Station in rejected state should not initiate messages per OCPP spec (B03.FR.03)
- expect(station.bootNotificationResponse?.status).toBe(RegistrationStatusEnumType.REJECTED)
- })
+ // Act - Simulate connection failure
+ mocks.webSocket.simulateClose(1006, 'Connection lost')
- await it('should maintain connector states during connection failure', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 2 })
- station = result.station
- 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)
- })
+ // 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
- // -------------------------------------------------------------------------
+ // -------------------------------------------------------------------------
+ // Cleanup on Errors Tests
+ // -------------------------------------------------------------------------
- await it('should clear event listeners on cleanup', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
+ 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)
+ // The cleanup function should clear all listeners
+ // Act
+ cleanupChargingStation(station)
- // Assert - Station should be properly cleaned up
- // (listenerCount returns 0 in mock implementation)
- expect(station.listenerCount('someEvent')).toBe(0)
- })
+ // Assert - Station should be properly cleaned up
+ // (listenerCount returns 0 in mock implementation)
+ expect(station.listenerCount('someEvent')).toBe(0)
+ })
- await it('should clear timers on cleanup', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = 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 */
- }, TEST_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
+ // Set up a heartbeat timer (simulated)
+ station.heartbeatSetInterval = setInterval(() => {
+ /* empty */
+ }, TEST_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
- // Act - Cleanup station
- cleanupChargingStation(station)
+ // Act - Cleanup station
+ cleanupChargingStation(station)
- // Assert - Timer should be cleared
- expect(station.heartbeatSetInterval).toBeUndefined()
- })
+ // Assert - Timer should be cleared
+ expect(station.heartbeatSetInterval).toBeUndefined()
+ })
- await it('should clear pending requests on cleanup', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.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'])
+ // 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)
+ // Act - Cleanup station
+ cleanupChargingStation(station)
- // Assert - Requests should be cleared
- expect(station.requests.size).toBe(0)
+ // Assert - Requests should be cleared
+ expect(station.requests.size).toBe(0)
+ })
})
-})
-await describe('ChargingStation Message Buffering', async () => {
- let station: ChargingStation
+ await describe('Message Buffering', async () => {
+ let station: ChargingStation
- beforeEach(() => {
- station = undefined
- })
+ beforeEach(() => {
+ station = undefined
+ })
- afterEach(() => {
- standardCleanup()
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- if (station != null) {
- cleanupChargingStation(station)
- }
- })
+ afterEach(() => {
+ standardCleanup()
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
- // -------------------------------------------------------------------------
- // Buffer Operations Tests
- // -------------------------------------------------------------------------
+ // -------------------------------------------------------------------------
+ // 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",{}]'
+ 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
+ // Ensure WebSocket is closed
+ mocks.webSocket.readyState = 3 // CLOSED
- // Act - Buffer a message
- station.bufferMessage(testMessage)
+ // 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)
- })
+ // 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",{}]'
+ 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()
+ // Ensure WebSocket is open
+ mocks.webSocket.readyState = 1 // OPEN
+ mocks.webSocket.simulateOpen()
- // Act - Send message
- station.bufferMessage(testMessage)
+ // 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)
- })
+ // 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 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",{}]'
- 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])
- }
- })
+ // Simulate offline: close the connection
+ mocks.webSocket.readyState = 3 // CLOSED
- 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
+ // 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)
+ })
- mocks.webSocket.readyState = 3 // CLOSED
+ 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",{}]',
+ ]
- // Act - Buffer many messages
- for (let i = 0; i < messageCount; i++) {
- const msg = `[2,"msg-${i.toString()}","Command",{"data":"${i.toString()}"}]`
- station.bufferMessage(msg)
- }
+ mocks.webSocket.readyState = 3 // CLOSED
- // Assert - All messages should be buffered
- expect(station.messageQueue.length).toBe(messageCount)
- expect(mocks.webSocket.sentMessages.length).toBe(0)
+ // Act - Buffer all messages
+ for (const msg of messages) {
+ station.bufferMessage(msg)
+ }
- // 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()}`)
- })
+ // 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])
+ }
+ })
- // -------------------------------------------------------------------------
- // Flush Behavior Tests
- // -------------------------------------------------------------------------
+ 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
- await it('should not send buffered messages while disconnected', () => {
- // Arrange
- const result = createMockChargingStation({ connectorsCount: 1 })
- station = result.station
- const mocks = result.mocks
- const testMessage = '[2,"offline-msg","Test",{}]'
+ mocks.webSocket.readyState = 3 // CLOSED
- 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)
+ }
- // Act - Buffer message
- station.bufferMessage(testMessage)
+ // Assert - All messages should be buffered
+ expect(station.messageQueue.length).toBe(messageCount)
+ expect(mocks.webSocket.sentMessages.length).toBe(0)
- // Small delay to ensure no async flush attempts
- const initialSentCount = mocks.webSocket.sentMessages.length
+ // 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()}`
+ )
+ })
- // Assert - Message should remain buffered
- expect(station.messageQueue.length).toBe(1)
- expect(mocks.webSocket.sentMessages.length).toBe(initialSentCount)
- })
+ // -------------------------------------------------------------------------
+ // Flush Behavior Tests
+ // -------------------------------------------------------------------------
- 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",{}]'
+ await it('should not send buffered messages while disconnected', () => {
+ // Arrange
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const mocks = result.mocks
+ const testMessage = '[2,"offline-msg","Test",{}]'
- mocks.webSocket.readyState = 3 // CLOSED
+ mocks.webSocket.readyState = 3 // CLOSED
- // Act - Buffer message
- station.bufferMessage(testMessage)
- const bufferedCount = station.messageQueue.length
+ // Act - Buffer message
+ station.bufferMessage(testMessage)
- // Assert - Message is buffered
- expect(bufferedCount).toBe(1)
+ // Small delay to ensure no async flush attempts
+ const initialSentCount = mocks.webSocket.sentMessages.length
- // Now simulate successful send by manually removing (simulating what sendMessageBuffer does)
- if (station.messageQueue.length > 0) {
- station.messageQueue.shift()
- }
+ // Assert - Message should remain buffered
+ expect(station.messageQueue.length).toBe(1)
+ expect(mocks.webSocket.sentMessages.length).toBe(initialSentCount)
+ })
- // Assert - Buffer should be cleared
- expect(station.messageQueue.length).toBe(0)
- })
+ 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",{}]'
- 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++
+ // 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 - 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()}`
- )
+
+ // 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()}`
+ )
+ })
})
})
},
] as const
-await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async () => {
- let mockChargingStation: ChargingStation
-
- beforeEach(() => {
- const { station } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 3,
- evseConfiguration: { evsesCount: 3 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
- ocppRequestService: {
- requestHandler: async () => Promise.resolve({} as EmptyObject),
- },
- stationInfo: {
- ocppStrictCompliance: true,
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
- })
- mockChargingStation = station
- resetLimits(mockChargingStation)
- })
-
- // Reset singleton state and timers after each test to ensure test isolation
- afterEach(() => {
- standardCleanup()
- })
- // FR: E01.FR.01 - TransactionEventRequest structure validation
- await describe('buildTransactionEvent', async () => {
- await it('should build valid TransactionEvent Started with sequence number 0', () => {
- const connectorId = 1
- const transactionId = generateUUID()
- const triggerReason = OCPP20TriggerReasonEnumType.Authorized
-
- // Reset sequence number to simulate new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- triggerReason,
- connectorId,
- transactionId
- )
-
- // Validate required fields
- expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(transactionEvent.triggerReason).toBe(triggerReason)
- expect(transactionEvent.seqNo).toBe(0) // First event should have seqNo 0
- expect(transactionEvent.timestamp).toBeInstanceOf(Date)
- expect(transactionEvent.evse).toBeDefined()
- expect(transactionEvent.evse?.id).toBe(1) // EVSE ID should match connector ID for this setup
- expect(transactionEvent.transactionInfo).toBeDefined()
- expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId)
-
- // Validate structure matches OCPP 2.0.1 schema requirements
- expect(typeof transactionEvent.eventType).toBe('string')
- expect(typeof transactionEvent.triggerReason).toBe('string')
- expect(typeof transactionEvent.seqNo).toBe('number')
- expect(transactionEvent.seqNo).toBeGreaterThanOrEqual(0)
- })
-
- await it('should increment sequence number for subsequent events', () => {
- const connectorId = 2
- const transactionId = generateUUID()
-
- // Reset for new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Build first event (Started)
- const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
-
- // Build second event (Updated)
- const updateEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- // Build third event (Ended)
- const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId,
- { stoppedReason: OCPP20ReasonEnumType.Local }
- )
-
- // Validate sequence number progression: 0 → 1 → 2
- expect(startEvent.seqNo).toBe(0)
- expect(updateEvent.seqNo).toBe(1)
- expect(endEvent.seqNo).toBe(2)
-
- // Validate all events share same transaction ID
- expect(startEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(updateEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(endEvent.transactionInfo.transactionId).toBe(transactionId)
- })
+await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
+ await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async () => {
+ let mockChargingStation: ChargingStation
- await it('should handle optional parameters correctly', () => {
- const connectorId = 3
- const transactionId = generateUUID()
- const options = {
- cableMaxCurrent: 32,
- chargingState: OCPP20ChargingStateEnumType.Charging,
- idToken: {
- idToken: 'TEST_TOKEN_123',
- type: OCPP20IdTokenEnumType.ISO14443,
+ beforeEach(() => {
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: async () => Promise.resolve({} as EmptyObject),
},
- numberOfPhasesUsed: 3,
- offline: false,
- remoteStartId: 12345,
- reservationId: 67890,
- }
-
- const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId,
- options
- )
-
- // Validate optional fields are included
- expect(transactionEvent.idToken).toBeDefined()
- expect(transactionEvent.idToken?.idToken).toBe('TEST_TOKEN_123')
- expect(transactionEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.ISO14443)
- expect(transactionEvent.transactionInfo.chargingState).toBe(
- OCPP20ChargingStateEnumType.Charging
- )
- expect(transactionEvent.transactionInfo.remoteStartId).toBe(12345)
- expect(transactionEvent.cableMaxCurrent).toBe(32)
- expect(transactionEvent.numberOfPhasesUsed).toBe(3)
- expect(transactionEvent.offline).toBe(false)
- expect(transactionEvent.reservationId).toBe(67890)
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+ mockChargingStation = station
+ resetLimits(mockChargingStation)
})
- await it('should validate transaction ID format (identifier string ≤36 chars)', () => {
- const connectorId = 1
- const invalidTransactionId =
- 'this-string-is-way-too-long-for-a-valid-transaction-id-exceeds-36-chars'
-
- try {
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- invalidTransactionId
- )
- throw new Error('Should have thrown error for invalid identifier string')
- } catch (error) {
- expect((error as Error).message).toContain('Invalid transaction ID format')
- expect((error as Error).message).toContain('≤36 characters')
- }
+ // Reset singleton state and timers after each test to ensure test isolation
+ afterEach(() => {
+ standardCleanup()
})
+ // FR: E01.FR.01 - TransactionEventRequest structure validation
+ await describe('buildTransactionEvent', async () => {
+ await it('should build valid TransactionEvent Started with sequence number 0', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const triggerReason = OCPP20TriggerReasonEnumType.Authorized
+
+ // Reset sequence number to simulate new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await it('should handle all TriggerReason enum values', () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- // Test a selection of TriggerReason values to ensure they're all handled
- const triggerReasons = [
- OCPP20TriggerReasonEnumType.Authorized,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- OCPP20TriggerReasonEnumType.ChargingRateChanged,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- OCPP20TriggerReasonEnumType.Deauthorized,
- OCPP20TriggerReasonEnumType.EnergyLimitReached,
- OCPP20TriggerReasonEnumType.EVCommunicationLost,
- OCPP20TriggerReasonEnumType.EVConnectTimeout,
- OCPP20TriggerReasonEnumType.MeterValueClock,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- OCPP20TriggerReasonEnumType.TimeLimitReached,
- OCPP20TriggerReasonEnumType.Trigger,
- OCPP20TriggerReasonEnumType.UnlockCommand,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- OCPP20TriggerReasonEnumType.EVDeparted,
- OCPP20TriggerReasonEnumType.EVDetected,
- OCPP20TriggerReasonEnumType.RemoteStop,
- OCPP20TriggerReasonEnumType.RemoteStart,
- OCPP20TriggerReasonEnumType.AbnormalCondition,
- OCPP20TriggerReasonEnumType.SignedDataReceived,
- OCPP20TriggerReasonEnumType.ResetCommand,
- ]
-
- // Reset sequence number
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- for (const triggerReason of triggerReasons) {
const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Started,
triggerReason,
connectorId,
transactionId
)
+ // Validate required fields
+ expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
expect(transactionEvent.triggerReason).toBe(triggerReason)
- expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
- }
- })
- })
-
- // FR: E02.FR.01 - TransactionEventRequest message sending
- await describe('sendTransactionEvent', async () => {
- await it('should send TransactionEvent and return response', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- const response = await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
-
- // Validate response structure (EmptyObject for OCPP 2.0.1 TransactionEventResponse)
- expect(response).toBeDefined()
- expect(typeof response).toBe('object')
- })
+ expect(transactionEvent.seqNo).toBe(0) // First event should have seqNo 0
+ expect(transactionEvent.timestamp).toBeInstanceOf(Date)
+ expect(transactionEvent.evse).toBeDefined()
+ expect(transactionEvent.evse?.id).toBe(1) // EVSE ID should match connector ID for this setup
+ expect(transactionEvent.transactionInfo).toBeDefined()
+ expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId)
- await it('should handle errors gracefully', async () => {
- // Create a mock charging station that throws an error
- const { station: errorMockChargingStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- evseConfiguration: { evsesCount: 1 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
- ocppRequestService: {
- requestHandler: () => {
- throw new Error('Network error')
- },
- },
- stationInfo: {
- ocppStrictCompliance: true,
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ // Validate structure matches OCPP 2.0.1 schema requirements
+ expect(typeof transactionEvent.eventType).toBe('string')
+ expect(typeof transactionEvent.triggerReason).toBe('string')
+ expect(typeof transactionEvent.seqNo).toBe('number')
+ expect(transactionEvent.seqNo).toBeGreaterThanOrEqual(0)
})
- const connectorId = 1
- const transactionId = generateUUID()
+ await it('should increment sequence number for subsequent events', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
+
+ // Reset for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- try {
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorMockChargingStation,
+ // Build first event (Started)
+ const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
transactionId
)
- throw new Error('Should have thrown error')
- } catch (error) {
- expect((error as Error).message).toContain('Network error')
- }
- })
- })
- // FR: E01.FR.03 - Sequence number management
- await describe('resetTransactionSequenceNumber', async () => {
- await it('should reset sequence number to undefined', () => {
- const connectorId = 1
-
- // First, build a transaction event to set sequence number
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- generateUUID()
- )
-
- // Verify sequence number is set
- const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
- expect(connectorStatus?.transactionSeqNo).toBeDefined()
-
- // Reset sequence number
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Verify sequence number is reset
- expect(connectorStatus?.transactionSeqNo).toBeUndefined()
- })
-
- await it('should handle non-existent connector gracefully', () => {
- const nonExistentConnectorId = 999
+ // Build second event (Updated)
+ const updateEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
- // Should not throw error for non-existent connector
- expect(() => {
- OCPP20ServiceUtils.resetTransactionSequenceNumber(
+ // Build third event (Ended)
+ const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
- nonExistentConnectorId
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId,
+ { stoppedReason: OCPP20ReasonEnumType.Local }
)
- }).not.toThrow()
- })
- })
- // FR: E01.FR.02 - Schema compliance verification
- await describe('OCPP 2.0.1 Schema Compliance', async () => {
- await it('should produce schema-compliant TransactionEvent payloads', () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId,
- {
+ // Validate sequence number progression: 0 → 1 → 2
+ expect(startEvent.seqNo).toBe(0)
+ expect(updateEvent.seqNo).toBe(1)
+ expect(endEvent.seqNo).toBe(2)
+
+ // Validate all events share same transaction ID
+ expect(startEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(updateEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(endEvent.transactionInfo.transactionId).toBe(transactionId)
+ })
+
+ await it('should handle optional parameters correctly', () => {
+ const connectorId = 3
+ const transactionId = generateUUID()
+ const options = {
+ cableMaxCurrent: 32,
+ chargingState: OCPP20ChargingStateEnumType.Charging,
idToken: {
- idToken: 'SCHEMA_TEST_TOKEN',
+ idToken: 'TEST_TOKEN_123',
type: OCPP20IdTokenEnumType.ISO14443,
},
- }
- )
-
- // Validate all required fields exist
- const requiredFields = [
- 'eventType',
- 'timestamp',
- 'triggerReason',
- 'seqNo',
- 'evse',
- 'transactionInfo',
- ]
- for (const field of requiredFields) {
- expect(transactionEvent).toHaveProperty(field)
- expect(transactionEvent[field as keyof typeof transactionEvent]).toBeDefined()
- }
-
- // Validate field types match schema requirements
- expect(typeof transactionEvent.eventType).toBe('string')
- expect(transactionEvent.timestamp).toBeInstanceOf(Date)
- expect(typeof transactionEvent.triggerReason).toBe('string')
- expect(typeof transactionEvent.seqNo).toBe('number')
- expect(typeof transactionEvent.evse).toBe('object')
- expect(typeof transactionEvent.transactionInfo).toBe('object')
-
- // Validate EVSE structure
- expect(transactionEvent.evse).toBeDefined()
- expect(typeof transactionEvent.evse?.id).toBe('number')
- expect(transactionEvent.evse?.id).toBeGreaterThan(0)
-
- // Validate transactionInfo structure
- expect(typeof transactionEvent.transactionInfo.transactionId).toBe('string')
-
- // Validate enum values are strings (not numbers)
- expect(Object.values(OCPP20TransactionEventEnumType)).toContain(transactionEvent.eventType)
- expect(Object.values(OCPP20TriggerReasonEnumType)).toContain(transactionEvent.triggerReason)
- })
-
- await it('should handle EVSE/connector mapping correctly', () => {
- const connectorId = 2
- const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
-
- // For this test setup, EVSE ID should match connector ID
- expect(transactionEvent.evse).toBeDefined()
- expect(transactionEvent.evse?.id).toBe(connectorId)
-
- // connectorId should only be included if different from EVSE ID
- // In this case they should be the same, so connectorId should not be present
- expect(transactionEvent.evse?.connectorId).toBeUndefined()
- })
- })
-
- // FR: E01.FR.04 - TriggerReason selection based on transaction context
- await describe('Context-Aware TriggerReason Selection', async () => {
- await describe('selectTriggerReason', async () => {
- await it('should select RemoteStart for remote_command context with RequestStartTransaction', () => {
- const context: OCPP20TransactionContext = {
- command: 'RequestStartTransaction',
- source: 'remote_command',
+ numberOfPhasesUsed: 3,
+ offline: false,
+ remoteStartId: 12345,
+ reservationId: 67890,
}
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- context
+ const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ options
)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStart)
+ // Validate optional fields are included
+ expect(transactionEvent.idToken).toBeDefined()
+ expect(transactionEvent.idToken?.idToken).toBe('TEST_TOKEN_123')
+ expect(transactionEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.ISO14443)
+ expect(transactionEvent.transactionInfo.chargingState).toBe(
+ OCPP20ChargingStateEnumType.Charging
+ )
+ expect(transactionEvent.transactionInfo.remoteStartId).toBe(12345)
+ expect(transactionEvent.cableMaxCurrent).toBe(32)
+ expect(transactionEvent.numberOfPhasesUsed).toBe(3)
+ expect(transactionEvent.offline).toBe(false)
+ expect(transactionEvent.reservationId).toBe(67890)
})
- await it('should select RemoteStop for remote_command context with RequestStopTransaction', () => {
- const context: OCPP20TransactionContext = {
- command: 'RequestStopTransaction',
- source: 'remote_command',
+ await it('should validate transaction ID format (identifier string ≤36 chars)', () => {
+ const connectorId = 1
+ const invalidTransactionId =
+ 'this-string-is-way-too-long-for-a-valid-transaction-id-exceeds-36-chars'
+
+ try {
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ invalidTransactionId
+ )
+ throw new Error('Should have thrown error for invalid identifier string')
+ } catch (error) {
+ expect((error as Error).message).toContain('Invalid transaction ID format')
+ expect((error as Error).message).toContain('≤36 characters')
}
+ })
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
+ await it('should handle all TriggerReason enum values', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStop)
- })
+ // Test a selection of TriggerReason values to ensure they're all handled
+ const triggerReasons = [
+ OCPP20TriggerReasonEnumType.Authorized,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ OCPP20TriggerReasonEnumType.ChargingRateChanged,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ OCPP20TriggerReasonEnumType.Deauthorized,
+ OCPP20TriggerReasonEnumType.EnergyLimitReached,
+ OCPP20TriggerReasonEnumType.EVCommunicationLost,
+ OCPP20TriggerReasonEnumType.EVConnectTimeout,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ OCPP20TriggerReasonEnumType.TimeLimitReached,
+ OCPP20TriggerReasonEnumType.Trigger,
+ OCPP20TriggerReasonEnumType.UnlockCommand,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ OCPP20TriggerReasonEnumType.EVDeparted,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ OCPP20TriggerReasonEnumType.RemoteStop,
+ OCPP20TriggerReasonEnumType.RemoteStart,
+ OCPP20TriggerReasonEnumType.AbnormalCondition,
+ OCPP20TriggerReasonEnumType.SignedDataReceived,
+ OCPP20TriggerReasonEnumType.ResetCommand,
+ ]
- await it('should select UnlockCommand for remote_command context with UnlockConnector', () => {
- const context: OCPP20TransactionContext = {
- command: 'UnlockConnector',
- source: 'remote_command',
- }
+ // Reset sequence number
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
+ for (const triggerReason of triggerReasons) {
+ const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ triggerReason,
+ connectorId,
+ transactionId
+ )
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.UnlockCommand)
+ expect(transactionEvent.triggerReason).toBe(triggerReason)
+ expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ }
})
+ })
- await it('should select ResetCommand for remote_command context with Reset', () => {
- const context: OCPP20TransactionContext = {
- command: 'Reset',
- source: 'remote_command',
- }
+ // FR: E02.FR.01 - TransactionEventRequest message sending
+ await describe('sendTransactionEvent', async () => {
+ await it('should send TransactionEvent and return response', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
+ const response = await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.ResetCommand)
+ // Validate response structure (EmptyObject for OCPP 2.0.1 TransactionEventResponse)
+ expect(response).toBeDefined()
+ expect(typeof response).toBe('object')
})
- await it('should select Trigger for remote_command context with TriggerMessage', () => {
- const context: OCPP20TransactionContext = {
- command: 'TriggerMessage',
- source: 'remote_command',
- }
+ await it('should handle errors gracefully', async () => {
+ // Create a mock charging station that throws an error
+ const { station: errorMockChargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: () => {
+ throw new Error('Network error')
+ },
+ },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
+ const connectorId = 1
+ const transactionId = generateUUID()
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Trigger)
+ try {
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorMockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ throw new Error('Should have thrown error')
+ } catch (error) {
+ expect((error as Error).message).toContain('Network error')
+ }
})
+ })
- await it('should select Authorized for local_authorization context with idToken', () => {
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'idToken',
- source: 'local_authorization',
- }
+ // FR: E01.FR.03 - Sequence number management
+ await describe('resetTransactionSequenceNumber', async () => {
+ await it('should reset sequence number to undefined', () => {
+ const connectorId = 1
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ // First, build a transaction event to set sequence number
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- context
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ generateUUID()
)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- })
-
- await it('should select StopAuthorized for local_authorization context with stopAuthorized', () => {
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'stopAuthorized',
- source: 'local_authorization',
- }
+ // Verify sequence number is set
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connectorStatus?.transactionSeqNo).toBeDefined()
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
+ // Reset sequence number
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.StopAuthorized)
+ // Verify sequence number is reset
+ expect(connectorStatus?.transactionSeqNo).toBeUndefined()
})
- await it('should select Deauthorized when isDeauthorized flag is true', () => {
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'idToken',
- isDeauthorized: true,
- source: 'local_authorization',
- }
-
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
+ await it('should handle non-existent connector gracefully', () => {
+ const nonExistentConnectorId = 999
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized)
+ // Should not throw error for non-existent connector
+ expect(() => {
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(
+ mockChargingStation,
+ nonExistentConnectorId
+ )
+ }).not.toThrow()
})
+ })
- await it('should select CablePluggedIn for cable_action context with plugged_in', () => {
- const context: OCPP20TransactionContext = {
- cableState: 'plugged_in',
- source: 'cable_action',
- }
+ // FR: E01.FR.02 - Schema compliance verification
+ await describe('OCPP 2.0.1 Schema Compliance', async () => {
+ await it('should produce schema-compliant TransactionEvent payloads', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- context
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ {
+ idToken: {
+ idToken: 'SCHEMA_TEST_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ }
)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- })
-
- await it('should select EVDetected for cable_action context with detected', () => {
- const context: OCPP20TransactionContext = {
- cableState: 'detected',
- source: 'cable_action',
+ // Validate all required fields exist
+ const requiredFields = [
+ 'eventType',
+ 'timestamp',
+ 'triggerReason',
+ 'seqNo',
+ 'evse',
+ 'transactionInfo',
+ ]
+ for (const field of requiredFields) {
+ expect(transactionEvent).toHaveProperty(field)
+ expect(transactionEvent[field as keyof typeof transactionEvent]).toBeDefined()
}
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
-
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
+ // Validate field types match schema requirements
+ expect(typeof transactionEvent.eventType).toBe('string')
+ expect(transactionEvent.timestamp).toBeInstanceOf(Date)
+ expect(typeof transactionEvent.triggerReason).toBe('string')
+ expect(typeof transactionEvent.seqNo).toBe('number')
+ expect(typeof transactionEvent.evse).toBe('object')
+ expect(typeof transactionEvent.transactionInfo).toBe('object')
+
+ // Validate EVSE structure
+ expect(transactionEvent.evse).toBeDefined()
+ expect(typeof transactionEvent.evse?.id).toBe('number')
+ expect(transactionEvent.evse?.id).toBeGreaterThan(0)
+
+ // Validate transactionInfo structure
+ expect(typeof transactionEvent.transactionInfo.transactionId).toBe('string')
+
+ // Validate enum values are strings (not numbers)
+ expect(Object.values(OCPP20TransactionEventEnumType)).toContain(transactionEvent.eventType)
+ expect(Object.values(OCPP20TriggerReasonEnumType)).toContain(transactionEvent.triggerReason)
})
- await it('should select EVDeparted for cable_action context with unplugged', () => {
- const context: OCPP20TransactionContext = {
- cableState: 'unplugged',
- source: 'cable_action',
- }
+ await it('should handle EVSE/connector mapping correctly', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ // For this test setup, EVSE ID should match connector ID
+ expect(transactionEvent.evse).toBeDefined()
+ expect(transactionEvent.evse?.id).toBe(connectorId)
+
+ // connectorId should only be included if different from EVSE ID
+ // In this case they should be the same, so connectorId should not be present
+ expect(transactionEvent.evse?.connectorId).toBeUndefined()
})
+ })
- await it('should select ChargingStateChanged for charging_state context', () => {
- const context: OCPP20TransactionContext = {
- chargingStateChange: {
- from: OCPP20ChargingStateEnumType.Idle,
- to: OCPP20ChargingStateEnumType.Charging,
- },
- source: 'charging_state',
- }
+ // FR: E01.FR.04 - TriggerReason selection based on transaction context
+ await describe('Context-Aware TriggerReason Selection', async () => {
+ await describe('selectTriggerReason', async () => {
+ await it('should select RemoteStart for remote_command context with RequestStartTransaction', () => {
+ const context: OCPP20TransactionContext = {
+ command: 'RequestStartTransaction',
+ source: 'remote_command',
+ }
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.ChargingStateChanged)
- })
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStart)
+ })
- await it('should select MeterValuePeriodic for meter_value context with periodic flag', () => {
- const context: OCPP20TransactionContext = {
- isPeriodicMeterValue: true,
- source: 'meter_value',
- }
+ await it('should select RemoteStop for remote_command context with RequestStopTransaction', () => {
+ const context: OCPP20TransactionContext = {
+ command: 'RequestStopTransaction',
+ source: 'remote_command',
+ }
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStop)
+ })
+
+ await it('should select UnlockCommand for remote_command context with UnlockConnector', () => {
+ const context: OCPP20TransactionContext = {
+ command: 'UnlockConnector',
+ source: 'remote_command',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.UnlockCommand)
+ })
+
+ await it('should select ResetCommand for remote_command context with Reset', () => {
+ const context: OCPP20TransactionContext = {
+ command: 'Reset',
+ source: 'remote_command',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.ResetCommand)
+ })
+
+ await it('should select Trigger for remote_command context with TriggerMessage', () => {
+ const context: OCPP20TransactionContext = {
+ command: 'TriggerMessage',
+ source: 'remote_command',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Trigger)
+ })
+
+ await it('should select Authorized for local_authorization context with idToken', () => {
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'idToken',
+ source: 'local_authorization',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ })
+
+ await it('should select StopAuthorized for local_authorization context with stopAuthorized', () => {
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'stopAuthorized',
+ source: 'local_authorization',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.StopAuthorized)
+ })
+
+ await it('should select Deauthorized when isDeauthorized flag is true', () => {
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'idToken',
+ isDeauthorized: true,
+ source: 'local_authorization',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized)
+ })
+
+ await it('should select CablePluggedIn for cable_action context with plugged_in', () => {
+ const context: OCPP20TransactionContext = {
+ cableState: 'plugged_in',
+ source: 'cable_action',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ })
+
+ await it('should select EVDetected for cable_action context with detected', () => {
+ const context: OCPP20TransactionContext = {
+ cableState: 'detected',
+ source: 'cable_action',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
+ })
+
+ await it('should select EVDeparted for cable_action context with unplugged', () => {
+ const context: OCPP20TransactionContext = {
+ cableState: 'unplugged',
+ source: 'cable_action',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ })
+
+ await it('should select ChargingStateChanged for charging_state context', () => {
+ const context: OCPP20TransactionContext = {
+ chargingStateChange: {
+ from: OCPP20ChargingStateEnumType.Idle,
+ to: OCPP20ChargingStateEnumType.Charging,
+ },
+ source: 'charging_state',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.ChargingStateChanged)
+ })
+
+ await it('should select MeterValuePeriodic for meter_value context with periodic flag', () => {
+ const context: OCPP20TransactionContext = {
+ isPeriodicMeterValue: true,
+ source: 'meter_value',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.MeterValuePeriodic)
+ })
+
+ await it('should select MeterValueClock for meter_value context without periodic flag', () => {
+ const context: OCPP20TransactionContext = {
+ isPeriodicMeterValue: false,
+ source: 'meter_value',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.MeterValueClock)
+ })
+
+ await it('should select SignedDataReceived when isSignedDataReceived flag is true', () => {
+ const context: OCPP20TransactionContext = {
+ isSignedDataReceived: true,
+ source: 'meter_value',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.SignedDataReceived)
+ })
+
+ await it('should select appropriate system events for system_event context', () => {
+ const testCases = [
+ {
+ expected: OCPP20TriggerReasonEnumType.EVDeparted,
+ systemEvent: 'ev_departed' as const,
+ },
+ {
+ expected: OCPP20TriggerReasonEnumType.EVDetected,
+ systemEvent: 'ev_detected' as const,
+ },
+ {
+ expected: OCPP20TriggerReasonEnumType.EVCommunicationLost,
+ systemEvent: 'ev_communication_lost' as const,
+ },
+ {
+ expected: OCPP20TriggerReasonEnumType.EVConnectTimeout,
+ systemEvent: 'ev_connect_timeout' as const,
+ },
+ ]
+
+ for (const testCase of testCases) {
+ const context: OCPP20TransactionContext = {
+ source: 'system_event',
+ systemEvent: testCase.systemEvent,
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(testCase.expected)
+ }
+ })
+
+ await it('should select EnergyLimitReached for energy_limit context', () => {
+ const context: OCPP20TransactionContext = {
+ source: 'energy_limit',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.MeterValuePeriodic)
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EnergyLimitReached)
+ })
+
+ await it('should select TimeLimitReached for time_limit context', () => {
+ const context: OCPP20TransactionContext = {
+ source: 'time_limit',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.TimeLimitReached)
+ })
+
+ await it('should select AbnormalCondition for abnormal_condition context', () => {
+ const context: OCPP20TransactionContext = {
+ abnormalCondition: 'OverCurrent',
+ source: 'abnormal_condition',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.AbnormalCondition)
+ })
+
+ await it('should handle priority ordering with multiple applicable contexts', () => {
+ // Test context with multiple applicable triggers - priority should be respected
+ const context: OCPP20TransactionContext = {
+ cableState: 'plugged_in', // Even lower priority
+ command: 'RequestStartTransaction',
+ isDeauthorized: true, // Lower priority but should be overridden
+ source: 'remote_command', // High priority
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
+
+ // Should select RemoteStart (priority 1) over Deauthorized (priority 2) or CablePluggedIn (priority 3)
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStart)
+ })
+
+ await it('should fallback to Trigger for unknown context source', () => {
+ const context: OCPP20TransactionContext = {
+ source: 'unknown_source' as OCPP20TransactionContext['source'], // Invalid source to test fallback
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Trigger)
+ })
+
+ await it('should fallback to Trigger for incomplete context', () => {
+ const context: OCPP20TransactionContext = {
+ source: 'remote_command',
+ // Missing command field
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Trigger)
+ })
})
- await it('should select MeterValueClock for meter_value context without periodic flag', () => {
- const context: OCPP20TransactionContext = {
- isPeriodicMeterValue: false,
- source: 'meter_value',
- }
+ await describe('buildTransactionEvent with context parameter', async () => {
+ await it('should build TransactionEvent with auto-selected TriggerReason from context', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const context: OCPP20TransactionContext = {
+ command: 'RequestStartTransaction',
+ source: 'remote_command',
+ }
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ context,
+ connectorId,
+ transactionId
+ )
+
+ expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(transactionEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStart)
+ expect(transactionEvent.seqNo).toBe(0)
+ expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId)
+ })
+
+ await it('should pass through optional parameters correctly', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'idToken',
+ source: 'local_authorization',
+ }
+ const options = {
+ chargingState: OCPP20ChargingStateEnumType.Charging,
+ idToken: {
+ idToken: 'CONTEXT_TEST_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ }
+
+ const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ context,
+ connectorId,
+ transactionId,
+ options
+ )
+
+ expect(transactionEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(transactionEvent.idToken?.idToken).toBe('CONTEXT_TEST_TOKEN')
+ expect(transactionEvent.transactionInfo.chargingState).toBe(
+ OCPP20ChargingStateEnumType.Charging
+ )
+ })
+ })
+
+ await describe('sendTransactionEvent with context parameter', async () => {
+ await it('should send TransactionEvent with context-aware TriggerReason selection', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const context: OCPP20TransactionContext = {
+ cableState: 'plugged_in',
+ source: 'cable_action',
+ }
+
+ const response = await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ context,
+ connectorId,
+ transactionId
+ )
+
+ // Validate response structure
+ expect(response).toBeDefined()
+ expect(typeof response).toBe('object')
+ })
+
+ await it('should handle context-aware error scenarios gracefully', async () => {
+ // Create error mock for this test
+ const { station: errorMockChargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: () => {
+ throw new Error('Context test error')
+ },
+ },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const context: OCPP20TransactionContext = {
+ abnormalCondition: 'TestError',
+ source: 'abnormal_condition',
+ }
+
+ try {
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorMockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ context,
+ connectorId,
+ transactionId
+ )
+ throw new Error('Should have thrown error')
+ } catch (error) {
+ expect((error as Error).message).toContain('Context test error')
+ }
+ })
+ })
+
+ await describe('Backward Compatibility', async () => {
+ await it('should maintain compatibility with existing buildTransactionEvent calls', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Old method call should still work
+ const oldEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+
+ expect(oldEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(oldEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(oldEvent.seqNo).toBe(0)
+ })
+
+ await it('should maintain compatibility with existing sendTransactionEvent calls', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ // Old method call should still work
+ const response = await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+
+ expect(response).toBeDefined()
+ expect(typeof response).toBe('object')
+ })
+ })
+ })
+
+ // ==========================================================================
+ // Parameterized Transaction Flow Tests (E02, E03, Remote Start)
+ // ==========================================================================
+ await describe('Transaction Flow Patterns', async () => {
+ for (const {
+ description,
+ expectedStartTrigger,
+ id,
+ includeIdToken,
+ name,
+ startContext,
+ } of TRANSACTION_FLOWS) {
+ await describe(`${name} Flow`, async () => {
+ await it(`should select ${expectedStartTrigger} trigger for ${description} transaction start`, () => {
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ startContext
+ )
+ expect(triggerReason).toBe(expectedStartTrigger)
+ })
+
+ await it(`should build correct Started event for ${description}`, () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType | undefined = includeIdToken
+ ? { idToken: `${id.toUpperCase()}_TOKEN_001`, type: OCPP20IdTokenEnumType.ISO14443 }
+ : undefined
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ expectedStartTrigger,
+ connectorId,
+ transactionId,
+ idToken != null ? { idToken } : undefined
+ )
+
+ expect(startedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(startedEvent.triggerReason).toBe(expectedStartTrigger)
+ expect(startedEvent.seqNo).toBe(0)
+ expect(startedEvent.transactionInfo.transactionId).toBe(transactionId)
+
+ if (includeIdToken) {
+ expect(startedEvent.idToken).toBeDefined()
+ expect(startedEvent.idToken?.idToken).toBe(`${id.toUpperCase()}_TOKEN_001`)
+ }
+ })
+
+ await it(`should support complete ${description} transaction lifecycle`, () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType | undefined = includeIdToken
+ ? {
+ idToken: `${id.toUpperCase()}_LIFECYCLE_001`,
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
+ : undefined
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Step 1: Started event
+ const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ expectedStartTrigger,
+ connectorId,
+ transactionId,
+ idToken != null ? { idToken } : undefined
+ )
+
+ // Step 2: Charging state change
+ const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ )
+
+ // Step 3: Ended event
+ const endedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ // Validate event sequence
+ expect(startedEvent.seqNo).toBe(0)
+ expect(chargingEvent.seqNo).toBe(1)
+ expect(endedEvent.seqNo).toBe(2)
+
+ // All events share same transaction ID
+ expect(startedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(chargingEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(endedEvent.transactionInfo.transactionId).toBe(transactionId)
+ })
+
+ await it(`should maintain independent sequence numbers on different connectors for ${description}`, () => {
+ const connector1 = 1
+ const connector2 = 2
+ const transaction1Id = generateUUID()
+ const transaction2Id = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connector1)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connector2)
+
+ // Start transaction on connector 1
+ const conn1Event1 = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ expectedStartTrigger,
+ connector1,
+ transaction1Id
+ )
+
+ // Start transaction on connector 2
+ const conn2Event1 = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ expectedStartTrigger,
+ connector2,
+ transaction2Id
+ )
+
+ // Update connector 1
+ const conn1Event2 = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connector1,
+ transaction1Id
+ )
+
+ // Update connector 2
+ const conn2Event2 = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connector2,
+ transaction2Id
+ )
+
+ // Verify independent sequence numbers
+ expect(conn1Event1.seqNo).toBe(0)
+ expect(conn1Event2.seqNo).toBe(1)
+ expect(conn2Event1.seqNo).toBe(0)
+ expect(conn2Event2.seqNo).toBe(1)
+
+ // Verify independent transaction IDs
+ expect(conn1Event1.transactionInfo.transactionId).toBe(transaction1Id)
+ expect(conn2Event1.transactionInfo.transactionId).toBe(transaction2Id)
+ })
+ })
+ }
+ })
+
+ // ==========================================================================
+ // E02 Cable-First Specific Tests
+ // ==========================================================================
+ await describe('E02 - Cable-First Transaction', async () => {
+ beforeEach(() => {
+ resetConnectorTransactionState(mockChargingStation)
+ })
+
+ await describe('Cable Plug Event Sequencing', async () => {
+ await it('should sequence CablePluggedIn → EVDetected → Charging correctly', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Step 1: Cable plugged in (Started)
+ const cablePluggedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ )
+
+ // Step 2: EV detected (Updated)
+ const evDetectedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ connectorId,
+ transactionId
+ )
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
+ // Step 3: Charging starts (Updated with ChargingStateChanged)
+ const chargingStartedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ )
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.MeterValueClock)
- })
+ // Assert sequence numbers follow correct order
+ expect(cablePluggedEvent.seqNo).toBe(0)
+ expect(evDetectedEvent.seqNo).toBe(1)
+ expect(chargingStartedEvent.seqNo).toBe(2)
- await it('should select SignedDataReceived when isSignedDataReceived flag is true', () => {
- const context: OCPP20TransactionContext = {
- isSignedDataReceived: true,
- source: 'meter_value',
- }
+ // Assert all events share the same transaction ID
+ expect(cablePluggedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(evDetectedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(chargingStartedEvent.transactionInfo.transactionId).toBe(transactionId)
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
- )
+ // Assert event types match expected pattern
+ expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(evDetectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(chargingStartedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ })
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.SignedDataReceived)
- })
+ await it('should handle EVDeparted for cable removal ending transaction', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
- await it('should select appropriate system events for system_event context', () => {
- const testCases = [
- { expected: OCPP20TriggerReasonEnumType.EVDeparted, systemEvent: 'ev_departed' as const },
- { expected: OCPP20TriggerReasonEnumType.EVDetected, systemEvent: 'ev_detected' as const },
- {
- expected: OCPP20TriggerReasonEnumType.EVCommunicationLost,
- systemEvent: 'ev_communication_lost' as const,
- },
- {
- expected: OCPP20TriggerReasonEnumType.EVConnectTimeout,
- systemEvent: 'ev_connect_timeout' as const,
- },
- ]
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- for (const testCase of testCases) {
- const context: OCPP20TransactionContext = {
- source: 'system_event',
- systemEvent: testCase.systemEvent,
- }
+ // Start transaction with cable plug
+ const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ )
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- context
+ // End transaction with EV departure (cable removal)
+ const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVDeparted,
+ connectorId,
+ transactionId
)
- expect(triggerReason).toBe(testCase.expected)
- }
+ // Assert proper sequencing for cable-initiated start and end
+ expect(startEvent.seqNo).toBe(0)
+ expect(startEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(endEvent.seqNo).toBe(1)
+ expect(endEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ expect(endEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+ })
})
- await it('should select EnergyLimitReached for energy_limit context', () => {
- const context: OCPP20TransactionContext = {
- source: 'energy_limit',
- }
+ await describe('EV Detection', async () => {
+ await it('should include EVDetected between cable plug and charging start', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EnergyLimitReached)
+ // Build full cable-first flow
+ const events = [
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ ]
+
+ // Assert EVDetected comes after CablePluggedIn and before authorization
+ expect(events[0].triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(events[1].triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
+ expect(events[2].triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(events[3].triggerReason).toBe(OCPP20TriggerReasonEnumType.ChargingStateChanged)
+
+ // Assert continuous sequence numbers
+ for (let i = 0; i < events.length; i++) {
+ expect(events[i].seqNo).toBe(i)
+ }
+ })
})
- await it('should select TimeLimitReached for time_limit context', () => {
- const context: OCPP20TransactionContext = {
- source: 'time_limit',
- }
-
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
-
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.TimeLimitReached)
- })
+ await describe('Connector Status Transitions', async () => {
+ await it('should track connector status through cable-first lifecycle', () => {
+ const connectorId = 1
- await it('should select AbnormalCondition for abnormal_condition context', () => {
- const context: OCPP20TransactionContext = {
- abnormalCondition: 'OverCurrent',
- source: 'abnormal_condition',
- }
+ // Get connector status object
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connectorStatus).toBeDefined()
+ if (connectorStatus == null) {
+ throw new Error('Connector status should be defined')
+ }
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
+ // Initial state: Available
+ connectorStatus.status = ConnectorStatusEnum.Available
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Available)
+
+ // After cable plug: Preparing (implied by transaction start)
+ connectorStatus.status = ConnectorStatusEnum.Preparing
+ connectorStatus.transactionStarted = true
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Preparing)
+ expect(connectorStatus.transactionStarted).toBe(true)
+
+ // After EV detected and auth: Charging
+ connectorStatus.status = ConnectorStatusEnum.Charging
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Charging)
+
+ // After EV departed: Available again
+ connectorStatus.status = ConnectorStatusEnum.Available
+ connectorStatus.transactionStarted = false
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Available)
+ expect(connectorStatus.transactionStarted).toBe(false)
+ })
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.AbnormalCondition)
- })
+ await it('should preserve transaction ID through cable-first flow states', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
- await it('should handle priority ordering with multiple applicable contexts', () => {
- // Test context with multiple applicable triggers - priority should be respected
- const context: OCPP20TransactionContext = {
- cableState: 'plugged_in', // Even lower priority
- command: 'RequestStartTransaction',
- isDeauthorized: true, // Lower priority but should be overridden
- source: 'remote_command', // High priority
- }
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connectorStatus).toBeDefined()
+ if (connectorStatus == null) {
+ throw new Error('Connector status should be defined')
+ }
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- context
- )
+ // Set transaction ID at start
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionStarted = true
+ connectorStatus.status = ConnectorStatusEnum.Preparing
- // Should select RemoteStart (priority 1) over Deauthorized (priority 2) or CablePluggedIn (priority 3)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStart)
- })
+ // Transition to charging
+ connectorStatus.status = ConnectorStatusEnum.Charging
- await it('should fallback to Trigger for unknown context source', () => {
- const context: OCPP20TransactionContext = {
- source: 'unknown_source' as OCPP20TransactionContext['source'], // Invalid source to test fallback
- }
+ // Transaction ID should persist through state changes
+ expect(connectorStatus.transactionId).toBe(transactionId)
+ expect(connectorStatus.transactionStarted).toBe(true)
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- context
- )
+ // Transition to finished
+ connectorStatus.status = ConnectorStatusEnum.Finishing
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Trigger)
+ // Still same transaction until fully ended
+ expect(connectorStatus.transactionId).toBe(transactionId)
+ })
})
- await it('should fallback to Trigger for incomplete context', () => {
- const context: OCPP20TransactionContext = {
- source: 'remote_command',
- // Missing command field
- }
+ await describe('Full Cable-First Transaction Lifecycle', async () => {
+ await it('should handle suspended charging states in cable-first flow', () => {
+ const connectorId = 3
+ const transactionId = generateUUID()
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- context
- )
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Trigger)
- })
- })
+ // Cable-first flow with suspended state
+ const events = [
+ // 1. Cable plugged
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ ),
+ // 2. Start charging
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ // 3. Suspended by EV
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.SuspendedEV }
+ ),
+ // 4. Resume charging
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ // 5. EV departed
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVDeparted,
+ connectorId,
+ transactionId
+ ),
+ ]
+
+ // Verify sequence numbers are continuous through suspend/resume
+ for (let i = 0; i < events.length; i++) {
+ expect(events[i].seqNo).toBe(i)
+ }
- await describe('buildTransactionEvent with context parameter', async () => {
- await it('should build TransactionEvent with auto-selected TriggerReason from context', () => {
- const connectorId = 1
- const transactionId = generateUUID()
- const context: OCPP20TransactionContext = {
- command: 'RequestStartTransaction',
- source: 'remote_command',
- }
+ // Verify all share same transaction ID
+ for (const event of events) {
+ expect(event.transactionInfo.transactionId).toBe(transactionId)
+ }
+ })
+ })
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ await describe('Context-Based Cable Event Trigger Selection', async () => {
+ await it('should select CablePluggedIn from cable_action context with plugged_in state', () => {
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ TransactionContextFixtures.cablePluggedIn()
+ )
- const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- context,
- connectorId,
- transactionId
- )
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ })
- expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(transactionEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStart)
- expect(transactionEvent.seqNo).toBe(0)
- expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId)
- })
+ await it('should select EVDetected from cable_action context with detected state', () => {
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ TransactionContextFixtures.evDetected()
+ )
- await it('should pass through optional parameters correctly', () => {
- const connectorId = 2
- const transactionId = generateUUID()
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'idToken',
- source: 'local_authorization',
- }
- const options = {
- chargingState: OCPP20ChargingStateEnumType.Charging,
- idToken: {
- idToken: 'CONTEXT_TEST_TOKEN',
- type: OCPP20IdTokenEnumType.ISO14443,
- },
- }
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
+ })
- const transactionEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- context,
- connectorId,
- transactionId,
- options
- )
+ await it('should select EVDeparted from cable_action context with unplugged state', () => {
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ TransactionContextFixtures.evDeparted()
+ )
- expect(transactionEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- expect(transactionEvent.idToken?.idToken).toBe('CONTEXT_TEST_TOKEN')
- expect(transactionEvent.transactionInfo.chargingState).toBe(
- OCPP20ChargingStateEnumType.Charging
- )
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ })
})
})
- await describe('sendTransactionEvent with context parameter', async () => {
- await it('should send TransactionEvent with context-aware TriggerReason selection', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
- const context: OCPP20TransactionContext = {
- cableState: 'plugged_in',
- source: 'cable_action',
- }
+ // ==========================================================================
+ // E03 IdToken-First Specific Tests
+ // ==========================================================================
+ await describe('E03 - IdToken-First Pre-Authorization', async () => {
+ beforeEach(() => {
+ resetConnectorTransactionState(mockChargingStation)
+ })
- const response = await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- context,
- connectorId,
- transactionId
- )
+ await describe('E03.FR.13 - Trigger Reason Selection', async () => {
+ await it('should select groupIdToken trigger for group authorization', () => {
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'groupIdToken',
+ source: 'local_authorization',
+ }
- // Validate response structure
- expect(response).toBeDefined()
- expect(typeof response).toBe('object')
- })
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
- await it('should handle context-aware error scenarios gracefully', async () => {
- // Create error mock for this test
- const { station: errorMockChargingStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- evseConfiguration: { evsesCount: 1 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
- ocppRequestService: {
- requestHandler: () => {
- throw new Error('Context test error')
- },
- },
- stationInfo: {
- ocppStrictCompliance: true,
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
})
- const connectorId = 1
- const transactionId = generateUUID()
- const context: OCPP20TransactionContext = {
- abnormalCondition: 'TestError',
- source: 'abnormal_condition',
- }
+ await it('should differentiate IdToken-first from Cable-first by trigger reason', () => {
+ // IdToken-first: Authorized trigger
+ const idTokenFirstContext: OCPP20TransactionContext = {
+ authorizationMethod: 'idToken',
+ source: 'local_authorization',
+ }
- try {
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorMockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- context,
- connectorId,
- transactionId
+ // Cable-first: CablePluggedIn trigger
+ const cableFirstContext: OCPP20TransactionContext = {
+ cableState: 'plugged_in',
+ source: 'cable_action',
+ }
+
+ const idTokenTrigger = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ idTokenFirstContext
)
- throw new Error('Should have thrown error')
- } catch (error) {
- expect((error as Error).message).toContain('Context test error')
- }
- })
- })
- await describe('Backward Compatibility', async () => {
- await it('should maintain compatibility with existing buildTransactionEvent calls', () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ const cableTrigger = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ cableFirstContext
+ )
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ expect(idTokenTrigger).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(cableTrigger).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(idTokenTrigger).not.toBe(cableTrigger)
+ })
+ })
- // Old method call should still work
- const oldEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
+ await describe('E03.FR.01 - IdToken in TransactionEvent', async () => {
+ await it('should include idToken in first TransactionEvent after authorization', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'VALID_TOKEN_E03_001',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
- expect(oldEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(oldEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- expect(oldEvent.seqNo).toBe(0)
- })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await it('should maintain compatibility with existing sendTransactionEvent calls', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ // Build Started event with idToken (E03.FR.01: IdToken must be in first event)
+ const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ { idToken }
+ )
- // Old method call should still work
- const response = await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
+ expect(startedEvent.idToken).toBeDefined()
+ expect(startedEvent.idToken?.idToken).toBe('VALID_TOKEN_E03_001')
+ expect(startedEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.ISO14443)
+ expect(startedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(startedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ })
- expect(response).toBeDefined()
- expect(typeof response).toBe('object')
- })
- })
- })
+ await it('should not include idToken in subsequent events (E03.FR.01 compliance)', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'VALID_TOKEN_E03_002',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
- // ==========================================================================
- // Parameterized Transaction Flow Tests (E02, E03, Remote Start)
- // ==========================================================================
- await describe('Transaction Flow Patterns', async () => {
- for (const {
- description,
- expectedStartTrigger,
- id,
- includeIdToken,
- name,
- startContext,
- } of TRANSACTION_FLOWS) {
- await describe(`${name} Flow`, async () => {
- await it(`should select ${expectedStartTrigger} trigger for ${description} transaction start`, () => {
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // First event includes idToken
+ const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- startContext
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ { idToken }
+ )
+
+ // Second event should NOT include idToken (flag is set after first inclusion)
+ const updatedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging, idToken }
)
- expect(triggerReason).toBe(expectedStartTrigger)
+
+ expect(startedEvent.idToken).toBeDefined()
+ expect(updatedEvent.idToken).toBeUndefined()
})
- await it(`should build correct Started event for ${description}`, () => {
+ await it('should support various IdToken types for E03 flow', () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType | undefined = includeIdToken
- ? { idToken: `${id.toUpperCase()}_TOKEN_001`, type: OCPP20IdTokenEnumType.ISO14443 }
- : undefined
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ // Test ISO14443 (RFID)
+ const rfidToken: OCPP20IdTokenType = {
+ idToken: 'RFID_TAG_123456',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
+
+ const rfidEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ { idToken: rfidToken }
+ )
+
+ expect(rfidEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.ISO14443)
+
+ // Reset for eMAID test
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ connectorStatus.transactionIdTokenSent = undefined
+ }
+
+ // Test eMAID (contract identifier)
+ const emaidToken: OCPP20IdTokenType = {
+ idToken: 'DE*ABC*E123456*1',
+ type: OCPP20IdTokenEnumType.eMAID,
+ }
+
+ const emaidEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- expectedStartTrigger,
+ OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId,
- idToken != null ? { idToken } : undefined
+ generateUUID(),
+ { idToken: emaidToken }
)
- expect(startedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(startedEvent.triggerReason).toBe(expectedStartTrigger)
- expect(startedEvent.seqNo).toBe(0)
- expect(startedEvent.transactionInfo.transactionId).toBe(transactionId)
-
- if (includeIdToken) {
- expect(startedEvent.idToken).toBeDefined()
- expect(startedEvent.idToken?.idToken).toBe(`${id.toUpperCase()}_TOKEN_001`)
- }
+ expect(emaidEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.eMAID)
+ expect(emaidEvent.idToken?.idToken).toBe('DE*ABC*E123456*1')
})
+ })
- await it(`should support complete ${description} transaction lifecycle`, () => {
+ await describe('Full IdToken-First Transaction Lifecycle', async () => {
+ await it('should support complete IdToken-first to cable to charging to end flow', () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType | undefined = includeIdToken
- ? { idToken: `${id.toUpperCase()}_LIFECYCLE_001`, type: OCPP20IdTokenEnumType.ISO14443 }
- : undefined
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'LIFECYCLE_TOKEN_001',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Step 1: Started event
- const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ // E03 Step 1: IdToken presented and authorized (Started with Authorized trigger)
+ const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- expectedStartTrigger,
+ OCPP20TriggerReasonEnumType.Authorized,
connectorId,
transactionId,
- idToken != null ? { idToken } : undefined
+ { idToken }
+ )
+
+ // E03 Step 2: Cable connected (Updated event)
+ const cableConnectedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
)
- // Step 2: Charging state change
+ // E03 Step 3: Charging starts
const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Updated,
{ chargingState: OCPP20ChargingStateEnumType.Charging }
)
- // Step 3: Ended event
+ // E03 Step 4: Transaction ends
const endedEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Ended,
)
// Validate event sequence
- expect(startedEvent.seqNo).toBe(0)
- expect(chargingEvent.seqNo).toBe(1)
- expect(endedEvent.seqNo).toBe(2)
+ expect(authorizedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(authorizedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(authorizedEvent.idToken).toBeDefined()
+ expect(authorizedEvent.seqNo).toBe(0)
+
+ expect(cableConnectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(cableConnectedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(cableConnectedEvent.idToken).toBeUndefined() // E03.FR.01: idToken only in first event
+ expect(cableConnectedEvent.seqNo).toBe(1)
+
+ expect(chargingEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(chargingEvent.seqNo).toBe(2)
+
+ expect(endedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+ expect(endedEvent.seqNo).toBe(3)
// All events share same transaction ID
- expect(startedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(authorizedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(cableConnectedEvent.transactionInfo.transactionId).toBe(transactionId)
expect(chargingEvent.transactionInfo.transactionId).toBe(transactionId)
expect(endedEvent.transactionInfo.transactionId).toBe(transactionId)
})
- await it(`should maintain independent sequence numbers on different connectors for ${description}`, () => {
- const connector1 = 1
- const connector2 = 2
- const transaction1Id = generateUUID()
- const transaction2Id = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connector1)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connector2)
+ await it('should differentiate E03 lifecycle from E02 Cable-First lifecycle', () => {
+ const connectorId = 1
+ const e03TransactionId = generateUUID()
+ const e02TransactionId = generateUUID()
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'COMPARE_TOKEN_001',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
- // Start transaction on connector 1
- const conn1Event1 = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- expectedStartTrigger,
- connector1,
- transaction1Id
- )
+ // E03 IdToken-First: Starts with Authorized trigger
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ connectorStatus.transactionIdTokenSent = undefined
+ }
- // Start transaction on connector 2
- const conn2Event1 = OCPP20ServiceUtils.buildTransactionEvent(
+ const e03Start = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- expectedStartTrigger,
- connector2,
- transaction2Id
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ e03TransactionId,
+ { idToken }
)
- // Update connector 1
- const conn1Event2 = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connector1,
- transaction1Id
- )
+ // E02 Cable-First: Starts with CablePluggedIn trigger
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ if (connectorStatus != null) {
+ connectorStatus.transactionIdTokenSent = undefined
+ }
- // Update connector 2
- const conn2Event2 = OCPP20ServiceUtils.buildTransactionEvent(
+ const e02Start = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connector2,
- transaction2Id
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ e02TransactionId
)
- // Verify independent sequence numbers
- expect(conn1Event1.seqNo).toBe(0)
- expect(conn1Event2.seqNo).toBe(1)
- expect(conn2Event1.seqNo).toBe(0)
- expect(conn2Event2.seqNo).toBe(1)
+ // Key difference: E03 starts with Authorized, E02 starts with CablePluggedIn
+ expect(e03Start.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(e02Start.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- // Verify independent transaction IDs
- expect(conn1Event1.transactionInfo.transactionId).toBe(transaction1Id)
- expect(conn2Event1.transactionInfo.transactionId).toBe(transaction2Id)
+ // E03 includes idToken in first event, E02 may not
+ expect(e03Start.idToken).toBeDefined()
+ expect(e02Start.idToken).toBeUndefined()
})
})
- }
- })
-
- // ==========================================================================
- // E02 Cable-First Specific Tests
- // ==========================================================================
- await describe('E02 - Cable-First Transaction', async () => {
- beforeEach(() => {
- resetConnectorTransactionState(mockChargingStation)
- })
-
- await describe('Cable Plug Event Sequencing', async () => {
- await it('should sequence CablePluggedIn → EVDetected → Charging correctly', () => {
- const connectorId = 1
- const transactionId = generateUUID()
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ await describe('E03.FR.05/06 - EVConnectionTimeOut', async () => {
+ await it('should support authorization cancellation event (cable not connected)', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'TIMEOUT_TOKEN_001',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
- // Step 1: Cable plugged in (Started)
- const cablePluggedEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- connectorId,
- transactionId
- )
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Step 2: EV detected (Updated)
- const evDetectedEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.EVDetected,
- connectorId,
- transactionId
- )
+ // E03.FR.05: User authorizes with IdToken
+ const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ { idToken }
+ )
- // Step 3: Charging starts (Updated with ChargingStateChanged)
- const chargingStartedEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId,
- { chargingState: OCPP20ChargingStateEnumType.Charging }
- )
+ // E03.FR.06: Cable not connected within timeout - transaction ends with Timeout
+ const timeoutEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVConnectTimeout,
+ connectorId,
+ transactionId
+ )
- // Assert sequence numbers follow correct order
- expect(cablePluggedEvent.seqNo).toBe(0)
- expect(evDetectedEvent.seqNo).toBe(1)
- expect(chargingStartedEvent.seqNo).toBe(2)
+ expect(authorizedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(authorizedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- // Assert all events share the same transaction ID
- expect(cablePluggedEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(evDetectedEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(chargingStartedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(timeoutEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+ expect(timeoutEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVConnectTimeout)
+ expect(timeoutEvent.seqNo).toBe(1)
- // Assert event types match expected pattern
- expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(evDetectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
- expect(chargingStartedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ // Same transaction ID for both events
+ expect(authorizedEvent.transactionInfo.transactionId).toBe(
+ timeoutEvent.transactionInfo.transactionId
+ )
+ })
})
- await it('should handle EVDeparted for cable removal ending transaction', () => {
- const connectorId = 2
- const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Start transaction with cable plug
- const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- connectorId,
- transactionId
- )
+ await describe('Authorization Status in E03', async () => {
+ await it('should support Deauthorized trigger for rejected authorization', () => {
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'idToken',
+ isDeauthorized: true,
+ source: 'local_authorization',
+ }
- // End transaction with EV departure (cable removal)
- const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.EVDeparted,
- connectorId,
- transactionId
- )
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
- // Assert proper sequencing for cable-initiated start and end
- expect(startEvent.seqNo).toBe(0)
- expect(startEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- expect(endEvent.seqNo).toBe(1)
- expect(endEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
- expect(endEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
- })
- })
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized)
+ })
- await describe('EV Detection', async () => {
- await it('should include EVDetected between cable plug and charging start', () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ await it('should handle transaction end after token revocation', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'REVOKED_TOKEN_001',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Build full cable-first flow
- const events = [
- OCPP20ServiceUtils.buildTransactionEvent(
+ // Transaction started with authorization
+ const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- connectorId,
- transactionId
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.EVDetected,
- connectorId,
- transactionId
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
transactionId,
- { chargingState: OCPP20ChargingStateEnumType.Charging }
- ),
- ]
+ { idToken }
+ )
- // Assert EVDetected comes after CablePluggedIn and before authorization
- expect(events[0].triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- expect(events[1].triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
- expect(events[2].triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- expect(events[3].triggerReason).toBe(OCPP20TriggerReasonEnumType.ChargingStateChanged)
+ // Transaction ended due to deauthorization (e.g., token revoked mid-session)
+ const revokedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.Deauthorized,
+ connectorId,
+ transactionId
+ )
- // Assert continuous sequence numbers
- for (let i = 0; i < events.length; i++) {
- expect(events[i].seqNo).toBe(i)
- }
- })
- })
+ expect(startEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(revokedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+ expect(revokedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized)
+ })
- await describe('Connector Status Transitions', async () => {
- await it('should track connector status through cable-first lifecycle', () => {
- const connectorId = 1
+ await it('should support StopAuthorized trigger for normal transaction end', () => {
+ const context: OCPP20TransactionContext = {
+ authorizationMethod: 'stopAuthorized',
+ source: 'local_authorization',
+ }
- // Get connector status object
- const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
- expect(connectorStatus).toBeDefined()
- if (connectorStatus == null) {
- throw new Error('Connector status should be defined')
- }
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
- // Initial state: Available
- connectorStatus.status = ConnectorStatusEnum.Available
- expect(connectorStatus.status).toBe(ConnectorStatusEnum.Available)
-
- // After cable plug: Preparing (implied by transaction start)
- connectorStatus.status = ConnectorStatusEnum.Preparing
- connectorStatus.transactionStarted = true
- expect(connectorStatus.status).toBe(ConnectorStatusEnum.Preparing)
- expect(connectorStatus.transactionStarted).toBe(true)
-
- // After EV detected and auth: Charging
- connectorStatus.status = ConnectorStatusEnum.Charging
- expect(connectorStatus.status).toBe(ConnectorStatusEnum.Charging)
-
- // After EV departed: Available again
- connectorStatus.status = ConnectorStatusEnum.Available
- connectorStatus.transactionStarted = false
- expect(connectorStatus.status).toBe(ConnectorStatusEnum.Available)
- expect(connectorStatus.transactionStarted).toBe(false)
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.StopAuthorized)
+ })
})
- await it('should preserve transaction ID through cable-first flow states', () => {
- const connectorId = 2
- const transactionId = generateUUID()
-
- const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
- expect(connectorStatus).toBeDefined()
- if (connectorStatus == null) {
- throw new Error('Connector status should be defined')
- }
-
- // Set transaction ID at start
- connectorStatus.transactionId = transactionId
- connectorStatus.transactionStarted = true
- connectorStatus.status = ConnectorStatusEnum.Preparing
+ await describe('E03.FR.07/08 - Sequence Numbers and Transaction ID', async () => {
+ await it('should maintain continuous sequence numbers throughout E03 lifecycle', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const idToken: OCPP20IdTokenType = {
+ idToken: 'SEQ_TOKEN_001',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ }
- // Transition to charging
- connectorStatus.status = ConnectorStatusEnum.Charging
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Transaction ID should persist through state changes
- expect(connectorStatus.transactionId).toBe(transactionId)
- expect(connectorStatus.transactionStarted).toBe(true)
+ const events = [
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ { idToken }
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ ),
+ ]
+
+ // E03.FR.07: Sequence numbers must be continuous
+ events.forEach((event, index) => {
+ expect(event.seqNo).toBe(index)
+ })
+ })
- // Transition to finished
- connectorStatus.status = ConnectorStatusEnum.Finishing
+ await it('should use unique transaction ID (E03.FR.08)', () => {
+ const connectorId = 1
- // Still same transaction until fully ended
- expect(connectorStatus.transactionId).toBe(transactionId)
- })
- })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await describe('Full Cable-First Transaction Lifecycle', async () => {
- await it('should handle suspended charging states in cable-first flow', () => {
- const connectorId = 3
- const transactionId = generateUUID()
+ const transaction1Id = generateUUID()
+ const transaction2Id = generateUUID()
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ // E03.FR.08: transactionId MUST be unique
+ expect(transaction1Id).not.toBe(transaction2Id)
- // Cable-first flow with suspended state
- const events = [
- // 1. Cable plugged
- OCPP20ServiceUtils.buildTransactionEvent(
+ const event1 = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- connectorId,
- transactionId
- ),
- // 2. Start charging
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId,
- { chargingState: OCPP20ChargingStateEnumType.Charging }
- ),
- // 3. Suspended by EV
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId,
- { chargingState: OCPP20ChargingStateEnumType.SuspendedEV }
- ),
- // 4. Resume charging
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId,
- { chargingState: OCPP20ChargingStateEnumType.Charging }
- ),
- // 5. EV departed
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.EVDeparted,
+ OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId
- ),
- ]
-
- // Verify sequence numbers are continuous through suspend/resume
- for (let i = 0; i < events.length; i++) {
- expect(events[i].seqNo).toBe(i)
- }
-
- // Verify all share same transaction ID
- for (const event of events) {
- expect(event.transactionInfo.transactionId).toBe(transactionId)
- }
- })
- })
-
- await describe('Context-Based Cable Event Trigger Selection', async () => {
- await it('should select CablePluggedIn from cable_action context with plugged_in state', () => {
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- TransactionContextFixtures.cablePluggedIn()
- )
-
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- })
-
- await it('should select EVDetected from cable_action context with detected state', () => {
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Updated,
- TransactionContextFixtures.evDetected()
- )
+ transaction1Id
+ )
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
- })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await it('should select EVDeparted from cable_action context with unplugged state', () => {
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- TransactionContextFixtures.evDeparted()
- )
+ const event2 = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transaction2Id
+ )
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ expect(event1.transactionInfo.transactionId).toBe(transaction1Id)
+ expect(event2.transactionInfo.transactionId).toBe(transaction2Id)
+ expect(event1.transactionInfo.transactionId).not.toBe(
+ event2.transactionInfo.transactionId
+ )
+ })
})
})
})
- // ==========================================================================
- // E03 IdToken-First Specific Tests
- // ==========================================================================
- await describe('E03 - IdToken-First Pre-Authorization', async () => {
- beforeEach(() => {
- resetConnectorTransactionState(mockChargingStation)
- })
-
- await describe('E03.FR.13 - Trigger Reason Selection', async () => {
- await it('should select groupIdToken trigger for group authorization', () => {
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'groupIdToken',
- source: 'local_authorization',
- }
-
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- context
- )
+ // ============================================================================
+ // Offline TransactionEvent Queueing Tests
+ // ============================================================================
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- })
+ await describe('E02 - OCPP 2.0.1 Offline TransactionEvent Queueing', async () => {
+ let mockTracking: MockStationWithTracking
+ let mockChargingStation: ChargingStation
+ let sentRequests: CapturedOCPPRequest[]
+ let setOnline: (online: boolean) => void
- await it('should differentiate IdToken-first from Cable-first by trigger reason', () => {
- // IdToken-first: Authorized trigger
- const idTokenFirstContext: OCPP20TransactionContext = {
- authorizationMethod: 'idToken',
- source: 'local_authorization',
- }
+ beforeEach(() => {
+ mockTracking = createMockStationWithRequestTracking()
+ mockChargingStation = mockTracking.station
+ sentRequests = mockTracking.sentRequests
+ setOnline = mockTracking.setOnline
+ })
- // Cable-first: CablePluggedIn trigger
- const cableFirstContext: OCPP20TransactionContext = {
- cableState: 'plugged_in',
- source: 'cable_action',
+ afterEach(() => {
+ for (let connectorId = 1; connectorId <= 3; connectorId++) {
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ if (connector != null) {
+ connector.transactionEventQueue = undefined
}
-
- const idTokenTrigger = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- idTokenFirstContext
- )
-
- const cableTrigger = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Started,
- cableFirstContext
- )
-
- expect(idTokenTrigger).toBe(OCPP20TriggerReasonEnumType.Authorized)
- expect(cableTrigger).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- expect(idTokenTrigger).not.toBe(cableTrigger)
- })
+ }
+ standardCleanup()
})
- await describe('E03.FR.01 - IdToken in TransactionEvent', async () => {
- await it('should include idToken in first TransactionEvent after authorization', () => {
+ await describe('Queue formation when offline', async () => {
+ await it('should queue TransactionEvent when WebSocket is disconnected', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'VALID_TOKEN_E03_001',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
+
+ setOnline(false)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Build Started event with idToken (E03.FR.01: IdToken must be in first event)
- const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ const response = await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId,
- { idToken }
+ transactionId
)
- expect(startedEvent.idToken).toBeDefined()
- expect(startedEvent.idToken?.idToken).toBe('VALID_TOKEN_E03_001')
- expect(startedEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.ISO14443)
- expect(startedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(startedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(sentRequests.length).toBe(0)
+
+ expect(response.idTokenInfo).toBeUndefined()
+
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector?.transactionEventQueue).toBeDefined()
+ expect(connector.transactionEventQueue.length).toBe(1)
+ expect(connector.transactionEventQueue[0].seqNo).toBe(0)
})
- await it('should not include idToken in subsequent events (E03.FR.01 compliance)', () => {
+ await it('should queue multiple TransactionEvents in order when offline', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'VALID_TOKEN_E03_002',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
+
+ setOnline(false)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // First event includes idToken
- const startedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId,
- { idToken }
+ transactionId
)
- // Second event should NOT include idToken (flag is set after first inclusion)
- const updatedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- transactionId,
- { chargingState: OCPP20ChargingStateEnumType.Charging, idToken }
+ transactionId
)
- expect(startedEvent.idToken).toBeDefined()
- expect(updatedEvent.idToken).toBeUndefined()
- })
-
- await it('should support various IdToken types for E03 flow', () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Test ISO14443 (RFID)
- const rfidToken: OCPP20IdTokenType = {
- idToken: 'RFID_TAG_123456',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
-
- const rfidEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
connectorId,
- transactionId,
- { idToken: rfidToken }
+ transactionId
)
- expect(rfidEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.ISO14443)
-
- // Reset for eMAID test
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
- if (connectorStatus != null) {
- connectorStatus.transactionIdTokenSent = undefined
- }
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector?.transactionEventQueue?.length).toBe(3)
- // Test eMAID (contract identifier)
- const emaidToken: OCPP20IdTokenType = {
- idToken: 'DE*ABC*E123456*1',
- type: OCPP20IdTokenEnumType.eMAID,
- }
+ expect(connector.transactionEventQueue[0].seqNo).toBe(0)
+ expect(connector.transactionEventQueue[1].seqNo).toBe(1)
+ expect(connector.transactionEventQueue[2].seqNo).toBe(2)
- const emaidEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- generateUUID(),
- { idToken: emaidToken }
+ expect(connector.transactionEventQueue[0].request.eventType).toBe(
+ OCPP20TransactionEventEnumType.Started
+ )
+ expect(connector.transactionEventQueue[1].request.eventType).toBe(
+ OCPP20TransactionEventEnumType.Updated
+ )
+ expect(connector.transactionEventQueue[2].request.eventType).toBe(
+ OCPP20TransactionEventEnumType.Ended
)
-
- expect(emaidEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.eMAID)
- expect(emaidEvent.idToken?.idToken).toBe('DE*ABC*E123456*1')
})
- })
- await describe('Full IdToken-First Transaction Lifecycle', async () => {
- await it('should support complete IdToken-first to cable to charging to end flow', () => {
+ await it('should preserve seqNo in queued events', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'LIFECYCLE_TOKEN_001',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
+ setOnline(true)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // E03 Step 1: IdToken presented and authorized (Started with Authorized trigger)
- const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId,
- { idToken }
+ transactionId
)
- // E03 Step 2: Cable connected (Updated event)
- const cableConnectedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ expect(sentRequests.length).toBe(1)
+ expect(sentRequests[0].payload.seqNo).toBe(0)
+
+ setOnline(false)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
transactionId
)
- // E03 Step 3: Charging starts
- const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId,
- { chargingState: OCPP20ChargingStateEnumType.Charging }
- )
-
- // E03 Step 4: Transaction ends
- const endedEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
transactionId
)
- // Validate event sequence
- expect(authorizedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(authorizedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- expect(authorizedEvent.idToken).toBeDefined()
- expect(authorizedEvent.seqNo).toBe(0)
-
- expect(cableConnectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
- expect(cableConnectedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
- expect(cableConnectedEvent.idToken).toBeUndefined() // E03.FR.01: idToken only in first event
- expect(cableConnectedEvent.seqNo).toBe(1)
-
- expect(chargingEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
- expect(chargingEvent.seqNo).toBe(2)
-
- expect(endedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
- expect(endedEvent.seqNo).toBe(3)
-
- // All events share same transaction ID
- expect(authorizedEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(cableConnectedEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(chargingEvent.transactionInfo.transactionId).toBe(transactionId)
- expect(endedEvent.transactionInfo.transactionId).toBe(transactionId)
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector?.transactionEventQueue?.length).toBe(2)
+ expect(connector.transactionEventQueue[0].seqNo).toBe(1)
+ expect(connector.transactionEventQueue[1].seqNo).toBe(2)
})
- await it('should differentiate E03 lifecycle from E02 Cable-First lifecycle', () => {
+ await it('should include timestamp in queued events', async () => {
const connectorId = 1
- const e03TransactionId = generateUUID()
- const e02TransactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'COMPARE_TOKEN_001',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
+ const transactionId = generateUUID()
- // E03 IdToken-First: Starts with Authorized trigger
+ setOnline(false)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
- if (connectorStatus != null) {
- connectorStatus.transactionIdTokenSent = undefined
- }
- const e03Start = OCPP20ServiceUtils.buildTransactionEvent(
+ const beforeQueue = new Date()
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- e03TransactionId,
- { idToken }
+ transactionId
)
+ const afterQueue = new Date()
- // E02 Cable-First: Starts with CablePluggedIn trigger
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- if (connectorStatus != null) {
- connectorStatus.transactionIdTokenSent = undefined
- }
-
- const e02Start = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- connectorId,
- e02TransactionId
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector?.transactionEventQueue?.[0]?.timestamp).toBeInstanceOf(Date)
+ expect(connector.transactionEventQueue[0].timestamp.getTime()).toBeGreaterThanOrEqual(
+ beforeQueue.getTime()
+ )
+ expect(connector.transactionEventQueue[0].timestamp.getTime()).toBeLessThanOrEqual(
+ afterQueue.getTime()
)
-
- // Key difference: E03 starts with Authorized, E02 starts with CablePluggedIn
- expect(e03Start.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
- expect(e02Start.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
-
- // E03 includes idToken in first event, E02 may not
- expect(e03Start.idToken).toBeDefined()
- expect(e02Start.idToken).toBeUndefined()
})
})
- await describe('E03.FR.05/06 - EVConnectionTimeOut', async () => {
- await it('should support authorization cancellation event (cable not connected)', () => {
+ await describe('Queue draining when coming online', async () => {
+ await it('should send all queued events when sendQueuedTransactionEvents is called', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'TIMEOUT_TOKEN_001',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
+ setOnline(false)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // E03.FR.05: User authorizes with IdToken
- const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId,
- { idToken }
+ transactionId
)
- // E03.FR.06: Cable not connected within timeout - transaction ends with Timeout
- const timeoutEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.EVConnectTimeout,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
transactionId
)
- expect(authorizedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(authorizedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(sentRequests.length).toBe(0)
- expect(timeoutEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
- expect(timeoutEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVConnectTimeout)
- expect(timeoutEvent.seqNo).toBe(1)
+ setOnline(true)
- // Same transaction ID for both events
- expect(authorizedEvent.transactionInfo.transactionId).toBe(
- timeoutEvent.transactionInfo.transactionId
- )
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+
+ expect(sentRequests.length).toBe(2)
+ expect(sentRequests[0].payload.seqNo).toBe(0)
+ expect(sentRequests[1].payload.seqNo).toBe(1)
})
- })
- await describe('Authorization Status in E03', async () => {
- await it('should support Deauthorized trigger for rejected authorization', () => {
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'idToken',
- isDeauthorized: true,
- source: 'local_authorization',
- }
+ await it('should clear queue after sending', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
+ setOnline(false)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
)
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized)
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector?.transactionEventQueue?.length).toBe(1)
+
+ setOnline(true)
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+
+ expect(connector.transactionEventQueue.length).toBe(0)
})
- await it('should handle transaction end after token revocation', () => {
+ await it('should preserve FIFO order when draining queue', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'REVOKED_TOKEN_001',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
+ setOnline(false)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Transaction started with authorization
- const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transactionId,
- { idToken }
+ transactionId
+ )
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId
)
- // Transaction ended due to deauthorization (e.g., token revoked mid-session)
- const revokedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.Deauthorized,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
connectorId,
transactionId
)
- expect(startEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(revokedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
- expect(revokedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized)
+ setOnline(true)
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+
+ expect(sentRequests[0].payload.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(sentRequests[1].payload.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(sentRequests[2].payload.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+
+ expect(sentRequests[0].payload.seqNo).toBe(0)
+ expect(sentRequests[1].payload.seqNo).toBe(1)
+ expect(sentRequests[2].payload.seqNo).toBe(2)
})
- await it('should support StopAuthorized trigger for normal transaction end', () => {
- const context: OCPP20TransactionContext = {
- authorizationMethod: 'stopAuthorized',
- source: 'local_authorization',
- }
+ await it('should handle empty queue gracefully', async () => {
+ const connectorId = 1
- const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
- OCPP20TransactionEventEnumType.Ended,
- context
- )
+ await expect(
+ OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+ ).resolves.toBeUndefined()
- expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.StopAuthorized)
+ expect(sentRequests.length).toBe(0)
})
- })
- await describe('E03.FR.07/08 - Sequence Numbers and Transaction ID', async () => {
- await it('should maintain continuous sequence numbers throughout E03 lifecycle', () => {
+ await it('should handle null queue gracefully', async () => {
const connectorId = 1
- const transactionId = generateUUID()
- const idToken: OCPP20IdTokenType = {
- idToken: 'SEQ_TOKEN_001',
- type: OCPP20IdTokenEnumType.ISO14443,
- }
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ connector.transactionEventQueue = undefined
- const events = [
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId,
- { idToken }
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.CablePluggedIn,
- connectorId,
- transactionId
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- ),
- OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId
- ),
- ]
+ await expect(
+ OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+ ).resolves.toBeUndefined()
- // E03.FR.07: Sequence numbers must be continuous
- events.forEach((event, index) => {
- expect(event.seqNo).toBe(index)
- })
+ expect(sentRequests.length).toBe(0)
})
+ })
- await it('should use unique transaction ID (E03.FR.08)', () => {
+ await describe('Sequence number continuity across queue boundary', async () => {
+ await it('should maintain seqNo continuity: online → offline → online', async () => {
const connectorId = 1
+ const transactionId = generateUUID()
+ setOnline(true)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- const transaction1Id = generateUUID()
- const transaction2Id = generateUUID()
-
- // E03.FR.08: transactionId MUST be unique
- expect(transaction1Id).not.toBe(transaction2Id)
-
- const event1 = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
- transaction1Id
+ transactionId
)
+ expect(sentRequests[0].payload.seqNo).toBe(0)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ setOnline(false)
- const event2 = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- transaction2Id
+ transactionId
)
- expect(event1.transactionInfo.transactionId).toBe(transaction1Id)
- expect(event2.transactionInfo.transactionId).toBe(transaction2Id)
- expect(event1.transactionInfo.transactionId).not.toBe(event2.transactionInfo.transactionId)
- })
- })
- })
-})
-
-// ============================================================================
-// Offline TransactionEvent Queueing Tests
-// ============================================================================
-await describe('E02 - OCPP 2.0.1 Offline TransactionEvent Queueing', async () => {
- let mockTracking: MockStationWithTracking
- let mockChargingStation: ChargingStation
- let sentRequests: CapturedOCPPRequest[]
- let setOnline: (online: boolean) => void
-
- beforeEach(() => {
- mockTracking = createMockStationWithRequestTracking()
- mockChargingStation = mockTracking.station
- sentRequests = mockTracking.sentRequests
- setOnline = mockTracking.setOnline
- })
-
- afterEach(() => {
- for (let connectorId = 1; connectorId <= 3; connectorId++) {
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- if (connector != null) {
- connector.transactionEventQueue = undefined
- }
- }
- standardCleanup()
- })
-
- await describe('Queue formation when offline', async () => {
- await it('should queue TransactionEvent when WebSocket is disconnected', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- setOnline(false)
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- const response = await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
- expect(sentRequests.length).toBe(0)
+ setOnline(true)
- expect(response.idTokenInfo).toBeUndefined()
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector?.transactionEventQueue).toBeDefined()
- expect(connector.transactionEventQueue.length).toBe(1)
- expect(connector.transactionEventQueue[0].seqNo).toBe(0)
- })
+ expect(sentRequests[1].payload.seqNo).toBe(1)
+ expect(sentRequests[2].payload.seqNo).toBe(2)
- await it('should queue multiple TransactionEvents in order when offline', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- setOnline(false)
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId
- )
-
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector?.transactionEventQueue?.length).toBe(3)
-
- expect(connector.transactionEventQueue[0].seqNo).toBe(0)
- expect(connector.transactionEventQueue[1].seqNo).toBe(1)
- expect(connector.transactionEventQueue[2].seqNo).toBe(2)
-
- expect(connector.transactionEventQueue[0].request.eventType).toBe(
- OCPP20TransactionEventEnumType.Started
- )
- expect(connector.transactionEventQueue[1].request.eventType).toBe(
- OCPP20TransactionEventEnumType.Updated
- )
- expect(connector.transactionEventQueue[2].request.eventType).toBe(
- OCPP20TransactionEventEnumType.Ended
- )
- })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
- await it('should preserve seqNo in queued events', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- setOnline(true)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
-
- expect(sentRequests.length).toBe(1)
- expect(sentRequests[0].payload.seqNo).toBe(0)
-
- setOnline(false)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector?.transactionEventQueue?.length).toBe(2)
- expect(connector.transactionEventQueue[0].seqNo).toBe(1)
- expect(connector.transactionEventQueue[1].seqNo).toBe(2)
- })
+ expect(sentRequests[3].payload.seqNo).toBe(3)
- await it('should include timestamp in queued events', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- const beforeQueue = new Date()
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
- const afterQueue = new Date()
-
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector?.transactionEventQueue?.[0]?.timestamp).toBeInstanceOf(Date)
- expect(connector.transactionEventQueue[0].timestamp.getTime()).toBeGreaterThanOrEqual(
- beforeQueue.getTime()
- )
- expect(connector.transactionEventQueue[0].timestamp.getTime()).toBeLessThanOrEqual(
- afterQueue.getTime()
- )
+ for (let i = 0; i < sentRequests.length; i++) {
+ expect(sentRequests[i].payload.seqNo).toBe(i)
+ }
+ })
})
- })
- await describe('Queue draining when coming online', async () => {
- await it('should send all queued events when sendQueuedTransactionEvents is called', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ await describe('Multiple connectors with independent queues', async () => {
+ await it('should maintain separate queues for each connector', async () => {
+ const transactionId1 = generateUUID()
+ const transactionId2 = generateUUID()
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ setOnline(false)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ 1,
+ transactionId1
+ )
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ 2,
+ transactionId2
+ )
- expect(sentRequests.length).toBe(0)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId1
+ )
- setOnline(true)
+ const connector1 = mockChargingStation.getConnectorStatus(1)
+ const connector2 = mockChargingStation.getConnectorStatus(2)
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+ expect(connector1?.transactionEventQueue?.length).toBe(2)
+ expect(connector2?.transactionEventQueue?.length).toBe(1)
- expect(sentRequests.length).toBe(2)
- expect(sentRequests[0].payload.seqNo).toBe(0)
- expect(sentRequests[1].payload.seqNo).toBe(1)
- })
+ expect(connector1.transactionEventQueue[0].request.transactionInfo.transactionId).toBe(
+ transactionId1
+ )
+ expect(connector2.transactionEventQueue[0].request.transactionInfo.transactionId).toBe(
+ transactionId2
+ )
+ })
- await it('should clear queue after sending', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ await it('should drain queues independently per connector', async () => {
+ const transactionId1 = generateUUID()
+ const transactionId2 = generateUUID()
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ setOnline(false)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ 1,
+ transactionId1
+ )
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector?.transactionEventQueue?.length).toBe(1)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ 2,
+ transactionId2
+ )
- setOnline(true)
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
+ setOnline(true)
- expect(connector.transactionEventQueue.length).toBe(0)
- })
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 1)
- await it('should preserve FIFO order when draining queue', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.ChargingStateChanged,
- connectorId,
- transactionId
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId
- )
-
- setOnline(true)
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
-
- expect(sentRequests[0].payload.eventType).toBe(OCPP20TransactionEventEnumType.Started)
- expect(sentRequests[1].payload.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
- expect(sentRequests[2].payload.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
-
- expect(sentRequests[0].payload.seqNo).toBe(0)
- expect(sentRequests[1].payload.seqNo).toBe(1)
- expect(sentRequests[2].payload.seqNo).toBe(2)
- })
+ expect(sentRequests.length).toBe(1)
+ expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId1)
- await it('should handle empty queue gracefully', async () => {
- const connectorId = 1
+ const connector2 = mockChargingStation.getConnectorStatus(2)
+ expect(connector2?.transactionEventQueue?.length).toBe(1)
- await expect(
- OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
- ).resolves.toBeUndefined()
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 2)
- expect(sentRequests.length).toBe(0)
+ expect(sentRequests.length).toBe(2)
+ expect(sentRequests[1].payload.transactionInfo.transactionId).toBe(transactionId2)
+ })
})
- await it('should handle null queue gracefully', async () => {
- const connectorId = 1
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- connector.transactionEventQueue = undefined
+ await describe('Error handling during queue drain', async () => {
+ await it('should continue sending remaining events if one fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let callCount = 0
- await expect(
- OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
- ).resolves.toBeUndefined()
+ const errorOnSecondMock = mock.fn(async () => {
+ callCount++
+ if (callCount === 2) {
+ throw new Error('Network error on second event')
+ }
+ return Promise.resolve({} as EmptyObject)
+ })
- expect(sentRequests.length).toBe(0)
- })
- })
+ const { station: errorStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: errorOnSecondMock,
+ },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
- await describe('Sequence number continuity across queue boundary', async () => {
- await it('should maintain seqNo continuity: online → offline → online', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- setOnline(true)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
- expect(sentRequests[0].payload.seqNo).toBe(0)
-
- setOnline(false)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- setOnline(true)
-
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId)
-
- expect(sentRequests[1].payload.seqNo).toBe(1)
- expect(sentRequests[2].payload.seqNo).toBe(2)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId
- )
-
- expect(sentRequests[3].payload.seqNo).toBe(3)
-
- for (let i = 0; i < sentRequests.length; i++) {
- expect(sentRequests[i].payload.seqNo).toBe(i)
- }
- })
- })
+ errorStation.isWebSocketConnectionOpened = () => false
- await describe('Multiple connectors with independent queues', async () => {
- await it('should maintain separate queues for each connector', async () => {
- const transactionId1 = generateUUID()
- const transactionId2 = generateUUID()
-
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- 1,
- transactionId1
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- 2,
- transactionId2
- )
-
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- 1,
- transactionId1
- )
-
- const connector1 = mockChargingStation.getConnectorStatus(1)
- const connector2 = mockChargingStation.getConnectorStatus(2)
-
- expect(connector1?.transactionEventQueue?.length).toBe(2)
- expect(connector2?.transactionEventQueue?.length).toBe(1)
-
- expect(connector1.transactionEventQueue[0].request.transactionInfo.transactionId).toBe(
- transactionId1
- )
- expect(connector2.transactionEventQueue[0].request.transactionInfo.transactionId).toBe(
- transactionId2
- )
- })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(errorStation, connectorId)
- await it('should drain queues independently per connector', async () => {
- const transactionId1 = generateUUID()
- const transactionId2 = generateUUID()
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- 1,
- transactionId1
- )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- 2,
- transactionId2
- )
+ errorStation.isWebSocketConnectionOpened = () => true
- setOnline(true)
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId)
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 1)
+ expect(callCount).toBe(3)
+ })
+ })
+ })
- expect(sentRequests.length).toBe(1)
- expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId1)
+ // ============================================================================
+ // Periodic TransactionEvent Tests
+ // ============================================================================
- const connector2 = mockChargingStation.getConnectorStatus(2)
- expect(connector2?.transactionEventQueue?.length).toBe(1)
+ await describe('E02 - OCPP 2.0.1 Periodic TransactionEvent at TxUpdatedInterval', async () => {
+ let mockTracking: MockStationWithTracking
+ let mockChargingStation: ChargingStation
+ let sentRequests: CapturedOCPPRequest[]
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 2)
+ beforeEach(() => {
+ mockTracking = createMockStationWithRequestTracking()
+ mockChargingStation = mockTracking.station
+ sentRequests = mockTracking.sentRequests
+ })
- expect(sentRequests.length).toBe(2)
- expect(sentRequests[1].payload.transactionInfo.transactionId).toBe(transactionId2)
+ afterEach(() => {
+ // Clean up any running timers
+ for (let connectorId = 1; connectorId <= 3; connectorId++) {
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ if (connector?.transactionTxUpdatedSetInterval != null) {
+ clearInterval(connector.transactionTxUpdatedSetInterval)
+ connector.transactionTxUpdatedSetInterval = undefined
+ }
+ }
+ standardCleanup()
})
- })
- await describe('Error handling during queue drain', async () => {
- await it('should continue sending remaining events if one fails', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
- let callCount = 0
+ await describe('startTxUpdatedInterval', async () => {
+ await it('should not start timer for non-OCPP 2.0 stations', () => {
+ const { station: ocpp16Station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
- const errorOnSecondMock = mock.fn(async () => {
- callCount++
- if (callCount === 2) {
- throw new Error('Network error on second event')
- }
- return Promise.resolve({} as EmptyObject)
- })
+ // Call startTxUpdatedInterval on OCPP 1.6 station
+ ocpp16Station.startTxUpdatedInterval(1, 60000)
- const { station: errorStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- evseConfiguration: { evsesCount: 1 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
- ocppRequestService: {
- requestHandler: errorOnSecondMock,
- },
- stationInfo: {
- ocppStrictCompliance: true,
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ // Verify no timer was started (method should return early)
+ const connector = ocpp16Station.getConnectorStatus(1)
+ expect(connector?.transactionTxUpdatedSetInterval).toBeUndefined()
})
- errorStation.isWebSocketConnectionOpened = () => false
+ await it('should not start timer when interval is zero', () => {
+ const connectorId = 1
- OCPP20ServiceUtils.resetTransactionSequenceNumber(errorStation, connectorId)
+ // Simulate startTxUpdatedInterval with zero interval
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector).toBeDefined()
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
+ // Zero interval should not start timer
+ // This is verified by the implementation logging debug message
+ expect(connector.transactionTxUpdatedSetInterval).toBeUndefined()
+ })
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
+ await it('should not start timer when interval is negative', () => {
+ const connectorId = 1
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector).toBeDefined()
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId
- )
+ // Negative interval should not start timer
+ expect(connector.transactionTxUpdatedSetInterval).toBeUndefined()
+ })
- errorStation.isWebSocketConnectionOpened = () => true
+ await it('should handle non-existent connector gracefully', () => {
+ const nonExistentConnectorId = 999
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId)
+ // Should not throw for non-existent connector
+ expect(() => {
+ mockChargingStation.getConnectorStatus(nonExistentConnectorId)
+ }).not.toThrow()
- expect(callCount).toBe(3)
+ // Should return undefined for non-existent connector
+ expect(mockChargingStation.getConnectorStatus(nonExistentConnectorId)).toBeUndefined()
+ })
})
- })
-})
-// ============================================================================
-// Periodic TransactionEvent Tests
-// ============================================================================
-await describe('E02 - OCPP 2.0.1 Periodic TransactionEvent at TxUpdatedInterval', async () => {
- let mockTracking: MockStationWithTracking
- let mockChargingStation: ChargingStation
- let sentRequests: CapturedOCPPRequest[]
-
- beforeEach(() => {
- mockTracking = createMockStationWithRequestTracking()
- mockChargingStation = mockTracking.station
- sentRequests = mockTracking.sentRequests
- })
+ await describe('Periodic TransactionEvent generation', async () => {
+ await it('should send TransactionEvent with MeterValuePeriodic trigger reason', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- afterEach(() => {
- // Clean up any running timers
- for (let connectorId = 1; connectorId <= 3; connectorId++) {
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- if (connector?.transactionTxUpdatedSetInterval != null) {
- clearInterval(connector.transactionTxUpdatedSetInterval)
- connector.transactionTxUpdatedSetInterval = undefined
- }
- }
- standardCleanup()
- })
+ // Reset sequence number
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await describe('startTxUpdatedInterval', async () => {
- await it('should not start timer for non-OCPP 2.0 stations', () => {
- const { station: ocpp16Station } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- stationInfo: {
- ocppVersion: OCPPVersion.VERSION_16,
- },
+ // Simulate sending periodic TransactionEvent (what the timer callback does)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ // Verify the request was sent with correct trigger reason
+ expect(sentRequests.length).toBe(1)
+ expect(sentRequests[0].command).toBe('TransactionEvent')
+ expect(sentRequests[0].payload.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(sentRequests[0].payload.triggerReason).toBe(
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic
+ )
})
- // Call startTxUpdatedInterval on OCPP 1.6 station
- ocpp16Station.startTxUpdatedInterval(1, 60000)
+ await it('should increment seqNo for each periodic event', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- // Verify no timer was started (method should return early)
- const connector = ocpp16Station.getConnectorStatus(1)
- expect(connector?.transactionTxUpdatedSetInterval).toBeUndefined()
- })
+ // Reset sequence number for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await it('should not start timer when interval is zero', () => {
- const connectorId = 1
+ // Send initial Started event
+ const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ expect(startEvent.seqNo).toBe(0)
- // Simulate startTxUpdatedInterval with zero interval
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector).toBeDefined()
+ // Send multiple periodic events (simulating timer ticks)
+ for (let i = 1; i <= 3; i++) {
+ const periodicEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ expect(periodicEvent.seqNo).toBe(i)
+ }
- // Zero interval should not start timer
- // This is verified by the implementation logging debug message
- expect(connector.transactionTxUpdatedSetInterval).toBeUndefined()
- })
+ // Verify sequence numbers are continuous: 0, 1, 2, 3
+ const connector = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connector?.transactionSeqNo).toBe(3)
+ })
- await it('should not start timer when interval is negative', () => {
- const connectorId = 1
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector).toBeDefined()
+ await it('should maintain correct eventType (Updated) for periodic events', async () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
- // Negative interval should not start timer
- expect(connector.transactionTxUpdatedSetInterval).toBeUndefined()
- })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await it('should handle non-existent connector gracefully', () => {
- const nonExistentConnectorId = 999
+ // Send periodic event
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
- // Should not throw for non-existent connector
- expect(() => {
- mockChargingStation.getConnectorStatus(nonExistentConnectorId)
- }).not.toThrow()
+ // Verify eventType is Updated (not Started or Ended)
+ expect(sentRequests[0].payload.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ })
- // Should return undefined for non-existent connector
- expect(mockChargingStation.getConnectorStatus(nonExistentConnectorId)).toBeUndefined()
- })
- })
+ await it('should include EVSE information in periodic events', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- await describe('Periodic TransactionEvent generation', async () => {
- await it('should send TransactionEvent with MeterValuePeriodic trigger reason', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- // Reset sequence number
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Simulate sending periodic TransactionEvent (what the timer callback does)
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
-
- // Verify the request was sent with correct trigger reason
- expect(sentRequests.length).toBe(1)
- expect(sentRequests[0].command).toBe('TransactionEvent')
- expect(sentRequests[0].payload.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
- expect(sentRequests[0].payload.triggerReason).toBe(
- OCPP20TriggerReasonEnumType.MeterValuePeriodic
- )
- })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await it('should increment seqNo for each periodic event', () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- // Reset sequence number for new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Send initial Started event
- const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
- expect(startEvent.seqNo).toBe(0)
-
- // Send multiple periodic events (simulating timer ticks)
- for (let i = 1; i <= 3; i++) {
- const periodicEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ await OCPP20ServiceUtils.sendTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
transactionId
)
- expect(periodicEvent.seqNo).toBe(i)
- }
- // Verify sequence numbers are continuous: 0, 1, 2, 3
- const connector = mockChargingStation.getConnectorStatus(connectorId)
- expect(connector?.transactionSeqNo).toBe(3)
- })
+ // Verify EVSE info is present
+ expect(sentRequests[0].payload.evse).toBeDefined()
+ expect(sentRequests[0].payload.evse.id).toBe(connectorId)
+ })
- await it('should maintain correct eventType (Updated) for periodic events', async () => {
- const connectorId = 2
- const transactionId = generateUUID()
+ await it('should include transactionInfo with correct transactionId', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- // Send periodic event
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
- // Verify eventType is Updated (not Started or Ended)
- expect(sentRequests[0].payload.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ // Verify transactionInfo contains the transaction ID
+ expect(sentRequests[0].payload.transactionInfo).toBeDefined()
+ expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId)
+ })
})
- await it('should include EVSE information in periodic events', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ await describe('Timer lifecycle integration', async () => {
+ await it('should continue seqNo sequence across multiple periodic events', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ // Reset for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
+ // Simulate full transaction lifecycle with periodic updates
+ // 1. Started event (seqNo: 0)
+ const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ expect(startEvent.seqNo).toBe(0)
- // Verify EVSE info is present
- expect(sentRequests[0].payload.evse).toBeDefined()
- expect(sentRequests[0].payload.evse.id).toBe(connectorId)
- })
+ // 2. Multiple periodic updates (seqNo: 1, 2, 3)
+ for (let i = 1; i <= 3; i++) {
+ const updateEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ expect(updateEvent.seqNo).toBe(i)
+ }
- await it('should include transactionInfo with correct transactionId', async () => {
- const connectorId = 1
- const transactionId = generateUUID()
+ // 3. Ended event (seqNo: 4)
+ const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ expect(endEvent.seqNo).toBe(4)
+ })
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+ await it('should handle multiple connectors with independent timers', () => {
+ const transactionId1 = generateUUID()
+ const transactionId2 = generateUUID()
- await OCPP20ServiceUtils.sendTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
+ // Reset sequence numbers for both connectors
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
- // Verify transactionInfo contains the transaction ID
- expect(sentRequests[0].payload.transactionInfo).toBeDefined()
- expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId)
- })
- })
+ // Build events for connector 1
+ const event1Start = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ 1,
+ transactionId1
+ )
+ const event1Update = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId1
+ )
- await describe('Timer lifecycle integration', async () => {
- await it('should continue seqNo sequence across multiple periodic events', () => {
- const connectorId = 1
- const transactionId = generateUUID()
-
- // Reset for new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
-
- // Simulate full transaction lifecycle with periodic updates
- // 1. Started event (seqNo: 0)
- const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId
- )
- expect(startEvent.seqNo).toBe(0)
-
- // 2. Multiple periodic updates (seqNo: 1, 2, 3)
- for (let i = 1; i <= 3; i++) {
- const updateEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ // Build events for connector 2
+ const event2Start = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ 2,
+ transactionId2
+ )
+ const event2Update = OCPP20ServiceUtils.buildTransactionEvent(
mockChargingStation,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
+ 2,
+ transactionId2
)
- expect(updateEvent.seqNo).toBe(i)
- }
- // 3. Ended event (seqNo: 4)
- const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
- connectorId,
- transactionId
- )
- expect(endEvent.seqNo).toBe(4)
- })
+ // Verify independent sequence numbers
+ expect(event1Start.seqNo).toBe(0)
+ expect(event1Update.seqNo).toBe(1)
+ expect(event2Start.seqNo).toBe(0)
+ expect(event2Update.seqNo).toBe(1)
- await it('should handle multiple connectors with independent timers', () => {
- const transactionId1 = generateUUID()
- const transactionId2 = generateUUID()
-
- // Reset sequence numbers for both connectors
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
-
- // Build events for connector 1
- const event1Start = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- 1,
- transactionId1
- )
- const event1Update = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- 1,
- transactionId1
- )
-
- // Build events for connector 2
- const event2Start = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- 2,
- transactionId2
- )
- const event2Update = OCPP20ServiceUtils.buildTransactionEvent(
- mockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- 2,
- transactionId2
- )
-
- // Verify independent sequence numbers
- expect(event1Start.seqNo).toBe(0)
- expect(event1Update.seqNo).toBe(1)
- expect(event2Start.seqNo).toBe(0)
- expect(event2Update.seqNo).toBe(1)
-
- // Verify different transaction IDs
- expect(event1Start.transactionInfo.transactionId).toBe(transactionId1)
- expect(event2Start.transactionInfo.transactionId).toBe(transactionId2)
+ // Verify different transaction IDs
+ expect(event1Start.transactionInfo.transactionId).toBe(transactionId1)
+ expect(event2Start.transactionInfo.transactionId).toBe(transactionId2)
+ })
})
- })
- await describe('Error handling', async () => {
- await it('should handle network errors gracefully during periodic event', async () => {
- const { station: errorMockChargingStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- evseConfiguration: { evsesCount: 1 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
- ocppRequestService: {
- requestHandler: () => {
- throw new Error('Network timeout')
+ await describe('Error handling', async () => {
+ await it('should handle network errors gracefully during periodic event', async () => {
+ const { station: errorMockChargingStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: () => {
+ throw new Error('Network timeout')
+ },
},
- },
- stationInfo: {
- ocppStrictCompliance: true,
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
- })
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
- // Mock WebSocket as open
- errorMockChargingStation.isWebSocketConnectionOpened = () => true
+ // Mock WebSocket as open
+ errorMockChargingStation.isWebSocketConnectionOpened = () => true
- const connectorId = 1
- const transactionId = generateUUID()
+ const connectorId = 1
+ const transactionId = generateUUID()
- try {
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorMockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
- )
- throw new Error('Should have thrown network error')
- } catch (error) {
- expect((error as Error).message).toContain('Network timeout')
- }
+ try {
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorMockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ throw new Error('Should have thrown network error')
+ } catch (error) {
+ expect((error as Error).message).toContain('Network timeout')
+ }
+ })
})
})
})