From aa8e4026ba66fb00194b10f196df1abed63bdc45 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 12 Jul 2026 01:15:05 +0200 Subject: [PATCH] test(ocpp): cover OCPP 2.0.1 log/firmware lifecycle supersession and cleanup paths (#1997) --- ...CPP20IncomingRequestService-GetLog.test.ts | 203 ++++++++++++++++-- ...equestService-PostStopResurrection.test.ts | 19 ++ ...omingRequestService-UpdateFirmware.test.ts | 68 ++++-- 3 files changed, 265 insertions(+), 25 deletions(-) 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 2f2bd02e..d87e1fe1 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts @@ -1,6 +1,6 @@ /** * @file Tests for OCPP20IncomingRequestService GetLog - * @description Unit tests for OCPP 2.0.1 GetLog command handling (K01) and + * @description Unit tests for OCPP 2.0.1 GetLog command handling (N01) and * LogStatusNotification lifecycle simulation per N01 */ @@ -17,10 +17,11 @@ import { type OCPP20GetLogRequest, type OCPP20GetLogResponse, OCPP20IncomingRequestCommand, + OCPP20RequestCommand, OCPPVersion, UploadLogStatusEnumType, } from '../../../../src/types/index.js' -import { Constants } from '../../../../src/utils/index.js' +import { Constants, logger } from '../../../../src/utils/index.js' import { flushMicrotasks, standardCleanup, @@ -30,7 +31,24 @@ import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConsta import { createMockChargingStation } from '../../helpers/StationHelpers.js' import { createMockStationWithRequestTracking } from './OCPP20TestUtils.js' -await describe('K01 - GetLog', async () => { +interface LogUploadPlumbing { + clearActiveLogUpload: (chargingStation: ChargingStation, requestId: number) => void + getOrCreateStationState: (chargingStation: ChargingStation) => LogUploadStateShape + simulateLogUploadLifecycle: (chargingStation: ChargingStation, requestId: number) => Promise + stationsState: WeakMap +} + +interface LogUploadStateShape { + activeLogUploadAbortController?: AbortController + activeLogUploadRequestId?: number + activeLogUploadStatus?: UploadLogStatusEnumType + stopped?: boolean +} + +const asPlumbing = (service: OCPP20IncomingRequestService): LogUploadPlumbing => + service as unknown as LogUploadPlumbing + +await describe('N01 - GetLog', async () => { let station: ChargingStation let testableService: ReturnType @@ -145,16 +163,29 @@ await describe('K01 - GetLog', async () => { assert.strictEqual(simulateMock.mock.callCount(), 0) }) - await it('should handle simulateLogUploadLifecycle rejection gracefully', async () => { - mock.method( - service as unknown as { - simulateLogUploadLifecycle: ( - chargingStation: ChargingStation, - requestId: number - ) => Promise + await it('should call simulateLogUploadLifecycle when GET_LOG event emitted with AcceptedCanceled response', () => { + const request: OCPP20GetLogRequest = { + log: { + remoteLocation: 'https://csms.example.com/logs', }, - 'simulateLogUploadLifecycle', - async () => Promise.reject(new Error('log upload error')) + logType: LogEnumType.DiagnosticsLog, + requestId: 12, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.AcceptedCanceled, + } + + service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) + + assert.strictEqual(simulateMock.mock.callCount(), 1) + assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 12) + }) + + await it('should handle simulateLogUploadLifecycle rejection gracefully', async t => { + const errorMock = t.mock.method(logger, 'error') + simulateMock.mock.mockImplementation(async () => + Promise.reject(new Error('log upload error')) ) const request: OCPP20GetLogRequest = { @@ -172,6 +203,7 @@ await describe('K01 - GetLog', async () => { service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) await flushMicrotasks() + assert.strictEqual(errorMock.mock.callCount(), 1) }) await describe('N01 - LogStatusNotification lifecycle', async () => { @@ -195,7 +227,11 @@ await describe('K01 - GetLog', async () => { await flushMicrotasks() // Assert - assert.ok(sentRequests.length >= 1, 'Expected at least one notification') + assert.strictEqual( + sentRequests.length, + 1, + 'Expected exactly one notification before the timer fires' + ) assert.deepStrictEqual(sentRequests[0].payload, { requestId: 42, status: UploadLogStatusEnumType.Uploading, @@ -277,4 +313,145 @@ await describe('K01 - GetLog', async () => { }) }) }) + + await describe('N01 - GetLog supersession (N01.FR.12/FR.20)', async () => { + await it('should return AcceptedCanceled, abort the prior upload, and notify with the previous requestId', () => { + // Arrange: seed an in-flight prior log upload (requestId 1) + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const testable = createTestableIncomingRequestService(service) + const state = asPlumbing(service).getOrCreateStationState(trackingStation) + const priorAbortController = new AbortController() + state.activeLogUploadAbortController = priorAbortController + state.activeLogUploadRequestId = 1 + + // Act: a new GetLog supersedes the in-flight upload + const request: OCPP20GetLogRequest = { + log: { remoteLocation: 'ftp://logs.example.com/' }, + logType: LogEnumType.DiagnosticsLog, + requestId: 2, + } + const response = testable.handleRequestGetLog(trackingStation, request) + + // Assert: superseded response, prior controller aborted, state reset + assert.deepStrictEqual(response, { + filename: 'simulator-log.txt', + status: LogStatusEnumType.AcceptedCanceled, + }) + assert.strictEqual(priorAbortController.signal.aborted, true) + assert.strictEqual(state.activeLogUploadRequestId, undefined) + assert.strictEqual(state.activeLogUploadAbortController, undefined) + // Terminal LogStatusNotification(AcceptedCanceled) carries the PREVIOUS requestId (1), not the new one (2) + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual(sentRequests[0].command, OCPP20RequestCommand.LOG_STATUS_NOTIFICATION) + assert.deepStrictEqual(sentRequests[0].payload, { + requestId: 1, + status: UploadLogStatusEnumType.AcceptedCanceled, + }) + }) + + await it('should swallow a failed AcceptedCanceled notification and still return AcceptedCanceled', async t => { + // Arrange: force the terminal LogStatusNotification send to reject + const { station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const testable = createTestableIncomingRequestService(service) + const errorMock = t.mock.method(logger, 'error') + ;( + trackingStation.ocppRequestService as unknown as { + requestHandler: (...args: unknown[]) => Promise + } + ).requestHandler = async () => Promise.reject(new Error('notification send failed')) + const state = asPlumbing(service).getOrCreateStationState(trackingStation) + state.activeLogUploadAbortController = new AbortController() + state.activeLogUploadRequestId = 1 + + // Act + const request: OCPP20GetLogRequest = { + log: { remoteLocation: 'ftp://logs.example.com/' }, + logType: LogEnumType.DiagnosticsLog, + requestId: 2, + } + const response = testable.handleRequestGetLog(trackingStation, request) + + // Assert: handler still returns AcceptedCanceled synchronously; the rejection is not thrown + assert.strictEqual(response.status, LogStatusEnumType.AcceptedCanceled) + // The rejection is swallowed by the .catch and logged at error + await flushMicrotasks() + assert.strictEqual(errorMock.mock.callCount(), 1) + }) + }) + + await describe('N01 - log upload lifecycle abort boundary', async () => { + await it('should exit at the interruptibleSleep boundary and not emit Uploaded when aborted mid-flight', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const plumbing = asPlumbing(service) + + // Act: drive the lifecycle; it emits Uploading then parks at interruptibleSleep + const lifecycle = plumbing.simulateLogUploadLifecycle(trackingStation, 77) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual(sentRequests[0].payload.status, UploadLogStatusEnumType.Uploading) + + // Abort mid-flight at the sleep boundary. The requestId stays claimed so + // the abort signal — not the requestId guard — is what suppresses Uploaded. + const state = plumbing.stationsState.get(trackingStation) + state?.activeLogUploadAbortController?.abort() + + // Advance past the step delay (LOG_UPLOAD_STEP_DELAY_MS) and settle the lifecycle + t.mock.timers.tick(1000) + await flushMicrotasks() + await lifecycle + + // Uploaded must NOT be emitted — the lifecycle exited at the boundary + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual( + sentRequests.some(request => request.payload.status === UploadLogStatusEnumType.Uploaded), + false + ) + // The finally block ran: clearActiveLogUpload reset the claimed requestId + assert.strictEqual(state?.activeLogUploadRequestId, undefined) + }) + }) + }) + + await describe('clearActiveLogUpload', async () => { + await it('should log at debug and preserve state for a superseded (non-matching) requestId', t => { + // Arrange + const service = new OCPP20IncomingRequestService() + const plumbing = asPlumbing(service) + const debugMock = t.mock.method(logger, 'debug') + const state = plumbing.getOrCreateStationState(station) + state.activeLogUploadRequestId = 200 + + // Act: a stale/superseded completion for requestId 199 + plumbing.clearActiveLogUpload(station, 199) + + // Assert: else-branch fired the debug log and left state untouched + assert.strictEqual(debugMock.mock.callCount(), 1) + assert.strictEqual(state.activeLogUploadRequestId, 200) + }) + + await it('should reset state and not log for a matching requestId', t => { + // Arrange + const service = new OCPP20IncomingRequestService() + const plumbing = asPlumbing(service) + const debugMock = t.mock.method(logger, 'debug') + const state = plumbing.getOrCreateStationState(station) + state.activeLogUploadAbortController = new AbortController() + state.activeLogUploadRequestId = 200 + state.activeLogUploadStatus = UploadLogStatusEnumType.Uploading + + // Act: the active upload completes cleanly + plumbing.clearActiveLogUpload(station, 200) + + // Assert: state cleared, no "Ignoring" debug log + assert.strictEqual(state.activeLogUploadRequestId, undefined) + assert.strictEqual(state.activeLogUploadAbortController, undefined) + assert.strictEqual(state.activeLogUploadStatus, undefined) + assert.strictEqual(debugMock.mock.callCount(), 0) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts index 16496fe2..9d64b3b8 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts @@ -377,4 +377,23 @@ await describe('OCPP20IncomingRequestService — post-stop resurrection guard', assert.strictEqual(requestHandlerMock.mock.callCount(), 0) }) }) + + await describe('resetStationState abort-on-stop', async () => { + await it('should abort in-flight firmware and log upload controllers on stop()', () => { + const station = createStation('reset-abort') + const state = plumbing.getOrCreateStationState(station) + const firmwareController = new AbortController() + const logController = new AbortController() + state.activeFirmwareUpdateAbortController = firmwareController + state.activeLogUploadAbortController = logController + + service.stop(station) + + assert.strictEqual(firmwareController.signal.aborted, true) + assert.strictEqual(logController.signal.aborted, true) + assert.strictEqual(state.activeFirmwareUpdateAbortController, undefined) + assert.strictEqual(state.activeLogUploadAbortController, undefined) + assert.strictEqual(state.stopped, true) + }) + }) }) 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 d7b9c818..2dc86c9a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts @@ -21,7 +21,7 @@ import { OCPPVersion, UpdateFirmwareStatusEnumType, } from '../../../../src/types/index.js' -import { Constants } from '../../../../src/utils/index.js' +import { Constants, logger } from '../../../../src/utils/index.js' import { flushMicrotasks, standardCleanup, @@ -35,6 +35,20 @@ import { createStationWithCertificateManager, } from './OCPP20TestUtils.js' +interface FirmwareUpdatePlumbing { + clearActiveFirmwareUpdate: (chargingStation: ChargingStation, requestId: number) => void + getOrCreateStationState: (chargingStation: ChargingStation) => FirmwareUpdateStateShape + stationsState: WeakMap +} + +interface FirmwareUpdateStateShape { + activeFirmwareUpdateAbortController?: AbortController + activeFirmwareUpdateRequestId?: number +} + +const asPlumbing = (service: OCPP20IncomingRequestService): FirmwareUpdatePlumbing => + service as unknown as FirmwareUpdatePlumbing + await describe('L01/L02 - UpdateFirmware', async () => { let station: ChargingStation let testableService: ReturnType @@ -281,17 +295,10 @@ await describe('L01/L02 - UpdateFirmware', async () => { assert.strictEqual(simulateMock.mock.callCount(), 0) }) - await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async () => { - mock.method( - service as unknown as { - simulateFirmwareUpdateLifecycle: ( - chargingStation: ChargingStation, - requestId: number, - firmware: FirmwareType - ) => Promise - }, - 'simulateFirmwareUpdateLifecycle', - async () => Promise.reject(new Error('firmware lifecycle error')) + await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async t => { + const errorMock = t.mock.method(logger, 'error') + simulateMock.mock.mockImplementation(async () => + Promise.reject(new Error('firmware lifecycle error')) ) const request: OCPP20UpdateFirmwareRequest = { @@ -308,6 +315,7 @@ await describe('L01/L02 - UpdateFirmware', async () => { service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) await flushMicrotasks() + assert.strictEqual(errorMock.mock.callCount(), 1) }) await it('should cancel previous firmware update when new one arrives', async t => { @@ -1318,4 +1326,40 @@ await describe('L01/L02 - UpdateFirmware', async () => { }) }) }) + + await describe('clearActiveFirmwareUpdate', async () => { + await it('should log at debug and preserve state for a superseded (non-matching) requestId', t => { + // Arrange + const service = new OCPP20IncomingRequestService() + const plumbing = asPlumbing(service) + const debugMock = t.mock.method(logger, 'debug') + const state = plumbing.getOrCreateStationState(station) + state.activeFirmwareUpdateRequestId = 200 + + // Act: a stale/superseded completion for requestId 199 + plumbing.clearActiveFirmwareUpdate(station, 199) + + // Assert: else-branch fired the debug log and left state untouched + assert.strictEqual(debugMock.mock.callCount(), 1) + assert.strictEqual(state.activeFirmwareUpdateRequestId, 200) + }) + + await it('should reset state and not log for a matching requestId', t => { + // Arrange + const service = new OCPP20IncomingRequestService() + const plumbing = asPlumbing(service) + const debugMock = t.mock.method(logger, 'debug') + const state = plumbing.getOrCreateStationState(station) + state.activeFirmwareUpdateAbortController = new AbortController() + state.activeFirmwareUpdateRequestId = 200 + + // Act: the active firmware update completes cleanly + plumbing.clearActiveFirmwareUpdate(station, 200) + + // Assert: state cleared, no "Ignoring" debug log + assert.strictEqual(state.activeFirmwareUpdateRequestId, undefined) + assert.strictEqual(state.activeFirmwareUpdateAbortController, undefined) + assert.strictEqual(debugMock.mock.callCount(), 0) + }) + }) }) -- 2.53.0