]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
test(ocpp): add E03 IdToken-First pre-authorization transaction tests
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 27 Feb 2026 13:47:22 +0000 (14:47 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 27 Feb 2026 14:00:21 +0000 (15:00 +0100)
E03 IdToken-First Pre-Authorization Flow (16 tests):
- E03.FR.01: IdToken inclusion in TransactionEvent (once per transaction)
- E03.FR.05/06: EVConnectionTimeOut handling (authorization cancellation)
- E03.FR.07/08: Sequence number continuity and unique transaction IDs
- E03.FR.13: Authorized trigger reason for IdToken-first start
- Authorization status handling (Deauthorized, StopAuthorized)
- Full IdToken-first lifecycle (authorize -> cable -> charge -> end)
- Differentiation from E02 Cable-First flow by trigger reason
- Multiple connector independence verification

Tests verify the IdToken-first flow where user presents their ID token
BEFORE connecting the cable, as opposed to E02 Cable-First where the
cable is connected first.

All 1131 tests pass.

tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent-IdTokenFirst.test.ts [new file with mode: 0644]

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
new file mode 100644 (file)
index 0000000..ea38f12
--- /dev/null
@@ -0,0 +1,679 @@
+import { expect } from '@std/expect'
+import { afterEach, describe, it } from 'node:test'
+
+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 {
+  OCPP20ChargingStateEnumType,
+  OCPP20IdTokenEnumType,
+  type OCPP20IdTokenType,
+  type OCPP20TransactionContext,
+} from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { Constants, generateUUID } from '../../../../src/utils/index.js'
+import { createChargingStation } from '../../../ChargingStationFactory.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from './OCPP20TestConstants.js'
+import { resetConnectorTransactionState, resetLimits } 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 () => {
+  const mockChargingStation = createChargingStation({
+    baseName: TEST_CHARGING_STATION_BASE_NAME,
+    connectorsCount: 3,
+    evseConfiguration: { evsesCount: 3 },
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: async () => {
+        return Promise.resolve({} as EmptyObject)
+      },
+    },
+    stationInfo: {
+      ocppStrictCompliance: true,
+      ocppVersion: OCPPVersion.VERSION_201,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+
+  // Reset limits and state before tests
+  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 context: OCPP20TransactionContext = {
+        authorizationMethod: 'idToken',
+        source: 'local_authorization',
+      }
+
+      const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+        OCPP20TransactionEventEnumType.Started,
+        context
+      )
+
+      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')
+    })
+  })
+})