From b50f9e5f547632af2187d1df351383d1a1222b8f Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Mon, 16 Mar 2026 18:58:26 +0100 Subject: [PATCH] refactor(tests): separate handler/listener tests and remove setTimeout hacks Align TriggerMessage, UpdateFirmware, and GetLog test files with the RequestStopTransaction reference pattern: - Split into 'Handler validation' + 'event listener' describe groups - Replace raw setTimeout delays with withMockTimers where needed - Use emit() directly for listener tests (no wrapper helpers) - Follows TEST_STYLE_GUIDE.md conventions --- ...CPP20IncomingRequestService-GetLog.test.ts | 428 ++++++------ ...omingRequestService-TriggerMessage.test.ts | 1 - ...omingRequestService-UpdateFirmware.test.ts | 628 +++++++++--------- 3 files changed, 538 insertions(+), 519 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 2e02c056..316e93fd 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts @@ -54,236 +54,236 @@ await describe('K01 - GetLog', async () => { standardCleanup() }) - await it('should return Accepted with filename for DiagnosticsLog request', () => { - const request: OCPP20GetLogRequest = { - log: { - remoteLocation: 'ftp://logs.example.com/uploads/', - }, - logType: LogEnumType.DiagnosticsLog, - requestId: 1, - } - - const response = testableService.handleRequestGetLog(station, request) - - assert.notStrictEqual(response, undefined) - assert.strictEqual(typeof response, 'object') - assert.strictEqual(response.status, LogStatusEnumType.Accepted) - assert.strictEqual(response.filename, 'simulator-log.txt') - }) - - await it('should return Accepted with filename for SecurityLog request', () => { - const request: OCPP20GetLogRequest = { - log: { - remoteLocation: 'https://logs.example.com/security/', - }, - logType: LogEnumType.SecurityLog, - requestId: 2, - } - - const response = testableService.handleRequestGetLog(station, request) - - assert.strictEqual(response.status, LogStatusEnumType.Accepted) - assert.strictEqual(response.filename, 'simulator-log.txt') - }) - - 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: 'https://csms.example.com/logs', - }, - logType: LogEnumType.DiagnosticsLog, - requestId: 10, - } - const response: OCPP20GetLogResponse = { - filename: 'simulator-log.txt', - status: LogStatusEnumType.Accepted, - } - - service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) - - assert.strictEqual(simulateMock.mock.callCount(), 1) - assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 10) - }) - - 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() - ) + await describe('Handler validation', async () => { + await it('should return Accepted with filename for DiagnosticsLog request', () => { + const request: OCPP20GetLogRequest = { + log: { + remoteLocation: 'ftp://logs.example.com/uploads/', + }, + logType: LogEnumType.DiagnosticsLog, + requestId: 1, + } + + const response = testableService.handleRequestGetLog(station, request) + + assert.notStrictEqual(response, undefined) + assert.strictEqual(typeof response, 'object') + assert.strictEqual(response.status, LogStatusEnumType.Accepted) + assert.strictEqual(response.filename, 'simulator-log.txt') + }) - const request: OCPP20GetLogRequest = { - log: { - remoteLocation: 'https://csms.example.com/logs', - }, - logType: LogEnumType.DiagnosticsLog, - requestId: 11, - } - const response: OCPP20GetLogResponse = { - status: LogStatusEnumType.Rejected, - } + await it('should return Accepted with filename for SecurityLog request', () => { + const request: OCPP20GetLogRequest = { + log: { + remoteLocation: 'https://logs.example.com/security/', + }, + logType: LogEnumType.SecurityLog, + requestId: 2, + } - service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) + const response = testableService.handleRequestGetLog(station, request) - assert.strictEqual(simulateMock.mock.callCount(), 0) + assert.strictEqual(response.status, LogStatusEnumType.Accepted) + assert.strictEqual(response.filename, 'simulator-log.txt') + }) }) - 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, - } + await describe('GET_LOG event listener', async () => { + await it('should register GET_LOG event listener in constructor', () => { + const service = new OCPP20IncomingRequestService() + assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.GET_LOG), 1) + }) - service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) + 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: 'https://csms.example.com/logs', + }, + logType: LogEnumType.DiagnosticsLog, + requestId: 10, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.Accepted, + } + + service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) + + assert.strictEqual(simulateMock.mock.callCount(), 1) + assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 10) + }) - await Promise.resolve() - }) + 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: { + remoteLocation: 'https://csms.example.com/logs', + }, + logType: LogEnumType.DiagnosticsLog, + requestId: 11, + } + const response: OCPP20GetLogResponse = { + status: LogStatusEnumType.Rejected, + } + + service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response) + + assert.strictEqual(simulateMock.mock.callCount(), 0) + }) - await describe('N01 - LogStatusNotification lifecycle', async () => { - afterEach(() => { - standardCleanup() + 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() }) - await it('should send Uploading notification with correct requestId', async t => { - await withMockTimers(t, ['setTimeout'], async () => { - // Arrange - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() - const service = new OCPP20IncomingRequestService() - const request: OCPP20GetLogRequest = { - log: { remoteLocation: 'ftp://logs.example.com/' }, - logType: LogEnumType.DiagnosticsLog, - requestId: 42, - } - const response: OCPP20GetLogResponse = { - filename: 'simulator-log.txt', - status: LogStatusEnumType.Accepted, - } - - // Act - service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response) - await flushMicrotasks() - - // Assert - assert.ok(sentRequests.length >= 1, 'Expected at least one notification') - assert.deepStrictEqual(sentRequests[0].payload, { - requestId: 42, - status: UploadLogStatusEnumType.Uploading, + await describe('N01 - LogStatusNotification lifecycle', async () => { + await it('should send Uploading notification with correct requestId', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const request: OCPP20GetLogRequest = { + log: { remoteLocation: 'ftp://logs.example.com/' }, + logType: LogEnumType.DiagnosticsLog, + requestId: 42, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.Accepted, + } + + // Act + service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response) + await flushMicrotasks() + + // Assert + assert.ok(sentRequests.length >= 1, 'Expected at least one notification') + assert.deepStrictEqual(sentRequests[0].payload, { + requestId: 42, + status: UploadLogStatusEnumType.Uploading, + }) }) }) - }) - await it('should send Uploaded notification with correct requestId after delay', async t => { - await withMockTimers(t, ['setTimeout'], async () => { - // Arrange - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() - const service = new OCPP20IncomingRequestService() - const request: OCPP20GetLogRequest = { - log: { remoteLocation: 'ftp://logs.example.com/' }, - logType: LogEnumType.DiagnosticsLog, - requestId: 42, - } - const response: OCPP20GetLogResponse = { - filename: 'simulator-log.txt', - status: LogStatusEnumType.Accepted, - } - - // Act - service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response) - await flushMicrotasks() - - // Only Uploading should be sent before the timer fires - assert.strictEqual(sentRequests.length, 1) - - // Advance past the simulated upload delay - t.mock.timers.tick(1000) - await flushMicrotasks() - - // Assert - assert.strictEqual(sentRequests.length, 2) - assert.deepStrictEqual(sentRequests[1].payload, { - requestId: 42, - status: UploadLogStatusEnumType.Uploaded, + await it('should send Uploaded notification with correct requestId after delay', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const request: OCPP20GetLogRequest = { + log: { remoteLocation: 'ftp://logs.example.com/' }, + logType: LogEnumType.DiagnosticsLog, + requestId: 42, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.Accepted, + } + + // Act + service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response) + await flushMicrotasks() + + // Only Uploading should be sent before the timer fires + assert.strictEqual(sentRequests.length, 1) + + // Advance past the simulated upload delay + t.mock.timers.tick(1000) + await flushMicrotasks() + + // Assert + assert.strictEqual(sentRequests.length, 2) + assert.deepStrictEqual(sentRequests[1].payload, { + requestId: 42, + status: UploadLogStatusEnumType.Uploaded, + }) }) }) - }) - await it('should send Uploading before Uploaded in correct sequence', async t => { - await withMockTimers(t, ['setTimeout'], async () => { - // Arrange - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() - const service = new OCPP20IncomingRequestService() - const request: OCPP20GetLogRequest = { - log: { remoteLocation: 'ftp://logs.example.com/' }, - logType: LogEnumType.DiagnosticsLog, - requestId: 7, - } - const response: OCPP20GetLogResponse = { - filename: 'simulator-log.txt', - status: LogStatusEnumType.Accepted, - } - - // Act - complete the full lifecycle - service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response) - await flushMicrotasks() - t.mock.timers.tick(1000) - await flushMicrotasks() - - // Assert - verify sequence and requestId propagation - assert.strictEqual(sentRequests.length, 2) - assert.strictEqual( - sentRequests[0].payload.status, - UploadLogStatusEnumType.Uploading, - 'First notification should be Uploading' - ) - assert.strictEqual( - sentRequests[1].payload.status, - UploadLogStatusEnumType.Uploaded, - 'Second notification should be Uploaded' - ) - assert.strictEqual(sentRequests[0].payload.requestId, 7) - assert.strictEqual(sentRequests[1].payload.requestId, 7) + await it('should send Uploading before Uploaded in correct sequence', async t => { + await withMockTimers(t, ['setTimeout'], async () => { + // Arrange + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const request: OCPP20GetLogRequest = { + log: { remoteLocation: 'ftp://logs.example.com/' }, + logType: LogEnumType.DiagnosticsLog, + requestId: 7, + } + const response: OCPP20GetLogResponse = { + filename: 'simulator-log.txt', + status: LogStatusEnumType.Accepted, + } + + // Act - complete the full lifecycle + service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response) + await flushMicrotasks() + t.mock.timers.tick(1000) + await flushMicrotasks() + + // Assert - verify sequence and requestId propagation + assert.strictEqual(sentRequests.length, 2) + assert.strictEqual( + sentRequests[0].payload.status, + UploadLogStatusEnumType.Uploading, + 'First notification should be Uploading' + ) + assert.strictEqual( + sentRequests[1].payload.status, + UploadLogStatusEnumType.Uploaded, + 'Second notification should be Uploaded' + ) + assert.strictEqual(sentRequests[0].payload.requestId, 7) + assert.strictEqual(sentRequests[1].payload.requestId, 7) + }) }) }) }) 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 8570e1d4..02b100d6 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts @@ -60,7 +60,6 @@ await describe('F06 - TriggerMessage', async () => { let testableService: ReturnType beforeEach(() => { - mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }) incomingRequestService = new OCPP20IncomingRequestService() testableService = createTestableIncomingRequestService(incomingRequestService) }) 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 f4b60c80..f3830d69 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts @@ -58,121 +58,23 @@ await describe('L01/L02 - UpdateFirmware', async () => { standardCleanup() }) - await it('should return Accepted for valid firmware update request', () => { - const request: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/update-v2.0.bin', - retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), - }, - requestId: 1, - } - - const response = testableService.handleRequestUpdateFirmware(station, request) - - assert.notStrictEqual(response, undefined) - assert.strictEqual(typeof response, 'object') - assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) - }) - - 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, - firmware: FirmwareType - ) => Promise - }, - 'simulateFirmwareUpdateLifecycle', - () => Promise.resolve() - ) - - const request: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/update.bin', - retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), - signature: 'dGVzdA==', - }, - requestId: 42, - } - const response: OCPP20UpdateFirmwareResponse = { - status: UpdateFirmwareStatusEnumType.Accepted, - } - - service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - - assert.strictEqual(simulateMock.mock.callCount(), 1) - assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 42) - assert.deepStrictEqual(simulateMock.mock.calls[0].arguments[2], request.firmware) - }) - - 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, - firmware: FirmwareType - ) => Promise - }, - 'simulateFirmwareUpdateLifecycle', - () => Promise.resolve() - ) - - const request: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/update.bin', - retrieveDateTime: new Date(), - }, - requestId: 43, - } - const response: OCPP20UpdateFirmwareResponse = { - status: UpdateFirmwareStatusEnumType.Rejected, - } - - service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - - assert.strictEqual(simulateMock.mock.callCount(), 0) - }) - - await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async () => { - const service = new OCPP20IncomingRequestService() - mock.method( - service as unknown as { - simulateFirmwareUpdateLifecycle: ( - chargingStation: ChargingStation, - requestId: number, - firmware: FirmwareType - ) => Promise - }, - 'simulateFirmwareUpdateLifecycle', - () => Promise.reject(new Error('firmware lifecycle error')) - ) - - const request: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/update.bin', - retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), - }, - requestId: 99, - } - const response: OCPP20UpdateFirmwareResponse = { - status: UpdateFirmwareStatusEnumType.Accepted, - } + await describe('Handler validation', async () => { + await it('should return Accepted for valid firmware update request', () => { + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update-v2.0.bin', + retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), + }, + requestId: 1, + } - service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) + const response = testableService.handleRequestUpdateFirmware(station, request) - await Promise.resolve() - }) + assert.notStrictEqual(response, undefined) + assert.strictEqual(typeof response, 'object') + assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) + }) - await describe('Security Features', async () => { await it('should return InvalidCertificate for invalid signing certificate PEM', () => { // Arrange const certManager = createMockCertificateManager() @@ -322,191 +224,120 @@ await describe('L01/L02 - UpdateFirmware', async () => { // Assert assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted) }) + }) - await it('should cancel previous firmware update when new one arrives', async t => { - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + await describe('UPDATE_FIRMWARE event listener', async () => { + await it('should register UPDATE_FIRMWARE event listener in constructor', () => { const service = new OCPP20IncomingRequestService() - const testable = createTestableIncomingRequestService(service) - - const firstRequest: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/v1.bin', - retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), - }, - requestId: 100, - } - const firstResponse: OCPP20UpdateFirmwareResponse = { - status: UpdateFirmwareStatusEnumType.Accepted, - } - - await withMockTimers(t, ['setTimeout'], async () => { - service.emit( - OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, - trackingStation, - firstRequest, - firstResponse - ) - - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 1) - assert.strictEqual(sentRequests[0].payload.status, OCPP20FirmwareStatusEnumType.Downloading) - - const secondRequest: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/v2.bin', - retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), - }, - requestId: 101, - } - - const secondResponse = testable.handleRequestUpdateFirmware(trackingStation, secondRequest) - assert.strictEqual(secondResponse.status, UpdateFirmwareStatusEnumType.AcceptedCanceled) - - await flushMicrotasks() - }) + assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE), 1) }) - }) - await describe('Firmware Update Lifecycle', async () => { - await it('should send full lifecycle Downloading→Downloaded→Installing→Installed + SecurityEvent', async t => { - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + 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, + firmware: FirmwareType + ) => Promise + }, + 'simulateFirmwareUpdateLifecycle', + () => Promise.resolve() + ) const request: OCPP20UpdateFirmwareRequest = { firmware: { location: 'https://firmware.example.com/update.bin', - retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), + signature: 'dGVzdA==', }, - requestId: 1, + requestId: 42, } const response: OCPP20UpdateFirmwareResponse = { status: UpdateFirmwareStatusEnumType.Accepted, } - await withMockTimers(t, ['setTimeout'], async () => { - service.emit( - OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, - trackingStation, - request, - response - ) + service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 1) - assert.strictEqual( - sentRequests[0].command, - OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION - ) - assert.strictEqual(sentRequests[0].payload.status, OCPP20FirmwareStatusEnumType.Downloading) - assert.strictEqual(sentRequests[0].payload.requestId, 1) - - t.mock.timers.tick(2000) - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 3) - assert.strictEqual(sentRequests[1].payload.status, OCPP20FirmwareStatusEnumType.Downloaded) - assert.strictEqual(sentRequests[2].payload.status, OCPP20FirmwareStatusEnumType.Installing) - - t.mock.timers.tick(1000) - await flushMicrotasks() - assert.strictEqual(sentRequests[3].payload.status, OCPP20FirmwareStatusEnumType.Installed) - - // H11: SecurityEventNotification for FirmwareUpdated - assert.strictEqual(sentRequests.length, 5) - assert.strictEqual( - sentRequests[4].command, - OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION - ) - assert.strictEqual(sentRequests[4].payload.type, 'FirmwareUpdated') - }) + assert.strictEqual(simulateMock.mock.callCount(), 1) + assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 42) + assert.deepStrictEqual(simulateMock.mock.calls[0].arguments[2], request.firmware) }) - await it('should send DownloadFailed for empty firmware location', async t => { - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + 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, + firmware: FirmwareType + ) => Promise + }, + 'simulateFirmwareUpdateLifecycle', + () => Promise.resolve() + ) const request: OCPP20UpdateFirmwareRequest = { firmware: { - location: '', - retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date(), }, - requestId: 7, + requestId: 43, } const response: OCPP20UpdateFirmwareResponse = { - status: UpdateFirmwareStatusEnumType.Accepted, + status: UpdateFirmwareStatusEnumType.Rejected, } - await withMockTimers(t, ['setTimeout'], async () => { - service.emit( - OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, - trackingStation, - request, - response - ) - - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 1) - assert.strictEqual(sentRequests[0].payload.status, OCPP20FirmwareStatusEnumType.Downloading) + service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - t.mock.timers.tick(2000) - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 2) - assert.strictEqual( - sentRequests[1].payload.status, - OCPP20FirmwareStatusEnumType.DownloadFailed - ) - assert.strictEqual(sentRequests[1].payload.requestId, 7) - }) + assert.strictEqual(simulateMock.mock.callCount(), 0) }) - await it('should send DownloadFailed for malformed firmware location', async t => { - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async () => { const service = new OCPP20IncomingRequestService() + mock.method( + service as unknown as { + simulateFirmwareUpdateLifecycle: ( + chargingStation: ChargingStation, + requestId: number, + firmware: FirmwareType + ) => Promise + }, + 'simulateFirmwareUpdateLifecycle', + () => Promise.reject(new Error('firmware lifecycle error')) + ) const request: OCPP20UpdateFirmwareRequest = { firmware: { - location: 'not-a-valid-url', - retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'), }, - requestId: 8, + requestId: 99, } const response: OCPP20UpdateFirmwareResponse = { status: UpdateFirmwareStatusEnumType.Accepted, } - await withMockTimers(t, ['setTimeout'], async () => { - service.emit( - OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, - trackingStation, - request, - response - ) - - await flushMicrotasks() - t.mock.timers.tick(2000) - await flushMicrotasks() + service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response) - assert.strictEqual(sentRequests.length, 2) - assert.strictEqual( - sentRequests[1].payload.status, - OCPP20FirmwareStatusEnumType.DownloadFailed - ) - assert.strictEqual(sentRequests[1].payload.requestId, 8) - }) + await Promise.resolve() }) - await it('should include requestId in all firmware status notifications', async t => { + await it('should cancel previous firmware update when new one arrives', async t => { const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() const service = new OCPP20IncomingRequestService() - const expectedRequestId = 42 + const testable = createTestableIncomingRequestService(service) - const request: OCPP20UpdateFirmwareRequest = { + const firstRequest: OCPP20UpdateFirmwareRequest = { firmware: { - location: 'https://firmware.example.com/update.bin', + location: 'https://firmware.example.com/v1.bin', retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), }, - requestId: expectedRequestId, + requestId: 100, } - const response: OCPP20UpdateFirmwareResponse = { + const firstResponse: OCPP20UpdateFirmwareResponse = { status: UpdateFirmwareStatusEnumType.Accepted, } @@ -514,79 +345,268 @@ await describe('L01/L02 - UpdateFirmware', async () => { service.emit( OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, trackingStation, - request, - response + firstRequest, + firstResponse ) await flushMicrotasks() - t.mock.timers.tick(2000) - await flushMicrotasks() - t.mock.timers.tick(1000) + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual(sentRequests[0].payload.status, OCPP20FirmwareStatusEnumType.Downloading) + + const secondRequest: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/v2.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + }, + requestId: 101, + } + + const secondResponse = testable.handleRequestUpdateFirmware(trackingStation, secondRequest) + assert.strictEqual(secondResponse.status, UpdateFirmwareStatusEnumType.AcceptedCanceled) + await flushMicrotasks() + }) + }) - const firmwareNotifications = sentRequests.filter( - r => - (r.command as OCPP20RequestCommand) === + await describe('Firmware Update Lifecycle', async () => { + await it('should send full lifecycle Downloading→Downloaded→Installing→Installed + SecurityEvent', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + }, + requestId: 1, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } + + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual( + sentRequests[0].command, OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION - ) - assert.strictEqual(firmwareNotifications.length, 4) - for (const req of firmwareNotifications) { - assert.strictEqual(req.payload.requestId, expectedRequestId) + ) + assert.strictEqual( + sentRequests[0].payload.status, + OCPP20FirmwareStatusEnumType.Downloading + ) + assert.strictEqual(sentRequests[0].payload.requestId, 1) + + t.mock.timers.tick(2000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 3) + assert.strictEqual( + sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.Downloaded + ) + assert.strictEqual( + sentRequests[2].payload.status, + OCPP20FirmwareStatusEnumType.Installing + ) + + t.mock.timers.tick(1000) + await flushMicrotasks() + assert.strictEqual(sentRequests[3].payload.status, OCPP20FirmwareStatusEnumType.Installed) + + // H11: SecurityEventNotification for FirmwareUpdated + assert.strictEqual(sentRequests.length, 5) + assert.strictEqual( + sentRequests[4].command, + OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION + ) + assert.strictEqual(sentRequests[4].payload.type, 'FirmwareUpdated') + }) + }) + + await it('should send DownloadFailed for empty firmware location', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: '', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + }, + requestId: 7, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, } + + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual( + sentRequests[0].payload.status, + OCPP20FirmwareStatusEnumType.Downloading + ) + + t.mock.timers.tick(2000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 2) + assert.strictEqual( + sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.DownloadFailed + ) + assert.strictEqual(sentRequests[1].payload.requestId, 7) + }) }) - }) - await it('should include SignatureVerified when firmware has signature', async t => { - const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() - const service = new OCPP20IncomingRequestService() + await it('should send DownloadFailed for malformed firmware location', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() - const request: OCPP20UpdateFirmwareRequest = { - firmware: { - location: 'https://firmware.example.com/update.bin', - retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), - signature: 'dGVzdA==', - }, - requestId: 5, - } - const response: OCPP20UpdateFirmwareResponse = { - status: UpdateFirmwareStatusEnumType.Accepted, - } + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'not-a-valid-url', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + }, + requestId: 8, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } - await withMockTimers(t, ['setTimeout'], async () => { - service.emit( - OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, - trackingStation, - request, - response - ) + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + t.mock.timers.tick(2000) + await flushMicrotasks() + + assert.strictEqual(sentRequests.length, 2) + assert.strictEqual( + sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.DownloadFailed + ) + assert.strictEqual(sentRequests[1].payload.requestId, 8) + }) + }) - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 1) + await it('should include requestId in all firmware status notifications', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const expectedRequestId = 42 - t.mock.timers.tick(2000) - await flushMicrotasks() - assert.strictEqual(sentRequests.length, 2) - assert.strictEqual(sentRequests[1].payload.status, OCPP20FirmwareStatusEnumType.Downloaded) + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + }, + requestId: expectedRequestId, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } - t.mock.timers.tick(500) - await flushMicrotasks() - assert.strictEqual( - sentRequests[2].payload.status, - OCPP20FirmwareStatusEnumType.SignatureVerified - ) - assert.strictEqual(sentRequests[3].payload.status, OCPP20FirmwareStatusEnumType.Installing) + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + t.mock.timers.tick(2000) + await flushMicrotasks() + t.mock.timers.tick(1000) + await flushMicrotasks() + + const firmwareNotifications = sentRequests.filter( + r => + (r.command as OCPP20RequestCommand) === + OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION + ) + assert.strictEqual(firmwareNotifications.length, 4) + for (const req of firmwareNotifications) { + assert.strictEqual(req.payload.requestId, expectedRequestId) + } + }) + }) - t.mock.timers.tick(1000) - await flushMicrotasks() - assert.strictEqual(sentRequests[4].payload.status, OCPP20FirmwareStatusEnumType.Installed) + await it('should include SignatureVerified when firmware has signature', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() - // H11: SecurityEventNotification after Installed - assert.strictEqual(sentRequests.length, 6) - assert.strictEqual( - sentRequests[5].command, - OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION - ) - assert.strictEqual(sentRequests[5].payload.type, 'FirmwareUpdated') + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + signature: 'dGVzdA==', + }, + requestId: 5, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } + + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + + t.mock.timers.tick(2000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 2) + assert.strictEqual( + sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.Downloaded + ) + + t.mock.timers.tick(500) + await flushMicrotasks() + assert.strictEqual( + sentRequests[2].payload.status, + OCPP20FirmwareStatusEnumType.SignatureVerified + ) + assert.strictEqual( + sentRequests[3].payload.status, + OCPP20FirmwareStatusEnumType.Installing + ) + + t.mock.timers.tick(1000) + await flushMicrotasks() + assert.strictEqual(sentRequests[4].payload.status, OCPP20FirmwareStatusEnumType.Installed) + + // H11: SecurityEventNotification after Installed + assert.strictEqual(sentRequests.length, 6) + assert.strictEqual( + sentRequests[5].command, + OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION + ) + assert.strictEqual(sentRequests[5].payload.type, 'FirmwareUpdated') + }) }) }) }) -- 2.53.0