From df314809b7e7c5458618235a061117e83ae48cda Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 18 Mar 2026 19:49:02 +0100 Subject: [PATCH] test(ocpp): add call chain integration tests for both OCPP stacks MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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 | 1 - .../OCPP16RequestService-CallChain.test.ts | 126 +++++++++++ .../OCPP20RequestService-CallChain.test.ts | 206 ++++++++++++++++++ 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 tests/charging-station/ocpp/1.6/OCPP16RequestService-CallChain.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts diff --git a/tests/TEST_STYLE_GUIDE.md b/tests/TEST_STYLE_GUIDE.md index b263e7d7..a4200a9c 100644 --- a/tests/TEST_STYLE_GUIDE.md +++ b/tests/TEST_STYLE_GUIDE.md @@ -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 index 00000000..27e26bab --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16RequestService-CallChain.test.ts @@ -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 + 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 index 00000000..9de0149d --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts @@ -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) + }) + }) +}) -- 2.53.0