From: Jérôme Benoit Date: Thu, 12 Mar 2026 21:43:33 +0000 (+0100) Subject: refactor(ocpp2): convert incoming request fire-and-forget patterns to event listeners... X-Git-Tag: ocpp-server@v3.1.0~55 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=12018fd396db8ad3a03b1e84bf4a56c9c9768e4e;p=e-mobility-charging-stations-simulator.git refactor(ocpp2): convert incoming request fire-and-forget patterns to event listeners (#1715) * refactor(ocpp2): convert UpdateFirmware fire-and-forget to event listener * refactor(ocpp2): convert GetLog fire-and-forget to event listener * refactor(ocpp2): convert CustomerInformation fire-and-forget to event listener * refactor(ocpp2): convert TriggerMessage fire-and-forget to event listener * [autofix.ci] apply automated fixes * test(ocpp2): improve TRIGGER_MESSAGE listener test coverage for SonarCloud * test(ocpp2): remove 10 redundant handler tests that add no branch coverage * test(ocpp2): remove 5 project-wide redundant tests identified by cross-validated audit * refactor(ocpp2): extract StatusNotification trigger methods to reduce cognitive complexity * [autofix.ci] apply automated fixes * test(ocpp2): strengthen StatusNotification trigger assertions with payload and options validation * [autofix.ci] apply automated fixes * fix(ocpp2): relocate B11.FR.01 spec reference to correct surviving test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 3b01501e..89bc7e84 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -266,6 +266,147 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } } ) + this.on( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + ( + chargingStation: ChargingStation, + request: OCPP20UpdateFirmwareRequest, + response: OCPP20UpdateFirmwareResponse + ) => { + if (response.status === UpdateFirmwareStatusEnumType.Accepted) { + this.simulateFirmwareUpdateLifecycle( + chargingStation, + request.requestId, + request.firmware.signature + ).catch((error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware lifecycle error:`, + error + ) + }) + } + } + ) + this.on( + OCPP20IncomingRequestCommand.GET_LOG, + ( + chargingStation: ChargingStation, + request: OCPP20GetLogRequest, + response: OCPP20GetLogResponse + ) => { + if (response.status === LogStatusEnumType.Accepted) { + this.simulateLogUploadLifecycle(chargingStation, request.requestId).catch( + (error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.constructor: GetLog lifecycle error:`, + error + ) + } + ) + } + } + ) + this.on( + OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, + ( + chargingStation: ChargingStation, + request: OCPP20CustomerInformationRequest, + response: OCPP20CustomerInformationResponse + ) => { + if (response.status === CustomerInformationStatusEnumType.Accepted && request.report) { + this.sendNotifyCustomerInformation(chargingStation, request.requestId).catch( + (error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.constructor: CustomerInformation notification error:`, + error + ) + } + ) + } + } + ) + this.on( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + ( + chargingStation: ChargingStation, + request: OCPP20TriggerMessageRequest, + response: OCPP20TriggerMessageResponse + ) => { + if (response.status !== TriggerMessageStatusEnumType.Accepted) { + return + } + const { evse, requestedMessage } = request + const errorHandler = (error: unknown): void => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.constructor: Trigger ${requestedMessage} error:`, + error + ) + } + switch (requestedMessage) { + case MessageTriggerEnumType.BootNotification: + chargingStation.ocppRequestService + .requestHandler< + OCPP20BootNotificationRequest, + OCPP20BootNotificationResponse + >(chargingStation, OCPP20RequestCommand.BOOT_NOTIFICATION, chargingStation.bootNotificationRequest as OCPP20BootNotificationRequest, { skipBufferingOnError: true, triggerMessage: true }) + .catch(errorHandler) + break + case MessageTriggerEnumType.FirmwareStatusNotification: + chargingStation.ocppRequestService + .requestHandler< + OCPP20FirmwareStatusNotificationRequest, + OCPP20FirmwareStatusNotificationResponse + >(chargingStation, OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, { status: FirmwareStatusEnumType.Idle }, { skipBufferingOnError: true, triggerMessage: true }) + .catch(errorHandler) + break + case MessageTriggerEnumType.Heartbeat: + chargingStation.ocppRequestService + .requestHandler< + OCPP20HeartbeatRequest, + OCPP20HeartbeatResponse + >(chargingStation, OCPP20RequestCommand.HEARTBEAT, {}, { skipBufferingOnError: true, triggerMessage: true }) + .catch(errorHandler) + break + case MessageTriggerEnumType.LogStatusNotification: + chargingStation.ocppRequestService + .requestHandler< + OCPP20LogStatusNotificationRequest, + OCPP20LogStatusNotificationResponse + >(chargingStation, OCPP20RequestCommand.LOG_STATUS_NOTIFICATION, { status: UploadLogStatusEnumType.Idle }, { skipBufferingOnError: true, triggerMessage: true }) + .catch(errorHandler) + break + case MessageTriggerEnumType.MeterValues: { + const evseId = evse?.id ?? 0 + chargingStation.ocppRequestService + .requestHandler( + chargingStation, + OCPP20RequestCommand.METER_VALUES, + { + evseId, + meterValue: [ + { + sampledValue: [ + { + context: OCPP20ReadingContextEnumType.TRIGGER, + measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, + value: 0, + }, + ], + timestamp: new Date(), + }, + ], + }, + { skipBufferingOnError: true, triggerMessage: true } + ) + .catch(errorHandler) + break + } + case MessageTriggerEnumType.StatusNotification: + this.triggerStatusNotification(chargingStation, evse, errorHandler) + break + } + } + ) this.validatePayload = this.validatePayload.bind(this) } @@ -1191,17 +1332,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Report request accepted, sending empty NotifyCustomerInformation` ) - // Fire-and-forget NotifyCustomerInformation with empty data - setImmediate(() => { - this.sendNotifyCustomerInformation(chargingStation, commandPayload.requestId).catch( - (error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Error sending NotifyCustomerInformation:`, - error - ) - } - ) - }) return { status: CustomerInformationStatusEnumType.Accepted, } @@ -1432,16 +1562,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Received GetLog request with requestId ${requestId.toString()} for logType '${logType}'` ) - // Fire-and-forget log upload state machine after response is returned - setImmediate(() => { - this.simulateLogUploadLifecycle(chargingStation, requestId).catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Error during log upload simulation:`, - error - ) - }) - }) - return { filename: 'simulator-log.txt', status: LogStatusEnumType.Accepted, @@ -2146,168 +2266,21 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { }, } } - chargingStation.ocppRequestService - .requestHandler< - OCPP20BootNotificationRequest, - OCPP20BootNotificationResponse - >(chargingStation, OCPP20RequestCommand.BOOT_NOTIFICATION, chargingStation.bootNotificationRequest as OCPP20BootNotificationRequest, { skipBufferingOnError: true, triggerMessage: true }) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending BootNotification:`, - error - ) - }) return { status: TriggerMessageStatusEnumType.Accepted } case MessageTriggerEnumType.FirmwareStatusNotification: - chargingStation.ocppRequestService - .requestHandler< - OCPP20FirmwareStatusNotificationRequest, - OCPP20FirmwareStatusNotificationResponse - >( - chargingStation, - OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION, - { - status: FirmwareStatusEnumType.Idle, - }, - { skipBufferingOnError: true, triggerMessage: true } - ) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending FirmwareStatusNotification:`, - error - ) - }) return { status: TriggerMessageStatusEnumType.Accepted } case MessageTriggerEnumType.Heartbeat: - chargingStation.ocppRequestService - .requestHandler< - OCPP20HeartbeatRequest, - OCPP20HeartbeatResponse - >(chargingStation, OCPP20RequestCommand.HEARTBEAT, {}, { skipBufferingOnError: true, triggerMessage: true }) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending Heartbeat:`, - error - ) - }) return { status: TriggerMessageStatusEnumType.Accepted } case MessageTriggerEnumType.LogStatusNotification: - chargingStation.ocppRequestService - .requestHandler< - OCPP20LogStatusNotificationRequest, - OCPP20LogStatusNotificationResponse - >( - chargingStation, - OCPP20RequestCommand.LOG_STATUS_NOTIFICATION, - { - status: UploadLogStatusEnumType.Idle, - }, - { skipBufferingOnError: true, triggerMessage: true } - ) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending LogStatusNotification:`, - error - ) - }) return { status: TriggerMessageStatusEnumType.Accepted } - case MessageTriggerEnumType.MeterValues: { - const evseId = evse?.id ?? 0 - chargingStation.ocppRequestService - .requestHandler( - chargingStation, - OCPP20RequestCommand.METER_VALUES, - { - evseId, - meterValue: [ - { - sampledValue: [ - { - context: OCPP20ReadingContextEnumType.TRIGGER, - measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, - value: 0, - }, - ], - timestamp: new Date(), - }, - ], - }, - { skipBufferingOnError: true, triggerMessage: true } - ) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending MeterValues:`, - error - ) - }) + case MessageTriggerEnumType.MeterValues: return { status: TriggerMessageStatusEnumType.Accepted } - } case MessageTriggerEnumType.StatusNotification: - if (evse?.id !== undefined && evse.id > 0 && evse.connectorId !== undefined) { - const evseStatus = chargingStation.evses.get(evse.id) - const connectorStatus = evseStatus?.connectors.get(evse.connectorId) - const resolvedStatus = - connectorStatus?.status != null - ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType) - : OCPP20ConnectorStatusEnumType.Available - chargingStation.ocppRequestService - .requestHandler( - chargingStation, - OCPP20RequestCommand.STATUS_NOTIFICATION, - { - connectorId: evse.connectorId, - connectorStatus: resolvedStatus, - evseId: evse.id, - timestamp: new Date(), - }, - { skipBufferingOnError: true, triggerMessage: true } - ) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending StatusNotification:`, - error - ) - }) - } else { - if (chargingStation.hasEvses) { - for (const [evseId, evseStatus] of chargingStation.evses) { - if (evseId > 0) { - for (const [connectorId, connectorStatus] of evseStatus.connectors) { - const resolvedConnectorStatus = - connectorStatus.status != null - ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType) - : OCPP20ConnectorStatusEnumType.Available - chargingStation.ocppRequestService - .requestHandler< - OCPP20StatusNotificationRequest, - OCPP20StatusNotificationResponse - >( - chargingStation, - OCPP20RequestCommand.STATUS_NOTIFICATION, - { - connectorId, - connectorStatus: resolvedConnectorStatus, - evseId, - timestamp: new Date(), - }, - { skipBufferingOnError: true, triggerMessage: true } - ) - .catch((error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Error sending StatusNotification:`, - error - ) - }) - } - } - } - } - } return { status: TriggerMessageStatusEnumType.Accepted } default: @@ -2442,18 +2415,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Received UpdateFirmware request with requestId ${requestId.toString()} for location '${firmware.location}'` ) - // Fire-and-forget firmware update state machine after response is returned - setImmediate(() => { - this.simulateFirmwareUpdateLifecycle(chargingStation, requestId, firmware.signature).catch( - (error: unknown) => { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Error during firmware update simulation:`, - error - ) - } - ) - }) - return { status: UpdateFirmwareStatusEnumType.Accepted, } @@ -3049,6 +3010,65 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { return handler as IncomingRequestHandler } + private triggerAllEvseStatusNotifications ( + chargingStation: ChargingStation, + errorHandler: (error: unknown) => void + ): void { + for (const [evseId, evseStatus] of chargingStation.evses) { + if (evseId > 0) { + for (const [connectorId, connectorStatus] of evseStatus.connectors) { + const resolvedConnectorStatus = + connectorStatus.status != null + ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType) + : OCPP20ConnectorStatusEnumType.Available + chargingStation.ocppRequestService + .requestHandler( + chargingStation, + OCPP20RequestCommand.STATUS_NOTIFICATION, + { + connectorId, + connectorStatus: resolvedConnectorStatus, + evseId, + timestamp: new Date(), + }, + { skipBufferingOnError: true, triggerMessage: true } + ) + .catch(errorHandler) + } + } + } + } + + private triggerStatusNotification ( + chargingStation: ChargingStation, + evse: OCPP20TriggerMessageRequest['evse'], + errorHandler: (error: unknown) => void + ): void { + if (evse?.id !== undefined && evse.id > 0 && evse.connectorId !== undefined) { + const evseStatus = chargingStation.evses.get(evse.id) + const connectorStatus = evseStatus?.connectors.get(evse.connectorId) + const resolvedStatus = + connectorStatus?.status != null + ? (connectorStatus.status as unknown as OCPP20ConnectorStatusEnumType) + : OCPP20ConnectorStatusEnumType.Available + chargingStation.ocppRequestService + .requestHandler( + chargingStation, + OCPP20RequestCommand.STATUS_NOTIFICATION, + { + connectorId: evse.connectorId, + connectorStatus: resolvedStatus, + evseId: evse.id, + timestamp: new Date(), + }, + { skipBufferingOnError: true, triggerMessage: true } + ) + .catch(errorHandler) + } else if (chargingStation.hasEvses) { + this.triggerAllEvseStatusNotifications(chargingStation, errorHandler) + } + } + private validateChargingProfile ( chargingStation: ChargingStation, chargingProfile: OCPP20ChargingProfileType, diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts index bd802e13..d53ffcb0 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts @@ -4,13 +4,19 @@ */ import assert from 'node:assert/strict' -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' import type { ChargingStation } from '../../../../src/charging-station/index.js' import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' -import { CustomerInformationStatusEnumType, OCPPVersion } from '../../../../src/types/index.js' +import { + CustomerInformationStatusEnumType, + type OCPP20CustomerInformationRequest, + type OCPP20CustomerInformationResponse, + OCPP20IncomingRequestCommand, + 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' @@ -82,25 +88,118 @@ await describe('N32 - CustomerInformation', async () => { assert.strictEqual(response.status, CustomerInformationStatusEnumType.Rejected) }) - // Verify clear request with explicit false report flag - await it('should respond with Accepted for clear=true and report=false', () => { - const response = testableService.handleRequestCustomerInformation(station, { + await it('should register CUSTOMER_INFORMATION event listener in constructor', () => { + const service = new OCPP20IncomingRequestService() + assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION), 1) + }) + + await it('should call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Accepted + report=true', () => { + const service = new OCPP20IncomingRequestService() + const notifyMock = mock.method( + service as unknown as { + sendNotifyCustomerInformation: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'sendNotifyCustomerInformation', + () => Promise.resolve() + ) + + const request: OCPP20CustomerInformationRequest = { + clear: false, + report: true, + requestId: 20, + } + const response: OCPP20CustomerInformationResponse = { + status: CustomerInformationStatusEnumType.Accepted, + } + + service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response) + + assert.strictEqual(notifyMock.mock.callCount(), 1) + assert.strictEqual(notifyMock.mock.calls[0].arguments[1], 20) + }) + + await it('should NOT call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Accepted + clear=true only', () => { + // CRITICAL: clear=true also returns Accepted — listener must NOT fire notification + const service = new OCPP20IncomingRequestService() + const notifyMock = mock.method( + service as unknown as { + sendNotifyCustomerInformation: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'sendNotifyCustomerInformation', + () => Promise.resolve() + ) + + const request: OCPP20CustomerInformationRequest = { clear: true, report: false, - requestId: 4, - }) + requestId: 21, + } + const response: OCPP20CustomerInformationResponse = { + status: CustomerInformationStatusEnumType.Accepted, + } - assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted) + service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response) + + assert.strictEqual(notifyMock.mock.callCount(), 0) }) - // Verify report request with explicit false clear flag - await it('should respond with Accepted for clear=false and report=true', () => { - const response = testableService.handleRequestCustomerInformation(station, { + await it('should NOT call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Rejected', () => { + const service = new OCPP20IncomingRequestService() + const notifyMock = mock.method( + service as unknown as { + sendNotifyCustomerInformation: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'sendNotifyCustomerInformation', + () => Promise.resolve() + ) + + const request: OCPP20CustomerInformationRequest = { + clear: false, + report: false, + requestId: 22, + } + const response: OCPP20CustomerInformationResponse = { + status: CustomerInformationStatusEnumType.Rejected, + } + + service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response) + + assert.strictEqual(notifyMock.mock.callCount(), 0) + }) + + await it('should handle sendNotifyCustomerInformation rejection gracefully', async () => { + const service = new OCPP20IncomingRequestService() + mock.method( + service as unknown as { + sendNotifyCustomerInformation: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'sendNotifyCustomerInformation', + () => Promise.reject(new Error('notification error')) + ) + + const request: OCPP20CustomerInformationRequest = { clear: false, report: true, - requestId: 5, - }) + requestId: 99, + } + const response: OCPP20CustomerInformationResponse = { + status: CustomerInformationStatusEnumType.Accepted, + } - assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted) + service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response) + + await Promise.resolve() }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts index bc867064..5bacfe09 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts @@ -4,7 +4,7 @@ */ import assert from 'node:assert/strict' -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' import type { ChargingStation } from '../../../../src/charging-station/index.js' @@ -14,6 +14,8 @@ import { LogEnumType, LogStatusEnumType, type OCPP20GetLogRequest, + type OCPP20GetLogResponse, + OCPP20IncomingRequestCommand, OCPPVersion, } from '../../../../src/types/index.js' import { Constants } from '../../../../src/utils/index.js' @@ -77,39 +79,98 @@ await describe('K01 - GetLog', async () => { assert.strictEqual(response.filename, 'simulator-log.txt') }) - await it('should pass through requestId correctly across different values', () => { - const testRequestId = 42 + await it('should register GET_LOG event listener in constructor', () => { + const service = new OCPP20IncomingRequestService() + assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.GET_LOG), 1) + }) + + await it('should call simulateLogUploadLifecycle when GET_LOG event emitted with Accepted response', () => { + const service = new OCPP20IncomingRequestService() + const simulateMock = mock.method( + service as unknown as { + simulateLogUploadLifecycle: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'simulateLogUploadLifecycle', + () => Promise.resolve() + ) + const request: OCPP20GetLogRequest = { log: { - remoteLocation: 'ftp://logs.example.com/uploads/', + remoteLocation: 'https://csms.example.com/logs', }, logType: LogEnumType.DiagnosticsLog, - requestId: testRequestId, + requestId: 10, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.Accepted, } - const response = testableService.handleRequestGetLog(station, request) + service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) - assert.strictEqual(response.status, LogStatusEnumType.Accepted) - assert.strictEqual(typeof response.status, 'string') - assert.strictEqual(response.filename, 'simulator-log.txt') + assert.strictEqual(simulateMock.mock.callCount(), 1) + assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 10) }) - await it('should return Accepted for request with retries and retryInterval', () => { + await it('should NOT call simulateLogUploadLifecycle when GET_LOG event emitted with Rejected response', () => { + const service = new OCPP20IncomingRequestService() + const simulateMock = mock.method( + service as unknown as { + simulateLogUploadLifecycle: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'simulateLogUploadLifecycle', + () => Promise.resolve() + ) + const request: OCPP20GetLogRequest = { log: { - latestTimestamp: new Date('2025-01-15T23:59:59.000Z'), - oldestTimestamp: new Date('2025-01-01T00:00:00.000Z'), - remoteLocation: 'ftp://logs.example.com/uploads/', + remoteLocation: 'https://csms.example.com/logs', }, logType: LogEnumType.DiagnosticsLog, - requestId: 5, - retries: 3, - retryInterval: 60, + requestId: 11, + } + const response: OCPP20GetLogResponse = { + status: LogStatusEnumType.Rejected, } - const response = testableService.handleRequestGetLog(station, request) + service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) - assert.strictEqual(response.status, LogStatusEnumType.Accepted) - assert.strictEqual(response.filename, 'simulator-log.txt') + assert.strictEqual(simulateMock.mock.callCount(), 0) + }) + + await it('should handle simulateLogUploadLifecycle rejection gracefully', async () => { + const service = new OCPP20IncomingRequestService() + mock.method( + service as unknown as { + simulateLogUploadLifecycle: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'simulateLogUploadLifecycle', + () => Promise.reject(new Error('log upload error')) + ) + + const request: OCPP20GetLogRequest = { + log: { + remoteLocation: 'https://csms.example.com/logs', + }, + logType: LogEnumType.DiagnosticsLog, + requestId: 99, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.Accepted, + } + + service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) + + await Promise.resolve() }) }) 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 9c1fb96b..a888f5bd 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts @@ -49,51 +49,6 @@ await describe('B11 & B12 - Reset', async () => { mockStation = ResetTestFixtures.createStandardStation() }) - // FR: B11.FR.01 - await it('should handle Reset request with Immediate type when no transactions', async () => { - const resetRequest: OCPP20ResetRequest = { - type: ResetEnumType.Immediate, - } - - const response: OCPP20ResetResponse = await testableService.handleRequestReset( - mockStation, - resetRequest - ) - - assert.notStrictEqual(response, undefined) - assert.strictEqual(typeof response, 'object') - assert.notStrictEqual(response.status, undefined) - assert.strictEqual(typeof response.status, 'string') - assert.ok( - [ - ResetStatusEnumType.Accepted, - ResetStatusEnumType.Rejected, - ResetStatusEnumType.Scheduled, - ].includes(response.status) - ) - }) - - await it('should handle Reset request with OnIdle type when no transactions', async () => { - const resetRequest: OCPP20ResetRequest = { - type: ResetEnumType.OnIdle, - } - - const response: OCPP20ResetResponse = await testableService.handleRequestReset( - mockStation, - resetRequest - ) - - assert.notStrictEqual(response, undefined) - assert.notStrictEqual(response.status, undefined) - assert.ok( - [ - ResetStatusEnumType.Accepted, - ResetStatusEnumType.Rejected, - ResetStatusEnumType.Scheduled, - ].includes(response.status) - ) - }) - // FR: B11.FR.03 await it('should handle EVSE-specific reset request when no transactions', async () => { const resetRequest: OCPP20ResetRequest = { @@ -140,6 +95,7 @@ await describe('B11 & B12 - Reset', async () => { assert.ok(response.statusInfo.additionalInfo.includes('EVSE 999')) }) + // FR: B11.FR.01 await it('should return proper response structure for immediate reset without transactions', async () => { const resetRequest: OCPP20ResetRequest = { type: ResetEnumType.Immediate, diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts index 93dc0e79..3199af07 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts @@ -16,6 +16,8 @@ import { createTestableIncomingRequestService } from '../../../../src/charging-s import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { MessageTriggerEnumType, + OCPP20IncomingRequestCommand, + OCPP20RequestCommand, OCPPVersion, ReasonCodeEnumType, RegistrationStatusEnumType, @@ -382,34 +384,256 @@ await describe('F06 - TriggerMessage', async () => { }) }) - await describe('F06 - Response structure', async () => { + await describe('F06 - TRIGGER_MESSAGE event listener', async () => { + let incomingRequestServiceForListener: OCPP20IncomingRequestService let mockStation: MockChargingStation + let requestHandlerMock: ReturnType beforeEach(() => { - ;({ mockStation } = createTriggerMessageStation()) + ;({ mockStation, requestHandlerMock } = createTriggerMessageStation()) + incomingRequestServiceForListener = new OCPP20IncomingRequestService() + }) + + await it('should register TRIGGER_MESSAGE event listener in constructor', () => { + assert.strictEqual( + incomingRequestServiceForListener.listenerCount( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE + ), + 1 + ) }) - await it('should return a plain object with a string status field', () => { + await it('should NOT fire requestHandler when response status is Rejected', () => { const request: OCPP20TriggerMessageRequest = { - requestedMessage: MessageTriggerEnumType.BootNotification, + requestedMessage: MessageTriggerEnumType.Heartbeat, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Rejected, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 0) + }) + + await it('should NOT fire requestHandler when response status is NotImplemented', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.Heartbeat, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.NotImplemented, } - const response = testableService.handleRequestTriggerMessage(mockStation, request) + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) - assert.notStrictEqual(response, undefined) - assert.strictEqual(typeof response, 'object') - assert.strictEqual(typeof response.status, 'string') + assert.strictEqual(requestHandlerMock.mock.callCount(), 0) }) - await it('should not return a Promise from synchronous handler', () => { + await it('should fire BootNotification requestHandler on Accepted', () => { const request: OCPP20TriggerMessageRequest = { requestedMessage: MessageTriggerEnumType.BootNotification, } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + + await it('should fire Heartbeat requestHandler on Accepted', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.Heartbeat, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + + await it('should fire FirmwareStatusNotification requestHandler on Accepted', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + + await it('should fire LogStatusNotification requestHandler on Accepted', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.LogStatusNotification, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + + await it('should fire MeterValues requestHandler on Accepted', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.MeterValues, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + }) + + await it('should broadcast StatusNotification for all EVSEs on Accepted without specific EVSE', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.StatusNotification, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + // 3 EVSEs (1, 2, 3) × 1 connector each = 3 StatusNotification calls + const callCount = requestHandlerMock.mock.callCount() + assert.strictEqual(callCount, 3) + for (const call of requestHandlerMock.mock.calls) { + const args = call.arguments as [ + unknown, + string, + Record, + Record + ] + const [, command, payload, options] = args + assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION) + assert.notStrictEqual(payload, undefined) + assert.ok('evseId' in payload, 'Expected payload to include evseId') + assert.ok('connectorId' in payload, 'Expected payload to include connectorId') + assert.ok('connectorStatus' in payload, 'Expected payload to include connectorStatus') + assert.ok('timestamp' in payload, 'Expected payload to include timestamp') + assert.ok((payload.evseId as number) > 0, 'Expected evseId > 0 (EVSE 0 excluded)') + assert.strictEqual(options.skipBufferingOnError, true) + assert.strictEqual(options.triggerMessage, true) + } + }) + + await it('should fire StatusNotification for specific EVSE and connector via listener', () => { + const request: OCPP20TriggerMessageRequest = { + evse: { connectorId: 1, id: 1 }, + requestedMessage: MessageTriggerEnumType.StatusNotification, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + assert.strictEqual(requestHandlerMock.mock.callCount(), 1) + const args = requestHandlerMock.mock.calls[0].arguments as [ + unknown, + string, + Record, + Record + ] + const [, command, payload, options] = args + assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION) + assert.strictEqual(payload.evseId, 1) + assert.strictEqual(payload.connectorId, 1) + assert.ok('connectorStatus' in payload) + assert.ok(payload.timestamp instanceof Date) + assert.strictEqual(options.skipBufferingOnError, true) + assert.strictEqual(options.triggerMessage, true) + }) + + await it('should handle requestHandler rejection gracefully via errorHandler', async () => { + const rejectingMock = mock.fn(async () => Promise.reject(new Error('test error'))) + const { station: rejectStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: rejectingMock, + }, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.Heartbeat, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + rejectStation, + request, + response + ) - const result = testableService.handleRequestTriggerMessage(mockStation, request) + // Flush microtask queue so .catch(errorHandler) executes + await Promise.resolve() - // A Promise would have a `then` property that is a function - assert.notStrictEqual(typeof (result as unknown as Promise).then, 'function') + assert.strictEqual(rejectingMock.mock.callCount(), 1) }) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts index c9b5bb78..24213025 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts @@ -4,14 +4,16 @@ */ import assert from 'node:assert/strict' -import { afterEach, beforeEach, describe, it } from 'node:test' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' import type { ChargingStation } from '../../../../src/charging-station/index.js' import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { + OCPP20IncomingRequestCommand, type OCPP20UpdateFirmwareRequest, + type OCPP20UpdateFirmwareResponse, OCPPVersion, UpdateFirmwareStatusEnumType, } from '../../../../src/types/index.js' @@ -60,66 +62,101 @@ await describe('J02 - UpdateFirmware', async () => { assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) }) - await it('should return Accepted for request with signature field', () => { + await it('should register UPDATE_FIRMWARE event listener in constructor', () => { + const service = new OCPP20IncomingRequestService() + assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE), 1) + }) + + await it('should call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Accepted response', () => { + const service = new OCPP20IncomingRequestService() + const simulateMock = mock.method( + service as unknown as { + simulateFirmwareUpdateLifecycle: ( + chargingStation: ChargingStation, + requestId: number, + signature?: string + ) => Promise + }, + 'simulateFirmwareUpdateLifecycle', + () => Promise.resolve() + ) + const request: OCPP20UpdateFirmwareRequest = { firmware: { - location: 'https://firmware.example.com/signed-update.bin', + location: 'https://firmware.example.com/update.bin', retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), - signature: 'dGVzdC1zaWduYXR1cmU=', - signingCertificate: '-----BEGIN CERTIFICATE-----\nMIIBkTCB...', + signature: 'dGVzdA==', }, - requestId: 2, + requestId: 42, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, } - const response = testableService.handleRequestUpdateFirmware(station, request) + service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) + assert.strictEqual(simulateMock.mock.callCount(), 1) + assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 42) + assert.strictEqual(simulateMock.mock.calls[0].arguments[2], 'dGVzdA==') }) - await it('should return Accepted for request without signature', () => { - const request: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/unsigned-update.bin', - retrieveDateTime: new Date('2025-01-15T12:00:00.000Z'), + await it('should NOT call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Rejected response', () => { + const service = new OCPP20IncomingRequestService() + const simulateMock = mock.method( + service as unknown as { + simulateFirmwareUpdateLifecycle: ( + chargingStation: ChargingStation, + requestId: number, + signature?: string + ) => Promise }, - requestId: 3, - } - - const response = testableService.handleRequestUpdateFirmware(station, request) + 'simulateFirmwareUpdateLifecycle', + () => Promise.resolve() + ) - assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) - }) - - await it('should pass through requestId correctly in the response', () => { - const testRequestId = 42 const request: OCPP20UpdateFirmwareRequest = { firmware: { location: 'https://firmware.example.com/update.bin', - retrieveDateTime: new Date('2025-01-15T14:00:00.000Z'), + retrieveDateTime: new Date(), }, - requestId: testRequestId, + requestId: 43, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Rejected, } - const response = testableService.handleRequestUpdateFirmware(station, request) + service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) - assert.strictEqual(typeof response.status, 'string') + assert.strictEqual(simulateMock.mock.callCount(), 0) }) - await it('should return Accepted for request with retries and retryInterval', () => { + await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async () => { + const service = new OCPP20IncomingRequestService() + mock.method( + service as unknown as { + simulateFirmwareUpdateLifecycle: ( + chargingStation: ChargingStation, + requestId: number, + signature?: string + ) => Promise + }, + 'simulateFirmwareUpdateLifecycle', + () => Promise.reject(new Error('firmware lifecycle error')) + ) + const request: OCPP20UpdateFirmwareRequest = { firmware: { - installDateTime: new Date('2025-01-15T16:00:00.000Z'), - location: 'https://firmware.example.com/update-retry.bin', - retrieveDateTime: new Date('2025-01-15T15:00:00.000Z'), + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), }, - requestId: 5, - retries: 3, - retryInterval: 60, + requestId: 99, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, } - const response = testableService.handleRequestUpdateFirmware(station, request) + service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) + await Promise.resolve() }) }) 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 bb367feb..5e98605f 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -593,48 +593,6 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.Deauthorized) }) - await it('should select CablePluggedIn for cable_action context with plugged_in', () => { - const context: OCPP20TransactionContext = { - cableState: 'plugged_in', - source: 'cable_action', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Started, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.CablePluggedIn) - }) - - await it('should select EVDetected for cable_action context with detected', () => { - const context: OCPP20TransactionContext = { - cableState: 'detected', - source: 'cable_action', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Updated, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EVDetected) - }) - - await it('should select EVDeparted for cable_action context with unplugged', () => { - const context: OCPP20TransactionContext = { - cableState: 'unplugged', - source: 'cable_action', - } - - const triggerReason = OCPP20ServiceUtils.selectTriggerReason( - OCPP20TransactionEventEnumType.Ended, - context - ) - - assert.strictEqual(triggerReason, OCPP20TriggerReasonEnumType.EVDeparted) - }) - await it('should select ChargingStateChanged for charging_state context', () => { const context: OCPP20TransactionContext = { chargingStateChange: {