From 902950177e9e75eea9e73cdd89d1d2ac32f4d3b8 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 28 Feb 2026 17:41:55 +0100 Subject: [PATCH] refactor(tests): harmonize test suite structure and eliminate duplication MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - Consolidate 5 TransactionEvent variant files into single parameterized test - Reduce OCPPAuthIntegration.test.ts scope (422→196 lines, focus on integration) - Remove duplicate resetConnectorTransactionStates function, use shared utility - Merge OCPP20AuthAdapter-Offline.test.ts into main adapter test file - Add beforeEach hooks for mock initialization in ChargingStation tests - Standardize OCPP requirement codes in describe blocks (I02-I04, G03) - Consolidate eslint-disable comments with explanatory reasons - Fix JSDoc header positions in OCPP 2.0 test files - Use standardCleanup() for consistent timer mock reset Net reduction: ~400 lines of test code while maintaining full coverage --- .../ChargingStation-Configuration.test.ts | 17 +- .../ChargingStation-Connectors.test.ts | 21 +- .../ChargingStation-Lifecycle.test.ts | 8 +- .../ChargingStation-Resilience.test.ts | 10 +- .../ChargingStation-Transactions.test.ts | 14 +- .../ocpp/2.0/OCPP20CertificateManager.test.ts | 2 +- ...comingRequestService-GetBaseReport.test.ts | 16 +- ...mingRequestService-RemoteStartAuth.test.ts | 2 +- ...estService-RequestStartTransaction.test.ts | 8 +- ...uestService-RequestStopTransaction.test.ts | 34 +- ...OCPP20IncomingRequestService-Reset.test.ts | 3 +- ...eUtils-TransactionEvent-CableFirst.test.ts | 504 ----- ...tils-TransactionEvent-IdTokenFirst.test.ts | 665 ------ ...viceUtils-TransactionEvent-Offline.test.ts | 533 ----- ...iceUtils-TransactionEvent-Periodic.test.ts | 353 ---- ...CPP20ServiceUtils-TransactionEvent.test.ts | 1852 ++++++++++++++++- .../ocpp/auth/OCPPAuthIntegration.test.ts | 244 +-- .../OCPP20AuthAdapter-Offline.test.ts | 130 -- .../auth/adapters/OCPP20AuthAdapter.test.ts | 105 + tests/utils/Utils.test.ts | 27 +- 20 files changed, 2058 insertions(+), 2490 deletions(-) delete mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-CableFirst.test.ts delete mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-IdTokenFirst.test.ts delete mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Offline.test.ts delete mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Periodic.test.ts delete mode 100644 tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter-Offline.test.ts diff --git a/tests/charging-station/ChargingStation-Configuration.test.ts b/tests/charging-station/ChargingStation-Configuration.test.ts index 09ff83ca..4f9db712 100644 --- a/tests/charging-station/ChargingStation-Configuration.test.ts +++ b/tests/charging-station/ChargingStation-Configuration.test.ts @@ -3,7 +3,7 @@ * @description Unit tests for boot notification, config persistence, and WebSocket handling */ import { expect } from '@std/expect' -import { afterEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' @@ -20,6 +20,9 @@ await describe('ChargingStation Configuration Management', async () => { await describe('B02 - Pending Boot Notification Behavior', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -114,6 +117,9 @@ await describe('ChargingStation Configuration Management', async () => { await describe('B03 - Rejected Boot Notification Behavior', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -237,6 +243,9 @@ await describe('ChargingStation Configuration Management', async () => { await describe('Configuration Persistence', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -415,6 +424,9 @@ await describe('ChargingStation Configuration Management', async () => { await describe('WebSocket Message Handling', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -734,6 +746,9 @@ await describe('ChargingStation Configuration Management', async () => { await describe('WebSocket Ping Interval', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) diff --git a/tests/charging-station/ChargingStation-Connectors.test.ts b/tests/charging-station/ChargingStation-Connectors.test.ts index 3e423b7f..227b9399 100644 --- a/tests/charging-station/ChargingStation-Connectors.test.ts +++ b/tests/charging-station/ChargingStation-Connectors.test.ts @@ -3,7 +3,7 @@ * @description Unit tests for connector queries, EVSE management, and availability */ import { expect } from '@std/expect' -import { afterEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' @@ -17,6 +17,10 @@ await describe('ChargingStation Connector and EVSE State', async () => { await describe('Connector Query Tests', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) + afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -95,6 +99,9 @@ await describe('ChargingStation Connector and EVSE State', async () => { await describe('Connector 0 (Shared Power) Tests', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -122,6 +129,9 @@ await describe('ChargingStation Connector and EVSE State', async () => { await describe('EVSE Query Tests (non-EVSE mode)', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -148,6 +158,9 @@ await describe('ChargingStation Connector and EVSE State', async () => { await describe('EVSE Mode Tests', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -241,6 +254,9 @@ await describe('ChargingStation Connector and EVSE State', async () => { await describe('Boot Notification State', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -342,6 +358,9 @@ await describe('ChargingStation Connector and EVSE State', async () => { await describe('Reservation Management', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) diff --git a/tests/charging-station/ChargingStation-Lifecycle.test.ts b/tests/charging-station/ChargingStation-Lifecycle.test.ts index 46761a77..e42648d6 100644 --- a/tests/charging-station/ChargingStation-Lifecycle.test.ts +++ b/tests/charging-station/ChargingStation-Lifecycle.test.ts @@ -3,7 +3,7 @@ * @description Unit tests for charging station start/stop/restart and delete operations */ import { expect } from '@std/expect' -import { afterEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' @@ -12,6 +12,9 @@ import { cleanupChargingStation, createMockChargingStation } from './ChargingSta await describe('ChargingStation Lifecycle', async () => { await describe('Start/Stop Operations', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { @@ -160,6 +163,9 @@ await describe('ChargingStation Lifecycle', async () => { await describe('Delete Operations', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { diff --git a/tests/charging-station/ChargingStation-Resilience.test.ts b/tests/charging-station/ChargingStation-Resilience.test.ts index b9e447e8..ee431b95 100644 --- a/tests/charging-station/ChargingStation-Resilience.test.ts +++ b/tests/charging-station/ChargingStation-Resilience.test.ts @@ -3,7 +3,7 @@ * @description Unit tests for charging station error handling, reconnection, and message queuing */ import { expect } from '@std/expect' -import { afterEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' @@ -13,6 +13,10 @@ import { cleanupChargingStation, createMockChargingStation } from './ChargingSta await describe('ChargingStation Error Recovery and Resilience', async () => { let station: ChargingStation + beforeEach(() => { + station = undefined + }) + afterEach(() => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (station != null) { @@ -274,6 +278,10 @@ await describe('ChargingStation Error Recovery and Resilience', async () => { await describe('ChargingStation Message Buffering', async () => { let station: ChargingStation + beforeEach(() => { + station = undefined + }) + afterEach(() => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (station != null) { diff --git a/tests/charging-station/ChargingStation-Transactions.test.ts b/tests/charging-station/ChargingStation-Transactions.test.ts index b125c8a8..32d15a19 100644 --- a/tests/charging-station/ChargingStation-Transactions.test.ts +++ b/tests/charging-station/ChargingStation-Transactions.test.ts @@ -3,7 +3,7 @@ * @description Unit tests for transaction queries, energy meters, and concurrent transaction handling */ import { expect } from '@std/expect' -import { afterEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' @@ -14,6 +14,9 @@ await describe('ChargingStation Transaction Management', async () => { await describe('Transaction Query Tests', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -167,6 +170,9 @@ await describe('ChargingStation Transaction Management', async () => { await describe('Energy Meter Tests', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -267,6 +273,9 @@ await describe('ChargingStation Transaction Management', async () => { await describe('Concurrent Transaction Scenarios', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) @@ -428,6 +437,9 @@ await describe('ChargingStation Transaction Management', async () => { await describe('Heartbeat and Meter Intervals', async () => { let station: ChargingStation | undefined + beforeEach(() => { + station = undefined + }) afterEach(() => { if (station != null) { cleanupChargingStation(station) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts index ea73f677..4ddeb1b6 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts @@ -47,7 +47,7 @@ const _EXPECTED_HASH_DATA: CertificateHashDataType = { serialNumber: expect.any(String), } -await describe('OCPP20CertificateManager', async () => { +await describe('I02-I04 - ISO15118 Certificate Management', async () => { afterEach(async () => { await rm(`dist/assets/configurations/${TEST_STATION_HASH_ID}`, { force: true, diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts index 222ed7aa..af451bca 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts @@ -1,3 +1,7 @@ +/** + * @file Tests for OCPP20IncomingRequestService GetBaseReport + * @description Unit tests for OCPP 2.0 GetBaseReport command handling (B07) + */ import { expect } from '@std/expect' import { afterEach, beforeEach, describe, it } from 'node:test' @@ -5,28 +9,22 @@ import { addConfigurationKey, setConfigurationKeyValue, } from '../../../../src/charging-station/ConfigurationKeyUtils.js' -/** - * @file Tests for OCPP20IncomingRequestService GetBaseReport - * @description Unit tests for OCPP 2.0 GetBaseReport command handling (B07) - */ import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js' import { + AttributeEnumType, GenericDeviceModelStatusEnumType, OCPP20ComponentName, OCPP20DeviceInfoVariableName, type OCPP20GetBaseReportRequest, + OCPP20OptionalVariableName, + OCPP20RequiredVariableName, type OCPP20SetVariableResultType, OCPPVersion, ReportBaseEnumType, type ReportDataType, } from '../../../../src/types/index.js' -import { - AttributeEnumType, - OCPP20OptionalVariableName, - OCPP20RequiredVariableName, -} from '../../../../src/types/index.js' import { StandardParametersKey } from '../../../../src/types/ocpp/Configuration.js' import { Constants } from '../../../../src/utils/index.js' import { createChargingStation } from '../../../ChargingStationFactory.js' diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts index eb510b71..c42f6d37 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts @@ -24,7 +24,7 @@ import { } from '../../../../src/types/ocpp/2.0/Transaction.js' import { OCPPVersion } from '../../../../src/types/ocpp/OCPPVersion.js' -await describe('OCPP20IncomingRequestService - G03.FR.03 Remote Start Pre-Authorization', async () => { +await describe('G03 - Remote Start Pre-Authorization', async () => { let service: OCPP20IncomingRequestService | undefined let mockChargingStation: ChargingStation | undefined diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts index 0998a1c9..49f0929c 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts @@ -1,3 +1,7 @@ +/** + * @file Tests for OCPP20IncomingRequestService RequestStartTransaction + * @description Unit tests for OCPP 2.0 RequestStartTransaction command handling (F01/F02) + */ import { expect } from '@std/expect' import { afterEach, beforeEach, describe, it } from 'node:test' @@ -7,10 +11,6 @@ import type { OCPP20ChargingRateUnitType, } from '../../../../src/types/ocpp/2.0/Transaction.js' -/** - * @file Tests for OCPP20IncomingRequestService RequestStartTransaction - * @description Unit tests for OCPP 2.0 RequestStartTransaction command handling (F01/F02) - */ import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js' diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts index c0f1a355..2762fb1a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts @@ -31,7 +31,11 @@ import { Constants } from '../../../../src/utils/index.js' import { createChargingStation } from '../../../ChargingStationFactory.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' import { createMockAuthService } from '../auth/helpers/MockFactories.js' -import { resetLimits, resetReportingValueSize } from './OCPP20TestUtils.js' +import { + resetConnectorTransactionState, + resetLimits, + resetReportingValueSize, +} from './OCPP20TestUtils.js' await describe('F03 - Remote Stop Transaction', async () => { let sentTransactionEvents: OCPP20TransactionEventRequest[] = [] @@ -77,28 +81,6 @@ await describe('F03 - Remote Stop Transaction', async () => { OCPPAuthServiceFactory.clearAllInstances() }) - /** - * Helper function to reset all connector transaction states - */ - function resetConnectorTransactionStates (): void { - // Reset all connectors across all EVSEs - for (const [, evse] of mockChargingStation.evses.entries()) { - for (const [connectorId] of evse.connectors.entries()) { - const status = mockChargingStation.getConnectorStatus(connectorId) - if (status) { - status.transactionStarted = false - status.transactionId = undefined - status.transactionIdTag = undefined - status.transactionStart = undefined - status.transactionEnergyActiveImportRegisterValue = undefined - status.remoteStartId = undefined - status.chargingProfiles = undefined - // Keep status as Available and availability as Operative - } - } - } - } - /** * Helper function to start a transaction and return the transaction ID * @param evseId - The EVSE ID to start transaction on @@ -113,7 +95,7 @@ await describe('F03 - Remote Stop Transaction', async () => { ): Promise { // Reset all connector states first to ensure clean state (unless skipped for multiple transactions) if (!skipReset) { - resetConnectorTransactionStates() + resetConnectorTransactionState(mockChargingStation) } const startRequest: OCPP20RequestStartTransactionRequest = { @@ -172,7 +154,7 @@ await describe('F03 - Remote Stop Transaction', async () => { // FR: F03.FR.02, F03.FR.03 await it('should handle multiple active transactions correctly', async () => { // Reset once before starting multiple transactions - resetConnectorTransactionStates() + resetConnectorTransactionState(mockChargingStation) // Start transactions on different EVSEs (skip reset for subsequent transactions) const transactionId1 = await startTransaction(1, 200, true) // Skip reset since we just did it @@ -480,7 +462,7 @@ await describe('F03 - Remote Stop Transaction', async () => { // FR: F03.FR.09 await it('should include final meter values in TransactionEvent(Ended)', async () => { - resetConnectorTransactionStates() + resetConnectorTransactionState(mockChargingStation) const transactionId = await startTransaction(3, 700) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts index edda5868..ecfd98d6 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts @@ -25,6 +25,7 @@ import { } from '../../../../src/types/index.js' import { Constants } from '../../../../src/utils/index.js' import { createChargingStation } from '../../../ChargingStationFactory.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' await describe('B11 & B12 - Reset', async () => { @@ -66,7 +67,7 @@ await describe('B11 & B12 - Reset', async () => { }) afterEach(() => { - mock.timers.reset() + standardCleanup() }) await describe('B11 - Reset - Without Ongoing Transaction', async () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-CableFirst.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-CableFirst.test.ts deleted file mode 100644 index cbd47542..00000000 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-CableFirst.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** - * @file Tests for OCPP20ServiceUtils TransactionEvent CableFirst - * @description Unit tests for OCPP 2.0 cable-first transaction flow (E02) - */ -import { expect } from '@std/expect' -import { afterEach, beforeEach, describe, it } from 'node:test' - -import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' -import { - ConnectorStatusEnum, - OCPP20TransactionEventEnumType, - OCPP20TriggerReasonEnumType, -} from '../../../../src/types/index.js' -import { OCPP20ChargingStateEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js' -import { generateUUID } from '../../../../src/utils/index.js' -import { - createMockOCPP20TransactionTestStation, - resetConnectorTransactionState, - resetLimits, - TransactionContextFixtures, -} from './OCPP20TestUtils.js' - -/** - * E02 - Cable-First Transaction Flow Tests - * - * Tests for the Cable-First (Plug-in First) transaction pattern where: - * 1. User plugs in the cable (CablePluggedIn) - * 2. EV is detected (EVDetected) - * 3. Authorization occurs (Authorized or implicit) - * 4. Charging starts (ChargingStateChanged) - * 5. Charging ends (StopAuthorized or EVDeparted) - * - * These tests verify the full transaction lifecycle, not just trigger reason selection - * (which is tested in OCPP20ServiceUtils-TransactionEvent.test.ts). - * - * FR References: - * - E02.FR.01: Cable plug event triggers transaction start consideration - * - E02.FR.02: EVDetected indicates vehicle presence for charging readiness - * - E02.FR.03: Connector status transitions reflect cable state changes - */ -await describe('E02 - Cable-First Transaction Flow', async () => { - let mockChargingStation: ReturnType - - beforeEach(() => { - mockChargingStation = createMockOCPP20TransactionTestStation() - resetLimits(mockChargingStation) - }) - - afterEach(() => { - resetConnectorTransactionState(mockChargingStation) - }) - - // ========================================================================= - // E02.FR.01: Cable Plug Event Flow Tests - // ========================================================================= - await describe('Cable Plug Event Sequencing', async () => { - await it('should generate CablePluggedIn event as first event in cable-first flow', () => { - const connectorId = 1 - const transactionId = generateUUID() - - // Reset sequence number for new transaction - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // Build the cable plug event (first event in cable-first flow) - const cablePluggedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, - connectorId, - transactionId - ) - - // Assert: First event should have seqNo 0 - expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) - expect(cablePluggedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn) - expect(cablePluggedEvent.seqNo).toBe(0) - expect(cablePluggedEvent.transactionInfo.transactionId).toBe(transactionId) - }) - - await it('should sequence CablePluggedIn → EVDetected → Charging correctly', () => { - const connectorId = 1 - const transactionId = generateUUID() - - // Reset sequence for new transaction - 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 - ) - - // Step 3: Charging starts (Updated with ChargingStateChanged) - const chargingStartedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connectorId, - transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ) - - // Assert sequence numbers follow correct order - expect(cablePluggedEvent.seqNo).toBe(0) - expect(evDetectedEvent.seqNo).toBe(1) - expect(chargingStartedEvent.seqNo).toBe(2) - - // 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) - - // Assert event types match expected pattern - expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) - expect(evDetectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated) - expect(chargingStartedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated) - }) - - await it('should handle EVDeparted for cable removal ending transaction', () => { - const connectorId = 2 - const transactionId = generateUUID() - - // Reset and setup transaction - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // Start transaction with cable plug - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, - connectorId, - transactionId - ) - - // End transaction with EV departure (cable removal) - const endEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVDeparted, - connectorId, - transactionId - ) - - // 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) - }) - }) - - // ========================================================================= - // E02.FR.02: EV Detection Flow Tests - // ========================================================================= - await describe('EV Detection Flow', async () => { - await it('should include EVDetected between cable plug and charging start', () => { - const connectorId = 1 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // 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) - } - }) - }) - - // ========================================================================= - // E02.FR.03: Connector Status Transitions - // ========================================================================= - await describe('Connector Status Transitions', async () => { - await it('should track connector status through cable-first lifecycle', () => { - const connectorId = 1 - - // Get connector status object - const connectorStatus = mockChargingStation.getConnectorStatus(connectorId) - expect(connectorStatus).toBeDefined() - if (connectorStatus == null) { - throw new Error('Connector status should be defined') - } - - // 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) - }) - - 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 - - // Transition to charging - connectorStatus.status = ConnectorStatusEnum.Charging - - // Transaction ID should persist through state changes - expect(connectorStatus.transactionId).toBe(transactionId) - expect(connectorStatus.transactionStarted).toBe(true) - - // Transition to finished - connectorStatus.status = ConnectorStatusEnum.Finishing - - // Still same transaction until fully ended - expect(connectorStatus.transactionId).toBe(transactionId) - }) - }) - - // ========================================================================= - // Full E02 Transaction Lifecycle Tests - // ========================================================================= - await describe('Full Cable-First Transaction Lifecycle', async () => { - await it('should support complete cable-first → charging → cable-removal flow', () => { - const connectorId = 1 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // Build complete cable-first transaction lifecycle - const lifecycle = { - cablePlugged: OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, - connectorId, - transactionId - ), - charging: OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connectorId, - transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ), - evDeparted: OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVDeparted, - connectorId, - transactionId - ), - } - - // Validate lifecycle event sequence - expect(lifecycle.cablePlugged.eventType).toBe(OCPP20TransactionEventEnumType.Started) - expect(lifecycle.charging.eventType).toBe(OCPP20TransactionEventEnumType.Updated) - expect(lifecycle.evDeparted.eventType).toBe(OCPP20TransactionEventEnumType.Ended) - - // Validate sequence numbers - expect(lifecycle.cablePlugged.seqNo).toBe(0) - expect(lifecycle.charging.seqNo).toBe(1) - expect(lifecycle.evDeparted.seqNo).toBe(2) - - // Validate trigger reasons match cable-first pattern - expect(lifecycle.cablePlugged.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn) - expect(lifecycle.charging.triggerReason).toBe( - OCPP20TriggerReasonEnumType.ChargingStateChanged - ) - expect(lifecycle.evDeparted.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted) - - // All events should share same transaction ID - expect(lifecycle.cablePlugged.transactionInfo.transactionId).toBe(transactionId) - expect(lifecycle.charging.transactionInfo.transactionId).toBe(transactionId) - expect(lifecycle.evDeparted.transactionInfo.transactionId).toBe(transactionId) - }) - - await it('should handle suspended charging states in cable-first flow', () => { - const connectorId = 3 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // 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) - } - - // Verify all share same transaction ID - for (const event of events) { - expect(event.transactionInfo.transactionId).toBe(transactionId) - } - }) - }) - - // ========================================================================= - // Context-Based Trigger Reason Selection for Cable Events - // ========================================================================= - 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() - ) - - expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected) - }) - - await it('should select EVDeparted from cable_action context with unplugged state', () => { - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - TransactionContextFixtures.evDeparted() - ) - - expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted) - }) - }) - - // ========================================================================= - // Multiple Connector Independence Tests - // ========================================================================= - await describe('Multiple Connector Independence', async () => { - await it('should maintain independent transaction sequences on different connectors', () => { - const transactionId1 = generateUUID() - const transactionId2 = generateUUID() - - // Reset both connectors - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1) - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2) - - // Start transaction on connector 1 - const conn1Start = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, - 1, - transactionId1 - ) - - // Start transaction on connector 2 - const conn2Start = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.CablePluggedIn, - 2, - transactionId2 - ) - - // Update connector 1 - const conn1Update = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.EVDetected, - 1, - transactionId1 - ) - - // Update connector 2 - const conn2Update = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.EVDetected, - 2, - transactionId2 - ) - - // Each connector should have independent sequence numbers - expect(conn1Start.seqNo).toBe(0) - expect(conn2Start.seqNo).toBe(0) - expect(conn1Update.seqNo).toBe(1) - expect(conn2Update.seqNo).toBe(1) - - // Different transaction IDs - expect(conn1Start.transactionInfo.transactionId).toBe(transactionId1) - expect(conn2Start.transactionInfo.transactionId).toBe(transactionId2) - expect(conn1Start.transactionInfo.transactionId).not.toBe( - conn2Start.transactionInfo.transactionId - ) - }) - }) -}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-IdTokenFirst.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-IdTokenFirst.test.ts deleted file mode 100644 index ef87b37a..00000000 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-IdTokenFirst.test.ts +++ /dev/null @@ -1,665 +0,0 @@ -/** - * @file Tests for OCPP20ServiceUtils TransactionEvent IdTokenFirst - * @description Unit tests for OCPP 2.0 IdToken-first pre-authorization flow (E03) - */ -import { expect } from '@std/expect' -import { afterEach, beforeEach, describe, it } from 'node:test' - -import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' -import { - OCPP20TransactionEventEnumType, - OCPP20TriggerReasonEnumType, -} from '../../../../src/types/index.js' -import { - OCPP20ChargingStateEnumType, - OCPP20IdTokenEnumType, - type OCPP20IdTokenType, - type OCPP20TransactionContext, -} from '../../../../src/types/ocpp/2.0/Transaction.js' -import { generateUUID } from '../../../../src/utils/index.js' -import { - createMockOCPP20TransactionTestStation, - resetConnectorTransactionState, - resetLimits, - TransactionContextFixtures, -} from './OCPP20TestUtils.js' - -/** - * E03 IdToken-First Transaction Flow Tests (OCPP 2.0.1) - * - * Tests the IdToken-first pre-authorization flow where a user presents - * their ID token BEFORE connecting the cable. - * - * Key E03 Functional Requirements: - * - E03.FR.01: When IdToken presented first, CS SHALL verify with CSMS - * - E03.FR.02: CSMS SHALL verify IdToken validity - * - E03.FR.05: CS SHALL handle EVConnectionTimeOut - * - E03.FR.06: If cable not connected within timeout, CS SHALL cancel authorization - * - E03.FR.13: triggerReason SHALL be Authorized for IdToken-first - * - * Key Difference from E02 (Cable-First): - * - E03: Authorization -> Cable connection -> Charging - * - E02: Cable connection -> EV detection -> Authorization -> Charging - */ -await describe('E03 - IdToken-First Pre-Authorization Flow', async () => { - let mockChargingStation: ReturnType - - beforeEach(() => { - mockChargingStation = createMockOCPP20TransactionTestStation() - resetLimits(mockChargingStation) - }) - - afterEach(() => { - resetConnectorTransactionState(mockChargingStation) - }) - - // ========================================================================= - // E03.FR.13: Trigger Reason Selection for IdToken-First - // ========================================================================= - await describe('E03.FR.13 - Trigger Reason Selection', async () => { - await it('should select Authorized trigger for IdToken-first transaction start', () => { - // E03.FR.13: triggerReason SHALL be Authorized for IdToken-first - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - TransactionContextFixtures.idTokenAuthorized() - ) - - expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized) - }) - - await it('should select groupIdToken trigger for group authorization', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'groupIdToken', - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) - - expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized) - }) - - await it('should differentiate IdToken-first from Cable-first by trigger reason', () => { - // IdToken-first: Authorized trigger - const idTokenFirstContext: OCPP20TransactionContext = { - authorizationMethod: 'idToken', - source: 'local_authorization', - } - - // Cable-first: CablePluggedIn trigger - const cableFirstContext: OCPP20TransactionContext = { - cableState: 'plugged_in', - source: 'cable_action', - } - - 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) - }) - }) - - // ========================================================================= - // E03.FR.01: IdToken Inclusion in TransactionEvent - // ========================================================================= - 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, - } - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // 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 } - ) - - 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) - }) - - 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, - } - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // First event includes idToken - const startedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - 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(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( - 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, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - generateUUID(), - { idToken: emaidToken } - ) - - expect(emaidEvent.idToken?.type).toBe(OCPP20IdTokenEnumType.eMAID) - expect(emaidEvent.idToken?.idToken).toBe('DE*ABC*E123456*1') - }) - }) - - // ========================================================================= - // Full E03 IdToken-First 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 = { - idToken: 'LIFECYCLE_TOKEN_001', - type: OCPP20IdTokenEnumType.ISO14443, - } - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // E03 Step 1: IdToken presented and authorized (Started with Authorized trigger) - const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId, - { idToken } - ) - - // E03 Step 2: Cable connected (Updated event) - const cableConnectedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.CablePluggedIn, - connectorId, - transactionId - ) - - // E03 Step 3: Charging starts - const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.ChargingStateChanged, - connectorId, - transactionId, - { chargingState: OCPP20ChargingStateEnumType.Charging } - ) - - // E03 Step 4: Transaction ends - const endedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, - 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) - }) - - 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, - } - - // E03 IdToken-First: Starts with Authorized trigger - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - const connectorStatus = mockChargingStation.getConnectorStatus(connectorId) - if (connectorStatus != null) { - connectorStatus.transactionIdTokenSent = undefined - } - - const e03Start = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - e03TransactionId, - { idToken } - ) - - // 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 - ) - - // 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() - }) - }) - - // ========================================================================= - // E03.FR.05/06: EVConnectionTimeOut Handling - // ========================================================================= - 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, - } - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // E03.FR.05: User authorizes with IdToken - const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId, - { idToken } - ) - - // E03.FR.06: Cable not connected within timeout - transaction ends with Timeout - const timeoutEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVConnectTimeout, - connectorId, - transactionId - ) - - expect(authorizedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) - expect(authorizedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized) - - expect(timeoutEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended) - expect(timeoutEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVConnectTimeout) - expect(timeoutEvent.seqNo).toBe(1) - - // Same transaction ID for both events - expect(authorizedEvent.transactionInfo.transactionId).toBe( - timeoutEvent.transactionInfo.transactionId - ) - }) - - await it('should track sequence numbers correctly for timeout scenario', () => { - const connectorId = 1 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // Started (seqNo: 0) - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId - ) - - // Ended due to timeout (seqNo: 1) - const endEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.EVConnectTimeout, - connectorId, - transactionId - ) - - expect(startEvent.seqNo).toBe(0) - expect(endEvent.seqNo).toBe(1) - }) - }) - - // ========================================================================= - // Authorization Status Handling - // ========================================================================= - await describe('Authorization Status in E03 Flow', async () => { - await it('should support Deauthorized trigger for rejected authorization', () => { - 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 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) - - // Transaction started with authorization - const startEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId, - { idToken } - ) - - // Transaction ended due to deauthorization (e.g., token revoked mid-session) - const revokedEvent = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.Deauthorized, - connectorId, - transactionId - ) - - expect(startEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) - expect(revokedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended) - expect(revokedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized) - }) - - await it('should support StopAuthorized trigger for normal transaction end', () => { - const context: OCPP20TransactionContext = { - authorizationMethod: 'stopAuthorized', - source: 'local_authorization', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.StopAuthorized) - }) - }) - - // ========================================================================= - // E03.FR.07/08: Sequence Numbers and Transaction ID - // ========================================================================= - 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, - } - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - 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) - }) - }) - - await it('should use unique transaction ID (E03.FR.08)', () => { - const connectorId = 1 - - 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( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transaction1Id - ) - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - const event2 = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transaction2Id - ) - - expect(event1.transactionInfo.transactionId).toBe(transaction1Id) - expect(event2.transactionInfo.transactionId).toBe(transaction2Id) - expect(event1.transactionInfo.transactionId).not.toBe(event2.transactionInfo.transactionId) - }) - }) - - // ========================================================================= - // Multiple Connector Independence - // ========================================================================= - await describe('Multiple Connector Independence in E03 Flow', async () => { - await it('should handle independent E03 transactions on different connectors', () => { - const connector1 = 1 - const connector2 = 2 - const transaction1Id = generateUUID() - const transaction2Id = generateUUID() - const token1: OCPP20IdTokenType = { - idToken: 'USER_A_TOKEN', - type: OCPP20IdTokenEnumType.ISO14443, - } - const token2: OCPP20IdTokenType = { - idToken: 'USER_B_TOKEN', - type: OCPP20IdTokenEnumType.eMAID, - } - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connector1) - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connector2) - - // User A authorizes on connector 1 - const conn1Event1 = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connector1, - transaction1Id, - { idToken: token1 } - ) - - // User B authorizes on connector 2 - const conn2Event1 = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connector2, - transaction2Id, - { idToken: token2 } - ) - - // User A plugs cable - const conn1Event2 = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.CablePluggedIn, - connector1, - transaction1Id - ) - - // User B plugs cable - const conn2Event2 = OCPP20ServiceUtils.buildTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.CablePluggedIn, - 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) - expect(transaction1Id).not.toBe(transaction2Id) - - // Verify independent idTokens - expect(conn1Event1.idToken?.idToken).toBe('USER_A_TOKEN') - expect(conn2Event1.idToken?.idToken).toBe('USER_B_TOKEN') - }) - }) -}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Offline.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Offline.test.ts deleted file mode 100644 index 250815d3..00000000 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Offline.test.ts +++ /dev/null @@ -1,533 +0,0 @@ -/** - * @file Tests for OCPP20ServiceUtils TransactionEvent Offline - * @description Unit tests for OCPP 2.0 offline TransactionEvent queueing (E02) - */ - -import { expect } from '@std/expect' -import { afterEach, beforeEach, describe, it, mock } from 'node:test' - -import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js' -import type { EmptyObject } from '../../../../src/types/index.js' - -import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' -import { - OCPP20TransactionEventEnumType, - OCPP20TriggerReasonEnumType, - OCPPVersion, -} from '../../../../src/types/index.js' -import { Constants, generateUUID } from '../../../../src/utils/index.js' -import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js' -import { createChargingStation } from '../../../ChargingStationFactory.js' -import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' -import { - type CapturedOCPPRequest, - createMockStationWithRequestTracking, - type MockStationWithTracking, -} from './OCPP20TestUtils.js' - -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 - ) - - 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 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 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) - }) - - 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() - ) - }) - }) - - 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() - - 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 - ) - - expect(sentRequests.length).toBe(0) - - setOnline(true) - - 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 it('should clear queue after sending', async () => { - const connectorId = 1 - const transactionId = generateUUID() - - setOnline(false) - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - await OCPP20ServiceUtils.sendTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId - ) - - 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 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) - }) - - await it('should handle empty queue gracefully', async () => { - const connectorId = 1 - - await expect( - OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId) - ).resolves.toBeUndefined() - - expect(sentRequests.length).toBe(0) - }) - - await it('should handle null queue gracefully', async () => { - const connectorId = 1 - const connector = mockChargingStation.getConnectorStatus(connectorId) - connector.transactionEventQueue = undefined - - await expect( - OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId) - ).resolves.toBeUndefined() - - expect(sentRequests.length).toBe(0) - }) - }) - - 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) - } - }) - }) - - 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 - ) - }) - - await it('should drain queues independently per 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 - ) - - setOnline(true) - - await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 1) - - expect(sentRequests.length).toBe(1) - expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId1) - - const connector2 = mockChargingStation.getConnectorStatus(2) - expect(connector2?.transactionEventQueue?.length).toBe(1) - - await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 2) - - expect(sentRequests.length).toBe(2) - expect(sentRequests[1].payload.transactionInfo.transactionId).toBe(transactionId2) - }) - }) - - 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 - - const errorOnSecondMock = mock.fn(async () => { - callCount++ - if (callCount === 2) { - throw new Error('Network error on second event') - } - return Promise.resolve({} as EmptyObject) - }) - - const errorStation = createChargingStation({ - 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, - }) - - errorStation.isWebSocketConnectionOpened = () => false - - OCPP20ServiceUtils.resetTransactionSequenceNumber(errorStation, connectorId) - - await OCPP20ServiceUtils.sendTransactionEvent( - errorStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId - ) - - await OCPP20ServiceUtils.sendTransactionEvent( - errorStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, - connectorId, - transactionId - ) - - await OCPP20ServiceUtils.sendTransactionEvent( - errorStation, - OCPP20TransactionEventEnumType.Ended, - OCPP20TriggerReasonEnumType.StopAuthorized, - connectorId, - transactionId - ) - - errorStation.isWebSocketConnectionOpened = () => true - - await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId) - - expect(callCount).toBe(3) - }) - }) -}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Periodic.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Periodic.test.ts deleted file mode 100644 index 8f087569..00000000 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-Periodic.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * @file Tests for OCPP20ServiceUtils TransactionEvent Periodic - * @description Unit tests for OCPP 2.0 periodic TransactionEvent at TxUpdatedInterval (E02) - */ - -import { expect } from '@std/expect' -import { afterEach, beforeEach, describe, it } from 'node:test' - -import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js' - -import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' -import { - OCPP20TransactionEventEnumType, - OCPP20TriggerReasonEnumType, - OCPPVersion, -} from '../../../../src/types/index.js' -import { Constants, generateUUID } from '../../../../src/utils/index.js' -import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js' -import { createChargingStation } from '../../../ChargingStationFactory.js' -import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' -import { - type CapturedOCPPRequest, - createMockStationWithRequestTracking, - type MockStationWithTracking, -} from './OCPP20TestUtils.js' - -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 - }) - - 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('startTxUpdatedInterval', async () => { - await it('should not start timer for non-OCPP 2.0 stations', () => { - const ocpp16Station = createChargingStation({ - baseName: TEST_CHARGING_STATION_BASE_NAME, - connectorsCount: 1, - stationInfo: { - ocppVersion: OCPPVersion.VERSION_16, - }, - }) - - // Call startTxUpdatedInterval on OCPP 1.6 station - ocpp16Station.startTxUpdatedInterval(1, 60000) - - // Verify no timer was started (method should return early) - const connector = ocpp16Station.getConnectorStatus(1) - expect(connector?.transactionTxUpdatedSetInterval).toBeUndefined() - }) - - await it('should not start timer when interval is zero', () => { - const connectorId = 1 - - // Simulate startTxUpdatedInterval with zero interval - const connector = mockChargingStation.getConnectorStatus(connectorId) - expect(connector).toBeDefined() - - // Zero interval should not start timer - // This is verified by the implementation logging debug message - expect(connector.transactionTxUpdatedSetInterval).toBeUndefined() - }) - - await it('should not start timer when interval is negative', () => { - const connectorId = 1 - const connector = mockChargingStation.getConnectorStatus(connectorId) - expect(connector).toBeDefined() - - // Negative interval should not start timer - expect(connector.transactionTxUpdatedSetInterval).toBeUndefined() - }) - - await it('should handle non-existent connector gracefully', () => { - const nonExistentConnectorId = 999 - - // Should not throw for non-existent connector - expect(() => { - mockChargingStation.getConnectorStatus(nonExistentConnectorId) - }).not.toThrow() - - // Should return undefined for non-existent connector - expect(mockChargingStation.getConnectorStatus(nonExistentConnectorId)).toBeUndefined() - }) - }) - - 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 - ) - }) - - 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( - 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) - }) - - await it('should maintain correct eventType (Updated) for periodic events', async () => { - const connectorId = 2 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - // Send periodic event - 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) - }) - - await it('should include EVSE information in periodic events', async () => { - const connectorId = 1 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - await OCPP20ServiceUtils.sendTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, - connectorId, - transactionId - ) - - // Verify EVSE info is present - expect(sentRequests[0].payload.evse).toBeDefined() - expect(sentRequests[0].payload.evse.id).toBe(connectorId) - }) - - await it('should include transactionInfo with correct transactionId', async () => { - const connectorId = 1 - const transactionId = generateUUID() - - OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) - - await OCPP20ServiceUtils.sendTransactionEvent( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, - connectorId, - transactionId - ) - - // Verify transactionInfo contains the transaction ID - expect(sentRequests[0].payload.transactionInfo).toBeDefined() - expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId) - }) - }) - - 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( - mockChargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.MeterValuePeriodic, - connectorId, - transactionId - ) - 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) - }) - - 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) - }) - }) - - await describe('Error handling', async () => { - await it('should handle network errors gracefully during periodic event', async () => { - const errorMockChargingStation = createChargingStation({ - 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, - }) - - // Mock WebSocket as open - errorMockChargingStation.isWebSocketConnectionOpened = () => true - - 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') - } - }) - }) -}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index a2f2d438..f38e1d45 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -1,13 +1,24 @@ /** * @file Tests for OCPP20ServiceUtils TransactionEvent - * @description Unit tests for OCPP 2.0 TransactionEvent building and trigger reasons (E01-E04) + * @description Consolidated unit tests for OCPP 2.0 TransactionEvent building and trigger reasons (E01-E04) + * + * This file consolidates tests from multiple variant files: + * - E01-E04 core TransactionEvent implementation + * - E02 Cable-First flow (cable plug event sequencing) + * - E03 IdToken-First flow (idToken presence in events) + * - Offline TransactionEvent queueing + * - Periodic TransactionEvent at TxUpdatedInterval */ import { expect } from '@std/expect' -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js' +import type { EmptyObject } from '../../../../src/types/index.js' import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' import { + ConnectorStatusEnum, OCPP20TransactionEventEnumType, OCPP20TriggerReasonEnumType, OCPPVersion, @@ -15,6 +26,7 @@ import { import { OCPP20ChargingStateEnumType, OCPP20IdTokenEnumType, + type OCPP20IdTokenType, OCPP20ReasonEnumType, type OCPP20TransactionContext, } from '../../../../src/types/ocpp/2.0/Transaction.js' @@ -22,7 +34,50 @@ import { Constants, generateUUID } from '../../../../src/utils/index.js' import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js' import { createChargingStation } from '../../../ChargingStationFactory.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' -import { createMockOCPP20TransactionTestStation, resetLimits } from './OCPP20TestUtils.js' +import { + type CapturedOCPPRequest, + createMockOCPP20TransactionTestStation, + createMockStationWithRequestTracking, + type MockStationWithTracking, + resetConnectorTransactionState, + resetLimits, + TransactionContextFixtures, +} from './OCPP20TestUtils.js' + +// ============================================================================ +// Transaction Flow Patterns for Parameterized Testing +// ============================================================================ + +/** + * Transaction flow variants for parameterized testing. + * Each flow represents a different transaction initiation pattern in OCPP 2.0.1. + */ +const TRANSACTION_FLOWS = [ + { + description: 'E02 Cable-First', + expectedStartTrigger: OCPP20TriggerReasonEnumType.CablePluggedIn, + id: 'cableFirst', + includeIdToken: false, + name: 'E02 - Cable-First', + startContext: TransactionContextFixtures.cablePluggedIn(), + }, + { + description: 'E03 IdToken-First', + expectedStartTrigger: OCPP20TriggerReasonEnumType.Authorized, + id: 'idTokenFirst', + includeIdToken: true, + name: 'E03 - IdToken-First', + startContext: TransactionContextFixtures.idTokenAuthorized(), + }, + { + description: 'Remote Start', + expectedStartTrigger: OCPP20TriggerReasonEnumType.RemoteStart, + id: 'remoteStart', + includeIdToken: false, + name: 'Remote Start', + startContext: TransactionContextFixtures.remoteStart(), + }, +] as const await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async () => { let mockChargingStation: ReturnType @@ -173,7 +228,7 @@ await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async () 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.message).toContain('≤36 characters') + expect((error as Error).message).toContain('≤36 characters') } }) @@ -890,4 +945,1793 @@ await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async () }) }) }) + + // ========================================================================== + // 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 Flow', 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 + ) + + // Step 3: Charging starts (Updated with ChargingStateChanged) + const chargingStartedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.ChargingStateChanged, + connectorId, + transactionId, + { chargingState: OCPP20ChargingStateEnumType.Charging } + ) + + // Assert sequence numbers follow correct order + expect(cablePluggedEvent.seqNo).toBe(0) + expect(evDetectedEvent.seqNo).toBe(1) + expect(chargingStartedEvent.seqNo).toBe(2) + + // 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) + + // Assert event types match expected pattern + expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) + expect(evDetectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated) + expect(chargingStartedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated) + }) + + 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 + ) + + // End transaction with EV departure (cable removal) + const endEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.EVDeparted, + connectorId, + transactionId + ) + + // 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 describe('EV Detection Flow', async () => { + await it('should include EVDetected between cable plug and charging start', () => { + const connectorId = 1 + const transactionId = generateUUID() + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // 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 describe('Connector Status Transitions', async () => { + await it('should track connector status through cable-first lifecycle', () => { + const connectorId = 1 + + // Get connector status object + const connectorStatus = mockChargingStation.getConnectorStatus(connectorId) + expect(connectorStatus).toBeDefined() + if (connectorStatus == null) { + throw new Error('Connector status should be defined') + } + + // 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) + }) + + 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 + + // Transition to charging + connectorStatus.status = ConnectorStatusEnum.Charging + + // Transaction ID should persist through state changes + expect(connectorStatus.transactionId).toBe(transactionId) + expect(connectorStatus.transactionStarted).toBe(true) + + // Transition to finished + connectorStatus.status = ConnectorStatusEnum.Finishing + + // Still same transaction until fully ended + expect(connectorStatus.transactionId).toBe(transactionId) + }) + }) + + 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() + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // 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) + } + + // 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() + ) + + expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected) + }) + + await it('should select EVDeparted from cable_action context with unplugged state', () => { + const triggerReason = OCPP20ServiceUtils.selectTriggerReason( + OCPP20TransactionEventEnumType.Ended, + TransactionContextFixtures.evDeparted() + ) + + expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted) + }) + }) + }) + + // ========================================================================== + // E03 IdToken-First Specific Tests + // ========================================================================== + await describe('E03 - IdToken-First Pre-Authorization Flow', 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 + ) + + expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized) + }) + + await it('should differentiate IdToken-first from Cable-first by trigger reason', () => { + // IdToken-first: Authorized trigger + const idTokenFirstContext: OCPP20TransactionContext = { + authorizationMethod: 'idToken', + source: 'local_authorization', + } + + // Cable-first: CablePluggedIn trigger + const cableFirstContext: OCPP20TransactionContext = { + cableState: 'plugged_in', + source: 'cable_action', + } + + 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) + }) + }) + + 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, + } + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // 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 } + ) + + 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) + }) + + 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, + } + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // First event includes idToken + const startedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + 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(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( + 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, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + generateUUID(), + { idToken: emaidToken } + ) + + 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', () => { + const connectorId = 1 + const transactionId = generateUUID() + const idToken: OCPP20IdTokenType = { + idToken: 'LIFECYCLE_TOKEN_001', + type: OCPP20IdTokenEnumType.ISO14443, + } + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // E03 Step 1: IdToken presented and authorized (Started with Authorized trigger) + const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId, + { idToken } + ) + + // E03 Step 2: Cable connected (Updated event) + const cableConnectedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.CablePluggedIn, + connectorId, + transactionId + ) + + // E03 Step 3: Charging starts + const chargingEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.ChargingStateChanged, + connectorId, + transactionId, + { chargingState: OCPP20ChargingStateEnumType.Charging } + ) + + // E03 Step 4: Transaction ends + const endedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.StopAuthorized, + 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) + }) + + 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, + } + + // E03 IdToken-First: Starts with Authorized trigger + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + const connectorStatus = mockChargingStation.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.transactionIdTokenSent = undefined + } + + const e03Start = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + e03TransactionId, + { idToken } + ) + + // 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 + ) + + // 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)', () => { + const connectorId = 1 + const transactionId = generateUUID() + const idToken: OCPP20IdTokenType = { + idToken: 'TIMEOUT_TOKEN_001', + type: OCPP20IdTokenEnumType.ISO14443, + } + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // E03.FR.05: User authorizes with IdToken + const authorizedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId, + { idToken } + ) + + // E03.FR.06: Cable not connected within timeout - transaction ends with Timeout + const timeoutEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.EVConnectTimeout, + connectorId, + transactionId + ) + + expect(authorizedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) + expect(authorizedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized) + + expect(timeoutEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended) + expect(timeoutEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVConnectTimeout) + expect(timeoutEvent.seqNo).toBe(1) + + // Same transaction ID for both events + expect(authorizedEvent.transactionInfo.transactionId).toBe( + timeoutEvent.transactionInfo.transactionId + ) + }) + }) + + await describe('Authorization Status in E03 Flow', async () => { + await it('should support Deauthorized trigger for rejected authorization', () => { + 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 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) + + // Transaction started with authorization + const startEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId, + { idToken } + ) + + // Transaction ended due to deauthorization (e.g., token revoked mid-session) + const revokedEvent = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.Deauthorized, + connectorId, + transactionId + ) + + expect(startEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started) + expect(revokedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended) + expect(revokedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.Deauthorized) + }) + + await it('should support StopAuthorized trigger for normal transaction end', () => { + const context: OCPP20TransactionContext = { + authorizationMethod: 'stopAuthorized', + source: 'local_authorization', + } + + const triggerReason = OCPP20ServiceUtils.selectTriggerReason( + OCPP20TransactionEventEnumType.Ended, + context + ) + + expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.StopAuthorized) + }) + }) + + 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, + } + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + 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) + }) + }) + + await it('should use unique transaction ID (E03.FR.08)', () => { + const connectorId = 1 + + 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( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transaction1Id + ) + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + const event2 = OCPP20ServiceUtils.buildTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transaction2Id + ) + + 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 + ) + + 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 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 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) + }) + + 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() + ) + }) + }) + + 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() + + 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 + ) + + expect(sentRequests.length).toBe(0) + + setOnline(true) + + 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 it('should clear queue after sending', async () => { + const connectorId = 1 + const transactionId = generateUUID() + + setOnline(false) + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + await OCPP20ServiceUtils.sendTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId + ) + + 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 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) + }) + + await it('should handle empty queue gracefully', async () => { + const connectorId = 1 + + await expect( + OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId) + ).resolves.toBeUndefined() + + expect(sentRequests.length).toBe(0) + }) + + await it('should handle null queue gracefully', async () => { + const connectorId = 1 + const connector = mockChargingStation.getConnectorStatus(connectorId) + connector.transactionEventQueue = undefined + + await expect( + OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, connectorId) + ).resolves.toBeUndefined() + + expect(sentRequests.length).toBe(0) + }) + }) + + 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) + } + }) + }) + + 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 + ) + }) + + await it('should drain queues independently per 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 + ) + + setOnline(true) + + await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 1) + + expect(sentRequests.length).toBe(1) + expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId1) + + const connector2 = mockChargingStation.getConnectorStatus(2) + expect(connector2?.transactionEventQueue?.length).toBe(1) + + await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockChargingStation, 2) + + expect(sentRequests.length).toBe(2) + expect(sentRequests[1].payload.transactionInfo.transactionId).toBe(transactionId2) + }) + }) + + 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 + + const errorOnSecondMock = mock.fn(async () => { + callCount++ + if (callCount === 2) { + throw new Error('Network error on second event') + } + return Promise.resolve({} as EmptyObject) + }) + + const errorStation = createChargingStation({ + 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, + }) + + errorStation.isWebSocketConnectionOpened = () => false + + OCPP20ServiceUtils.resetTransactionSequenceNumber(errorStation, connectorId) + + await OCPP20ServiceUtils.sendTransactionEvent( + errorStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId + ) + + await OCPP20ServiceUtils.sendTransactionEvent( + errorStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.MeterValuePeriodic, + connectorId, + transactionId + ) + + await OCPP20ServiceUtils.sendTransactionEvent( + errorStation, + OCPP20TransactionEventEnumType.Ended, + OCPP20TriggerReasonEnumType.StopAuthorized, + connectorId, + transactionId + ) + + errorStation.isWebSocketConnectionOpened = () => true + + await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId) + + expect(callCount).toBe(3) + }) + }) +}) + +// ============================================================================ +// 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 + }) + + 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('startTxUpdatedInterval', async () => { + await it('should not start timer for non-OCPP 2.0 stations', () => { + const ocpp16Station = createChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + + // Call startTxUpdatedInterval on OCPP 1.6 station + ocpp16Station.startTxUpdatedInterval(1, 60000) + + // Verify no timer was started (method should return early) + const connector = ocpp16Station.getConnectorStatus(1) + expect(connector?.transactionTxUpdatedSetInterval).toBeUndefined() + }) + + await it('should not start timer when interval is zero', () => { + const connectorId = 1 + + // Simulate startTxUpdatedInterval with zero interval + const connector = mockChargingStation.getConnectorStatus(connectorId) + expect(connector).toBeDefined() + + // Zero interval should not start timer + // This is verified by the implementation logging debug message + expect(connector.transactionTxUpdatedSetInterval).toBeUndefined() + }) + + await it('should not start timer when interval is negative', () => { + const connectorId = 1 + const connector = mockChargingStation.getConnectorStatus(connectorId) + expect(connector).toBeDefined() + + // Negative interval should not start timer + expect(connector.transactionTxUpdatedSetInterval).toBeUndefined() + }) + + await it('should handle non-existent connector gracefully', () => { + const nonExistentConnectorId = 999 + + // Should not throw for non-existent connector + expect(() => { + mockChargingStation.getConnectorStatus(nonExistentConnectorId) + }).not.toThrow() + + // Should return undefined for non-existent connector + expect(mockChargingStation.getConnectorStatus(nonExistentConnectorId)).toBeUndefined() + }) + }) + + 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 + ) + }) + + 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( + 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) + }) + + await it('should maintain correct eventType (Updated) for periodic events', async () => { + const connectorId = 2 + const transactionId = generateUUID() + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + // Send periodic event + 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) + }) + + await it('should include EVSE information in periodic events', async () => { + const connectorId = 1 + const transactionId = generateUUID() + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + await OCPP20ServiceUtils.sendTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.MeterValuePeriodic, + connectorId, + transactionId + ) + + // Verify EVSE info is present + expect(sentRequests[0].payload.evse).toBeDefined() + expect(sentRequests[0].payload.evse.id).toBe(connectorId) + }) + + await it('should include transactionInfo with correct transactionId', async () => { + const connectorId = 1 + const transactionId = generateUUID() + + OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId) + + await OCPP20ServiceUtils.sendTransactionEvent( + mockChargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.MeterValuePeriodic, + connectorId, + transactionId + ) + + // Verify transactionInfo contains the transaction ID + expect(sentRequests[0].payload.transactionInfo).toBeDefined() + expect(sentRequests[0].payload.transactionInfo.transactionId).toBe(transactionId) + }) + }) + + 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( + mockChargingStation, + OCPP20TransactionEventEnumType.Updated, + OCPP20TriggerReasonEnumType.MeterValuePeriodic, + connectorId, + transactionId + ) + 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) + }) + + 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) + }) + }) + + await describe('Error handling', async () => { + await it('should handle network errors gracefully during periodic event', async () => { + const errorMockChargingStation = createChargingStation({ + 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, + }) + + // Mock WebSocket as open + errorMockChargingStation.isWebSocketConnectionOpened = () => true + + 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') + } + }) + }) }) diff --git a/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts b/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts index 8877e979..4fd434ce 100644 --- a/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts +++ b/tests/charging-station/ocpp/auth/OCPPAuthIntegration.test.ts @@ -1,7 +1,8 @@ /** * @file Tests for OCPPAuthIntegration - * @description Unit tests for OCPP authentication integration with deterministic mocked responses + * @description Integration tests for OCPP authentication flows across service, adapters, cache, and strategies */ + import { expect } from '@std/expect' import { afterEach, beforeEach, describe, it, mock } from 'node:test' @@ -10,21 +11,15 @@ import type { ChargingStation } from '../../../../src/charging-station/ChargingS import { OCPPAuthServiceImpl } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.js' import { AuthContext, - AuthenticationMethod, AuthorizationStatus, IdentifierType, } from '../../../../src/charging-station/ocpp/auth/types/AuthTypes.js' import { OCPPVersion } from '../../../../src/types/ocpp/OCPPVersion.js' import { createChargingStation } from '../../../ChargingStationFactory.js' import { - createMockAuthorizationResult, createMockAuthRequest, - createMockAuthService, createMockOCPP16Identifier, createMockOCPP20Identifier, - createTestAuthConfig, - expectAcceptedAuthorization, - expectRejectedAuthorization, } from './helpers/MockFactories.js' await describe('OCPP Authentication Integration Tests', async () => { @@ -59,111 +54,6 @@ await describe('OCPP Authentication Integration Tests', async () => { mock.reset() }) - await describe('Service Initialization', async () => { - await it('should create auth service for OCPP 1.6 station', () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - - // Service should be created with valid configuration - expect(authService.getConfiguration()).toBeDefined() - expect(typeof authService.getConfiguration().authorizationTimeout).toBe('number') - - const stats = authService.getAuthenticationStats() - expect(stats.ocppVersion).toBe(OCPPVersion.VERSION_16) - }) - - await it('should create auth service for OCPP 2.0 station', () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation20) - - // Service should be created with valid configuration - expect(authService.getConfiguration()).toBeDefined() - expect(typeof authService.getConfiguration().authorizationTimeout).toBe('number') - - const stats = authService.getAuthenticationStats() - expect(stats.ocppVersion).toBe(OCPPVersion.VERSION_20) - }) - - await it('should create mock auth service with deterministic responses', async () => { - const mockService = createMockAuthService() - - const request = createMockAuthRequest() - const result = await mockService.authorize(request) - - expect(result.status).toBe(AuthorizationStatus.ACCEPTED) - expect(result.isOffline).toBe(false) - expectAcceptedAuthorization(result) - }) - }) - - await describe('Configuration Management', async () => { - await it('should update and retrieve configuration for OCPP 1.6', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - const originalConfig = authService.getConfiguration() - - const updates = { - authorizationTimeout: 60, - localAuthListEnabled: false, - maxCacheEntries: 2000, - } - - await authService.updateConfiguration(updates) - const updatedConfig = authService.getConfiguration() - - expect(updatedConfig.authorizationTimeout).toBe(60) - expect(updatedConfig.localAuthListEnabled).toBe(false) - expect(updatedConfig.maxCacheEntries).toBe(2000) - - // Restore original configuration - await authService.updateConfiguration(originalConfig) - }) - - await it('should update and retrieve configuration for OCPP 2.0', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation20) - const originalConfig = authService.getConfiguration() - - const updates = { - authorizationTimeout: 45, - certificateAuthEnabled: true, - remoteAuthorization: true, - } - - await authService.updateConfiguration(updates) - const updatedConfig = authService.getConfiguration() - - expect(updatedConfig.authorizationTimeout).toBe(45) - expect(updatedConfig.certificateAuthEnabled).toBe(true) - expect(updatedConfig.remoteAuthorization).toBe(true) - - // Restore original configuration - await authService.updateConfiguration(originalConfig) - }) - }) - - await describe('Strategy Selection', async () => { - await it('should return available strategies list (empty before initialization)', () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - const strategies = authService.getAvailableStrategies() - - // Before initialize() is called, strategies list is empty - expect(Array.isArray(strategies)).toBe(true) - expect(strategies.length).toBe(0) - }) - - await it('should detect identifier support correctly', () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - const identifier = createMockOCPP16Identifier('SUPPORT_TEST_ID') - - const isSupported = authService.isSupported(identifier) - expect(typeof isSupported).toBe('boolean') - }) - - await it('should get strategy by name returns undefined for non-existent', () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - - const strategy = authService.getStrategy('non-existent') - expect(strategy).toBeUndefined() - }) - }) - await describe('OCPP 1.6 Authentication Flow', async () => { await it('should authenticate with valid identifier', async () => { const authService = new OCPPAuthServiceImpl(mockChargingStation16) @@ -257,8 +147,8 @@ await describe('OCPP Authentication Integration Tests', async () => { }) }) - await describe('Error Handling', async () => { - await it('should handle invalid identifier gracefully', async () => { + await describe('Integration Error Scenarios', async () => { + await it('should handle invalid identifier gracefully during auth flow', async () => { const authService = new OCPPAuthServiceImpl(mockChargingStation16) const request = createMockAuthRequest({ connectorId: 999, // Invalid connector @@ -276,72 +166,10 @@ await describe('OCPP Authentication Integration Tests', async () => { expect(result).toBeDefined() expect(result.status).not.toBe(AuthorizationStatus.ACCEPTED) }) - - await it('should throw error for non-existent strategy', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - const request = createMockAuthRequest() - - await expect( - authService.authorizeWithStrategy('non-existent-strategy', request) - ).rejects.toThrow() - }) - }) - - await describe('Cache Operations', async () => { - await it('should invalidate cache without error', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - const identifier = createMockOCPP16Identifier('CACHE_TEST_ID') - - // Should not throw - await authService.invalidateCache(identifier) - }) - - await it('should clear cache without error', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - - // Should not throw - await authService.clearCache() - }) - - await it('should check local authorization after cache operations', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - const identifier = createMockOCPP16Identifier('LOCAL_AUTH_TEST') - - await authService.clearCache() - const localResult = await authService.isLocallyAuthorized(identifier, 1) - - // Result can be undefined (not locally authorized) or an AuthorizationResult - if (localResult !== undefined) { - expect(localResult.timestamp).toBeInstanceOf(Date) - } - }) }) - await describe('Performance and Statistics', async () => { - await it('should test connectivity successfully', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - - const connectivity = await authService.testConnectivity() - expect(typeof connectivity).toBe('boolean') - }) - - await it('should retrieve valid statistics', async () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - - const stats = await authService.getStats() - expect(typeof stats.totalRequests).toBe('number') - expect(stats.totalRequests).toBeGreaterThanOrEqual(0) - }) - - await it('should retrieve authentication statistics', () => { - const authService = new OCPPAuthServiceImpl(mockChargingStation16) - - const authStats = authService.getAuthenticationStats() - expect(Array.isArray(authStats.availableStrategies)).toBe(true) - expect(authStats.ocppVersion).toBeDefined() - }) - - await it('should handle concurrent authentication requests', async () => { + await describe('Concurrent Operations', async () => { + await it('should handle concurrent authentication requests with mixed contexts', async () => { const authService = new OCPPAuthServiceImpl(mockChargingStation16) const requestCount = 10 const promises = [] @@ -349,14 +177,15 @@ await describe('OCPP Authentication Integration Tests', async () => { for (let i = 0; i < requestCount; i++) { const request = createMockAuthRequest({ connectorId: 1, - identifier: createMockOCPP16Identifier(`PERF_TEST_${String(i)}`), + context: i % 2 === 0 ? AuthContext.TRANSACTION_START : AuthContext.TRANSACTION_STOP, + identifier: createMockOCPP16Identifier(`CONCURRENT_${String(i)}`), }) promises.push(authService.authenticate(request)) } const results = await Promise.all(promises) - // All requests should complete + // All requests should complete successfully expect(results.length).toBe(requestCount) for (const result of results) { expect(result).toBeDefined() @@ -364,59 +193,4 @@ await describe('OCPP Authentication Integration Tests', async () => { } }) }) - - await describe('Mock Factory Integration', async () => { - await it('should use mock authorization result correctly', () => { - const mockResult = createMockAuthorizationResult() - - expectAcceptedAuthorization(mockResult, AuthenticationMethod.LOCAL_LIST) - }) - - await it('should use mock rejected result correctly', () => { - const mockResult = createMockAuthorizationResult({ - status: AuthorizationStatus.INVALID, - }) - - expectRejectedAuthorization(mockResult, AuthorizationStatus.INVALID) - }) - - await it('should create valid test auth config', () => { - const config = createTestAuthConfig({ - localAuthListEnabled: true, - remoteAuthorization: true, - }) - - expect(config.localAuthListEnabled).toBe(true) - expect(config.remoteAuthorization).toBe(true) - expect(config.authorizationTimeout).toBeDefined() - }) - - await it('should create mock identifiers for both OCPP versions', () => { - const ocpp16Id = createMockOCPP16Identifier('TEST_16') - const ocpp20Id = createMockOCPP20Identifier('TEST_20') - - expect(ocpp16Id.ocppVersion).toBe(OCPPVersion.VERSION_16) - expect(ocpp20Id.ocppVersion).toBe(OCPPVersion.VERSION_20) - expect(ocpp16Id.type).toBe(IdentifierType.ID_TAG) - expect(ocpp20Id.type).toBe(IdentifierType.ID_TAG) - }) - - await it('should create mock auth service with overrides', async () => { - const mockService = createMockAuthService({ - authorize: () => - Promise.resolve({ - isOffline: false, - method: AuthenticationMethod.REMOTE_AUTHORIZATION, - status: AuthorizationStatus.BLOCKED, - timestamp: new Date(), - }), - }) - - const request = createMockAuthRequest() - const result = await mockService.authorize(request) - - expect(result.status).toBe(AuthorizationStatus.BLOCKED) - expect(result.method).toBe(AuthenticationMethod.REMOTE_AUTHORIZATION) - }) - }) }) diff --git a/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter-Offline.test.ts b/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter-Offline.test.ts deleted file mode 100644 index 679a52c2..00000000 --- a/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter-Offline.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @file Tests for OCPP20AuthAdapter Offline - * @description Unit tests for OCPP 2.0 offline authorization scenarios (G03.FR.02) - */ -/** - * G03.FR.02 - OCPP 2.0 Offline Authorization Tests - * - * Tests for offline authorization scenarios: - * - G03.FR.02.001: Authorize locally when offline with LocalAuthListEnabled=true - * - G03.FR.02.002: Reject when offline and local auth disabled - * - G03.FR.02.003: Reconnection sync auth state - * - * OCPP 2.0.1 Specification References: - * - Section G03 - Authorization - * - AuthCtrlr.LocalAuthorizeOffline variable - * - AuthCtrlr.LocalAuthListEnabled variable - */ - -import { expect } from '@std/expect' -import { afterEach, beforeEach, describe, it, mock } from 'node:test' - -import type { ChargingStation } from '../../../../../src/charging-station/ChargingStation.js' - -import { OCPP20AuthAdapter } from '../../../../../src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.js' -import { OCPPVersion } from '../../../../../src/types/ocpp/OCPPVersion.js' - -await describe('OCPP20AuthAdapter - G03.FR.02 Offline Authorization', async () => { - let adapter: OCPP20AuthAdapter - let mockChargingStation: ChargingStation - - beforeEach(() => { - mockChargingStation = { - inAcceptedState: () => true, - logPrefix: () => '[TEST-STATION-OFFLINE]', - stationInfo: { - chargingStationId: 'TEST-OFFLINE', - }, - } as unknown as ChargingStation - - adapter = new OCPP20AuthAdapter(mockChargingStation) - }) - - afterEach(() => { - mock.reset() - }) - - await describe('G03.FR.02.001 - Offline detection', async () => { - await it('should detect station is offline when not in accepted state', async () => { - // Given: Station is offline (not in accepted state) - mockChargingStation.inAcceptedState = () => false - - // When: Check if remote authorization is available - const isAvailable = await adapter.isRemoteAvailable() - - // Then: Remote should not be available - expect(isAvailable).toBe(false) - }) - - await it('should detect station is online when in accepted state', async () => { - // Given: Station is online (in accepted state) - mockChargingStation.inAcceptedState = () => true - - // When: Check if remote authorization is available - const isAvailable = await adapter.isRemoteAvailable() - - // Then: Remote should be available (assuming AuthorizeRemoteStart is enabled by default) - expect(isAvailable).toBe(true) - }) - - await it('should have correct OCPP version for offline tests', () => { - // Verify we're testing the correct OCPP version - expect(adapter.ocppVersion).toBe(OCPPVersion.VERSION_20) - }) - }) - - await describe('G03.FR.02.002 - Remote availability check', async () => { - await it('should return false when offline even with valid configuration', async () => { - // Given: Station is offline - mockChargingStation.inAcceptedState = () => false - - // When: Check remote availability - const isAvailable = await adapter.isRemoteAvailable() - - // Then: Should not be available - expect(isAvailable).toBe(false) - }) - - await it('should handle errors gracefully when checking availability', async () => { - // Given: inAcceptedState throws an error - mockChargingStation.inAcceptedState = () => { - throw new Error('Connection error') - } - - // When: Check remote availability - const isAvailable = await adapter.isRemoteAvailable() - - // Then: Should safely return false - expect(isAvailable).toBe(false) - }) - }) - - await describe('G03.FR.02.003 - Configuration validation', async () => { - await it('should initialize with default configuration for offline scenarios', () => { - // When: Adapter is created - // Then: Should have OCPP 2.0 version - expect(adapter.ocppVersion).toBe(OCPPVersion.VERSION_20) - }) - - await it('should validate configuration schema for offline auth', () => { - // When: Get configuration schema - const schema = adapter.getConfigurationSchema() - - // Then: Should have required offline auth properties - expect(schema).toBeDefined() - expect(schema.properties).toBeDefined() - // OCPP 2.0 uses variables, not configuration keys - // The actual offline behavior is controlled by AuthCtrlr variables - }) - - await it('should have getStatus method for monitoring offline state', () => { - // When: Get adapter status - const status = adapter.getStatus() - - // Then: Status should be defined and include online state - expect(status).toBeDefined() - expect(typeof status.isOnline).toBe('boolean') - expect(status.ocppVersion).toBe(OCPPVersion.VERSION_20) - }) - }) -}) diff --git a/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts b/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts index 543df57c..41f8c8e0 100644 --- a/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts +++ b/tests/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.test.ts @@ -373,4 +373,109 @@ await describe('OCPP20AuthAdapter', async () => { } }) }) + + await describe('OCPP20AuthAdapter - G03.FR.02 Offline Authorization', async () => { + let offlineAdapter: OCPP20AuthAdapter + let offlineMockChargingStation: ChargingStation + + beforeEach(() => { + offlineMockChargingStation = { + inAcceptedState: () => true, + logPrefix: () => '[TEST-STATION-OFFLINE]', + stationInfo: { + chargingStationId: 'TEST-OFFLINE', + }, + } as unknown as ChargingStation + + offlineAdapter = new OCPP20AuthAdapter(offlineMockChargingStation) + }) + + afterEach(() => { + mock.reset() + }) + + await describe('G03.FR.02.001 - Offline detection', async () => { + await it('should detect station is offline when not in accepted state', async () => { + // Given: Station is offline (not in accepted state) + offlineMockChargingStation.inAcceptedState = () => false + + // When: Check if remote authorization is available + const isAvailable = await offlineAdapter.isRemoteAvailable() + + // Then: Remote should not be available + expect(isAvailable).toBe(false) + }) + + await it('should detect station is online when in accepted state', async () => { + // Given: Station is online (in accepted state) + offlineMockChargingStation.inAcceptedState = () => true + + // When: Check if remote authorization is available + const isAvailable = await offlineAdapter.isRemoteAvailable() + + // Then: Remote should be available (assuming AuthorizeRemoteStart is enabled by default) + expect(isAvailable).toBe(true) + }) + + await it('should have correct OCPP version for offline tests', () => { + // Verify we're testing the correct OCPP version + expect(offlineAdapter.ocppVersion).toBe(OCPPVersion.VERSION_20) + }) + }) + + await describe('G03.FR.02.002 - Remote availability check', async () => { + await it('should return false when offline even with valid configuration', async () => { + // Given: Station is offline + offlineMockChargingStation.inAcceptedState = () => false + + // When: Check remote availability + const isAvailable = await offlineAdapter.isRemoteAvailable() + + // Then: Should not be available + expect(isAvailable).toBe(false) + }) + + await it('should handle errors gracefully when checking availability', async () => { + // Given: inAcceptedState throws an error + offlineMockChargingStation.inAcceptedState = () => { + throw new Error('Connection error') + } + + // When: Check remote availability + const isAvailable = await offlineAdapter.isRemoteAvailable() + + // Then: Should safely return false + expect(isAvailable).toBe(false) + }) + }) + + await describe('G03.FR.02.003 - Configuration validation', async () => { + await it('should initialize with default configuration for offline scenarios', () => { + // When: Adapter is created + // Then: Should have OCPP 2.0 version + expect(offlineAdapter.ocppVersion).toBe(OCPPVersion.VERSION_20) + }) + + await it('should validate configuration schema for offline auth', () => { + // When: Get configuration schema + const schema = offlineAdapter.getConfigurationSchema() + + // Then: Should have required offline auth properties + expect(schema).toBeDefined() + expect(schema.properties).toBeDefined() + // OCPP 2.0 uses variables, not configuration keys + // The actual offline behavior is controlled by AuthCtrlr variables + }) + + await it('should have getStatus method for monitoring offline state', () => { + // When: Get adapter status + const status = offlineAdapter.getStatus() + + // Then: Status should be defined and include online state + expect(status).toBeDefined() + expect(typeof status.isOnline).toBe('boolean') + expect(status.ocppVersion).toBe(OCPPVersion.VERSION_20) + }) + }) + }) }) diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index 2d9c31fa..d1901ff9 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -304,53 +304,41 @@ await describe('Utils test suite', async () => { expect(isAsyncFunction(new Float64Array())).toBe(false) expect(isAsyncFunction(new BigInt64Array())).toBe(false) expect(isAsyncFunction(new BigUint64Array())).toBe(false) - // eslint-disable-next-line @typescript-eslint/no-empty-function + /* eslint-disable @typescript-eslint/no-empty-function -- Testing with empty functions to verify isAsyncFunction correctly identifies async vs sync */ expect(isAsyncFunction(new Promise(() => {}))).toBe(false) expect(isAsyncFunction(new WeakRef({}))).toBe(false) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(new FinalizationRegistry(() => {}))).toBe(false) expect(isAsyncFunction(new ArrayBuffer(16))).toBe(false) expect(isAsyncFunction(new SharedArrayBuffer(16))).toBe(false) expect(isAsyncFunction(new DataView(new ArrayBuffer(16)))).toBe(false) expect(isAsyncFunction({})).toBe(false) expect(isAsyncFunction({ a: 1 })).toBe(false) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(() => {})).toBe(false) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(function () {})).toBe(false) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(function named () {})).toBe(false) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(async () => {})).toBe(true) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(async function () {})).toBe(true) - // eslint-disable-next-line @typescript-eslint/no-empty-function expect(isAsyncFunction(async function named () {})).toBe(true) + /* eslint-enable @typescript-eslint/no-empty-function */ class TestClass { - // eslint-disable-next-line @typescript-eslint/no-empty-function + /* eslint-disable @typescript-eslint/no-empty-function -- Testing class methods and properties */ public static async testStaticAsync (): Promise {} - // eslint-disable-next-line @typescript-eslint/no-empty-function public static testStaticSync (): void {} - // eslint-disable-next-line @typescript-eslint/no-empty-function public testArrowAsync = async (): Promise => {} - // eslint-disable-next-line @typescript-eslint/no-empty-function public testArrowSync = (): void => {} - // eslint-disable-next-line @typescript-eslint/no-empty-function public async testAsync (): Promise {} - // eslint-disable-next-line @typescript-eslint/no-empty-function public testSync (): void {} + /* eslint-enable @typescript-eslint/no-empty-function */ } const testClass = new TestClass() - // eslint-disable-next-line @typescript-eslint/unbound-method + /* eslint-disable @typescript-eslint/unbound-method -- Testing unbound method detection for async/sync determination */ expect(isAsyncFunction(testClass.testSync)).toBe(false) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(isAsyncFunction(testClass.testAsync)).toBe(true) expect(isAsyncFunction(testClass.testArrowSync)).toBe(false) expect(isAsyncFunction(testClass.testArrowAsync)).toBe(true) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(isAsyncFunction(TestClass.testStaticSync)).toBe(false) - // eslint-disable-next-line @typescript-eslint/unbound-method expect(isAsyncFunction(TestClass.testStaticAsync)).toBe(true) + /* eslint-enable @typescript-eslint/unbound-method */ }) await it('should deep clone objects, arrays, dates, maps and sets', () => { @@ -472,8 +460,9 @@ await describe('Utils test suite', async () => { await it('should insert substring at specified index position', () => { expect(insertAt('test', 'ing', 'test'.length)).toBe('testing') - // eslint-disable-next-line @cspell/spellchecker + /* eslint-disable @cspell/spellchecker -- Testing string insertion with intentional misspelling 'ing' at position 2 */ expect(insertAt('test', 'ing', 2)).toBe('teingst') + /* eslint-enable @cspell/spellchecker */ }) await it('should convert to integer or return NaN for invalid input', () => { -- 2.53.0