]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
test(ocpp): add call chain integration tests for both OCPP stacks
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 18 Mar 2026 18:49:02 +0000 (19:49 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 18 Mar 2026 18:49:02 +0000 (19:49 +0100)
Verify the single-path contract: requestHandler(minimal params) →
buildRequestPayload(constructs) → sendMessage(complete payload).

OCPP 2.0: StatusNotification, TransactionEvent (with default resolution
for triggerReason, transactionId, connectorId from evse), Heartbeat,
and rawPayload bypass.

OCPP 1.6: StatusNotification (errorCode added by builder),
StartTransaction (meterStart/timestamp enrichment), StopTransaction
(meterStop/timestamp enrichment), Heartbeat.

Remove deleted TransactionContextFixtures from TEST_STYLE_GUIDE.

tests/TEST_STYLE_GUIDE.md
tests/charging-station/ocpp/1.6/OCPP16RequestService-CallChain.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts [new file with mode: 0644]

index b263e7d7557b3ce4ed982ffb0ff52a983521aa02..a4200a9c4d76041fd7374910d24b453a7ac96a5e 100644 (file)
@@ -336,7 +336,6 @@ assert.strictEqual(mocks.webSocket.sentMessages.length, 1)
 | -------------------------------------- | ------------------------------- |
 | `createTestableOCPP20RequestService()` | Type-safe private method access |
 | `IdTokenFixtures`                      | Pre-built IdToken fixtures      |
-| `TransactionContextFixtures`           | Transaction context fixtures    |
 
 ### Auth (`ocpp/auth/helpers/MockFactories.ts`)
 
diff --git a/tests/charging-station/ocpp/1.6/OCPP16RequestService-CallChain.test.ts b/tests/charging-station/ocpp/1.6/OCPP16RequestService-CallChain.test.ts
new file mode 100644 (file)
index 0000000..27e26ba
--- /dev/null
@@ -0,0 +1,126 @@
+/**
+ * @file Call chain integration tests for OCPP 1.6 request pipeline
+ * @description Verifies that requestHandler → buildRequestPayload → sendMessage
+ *   is the single path for all outgoing requests.
+ */
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { JsonType } from '../../../../src/types/index.js'
+
+import { OCPP16RequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16RequestService.js'
+import { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js'
+import {
+  OCPP16ChargePointErrorCode,
+  OCPP16ChargePointStatus,
+  OCPP16RequestCommand,
+  type OCPP16StartTransactionRequest,
+  type OCPP16StatusNotificationRequest,
+  type OCPP16StopTransactionRequest,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('OCPP 1.6 Request Call Chain — requestHandler → buildRequestPayload → sendMessage', async () => {
+  let requestService: OCPP16RequestService
+  let sendMessageMock: ReturnType<typeof mock.fn>
+  let station: ChargingStation
+
+  beforeEach(() => {
+    const responseService = new OCPP16ResponseService()
+    requestService = new OCPP16RequestService(responseService)
+
+    sendMessageMock = mock.fn(() => Promise.resolve({} as JsonType))
+    Object.defineProperty(requestService, 'sendMessage', {
+      configurable: true,
+      value: sendMessageMock,
+      writable: true,
+    })
+
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 2,
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      ocppRequestService: {
+        requestHandler: async () => Promise.resolve({} as JsonType),
+      },
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_16,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('STATUS_NOTIFICATION — minimal params → complete payload', async () => {
+    await it('should build complete StatusNotificationRequest with errorCode from connectorId + status', async () => {
+      await requestService.requestHandler(station, OCPP16RequestCommand.STATUS_NOTIFICATION, {
+        connectorId: 1,
+        status: OCPP16ChargePointStatus.Available,
+      } as unknown as JsonType)
+
+      assert.strictEqual(sendMessageMock.mock.callCount(), 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP16StatusNotificationRequest
+      assert.strictEqual(sentPayload.connectorId, 1)
+      assert.strictEqual(sentPayload.status, OCPP16ChargePointStatus.Available)
+      assert.strictEqual(sentPayload.errorCode, OCPP16ChargePointErrorCode.NO_ERROR)
+    })
+  })
+
+  await describe('START_TRANSACTION — enrichment from station context', async () => {
+    await it('should enrich StartTransaction with meterStart and timestamp', async () => {
+      await requestService.requestHandler(station, OCPP16RequestCommand.START_TRANSACTION, {
+        connectorId: 1,
+        idTag: 'TEST001',
+      } as unknown as JsonType)
+
+      assert.strictEqual(sendMessageMock.mock.callCount(), 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP16StartTransactionRequest
+      assert.strictEqual(sentPayload.connectorId, 1)
+      assert.strictEqual(sentPayload.idTag, 'TEST001')
+      assert.ok('meterStart' in sentPayload)
+      assert.ok(sentPayload.timestamp instanceof Date)
+    })
+  })
+
+  await describe('STOP_TRANSACTION — enrichment from station context', async () => {
+    await it('should enrich StopTransaction with meterStop and timestamp', async () => {
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.transactionId = 12345
+        connectorStatus.transactionIdTag = 'TEST001'
+      }
+
+      await requestService.requestHandler(station, OCPP16RequestCommand.STOP_TRANSACTION, {
+        transactionId: 12345,
+      } as unknown as JsonType)
+
+      assert.strictEqual(sendMessageMock.mock.callCount(), 1)
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP16StopTransactionRequest
+      assert.strictEqual(sentPayload.transactionId, 12345)
+      assert.ok('meterStop' in sentPayload)
+      assert.ok(sentPayload.timestamp instanceof Date)
+    })
+  })
+
+  await describe('HEARTBEAT — no builder, empty payload', async () => {
+    await it('should send empty payload for Heartbeat', async () => {
+      await requestService.requestHandler(station, OCPP16RequestCommand.HEARTBEAT)
+
+      assert.strictEqual(sendMessageMock.mock.callCount(), 1)
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2]
+      assert.deepStrictEqual(sentPayload, Object.freeze({}))
+    })
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts
new file mode 100644 (file)
index 0000000..9de0149
--- /dev/null
@@ -0,0 +1,206 @@
+/**
+ * @file Call chain integration tests for OCPP 2.0 request pipeline
+ * @description Verifies that requestHandler → buildRequestPayload → sendMessage
+ *   is the single path for all outgoing requests. Minimal params in, complete
+ *   spec-compliant payload in sendMessage.
+ */
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import {
+  createTestableRequestService,
+  type SendMessageMock,
+  type TestableOCPP20RequestService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.js'
+import {
+  ConnectorStatusEnum,
+  OCPP20ConnectorStatusEnumType,
+  OCPP20RequestCommand,
+  type OCPP20StatusNotificationRequest,
+  OCPP20TransactionEventEnumType,
+  type OCPP20TransactionEventRequest,
+  OCPP20TriggerReasonEnumType,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants, generateUUID } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+await describe('OCPP 2.0 Request Call Chain — requestHandler → buildRequestPayload → sendMessage', async () => {
+  let service: TestableOCPP20RequestService
+  let sendMessageMock: SendMessageMock
+  let station: ChargingStation
+
+  beforeEach(() => {
+    const result = createTestableRequestService()
+    service = result.service
+    sendMessageMock = result.sendMessageMock
+
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    station = mockStation
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('STATUS_NOTIFICATION — minimal params → complete payload', async () => {
+    await it('should build complete StatusNotificationRequest from connectorId + status', async () => {
+      await service.requestHandler(station, OCPP20RequestCommand.STATUS_NOTIFICATION, {
+        connectorId: 1,
+        evseId: 1,
+        status: ConnectorStatusEnum.Available,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20StatusNotificationRequest
+      assert.strictEqual(sentPayload.connectorId, 1)
+      assert.strictEqual(sentPayload.evseId, 1)
+      assert.strictEqual(sentPayload.connectorStatus, OCPP20ConnectorStatusEnumType.Available)
+      assert.ok(sentPayload.timestamp instanceof Date)
+    })
+
+    await it('should resolve evseId from station when not provided', async () => {
+      await service.requestHandler(station, OCPP20RequestCommand.STATUS_NOTIFICATION, {
+        connectorId: 1,
+        status: ConnectorStatusEnum.Occupied,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20StatusNotificationRequest
+      assert.strictEqual(sentPayload.connectorId, 1)
+      assert.strictEqual(sentPayload.connectorStatus, OCPP20ConnectorStatusEnumType.Occupied)
+      assert.ok(sentPayload.timestamp instanceof Date)
+    })
+  })
+
+  await describe('TRANSACTION_EVENT — minimal params → complete payload', async () => {
+    await it('should build complete TransactionEventRequest from minimal params', async () => {
+      const transactionId = generateUUID()
+      await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+        connectorId: 1,
+        eventType: OCPP20TransactionEventEnumType.Started,
+        transactionId,
+        triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20TransactionEventRequest
+      assert.strictEqual(sentPayload.eventType, OCPP20TransactionEventEnumType.Started)
+      assert.strictEqual(sentPayload.triggerReason, OCPP20TriggerReasonEnumType.Authorized)
+      assert.strictEqual(sentPayload.transactionInfo.transactionId, transactionId)
+      assert.strictEqual(sentPayload.seqNo, 0)
+      assert.ok(sentPayload.timestamp instanceof Date)
+      assert.notStrictEqual(sentPayload.evse, undefined)
+    })
+
+    await it('should generate transactionId when not provided (Started)', async () => {
+      await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+        connectorId: 1,
+        eventType: OCPP20TransactionEventEnumType.Started,
+        triggerReason: OCPP20TriggerReasonEnumType.CablePluggedIn,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20TransactionEventRequest
+      assert.ok(sentPayload.transactionInfo.transactionId.length > 0)
+    })
+
+    await it('should default triggerReason to Authorized for Started when not provided', async () => {
+      await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+        connectorId: 1,
+        eventType: OCPP20TransactionEventEnumType.Started,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20TransactionEventRequest
+      assert.strictEqual(sentPayload.triggerReason, OCPP20TriggerReasonEnumType.Authorized)
+    })
+
+    await it('should default triggerReason to RemoteStop for Ended when not provided', async () => {
+      // First create a Started event to set up connector state
+      await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+        connectorId: 1,
+        eventType: OCPP20TransactionEventEnumType.Started,
+        triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+      })
+
+      await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+        connectorId: 1,
+        eventType: OCPP20TransactionEventEnumType.Ended,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 2)
+      const sentPayload = sendMessageMock.mock.calls[1]
+        .arguments[2] as OCPP20TransactionEventRequest
+      assert.strictEqual(sentPayload.triggerReason, OCPP20TriggerReasonEnumType.RemoteStop)
+    })
+
+    await it('should resolve connectorId from evse when passed in OCPP wire format', async () => {
+      await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+        eventType: OCPP20TransactionEventEnumType.Started,
+        evse: { connectorId: 2, id: 1 },
+        triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+      })
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20TransactionEventRequest
+      assert.ok(sentPayload.transactionInfo.transactionId.length > 0)
+      assert.strictEqual(sentPayload.eventType, OCPP20TransactionEventEnumType.Started)
+    })
+  })
+
+  await describe('HEARTBEAT — no builder, empty payload', async () => {
+    await it('should send empty payload for Heartbeat', async () => {
+      await service.requestHandler(station, OCPP20RequestCommand.HEARTBEAT)
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2]
+      assert.deepStrictEqual(sentPayload, Object.freeze({}))
+    })
+  })
+
+  await describe('rawPayload bypass', async () => {
+    await it('should pass pre-built payload through when rawPayload is true', async () => {
+      const preBuiltPayload = {
+        eventType: OCPP20TransactionEventEnumType.Updated,
+        seqNo: 42,
+        timestamp: new Date(),
+        transactionInfo: { transactionId: generateUUID() },
+        triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+      }
+
+      await service.requestHandler(
+        station,
+        OCPP20RequestCommand.TRANSACTION_EVENT,
+        preBuiltPayload as unknown as OCPP20TransactionEventRequest,
+        { rawPayload: true }
+      )
+
+      assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+      const sentPayload = sendMessageMock.mock.calls[0]
+        .arguments[2] as OCPP20TransactionEventRequest
+      assert.strictEqual(sentPayload.seqNo, 42)
+      assert.strictEqual(sentPayload.triggerReason, OCPP20TriggerReasonEnumType.MeterValuePeriodic)
+    })
+  })
+})