]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
test(ocpp): cover OCPP 2.0.1 log/firmware lifecycle supersession and cleanup paths...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 11 Jul 2026 23:15:05 +0000 (01:15 +0200)
committerGitHub <noreply@github.com>
Sat, 11 Jul 2026 23:15:05 +0000 (01:15 +0200)
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts

index 2f2bd02eb2131454e0ba779e14276cff794ab54f..d87e1fe115f147e1bd141a213eb5d35d6b3b0288 100644 (file)
@@ -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<void>
+  stationsState: WeakMap<ChargingStation, LogUploadStateShape>
+}
+
+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<typeof createTestableIncomingRequestService>
 
@@ -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<void>
+    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<unknown>
+        }
+      ).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)
+    })
+  })
 })
index 16496fe23aa79b7b77303e378359969846e278e9..9d64b3b8d5ebe51d39accaf0dfc10b9abad9f54b 100644 (file)
@@ -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)
+    })
+  })
 })
index d7b9c818708860cb99ee8da969149b09f6cb0ea5..2dc86c9a57390312d44d4dc05e09c1052ec41e75 100644 (file)
@@ -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<ChargingStation, FirmwareUpdateStateShape>
+}
+
+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<typeof createTestableIncomingRequestService>
@@ -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<void>
-        },
-        '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)
+    })
+  })
 })