]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
test: harmonize event listener test pattern across OCPP command test files (#1730)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 16 Mar 2026 21:36:24 +0000 (22:36 +0100)
committerGitHub <noreply@github.com>
Mon, 16 Mar 2026 21:36:24 +0000 (22:36 +0100)
* test: harmonize event listener test pattern across OCPP command test files

Add event listener test sections to 7 OCPP incoming request command test
files (4 OCPP 1.6, 3 OCPP 2.0) following the reference pattern from
RequestStopTransaction, TriggerMessage, UpdateFirmware, and GetLog tests.

Each listener section contains: registration test, accepted-fires test,
rejected-not-fires test, and error-graceful test.

Also restructures CustomerInformation to wrap existing listener tests in a
properly named describe block, and adds createOCPP16ListenerStation helper
to OCPP16TestUtils.ts.

Files modified:
- tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts
- tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts
- tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts
- tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts
- tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts
- tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts
- tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
- tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts
- tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts

* fix(tests): address PR review — extract shared helper, fix mock cleanup, use flushMicrotasks

- Extract createOCPP20ListenerStation to OCPP20TestUtils.ts, removing
  duplication between RequestStart and RequestStop test files
- Replace inline import('node:test').mock.fn with top-level mock import
  in RemoteStartTransaction tests
- Remove redundant mock.reset() from 3 listener afterEach blocks —
  standardCleanup() already calls mock.restoreAll()
- Replace all await Promise.resolve() with flushMicrotasks() across 5
  OCPP 2.0 test files for more robust async side-effect flushing

* fix(tests): replace remaining await Promise.resolve() with flushMicrotasks()

8 occurrences in 5 files (3 OCPP16, 2 OCPP20) missed in the initial
review fix. Now all listener tests use flushMicrotasks() consistently.

* fix(tests): fix 3 audit findings — JSDoc headers and missing afterEach

- GetBaseReport: move @file JSDoc above first import (style guide §2)
- OCPP20TestUtils: add missing @file/@description header (style guide §2)
- RequestStopTransaction: add afterEach with standardCleanup to listener
  describe block (style guide §3)

* fix(tests): address 5 minor audit findings

- Fix import paths in 7 OCPP 2.0 test files: ../../../../tests/helpers/
  → ../../../helpers/ (correct relative path, consistent with 35 sibling
  files in the same directory)
- Add eventType assertion in RequestStartTransaction listener test to
  verify TransactionEvent(Started) per E02.FR.01
- Add flushMicrotasks() to RequestStopTransaction listener test for
  consistent emit→flush→assert pattern

* refactor(tests): move mock.method to beforeEach and parameterize trigger tests

- Move duplicated mock.method calls into listener beforeEach blocks
  in 5 files (UpdateFirmware, GetLog, GetBaseReport, CustomerInfo,
  Firmware). Rejection tests override inline. Net -147 lines.
- Parameterize OCPP16 + OCPP20 TriggerMessage trigger-fires tests
  using data-driven triggerCases arrays (already done in prior commit,
  this commit includes the Firmware mock cleanup).

* style(tests): remove inconsistent separator comment in RemoteStopUnlock

The listener section had a '// ───' separator not used in any of
the other 10 test files. The await describe block is sufficient.

* docs(tests): add summary line to startTransaction JSDoc

* docs(tests): add event listener testing section to TEST_STYLE_GUIDE

Add §11 documenting the established listener test pattern: emit()
direct, flushMicrotasks(), listenerCount first, accepted/rejected/error
triad, mock.method in beforeEach. Add listener station factories to
§9 mock factories table and flushMicrotasks to §10 utility table.

* docs(tests): fix incorrect mock API in §11 code example

Replace Jest-style mockImplementation() with Node.js test runner
mock.method() override pattern matching actual test code.

* fix(tests): align all 112 test files with TEST_STYLE_GUIDE

- Move @file JSDoc headers above first import in 3 files (GetVariables,
  MessageChannelUtils, Utils)
- Replace await Promise.resolve() with flushMicrotasks() in
  AutomaticTransactionGenerator
- Replace 6 setTimeout(resolve, 50) hacks with flushMicrotasks() in
  ChargingStationWorkerBroadcastChannel
- Document spec traceability prefix exception (G03.FR.xx, G04.INT.xx)
  in TEST_STYLE_GUIDE §1 naming conventions

* docs(tests): align TEST_STYLE_GUIDE with actual test infrastructure

- Fix createMockChargingStation location (ChargingStationTestUtils →
  helpers/StationHelpers)
- Add 7 widely-used factories to §9 table (10+ usages each)
- Remove unused expectAcceptedAuthorization from §9 auth table
- All locations verified against actual exports

* docs(tests): fix guide inconsistencies — deduplicate entries, fix assertion example

- Remove createMockCertificateManager from §10 OCPP 2.0 sub-table
  (already in §9 factory table — no duplication)
- Fix §9 usage example: assert.ok → assert.strictEqual (§7 compliance)
- Remove setupConnectorWithTransaction/clearConnectorTransaction from
  §10 lifecycle table (test setup helpers, not lifecycle utilities)
- Reorder §10: group cleanup/async utilities before spy factories

* docs(tests): fix guide precision — assert.ok scope, test script quotes, restore helpers

- Core Principles: clarify assert.ok is for boolean/existence only
- §5: add missing quotes around glob in test script (matches package.json)
- §10: restore setupConnectorWithTransaction/clearConnectorTransaction
  (27 usages across test suite — should not have been removed)

22 files changed:
tests/TEST_STYLE_GUIDE.md
tests/charging-station/AutomaticTransactionGenerator.test.ts
tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/utils/MessageChannelUtils.test.ts
tests/utils/Utils.test.ts

index 01c7c5aa0a0d89d721857beb38f1cbcec10ed624..b263e7d7557b3ce4ed982ffb0ff52a983521aa02 100644 (file)
@@ -7,7 +7,7 @@ Conventions for writing maintainable, consistent tests in the e-mobility chargin
 - **Test behavior, not implementation**: Focus on what code does, not how
 - **Isolation is mandatory**: Each test runs independently with fresh state
 - **Determinism required**: Tests must produce identical results on every run
-- **Strict assertions**: Use `assert.strictEqual`, `assert.deepStrictEqual`, `assert.ok` — never loose equality
+- **Strict assertions**: Use `assert.strictEqual`, `assert.deepStrictEqual` — never loose equality. Use `assert.ok` only for boolean/existence checks
 - **Coverage target**: 80%+ on new code
 
 ---
@@ -23,6 +23,10 @@ Pattern: `should [verb]` in **lowercase**
 it('should start successfully with valid configuration', async () => {})
 it('should reject invalid identifier', () => {})
 
+// ✅ Good — with spec traceability prefix (for FR-referenced tests)
+it('G03.FR.01.T5.01 - should evict non-valid entry before valid one', () => {})
+it('G04.INT.01: should wire auth cache into local strategy', async () => {})
+
 // ❌ Bad
 it('Should start successfully', () => {}) // Capital 'S'
 it('Verify generateUUID()', () => {}) // Imperative
@@ -177,7 +181,7 @@ it('should timeout', async () => {
 The test command uses `--test-force-exit` flag to prevent Windows CI hangs:
 
 ```json
-"test": "node --import tsx --test --test-force-exit tests/**/*.test.ts"
+"test": "node --import tsx --test --test-force-exit 'tests/**/*.test.ts'"
 ```
 
 **Why**: Windows Named Pipes for stdout/stderr remain "ref'd" (keep event loop alive) while Unix file descriptors are auto-unref'd. Without `--test-force-exit`, the Node.js process hangs indefinitely after tests complete on Windows.
@@ -272,11 +276,20 @@ assert.strictEqual(AuthValidators.isValidIdentifierValue(123 as any), false)
 
 ### Choose the Right Factory
 
-| Factory                                  | Use Case                         | Location                             |
-| ---------------------------------------- | -------------------------------- | ------------------------------------ |
-| `createMockChargingStation()`            | Full OCPP protocol testing       | `ChargingStationTestUtils.ts`        |
-| `createMockAuthServiceTestStation()`     | Auth service tests (lightweight) | `ocpp/auth/helpers/MockFactories.ts` |
-| `createMockStationWithRequestTracking()` | Verify sent OCPP requests        | `ocpp/2.0/OCPP20TestUtils.ts`        |
+| Factory                                    | Use Case                        | Location                             |
+| ------------------------------------------ | ------------------------------- | ------------------------------------ |
+| `createMockChargingStation()`              | Full OCPP protocol testing      | `helpers/StationHelpers.ts`          |
+| `createStandardStation()`                  | Pre-configured OCPP 1.6 station | `ocpp/1.6/OCPP16TestUtils.ts`        |
+| `createOCPP16IncomingRequestTestContext()` | OCPP 1.6 handler test context   | `ocpp/1.6/OCPP16TestUtils.ts`        |
+| `createOCPP16ListenerStation()`            | OCPP 1.6 event listener tests   | `ocpp/1.6/OCPP16TestUtils.ts`        |
+| `createOCPP20ListenerStation()`            | OCPP 2.0 event listener tests   | `ocpp/2.0/OCPP20TestUtils.ts`        |
+| `createOCPP20RequestTestContext()`         | OCPP 2.0 request test context   | `ocpp/2.0/OCPP20TestUtils.ts`        |
+| `createMockStationWithRequestTracking()`   | Verify sent OCPP requests       | `ocpp/2.0/OCPP20TestUtils.ts`        |
+| `createStationWithCertificateManager()`    | Certificate operation tests     | `ocpp/2.0/OCPP20TestUtils.ts`        |
+| `createMockCertificateManager()`           | Certificate manager mock        | `ocpp/2.0/OCPP20TestUtils.ts`        |
+| `createMockAuthService()`                  | Auth service mock               | `ocpp/auth/helpers/MockFactories.ts` |
+| `createMockAuthServiceTestStation()`       | Auth service integration tests  | `ocpp/auth/helpers/MockFactories.ts` |
+| `createMockUIWebSocket()`                  | UI server WebSocket mock        | `ui-server/UIServerTestUtils.ts`     |
 
 ### Usage
 
@@ -288,7 +301,7 @@ const { station, mocks } = createMockChargingStation({
 })
 
 // Verify sent messages
-assert.ok(mocks.webSocket.sentMessages.includes(expectedMessage))
+assert.strictEqual(mocks.webSocket.sentMessages.length, 1)
 ```
 
 ---
@@ -300,9 +313,10 @@ assert.ok(mocks.webSocket.sentMessages.includes(expectedMessage))
 | Utility                           | Purpose                                  |
 | --------------------------------- | ---------------------------------------- |
 | `standardCleanup()`               | **MANDATORY** afterEach cleanup          |
-| `sleep(ms)`                       | Real-time delay                          |
+| `flushMicrotasks()`               | Drain async side-effects from `emit()`   |
 | `withMockTimers()`                | Execute test with timer mocking          |
 | `createTimerScope()`              | Manual timer control                     |
+| `sleep(ms)`                       | Real-time delay (avoid in tests)         |
 | `createLoggerMocks()`             | Create logger spies (error, warn)        |
 | `createConsoleMocks()`            | Create console spies (error, warn, info) |
 | `setupConnectorWithTransaction()` | Setup connector in transaction state     |
@@ -321,7 +335,6 @@ assert.ok(mocks.webSocket.sentMessages.includes(expectedMessage))
 | Utility                                | Purpose                         |
 | -------------------------------------- | ------------------------------- |
 | `createTestableOCPP20RequestService()` | Type-safe private method access |
-| `createMockCertificateManager()`       | Certificate operations mock     |
 | `IdTokenFixtures`                      | Pre-built IdToken fixtures      |
 | `TransactionContextFixtures`           | Transaction context fixtures    |
 
@@ -332,7 +345,72 @@ assert.ok(mocks.webSocket.sentMessages.includes(expectedMessage))
 | `createMockIdentifier()`          | UnifiedIdentifier factory   |
 | `createMockAuthRequest()`         | AuthRequest factory         |
 | `createMockAuthorizationResult()` | AuthorizationResult factory |
-| `expectAcceptedAuthorization()`   | Assert accepted result      |
+
+---
+
+## 11. Event Listener Testing
+
+Commands that use the post-response event listener pattern (handler validates → returns response → event triggers async action) require dedicated listener tests.
+
+### Structure
+
+```typescript
+await describe('COMMAND_NAME event listener', async () => {
+  let listenerService: OCPP16IncomingRequestService // or OCPP20
+  let requestHandlerMock: ReturnType<typeof mock.fn>
+  let station: ChargingStation
+
+  beforeEach(() => {
+    ;({ requestHandlerMock, station } = createOCPP16ListenerStation('test-listener'))
+    listenerService = new OCPP16IncomingRequestService()
+  })
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // 1. Registration test (always first)
+  await it('should register COMMAND_NAME event listener in constructor', () => {
+    assert.strictEqual(
+      listenerService.listenerCount(OCPP16IncomingRequestCommand.COMMAND_NAME),
+      1
+    )
+  })
+
+  // 2. Accepted → fires action
+  await it('should call X when response is Accepted', async () => {
+    listenerService.emit(OCPP16IncomingRequestCommand.COMMAND_NAME, station, request, response)
+    await flushMicrotasks()
+    assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+  })
+
+  // 3. Rejected → does NOT fire
+  await it('should NOT call X when response is Rejected', () => {
+    listenerService.emit(...)
+    assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+  })
+
+  // 4. Error → handled gracefully
+  await it('should handle X failure gracefully', async () => {
+    // Override mock to reject (mock.method for lifecycle, new factory for requestHandler)
+    mock.method(listenerService as unknown as Record<string, unknown>, 'privateMethod',
+      () => Promise.reject(new Error('test'))
+    )
+    listenerService.emit(...)
+    await flushMicrotasks()
+    // No crash = pass
+  })
+})
+```
+
+### Rules
+
+- Use `emit()` directly on the service instance — no wrapper helpers
+- Use `flushMicrotasks()` to drain async side-effects — never `await Promise.resolve()`
+- Use `createOCPP16ListenerStation()` or `createOCPP20ListenerStation()` for `requestHandler` mock
+- Use `mock.method()` in `beforeEach` for private lifecycle methods; override in rejection tests
+- Use `listenerCount` as the first test in every listener describe block
+- Listener tests go inside the same top-level describe as handler tests
 
 ---
 
@@ -348,3 +426,4 @@ assert.ok(mocks.webSocket.sentMessages.includes(expectedMessage))
 8. **Types**: No `as any`, use testable interfaces
 9. **Mocks**: Use appropriate factory for your use case
 10. **Utils**: Leverage lifecycle helpers and mock classes
+11. **Listeners**: `emit()` direct, `flushMicrotasks()`, `listenerCount` first, accepted/rejected/error triad
index e6eb6943d0363ef8d2774e7892abe547d4a64206..1fa13994f090cf242198e8dbd11a73083ca5fc27 100644 (file)
@@ -22,6 +22,7 @@ import type { ChargingStation } from '../../src/charging-station/ChargingStation
 import { AutomaticTransactionGenerator } from '../../src/charging-station/AutomaticTransactionGenerator.js'
 import { BaseError } from '../../src/exception/index.js'
 import { AuthorizationStatus, type StartTransactionResponse } from '../../src/types/index.js'
+import { flushMicrotasks } from '../helpers/TestLifecycleHelpers.js'
 import { createMockChargingStation, standardCleanup } from './ChargingStationTestUtils.js'
 
 type ConnectorStatus = ReturnType<AutomaticTransactionGenerator['connectorsStatus']['get']>
@@ -120,7 +121,7 @@ function mockInternalStartConnector (atg: AutomaticTransactionGenerator): void {
     internalStartConnector: (...args: unknown[]) => Promise<void>
   }
   atgPrivate.internalStartConnector = async () => {
-    await Promise.resolve()
+    await flushMicrotasks()
   }
 }
 
index 902dcd088f1b993e43f5ab72bb9d168cf698ee1c..8a7eb717aac58672ff0570f8d48f75371fb226a7 100644 (file)
@@ -24,7 +24,7 @@ import {
   ResponseStatus,
 } from '../../../src/types/index.js'
 import { Constants } from '../../../src/utils/index.js'
-import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { flushMicrotasks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 import { createMockChargingStation } from '../ChargingStationTestUtils.js'
 import {
   createMockStationWithRequestTracking,
@@ -712,9 +712,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         ],
       })
 
-      await new Promise(resolve => {
-        setTimeout(resolve, 50)
-      })
+      await flushMicrotasks()
 
       assert.strictEqual(sentRequests.length, 1)
       assert.strictEqual(sentRequests[0].command, RequestCommand.GET_15118_EV_CERTIFICATE)
@@ -734,9 +732,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         ],
       })
 
-      await new Promise(resolve => {
-        setTimeout(resolve, 50)
-      })
+      await flushMicrotasks()
 
       assert.strictEqual(sentRequests.length, 1)
       assert.strictEqual(sentRequests[0].command, RequestCommand.LOG_STATUS_NOTIFICATION)
@@ -756,9 +752,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         ],
       })
 
-      await new Promise(resolve => {
-        setTimeout(resolve, 50)
-      })
+      await flushMicrotasks()
 
       assert.strictEqual(sentRequests.length, 1)
       assert.strictEqual(sentRequests[0].command, RequestCommand.NOTIFY_CUSTOMER_INFORMATION)
@@ -778,9 +772,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         ],
       })
 
-      await new Promise(resolve => {
-        setTimeout(resolve, 50)
-      })
+      await flushMicrotasks()
 
       assert.strictEqual(sentRequests.length, 1)
       assert.strictEqual(sentRequests[0].command, RequestCommand.NOTIFY_REPORT)
@@ -800,9 +792,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         ],
       })
 
-      await new Promise(resolve => {
-        setTimeout(resolve, 50)
-      })
+      await flushMicrotasks()
 
       assert.strictEqual(sentRequests.length, 1)
       assert.strictEqual(sentRequests[0].command, RequestCommand.SECURITY_EVENT_NOTIFICATION)
@@ -822,9 +812,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         ],
       })
 
-      await new Promise(resolve => {
-        setTimeout(resolve, 50)
-      })
+      await flushMicrotasks()
 
       assert.strictEqual(sentRequests.length, 1)
       assert.strictEqual(sentRequests[0].command, RequestCommand.METER_VALUES)
index b105670233bd83733eb77053720cf96ce3a980fe..d5038ac41eed1009d9d33d9dbaef7036ecd90c91 100644 (file)
@@ -5,15 +5,26 @@
  */
 
 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 { GetDiagnosticsRequest } from '../../../../src/types/index.js'
 
-import { OCPP16StandardParametersKey } from '../../../../src/types/index.js'
+import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
+import {
+  OCPP16IncomingRequestCommand,
+  OCPP16StandardParametersKey,
+  type OCPP16UpdateFirmwareRequest,
+  type OCPP16UpdateFirmwareResponse,
+} from '../../../../src/types/index.js'
 import { OCPP16FirmwareStatus } from '../../../../src/types/ocpp/1.6/Requests.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+  flushMicrotasks,
+  standardCleanup,
+  withMockTimers,
+} from '../../../helpers/TestLifecycleHelpers.js'
 import {
   createOCPP16IncomingRequestTestContext,
+  createOCPP16ListenerStation,
   type OCPP16IncomingRequestTestContext,
   upsertConfigurationKey,
 } from './OCPP16TestUtils.js'
@@ -152,4 +163,109 @@ await describe('OCPP16IncomingRequestService — Firmware', async () => {
       assert.strictEqual(Object.keys(response).length, 0)
     })
   })
+
+  // §6.4: UpdateFirmware event listener
+  await describe('UPDATE_FIRMWARE event listener', async () => {
+    let listenerService: OCPP16IncomingRequestService
+    let updateFirmwareMock: ReturnType<typeof mock.fn>
+
+    beforeEach(() => {
+      listenerService = new OCPP16IncomingRequestService()
+      updateFirmwareMock = mock.method(
+        listenerService as unknown as {
+          updateFirmwareSimulation: (chargingStation: unknown) => Promise<void>
+        },
+        'updateFirmwareSimulation',
+        mock.fn(() => Promise.resolve())
+      )
+    })
+
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should register UPDATE_FIRMWARE event listener in constructor', () => {
+      assert.strictEqual(
+        listenerService.listenerCount(OCPP16IncomingRequestCommand.UPDATE_FIRMWARE),
+        1
+      )
+    })
+
+    await it('should call updateFirmwareSimulation when retrieveDate is in the past', async () => {
+      // Arrange
+      const { station } = createOCPP16ListenerStation('listener-station-past')
+
+      const request: OCPP16UpdateFirmwareRequest = {
+        location: 'ftp://localhost/firmware.bin',
+        retrieveDate: new Date(Date.now() - 10000),
+      }
+      const response: OCPP16UpdateFirmwareResponse = {}
+
+      // Act
+      listenerService.emit(OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response)
+      await flushMicrotasks()
+
+      // Assert
+      assert.strictEqual(updateFirmwareMock.mock.callCount(), 1)
+    })
+
+    await it('should schedule deferred updateFirmwareSimulation when retrieveDate is in the future', async t => {
+      // Arrange
+      const { station } = createOCPP16ListenerStation('listener-station-future')
+
+      const futureMs = 5000
+      const request: OCPP16UpdateFirmwareRequest = {
+        location: 'ftp://localhost/firmware.bin',
+        retrieveDate: new Date(Date.now() + futureMs),
+      }
+      const response: OCPP16UpdateFirmwareResponse = {}
+
+      // Act & Assert
+      await withMockTimers(t, ['setTimeout'], async () => {
+        listenerService.emit(
+          OCPP16IncomingRequestCommand.UPDATE_FIRMWARE,
+          station,
+          request,
+          response
+        )
+
+        // Before tick: simulation not yet called
+        assert.strictEqual(updateFirmwareMock.mock.callCount(), 0)
+
+        // Advance timers past the retrieve date delay
+        t.mock.timers.tick(futureMs + 1)
+        await flushMicrotasks()
+
+        // After tick: simulation should have been called
+        assert.strictEqual(updateFirmwareMock.mock.callCount(), 1)
+      })
+    })
+
+    await it('should handle updateFirmwareSimulation failure gracefully', async () => {
+      // Arrange
+      const { station } = createOCPP16ListenerStation('listener-station-error')
+      mock.method(
+        listenerService as unknown as {
+          updateFirmwareSimulation: (chargingStation: unknown) => Promise<void>
+        },
+        'updateFirmwareSimulation',
+        mock.fn(() => Promise.reject(new Error('firmware simulation error')))
+      )
+
+      const request: OCPP16UpdateFirmwareRequest = {
+        location: 'ftp://localhost/firmware.bin',
+        retrieveDate: new Date(Date.now() - 1000),
+      }
+      const response: OCPP16UpdateFirmwareResponse = {}
+
+      // Act: emit should not throw even if simulation rejects
+      listenerService.emit(OCPP16IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response)
+
+      // Allow the rejected promise to be handled by the error handler in the listener
+      await flushMicrotasks()
+      await flushMicrotasks()
+
+      // Assert: test passes if no unhandled rejection was thrown
+    })
+  })
 })
index e39be6374112c6c2201681f83866c79df1d9990f..6abb67024ff0d0a0098bd85afa57498e3b84884d 100644 (file)
@@ -4,14 +4,21 @@
  */
 
 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 { RemoteStartTransactionRequest } from '../../../../src/types/index.js'
 
-import { AvailabilityType, GenericStatus } from '../../../../src/types/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
+import {
+  AvailabilityType,
+  GenericStatus,
+  OCPP16IncomingRequestCommand,
+  OCPP16RequestCommand,
+} from '../../../../src/types/index.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import {
   createOCPP16IncomingRequestTestContext,
+  createOCPP16ListenerStation,
   type OCPP16IncomingRequestTestContext,
 } from './OCPP16TestUtils.js'
 
@@ -159,4 +166,141 @@ await describe('OCPP16IncomingRequestService — RemoteStartTransaction', async
     // Assert
     assert.strictEqual(response.status, GenericStatus.Rejected)
   })
+
+  await describe('REMOTE_START_TRANSACTION event listener', async () => {
+    let incomingRequestService: OCPP16IncomingRequestService
+    let requestHandlerMock: ReturnType<typeof mock.fn>
+    let listenerStation: import('../../../../src/charging-station/ChargingStation.js').ChargingStation
+
+    beforeEach(() => {
+      ;({ requestHandlerMock, station: listenerStation } = createOCPP16ListenerStation(
+        'test-remote-start-listener'
+      ))
+      incomingRequestService = new OCPP16IncomingRequestService()
+    })
+
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should register REMOTE_START_TRANSACTION event listener in constructor', () => {
+      assert.strictEqual(
+        incomingRequestService.listenerCount(OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION),
+        1
+      )
+    })
+
+    await it('should call StartTransaction when response is Accepted', async () => {
+      // Arrange
+      const connectorStatus = listenerStation.getConnectorStatus(1)
+      assert.notStrictEqual(connectorStatus, undefined)
+
+      const request: RemoteStartTransactionRequest = {
+        connectorId: 1,
+        idTag: 'TEST-TAG-001',
+      }
+      const response = { status: GenericStatus.Accepted }
+
+      // Act
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Flush microtask queue so the async requestHandler call executes
+      await flushMicrotasks()
+
+      // Assert
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+      const args = requestHandlerMock.mock.calls[0].arguments as [unknown, string, unknown]
+      assert.strictEqual(args[1], OCPP16RequestCommand.START_TRANSACTION)
+    })
+
+    await it('should NOT call StartTransaction when response is Rejected', () => {
+      // Arrange
+      const request: RemoteStartTransactionRequest = {
+        connectorId: 1,
+        idTag: 'TEST-TAG-001',
+      }
+      const response = { status: GenericStatus.Rejected }
+
+      // Act
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Assert
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+    })
+
+    await it('should set transactionRemoteStarted to true on Accepted', async () => {
+      // Arrange
+      const connectorStatus = listenerStation.getConnectorStatus(1)
+      assert.notStrictEqual(connectorStatus, undefined)
+      if (connectorStatus != null) {
+        connectorStatus.transactionRemoteStarted = false
+      }
+
+      const request: RemoteStartTransactionRequest = {
+        connectorId: 1,
+        idTag: 'TEST-TAG-001',
+      }
+      const response = { status: GenericStatus.Accepted }
+
+      // Act
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Flush microtask queue
+      await flushMicrotasks()
+
+      // Assert
+      assert.strictEqual(connectorStatus?.transactionRemoteStarted, true)
+    })
+
+    await it('should handle StartTransaction failure gracefully', async () => {
+      // Arrange — override requestHandler to reject
+      let startTransactionCallCount = 0
+      ;(
+        listenerStation.ocppRequestService as unknown as {
+          requestHandler: (...args: unknown[]) => Promise<unknown>
+        }
+      ).requestHandler = async (_station: unknown, commandName: unknown) => {
+        if (commandName === OCPP16RequestCommand.START_TRANSACTION) {
+          startTransactionCallCount++
+          throw new Error('StartTransaction rejected by server')
+        }
+        return Promise.resolve({})
+      }
+
+      const request: RemoteStartTransactionRequest = {
+        connectorId: 1,
+        idTag: 'TEST-TAG-001',
+      }
+      const response = { status: GenericStatus.Accepted }
+
+      // Act — should not throw
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Flush microtask queue so .catch(errorHandler) executes
+      await flushMicrotasks()
+
+      // Assert — handler was called and error was swallowed
+      assert.strictEqual(startTransactionCallCount, 1)
+    })
+  })
 })
index 16c34915a7762085e825819235fbc57aa80b4269..9a4e4a8da5814bd7a0036473d5c9b64d24022170 100644 (file)
@@ -6,19 +6,29 @@
  */
 
 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/ChargingStation.js'
 import type { TestableOCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/__testable__/index.js'
+import type { RemoteStopTransactionRequest } from '../../../../src/types/ocpp/1.6/Requests.js'
+import type { GenericResponse } from '../../../../src/types/ocpp/Common.js'
 
+import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import { OCPP16IncomingRequestCommand } from '../../../../src/types/ocpp/1.6/Requests.js'
 import { OCPP16UnlockStatus } from '../../../../src/types/ocpp/1.6/Responses.js'
 import { OCPP16AuthorizationStatus } from '../../../../src/types/ocpp/1.6/Transaction.js'
 import { GenericStatus } from '../../../../src/types/ocpp/Common.js'
 import {
+  flushMicrotasks,
   setupConnectorWithTransaction,
   standardCleanup,
 } from '../../../helpers/TestLifecycleHelpers.js'
-import { createOCPP16IncomingRequestTestContext, setMockRequestHandler } from './OCPP16TestUtils.js'
+import {
+  createOCPP16IncomingRequestTestContext,
+  createOCPP16ListenerStation,
+  setMockRequestHandler,
+} from './OCPP16TestUtils.js'
 
 await describe('OCPP16IncomingRequestService — RemoteStopTransaction and UnlockConnector', async () => {
   let station: ChargingStation
@@ -159,4 +169,100 @@ await describe('OCPP16IncomingRequestService — RemoteStopTransaction and Unloc
       assert.notStrictEqual(response.status, undefined)
     })
   })
+
+  await describe('REMOTE_STOP_TRANSACTION event listener', async () => {
+    let incomingRequestService: OCPP16IncomingRequestService
+    let listenerStation: ChargingStation
+
+    beforeEach(() => {
+      incomingRequestService = new OCPP16IncomingRequestService()
+      ;({ station: listenerStation } = createOCPP16ListenerStation('test-remote-stop-listener'))
+    })
+
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should register REMOTE_STOP_TRANSACTION event listener in constructor', () => {
+      // Assert
+      assert.strictEqual(
+        incomingRequestService.listenerCount(OCPP16IncomingRequestCommand.REMOTE_STOP_TRANSACTION),
+        1
+      )
+    })
+
+    await it('should call remoteStopTransaction when response is Accepted', async () => {
+      // Arrange
+      setupConnectorWithTransaction(listenerStation, 1, { transactionId: 42 })
+
+      const mockRemoteStop = mock.method(OCPP16ServiceUtils, 'remoteStopTransaction', () =>
+        Promise.resolve({ status: GenericStatus.Accepted } satisfies GenericResponse)
+      )
+
+      const request: RemoteStopTransactionRequest = { transactionId: 42 }
+      const response: GenericResponse = { status: GenericStatus.Accepted }
+
+      // Act
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_STOP_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Flush microtask queue so the async .then() executes
+      await flushMicrotasks()
+
+      // Assert
+      assert.strictEqual(mockRemoteStop.mock.callCount(), 1)
+      assert.strictEqual(mockRemoteStop.mock.calls[0].arguments[0], listenerStation)
+      assert.strictEqual(mockRemoteStop.mock.calls[0].arguments[1], 1)
+    })
+
+    await it('should NOT call remoteStopTransaction when response is Rejected', () => {
+      // Arrange
+      const mockRemoteStop = mock.method(OCPP16ServiceUtils, 'remoteStopTransaction', () =>
+        Promise.resolve({ status: GenericStatus.Rejected } satisfies GenericResponse)
+      )
+
+      const request: RemoteStopTransactionRequest = { transactionId: 99 }
+      const response: GenericResponse = { status: GenericStatus.Rejected }
+
+      // Act
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_STOP_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Assert
+      assert.strictEqual(mockRemoteStop.mock.callCount(), 0)
+    })
+
+    await it('should handle remoteStopTransaction failure gracefully', async () => {
+      // Arrange
+      setupConnectorWithTransaction(listenerStation, 1, { transactionId: 77 })
+
+      mock.method(OCPP16ServiceUtils, 'remoteStopTransaction', () =>
+        Promise.reject(new Error('remoteStopTransaction failed'))
+      )
+
+      const request: RemoteStopTransactionRequest = { transactionId: 77 }
+      const response: GenericResponse = { status: GenericStatus.Accepted }
+
+      // Act — should not throw
+      incomingRequestService.emit(
+        OCPP16IncomingRequestCommand.REMOTE_STOP_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      // Flush microtask queue so .catch() executes
+      await flushMicrotasks()
+
+      // Assert — no crash, test completes normally
+    })
+  })
 })
index f60a0e6f88bab2c4ad2900478c9f5ab46c531fe6..fa4d612c6d9db5d1f43920f2a4ebb73f2d9c72bd 100644 (file)
@@ -5,16 +5,29 @@
  */
 
 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 {
+  OCPP16TriggerMessageRequest,
+  OCPP16TriggerMessageResponse,
+} from '../../../../src/types/index.js'
+
+import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
 import {
+  OCPP16IncomingRequestCommand,
   OCPP16MessageTrigger,
+  OCPP16RequestCommand,
   OCPP16StandardParametersKey,
   OCPP16TriggerMessageStatus,
+  OCPPVersion,
 } from '../../../../src/types/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import {
   createOCPP16IncomingRequestTestContext,
+  createOCPP16ListenerStation,
   type OCPP16IncomingRequestTestContext,
   upsertConfigurationKey,
 } from './OCPP16TestUtils.js'
@@ -159,4 +172,138 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
       assert.strictEqual(response.status, OCPP16TriggerMessageStatus.NOT_IMPLEMENTED)
     })
   })
+
+  await describe('TRIGGER_MESSAGE event listener', async () => {
+    let incomingRequestServiceForListener: OCPP16IncomingRequestService
+    let station: ReturnType<typeof createOCPP16ListenerStation>['station']
+    let requestHandlerMock: ReturnType<typeof mock.fn>
+
+    beforeEach(() => {
+      ;({ requestHandlerMock, station } = createOCPP16ListenerStation('test-trigger-listener'))
+      incomingRequestServiceForListener = new OCPP16IncomingRequestService()
+    })
+
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should register TRIGGER_MESSAGE event listener in constructor', () => {
+      assert.strictEqual(
+        incomingRequestServiceForListener.listenerCount(
+          OCPP16IncomingRequestCommand.TRIGGER_MESSAGE
+        ),
+        1
+      )
+    })
+
+    await it('should NOT fire requestHandler when response is NotImplemented', () => {
+      const request: OCPP16TriggerMessageRequest = {
+        requestedMessage: OCPP16MessageTrigger.BootNotification,
+      }
+      const response: OCPP16TriggerMessageResponse = {
+        status: OCPP16TriggerMessageStatus.NOT_IMPLEMENTED,
+      }
+
+      incomingRequestServiceForListener.emit(
+        OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+        station,
+        request,
+        response
+      )
+
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+    })
+
+    const triggerCases: {
+      connectorId?: number
+      expectedCommand: OCPP16RequestCommand
+      name: string
+      trigger: OCPP16MessageTrigger
+    }[] = [
+      {
+        expectedCommand: OCPP16RequestCommand.BOOT_NOTIFICATION,
+        name: 'BootNotification',
+        trigger: OCPP16MessageTrigger.BootNotification,
+      },
+      {
+        expectedCommand: OCPP16RequestCommand.HEARTBEAT,
+        name: 'Heartbeat',
+        trigger: OCPP16MessageTrigger.Heartbeat,
+      },
+      {
+        connectorId: 1,
+        expectedCommand: OCPP16RequestCommand.STATUS_NOTIFICATION,
+        name: 'StatusNotification',
+        trigger: OCPP16MessageTrigger.StatusNotification,
+      },
+      {
+        expectedCommand: OCPP16RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
+        name: 'FirmwareStatusNotification',
+        trigger: OCPP16MessageTrigger.FirmwareStatusNotification,
+      },
+      {
+        expectedCommand: OCPP16RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION,
+        name: 'DiagnosticsStatusNotification',
+        trigger: OCPP16MessageTrigger.DiagnosticsStatusNotification,
+      },
+    ]
+
+    for (const { connectorId, expectedCommand, name, trigger } of triggerCases) {
+      await it(`should fire ${name} requestHandler on Accepted`, () => {
+        const request: OCPP16TriggerMessageRequest = {
+          requestedMessage: trigger,
+          ...(connectorId != null && { connectorId }),
+        }
+        const response: OCPP16TriggerMessageResponse = {
+          status: OCPP16TriggerMessageStatus.ACCEPTED,
+        }
+
+        incomingRequestServiceForListener.emit(
+          OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+          station,
+          request,
+          response
+        )
+
+        assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+        const args = requestHandlerMock.mock.calls[0].arguments as [unknown, string, ...unknown[]]
+        assert.strictEqual(args[1], expectedCommand)
+      })
+    }
+
+    await it('should handle requestHandler rejection gracefully', async () => {
+      const rejectingMock = mock.fn(async () => Promise.reject(new Error('test error')))
+      const { station: rejectStation } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 2,
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        ocppRequestService: {
+          requestHandler: rejectingMock,
+        },
+        stationInfo: {
+          ocppVersion: OCPPVersion.VERSION_16,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      const request: OCPP16TriggerMessageRequest = {
+        requestedMessage: OCPP16MessageTrigger.BootNotification,
+      }
+      const response: OCPP16TriggerMessageResponse = {
+        status: OCPP16TriggerMessageStatus.ACCEPTED,
+      }
+
+      incomingRequestServiceForListener.emit(
+        OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+        rejectStation,
+        request,
+        response
+      )
+
+      // Flush microtask queue so .catch(errorHandler) executes
+      await flushMicrotasks()
+
+      assert.strictEqual(rejectingMock.mock.callCount(), 1)
+    })
+  })
 })
index a0bc79a499db5a66bcac7fb6dcd6d75e23449a1b..ea99cf8b2e0193c18c45f637892f3c949dffc3a4 100644 (file)
@@ -4,6 +4,8 @@
  *   and configuration key helpers for OCPP 1.6 unit and integration tests.
  */
 
+import { mock } from 'node:test'
+
 import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
 import type { ChargingStationInfo } from '../../../../src/types/ChargingStationInfo.js'
 import type { ConfigurationKey } from '../../../../src/types/ChargingStationOcppConfiguration.js'
@@ -148,6 +150,32 @@ export function createOCPP16IncomingRequestTestContext (
   return { incomingRequestService, station, testableService }
 }
 
+/**
+ * Create a listener station with a mocked request handler for OCPP 1.6 tests.
+ * @param baseName - Base name for the charging station
+ * @returns Object containing the mock request handler and charging station
+ */
+export function createOCPP16ListenerStation (baseName: string): {
+  requestHandlerMock: ReturnType<typeof mock.fn>
+  station: ChargingStation
+} {
+  const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+  const { station } = createMockChargingStation({
+    baseName,
+    connectorsCount: 2,
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: requestHandlerMock,
+    },
+    stationInfo: {
+      ocppStrictCompliance: false,
+      ocppVersion: OCPPVersion.VERSION_16,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  return { requestHandlerMock, station }
+}
+
 /**
  * Create a standard OCPP 1.6 request test context with response service,
  * request service, testable wrapper, and mock charging station.
index 9965b580180a8b9e004484c4193f219f9967512e..22f82e65abe15a0b1bf7efc4168f353a593d24f4 100644 (file)
@@ -20,7 +20,7 @@ import {
 } from '../../../../src/types/index.js'
 import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 
@@ -161,120 +161,128 @@ await describe('N32 - CustomerInformation', async () => {
     })
   })
 
-  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<void>
-      },
-      'sendNotifyCustomerInformation',
-      () => Promise.resolve()
-    )
-
-    const request: OCPP20CustomerInformationRequest = {
-      clear: false,
-      idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
-      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<void>
-      },
-      'sendNotifyCustomerInformation',
-      () => Promise.resolve()
-    )
-
-    const request: OCPP20CustomerInformationRequest = {
-      clear: true,
-      report: false,
-      requestId: 21,
-    }
-    const response: OCPP20CustomerInformationResponse = {
-      status: CustomerInformationStatusEnumType.Accepted,
-    }
-
-    service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
-
-    assert.strictEqual(notifyMock.mock.callCount(), 0)
-  })
-
-  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<void>
-      },
-      'sendNotifyCustomerInformation',
-      () => Promise.resolve()
-    )
+  await describe('CUSTOMER_INFORMATION event listener', async () => {
+    let incomingRequestService: OCPP20IncomingRequestService
+    let notifyMock: ReturnType<typeof mock.fn>
+
+    beforeEach(() => {
+      incomingRequestService = new OCPP20IncomingRequestService()
+      notifyMock = mock.method(
+        incomingRequestService as unknown as {
+          sendNotifyCustomerInformation: (
+            chargingStation: ChargingStation,
+            requestId: number
+          ) => Promise<void>
+        },
+        'sendNotifyCustomerInformation',
+        () => Promise.resolve()
+      )
+    })
 
-    const request: OCPP20CustomerInformationRequest = {
-      clear: false,
-      report: false,
-      requestId: 22,
-    }
-    const response: OCPP20CustomerInformationResponse = {
-      status: CustomerInformationStatusEnumType.Rejected,
-    }
+    afterEach(() => {
+      standardCleanup()
+    })
 
-    service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
+    await it('should register CUSTOMER_INFORMATION event listener in constructor', () => {
+      assert.strictEqual(
+        incomingRequestService.listenerCount(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION),
+        1
+      )
+    })
 
-    assert.strictEqual(notifyMock.mock.callCount(), 0)
-  })
+    await it('should call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Accepted + report=true', () => {
+      const request: OCPP20CustomerInformationRequest = {
+        clear: false,
+        idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
+        report: true,
+        requestId: 20,
+      }
+      const response: OCPP20CustomerInformationResponse = {
+        status: CustomerInformationStatusEnumType.Accepted,
+      }
+
+      incomingRequestService.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 handle sendNotifyCustomerInformation rejection gracefully', async () => {
-    const service = new OCPP20IncomingRequestService()
-    mock.method(
-      service as unknown as {
-        sendNotifyCustomerInformation: (
-          chargingStation: ChargingStation,
-          requestId: number
-        ) => Promise<void>
-      },
-      'sendNotifyCustomerInformation',
-      () => Promise.reject(new Error('notification error'))
-    )
+    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 request: OCPP20CustomerInformationRequest = {
+        clear: true,
+        report: false,
+        requestId: 21,
+      }
+      const response: OCPP20CustomerInformationResponse = {
+        status: CustomerInformationStatusEnumType.Accepted,
+      }
+
+      incomingRequestService.emit(
+        OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION,
+        station,
+        request,
+        response
+      )
+
+      assert.strictEqual(notifyMock.mock.callCount(), 0)
+    })
 
-    const request: OCPP20CustomerInformationRequest = {
-      clear: false,
-      idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
-      report: true,
-      requestId: 99,
-    }
-    const response: OCPP20CustomerInformationResponse = {
-      status: CustomerInformationStatusEnumType.Accepted,
-    }
+    await it('should NOT call sendNotifyCustomerInformation when CUSTOMER_INFORMATION event emitted with Rejected', () => {
+      const request: OCPP20CustomerInformationRequest = {
+        clear: false,
+        report: false,
+        requestId: 22,
+      }
+      const response: OCPP20CustomerInformationResponse = {
+        status: CustomerInformationStatusEnumType.Rejected,
+      }
+
+      incomingRequestService.emit(
+        OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION,
+        station,
+        request,
+        response
+      )
+
+      assert.strictEqual(notifyMock.mock.callCount(), 0)
+    })
 
-    service.emit(OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION, station, request, response)
+    await it('should handle sendNotifyCustomerInformation rejection gracefully', async () => {
+      mock.method(
+        incomingRequestService as unknown as {
+          sendNotifyCustomerInformation: (
+            chargingStation: ChargingStation,
+            requestId: number
+          ) => Promise<void>
+        },
+        'sendNotifyCustomerInformation',
+        () => Promise.reject(new Error('notification error'))
+      )
 
-    await Promise.resolve()
+      const request: OCPP20CustomerInformationRequest = {
+        clear: false,
+        idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
+        report: true,
+        requestId: 99,
+      }
+      const response: OCPP20CustomerInformationResponse = {
+        status: CustomerInformationStatusEnumType.Accepted,
+      }
+
+      incomingRequestService.emit(
+        OCPP20IncomingRequestCommand.CUSTOMER_INFORMATION,
+        station,
+        request,
+        response
+      )
+
+      await flushMicrotasks()
+    })
   })
 })
index cd25ca4d787e534fea513461abd19adf27e2ba23..c84ae05df4a1cebde619dd292a9f8b80e8a63e6f 100644 (file)
@@ -1,10 +1,10 @@
-import { millisecondsToSeconds } from 'date-fns'
 /**
  * @file Tests for OCPP20IncomingRequestService GetBaseReport
  * @description Unit tests for OCPP 2.0 GetBaseReport command handling (B07)
  */
+import { millisecondsToSeconds } from 'date-fns'
 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'
 
@@ -21,6 +21,8 @@ import {
   OCPP20ComponentName,
   OCPP20DeviceInfoVariableName,
   type OCPP20GetBaseReportRequest,
+  type OCPP20GetBaseReportResponse,
+  OCPP20IncomingRequestCommand,
   OCPP20OptionalVariableName,
   OCPP20RequiredVariableName,
   type OCPP20SetVariableResultType,
@@ -30,7 +32,7 @@ import {
 } from '../../../../src/types/index.js'
 import { StandardParametersKey } from '../../../../src/types/ocpp/Configuration.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import {
   TEST_CHARGE_POINT_MODEL,
   TEST_CHARGE_POINT_SERIAL_NUMBER,
@@ -354,4 +356,89 @@ await describe('B07 - Get Base Report', async () => {
     assert.ok(Array.isArray(reportData))
     assert.strictEqual(reportData.length, 0)
   })
+
+  await describe('GET_BASE_REPORT event listener', async () => {
+    let listenerService: OCPP20IncomingRequestService
+    let sendNotifyMock: ReturnType<typeof mock.fn>
+
+    beforeEach(() => {
+      listenerService = new OCPP20IncomingRequestService()
+      sendNotifyMock = mock.method(
+        listenerService as unknown as {
+          sendNotifyReportRequest: (
+            chargingStation: ChargingStation,
+            request: OCPP20GetBaseReportRequest,
+            response: OCPP20GetBaseReportResponse
+          ) => Promise<void>
+        },
+        'sendNotifyReportRequest',
+        () => Promise.resolve()
+      )
+    })
+
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should register GET_BASE_REPORT event listener in constructor', () => {
+      assert.strictEqual(
+        listenerService.listenerCount(OCPP20IncomingRequestCommand.GET_BASE_REPORT),
+        1
+      )
+    })
+
+    await it('should call sendNotifyReportRequest when response is Accepted', () => {
+      const request: OCPP20GetBaseReportRequest = {
+        reportBase: ReportBaseEnumType.FullInventory,
+        requestId: 1,
+      }
+      const response: OCPP20GetBaseReportResponse = {
+        status: GenericDeviceModelStatusEnumType.Accepted,
+      }
+
+      listenerService.emit(OCPP20IncomingRequestCommand.GET_BASE_REPORT, station, request, response)
+
+      assert.strictEqual(sendNotifyMock.mock.callCount(), 1)
+    })
+
+    await it('should NOT call sendNotifyReportRequest when response is NotSupported', () => {
+      const request: OCPP20GetBaseReportRequest = {
+        reportBase: ReportBaseEnumType.FullInventory,
+        requestId: 2,
+      }
+      const response: OCPP20GetBaseReportResponse = {
+        status: GenericDeviceModelStatusEnumType.NotSupported,
+      }
+
+      listenerService.emit(OCPP20IncomingRequestCommand.GET_BASE_REPORT, station, request, response)
+
+      assert.strictEqual(sendNotifyMock.mock.callCount(), 0)
+    })
+
+    await it('should handle sendNotifyReportRequest rejection gracefully', async () => {
+      mock.method(
+        listenerService as unknown as {
+          sendNotifyReportRequest: (
+            chargingStation: ChargingStation,
+            request: OCPP20GetBaseReportRequest,
+            response: OCPP20GetBaseReportResponse
+          ) => Promise<void>
+        },
+        'sendNotifyReportRequest',
+        () => Promise.reject(new Error('notify report error'))
+      )
+
+      const request: OCPP20GetBaseReportRequest = {
+        reportBase: ReportBaseEnumType.FullInventory,
+        requestId: 3,
+      }
+      const response: OCPP20GetBaseReportResponse = {
+        status: GenericDeviceModelStatusEnumType.Accepted,
+      }
+
+      listenerService.emit(OCPP20IncomingRequestCommand.GET_BASE_REPORT, station, request, response)
+
+      await flushMicrotasks()
+    })
+  })
 })
index 316e93fd322d84686268feb73696b38094cb0661..12c16ed33dbfca914557bc2b65ff3b8279726238 100644 (file)
@@ -89,14 +89,12 @@ await describe('K01 - GetLog', async () => {
   })
 
   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)
-    })
+    let service: OCPP20IncomingRequestService
+    let simulateMock: ReturnType<typeof mock.fn>
 
-    await it('should call simulateLogUploadLifecycle when GET_LOG event emitted with Accepted response', () => {
-      const service = new OCPP20IncomingRequestService()
-      const simulateMock = mock.method(
+    beforeEach(() => {
+      service = new OCPP20IncomingRequestService()
+      simulateMock = mock.method(
         service as unknown as {
           simulateLogUploadLifecycle: (
             chargingStation: ChargingStation,
@@ -106,7 +104,13 @@ await describe('K01 - GetLog', async () => {
         'simulateLogUploadLifecycle',
         () => Promise.resolve()
       )
+    })
 
+    await it('should register GET_LOG event listener in constructor', () => {
+      assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.GET_LOG), 1)
+    })
+
+    await it('should call simulateLogUploadLifecycle when GET_LOG event emitted with Accepted response', () => {
       const request: OCPP20GetLogRequest = {
         log: {
           remoteLocation: 'https://csms.example.com/logs',
@@ -126,18 +130,6 @@ await describe('K01 - GetLog', async () => {
     })
 
     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<void>
-        },
-        'simulateLogUploadLifecycle',
-        () => Promise.resolve()
-      )
-
       const request: OCPP20GetLogRequest = {
         log: {
           remoteLocation: 'https://csms.example.com/logs',
@@ -155,7 +147,6 @@ await describe('K01 - GetLog', async () => {
     })
 
     await it('should handle simulateLogUploadLifecycle rejection gracefully', async () => {
-      const service = new OCPP20IncomingRequestService()
       mock.method(
         service as unknown as {
           simulateLogUploadLifecycle: (
@@ -181,7 +172,7 @@ await describe('K01 - GetLog', async () => {
 
       service.emit(OCPP20IncomingRequestCommand.GET_LOG, station, request, response)
 
-      await Promise.resolve()
+      await flushMicrotasks()
     })
 
     await describe('N01 - LogStatusNotification lifecycle', async () => {
index fe078261562dfa69085416d40bf475b38304209d..95cabf847473e4c426ca8fdf183c50e58a321614 100644 (file)
@@ -1,8 +1,9 @@
-import { millisecondsToSeconds } from 'date-fns'
 /**
  * @file Tests for OCPP20IncomingRequestService GetVariables
  * @description Unit tests for OCPP 2.0 GetVariables command handling (B06)
  */
+
+import { millisecondsToSeconds } from 'date-fns'
 import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
@@ -21,7 +22,7 @@ import {
   ReasonCodeEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import {
   TEST_CHARGING_STATION_BASE_NAME,
   TEST_CONNECTOR_ID_VALID_INSTANCE,
index 257fde13fe526a739dd0b3ae89d38ffe1524675d..ee1e01640cf4b095838e6ef0a0767653d3491f11 100644 (file)
@@ -23,7 +23,7 @@ import {
   type OCPP20IdTokenType,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { OCPPVersion } from '../../../../src/types/ocpp/OCPPVersion.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 
 await describe('G03 - Remote Start Pre-Authorization', async () => {
   let service: OCPP20IncomingRequestService | undefined
index d77ee04db9d95ce38adcabd4555b88e8d2a8d69e..338d0e78f77639760b5d62f50a56ebd375b53855 100644 (file)
@@ -3,10 +3,14 @@
  * @description Unit tests for OCPP 2.0 RequestStartTransaction command handling (F01/F02)
  */
 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 type { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js'
+import type {
+  OCPP20RequestStartTransactionRequest,
+  OCPP20RequestStartTransactionResponse,
+  OCPP20TransactionEventRequest,
+} from '../../../../src/types/index.js'
 import type {
   OCPP20ChargingProfileType,
   OCPP20ChargingRateUnitEnumType,
@@ -15,18 +19,26 @@ import type {
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
-import { OCPPVersion, RequestStartStopStatusEnumType } from '../../../../src/types/index.js'
+import {
+  OCPP20IncomingRequestCommand,
+  OCPP20RequestCommand,
+  OCPP20TransactionEventEnumType,
+  OCPP20TriggerReasonEnumType,
+  OCPPVersion,
+  RequestStartStopStatusEnumType,
+} from '../../../../src/types/index.js'
 import {
   OCPP20ChargingProfileKindEnumType,
   OCPP20ChargingProfilePurposeEnumType,
   OCPP20IdTokenEnumType,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import { createMockAuthService } from '../auth/helpers/MockFactories.js'
 import {
+  createOCPP20ListenerStation,
   resetConnectorTransactionState,
   resetLimits,
   resetReportingValueSize,
@@ -362,4 +374,214 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
     }
     assert.ok(response.transactionId.length > 0)
   })
+
+  await describe('REQUEST_START_TRANSACTION event listener', async () => {
+    let listenerService: OCPP20IncomingRequestService
+    let requestHandlerMock: ReturnType<typeof mock.fn>
+    let listenerStation: ChargingStation
+
+    beforeEach(() => {
+      ;({ requestHandlerMock, station: listenerStation } = createOCPP20ListenerStation(
+        TEST_CHARGING_STATION_BASE_NAME + '-LISTENER'
+      ))
+      listenerService = new OCPP20IncomingRequestService()
+      testableService = createTestableIncomingRequestService(listenerService)
+      const stationId = listenerStation.stationInfo?.chargingStationId ?? 'unknown'
+      OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+      resetConnectorTransactionState(listenerStation)
+      resetLimits(listenerStation)
+      resetReportingValueSize(listenerStation)
+    })
+
+    afterEach(() => {
+      standardCleanup()
+      OCPPAuthServiceFactory.clearAllInstances()
+    })
+
+    await it('should register REQUEST_START_TRANSACTION event listener in constructor', () => {
+      assert.strictEqual(
+        listenerService.listenerCount(OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION),
+        1
+      )
+    })
+
+    await it('should call TransactionEvent(Started) when response is Accepted', async () => {
+      const startRequest: OCPP20RequestStartTransactionRequest = {
+        evseId: 1,
+        idToken: {
+          idToken: 'LISTENER_TOKEN_1',
+          type: OCPP20IdTokenEnumType.ISO14443,
+        },
+        remoteStartId: 1,
+      }
+      const startResponse = await testableService.handleRequestStartTransaction(
+        listenerStation,
+        startRequest
+      )
+      assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
+      requestHandlerMock.mock.resetCalls()
+
+      const request: OCPP20RequestStartTransactionRequest = {
+        evseId: 1,
+        idToken: {
+          idToken: 'LISTENER_TOKEN_1',
+          type: OCPP20IdTokenEnumType.ISO14443,
+        },
+        remoteStartId: 1,
+      }
+      const response: OCPP20RequestStartTransactionResponse = {
+        status: RequestStartStopStatusEnumType.Accepted,
+        transactionId: startResponse.transactionId,
+      }
+
+      listenerService.emit(
+        OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      await flushMicrotasks()
+
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+      const args = requestHandlerMock.mock.calls[0].arguments as [
+        unknown,
+        string,
+        OCPP20TransactionEventRequest
+      ]
+      assert.strictEqual(args[1], OCPP20RequestCommand.TRANSACTION_EVENT)
+      assert.strictEqual(args[2].eventType, OCPP20TransactionEventEnumType.Started)
+    })
+
+    await it('should NOT call TransactionEvent when response is Rejected', () => {
+      const request: OCPP20RequestStartTransactionRequest = {
+        evseId: 1,
+        idToken: {
+          idToken: 'REJECTED_TOKEN',
+          type: OCPP20IdTokenEnumType.ISO14443,
+        },
+        remoteStartId: 2,
+      }
+      const response: OCPP20RequestStartTransactionResponse = {
+        status: RequestStartStopStatusEnumType.Rejected,
+      }
+
+      listenerService.emit(
+        OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+    })
+
+    // E02.FR.01 — CS SHALL send TransactionEvent(Started) with RemoteStart trigger reason
+    await it('should send TransactionEvent(Started) with RemoteStart trigger reason', async () => {
+      const startRequest: OCPP20RequestStartTransactionRequest = {
+        evseId: 2,
+        idToken: {
+          idToken: 'TRIGGER_REASON_TOKEN',
+          type: OCPP20IdTokenEnumType.ISO14443,
+        },
+        remoteStartId: 3,
+      }
+      const startResponse = await testableService.handleRequestStartTransaction(
+        listenerStation,
+        startRequest
+      )
+      assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
+      requestHandlerMock.mock.resetCalls()
+
+      const request: OCPP20RequestStartTransactionRequest = {
+        evseId: 2,
+        idToken: {
+          idToken: 'TRIGGER_REASON_TOKEN',
+          type: OCPP20IdTokenEnumType.ISO14443,
+        },
+        remoteStartId: 3,
+      }
+      const response: OCPP20RequestStartTransactionResponse = {
+        status: RequestStartStopStatusEnumType.Accepted,
+        transactionId: startResponse.transactionId,
+      }
+
+      listenerService.emit(
+        OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+        listenerStation,
+        request,
+        response
+      )
+
+      await flushMicrotasks()
+
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+      const args = requestHandlerMock.mock.calls[0].arguments as [
+        unknown,
+        string,
+        OCPP20TransactionEventRequest
+      ]
+      const transactionEvent = args[2]
+      assert.strictEqual(transactionEvent.triggerReason, OCPP20TriggerReasonEnumType.RemoteStart)
+    })
+
+    await it('should handle TransactionEvent failure gracefully', async () => {
+      let transactionEventCallCount = 0
+      const { station: failStation } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME + '-FAIL-START',
+        connectorsCount: 1,
+        evseConfiguration: { evsesCount: 1 },
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        ocppRequestService: {
+          requestHandler: async (_chargingStation: unknown, commandName: unknown) => {
+            if (commandName === OCPP20RequestCommand.TRANSACTION_EVENT) {
+              transactionEventCallCount++
+              throw new Error('TransactionEvent rejected by server')
+            }
+            return Promise.resolve({})
+          },
+        },
+        stationInfo: {
+          ocppStrictCompliance: false,
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      const failStationId = failStation.stationInfo?.chargingStationId ?? 'unknown'
+      OCPPAuthServiceFactory.setInstanceForTesting(failStationId, createMockAuthService())
+
+      resetConnectorTransactionState(failStation)
+      const startResponse = await testableService.handleRequestStartTransaction(failStation, {
+        evseId: 1,
+        idToken: {
+          idToken: 'FAIL_START_TOKEN',
+          type: OCPP20IdTokenEnumType.ISO14443,
+        },
+        remoteStartId: 999,
+      })
+      assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
+
+      listenerService.emit(
+        OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+        failStation,
+        {
+          evseId: 1,
+          idToken: {
+            idToken: 'FAIL_START_TOKEN',
+            type: OCPP20IdTokenEnumType.ISO14443,
+          },
+          remoteStartId: 999,
+        } satisfies OCPP20RequestStartTransactionRequest,
+        {
+          status: RequestStartStopStatusEnumType.Accepted,
+          transactionId: startResponse.transactionId,
+        } satisfies OCPP20RequestStartTransactionResponse
+      )
+
+      await flushMicrotasks()
+
+      assert.strictEqual(transactionEventCallCount, 1)
+    })
+  })
 })
index 8052900f3b6924632c7fb23d1006cf7ccf56fabf..edee1bc9237b5a9146b3052a4c624a54b44e2fab 100644 (file)
@@ -31,49 +31,24 @@ import {
   OCPP20ReasonEnumType,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import { createMockAuthService } from '../auth/helpers/MockFactories.js'
 import {
+  createOCPP20ListenerStation,
   resetConnectorTransactionState,
   resetLimits,
   resetReportingValueSize,
 } from './OCPP20TestUtils.js'
 
-/**
- * @param baseName
- * @returns The mock station and its request handler spy
- */
-function createListenerStation (baseName: string): {
-  requestHandlerMock: ReturnType<typeof mock.fn>
-  station: ChargingStation
-} {
-  const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
-  const { station } = createMockChargingStation({
-    baseName,
-    connectorsCount: 3,
-    evseConfiguration: { evsesCount: 3 },
-    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
-    ocppRequestService: {
-      requestHandler: requestHandlerMock,
-    },
-    stationInfo: {
-      ocppStrictCompliance: false,
-      ocppVersion: OCPPVersion.VERSION_201,
-    },
-    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
-  })
-  return { requestHandlerMock, station }
-}
-
 await describe('F03 - Remote Stop Transaction', async () => {
   let mockStation: ChargingStation
   let incomingRequestService: OCPP20IncomingRequestService
   let testableService: ReturnType<typeof createTestableIncomingRequestService>
 
   beforeEach(() => {
-    const { station } = createListenerStation(TEST_CHARGING_STATION_BASE_NAME)
+    const { station } = createOCPP20ListenerStation(TEST_CHARGING_STATION_BASE_NAME)
     mockStation = station
     incomingRequestService = new OCPP20IncomingRequestService()
     testableService = createTestableIncomingRequestService(incomingRequestService)
@@ -89,10 +64,11 @@ await describe('F03 - Remote Stop Transaction', async () => {
   })
 
   /**
-   * @param station
-   * @param evseId
-   * @param remoteStartId
-   * @param skipReset
+   * Starts a transaction via RequestStartTransaction and returns its ID.
+   * @param station - The charging station to start a transaction on
+   * @param evseId - EVSE ID to use
+   * @param remoteStartId - Remote start ID
+   * @param skipReset - Whether to skip resetting mock call counts
    * @returns The transaction ID of the started transaction
    */
   async function startTransaction (
@@ -246,7 +222,7 @@ await describe('F03 - Remote Stop Transaction', async () => {
     let listenerStation: ChargingStation
 
     beforeEach(() => {
-      ;({ requestHandlerMock, station: listenerStation } = createListenerStation(
+      ;({ requestHandlerMock, station: listenerStation } = createOCPP20ListenerStation(
         TEST_CHARGING_STATION_BASE_NAME + '-LISTENER'
       ))
       listenerService = new OCPP20IncomingRequestService()
@@ -257,6 +233,10 @@ await describe('F03 - Remote Stop Transaction', async () => {
       resetReportingValueSize(listenerStation)
     })
 
+    afterEach(() => {
+      standardCleanup()
+    })
+
     await it('should register REQUEST_STOP_TRANSACTION event listener in constructor', () => {
       assert.strictEqual(
         listenerService.listenerCount(OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION),
@@ -282,7 +262,9 @@ await describe('F03 - Remote Stop Transaction', async () => {
         response
       )
 
-      assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+      await flushMicrotasks()
+
+      assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
       const args = requestHandlerMock.mock.calls[0].arguments as [
         unknown,
         string,
@@ -356,7 +338,7 @@ await describe('F03 - Remote Stop Transaction', async () => {
       )
 
       // Flush microtask queue so .catch(errorHandler) executes
-      await Promise.resolve()
+      await flushMicrotasks()
 
       assert.strictEqual(transactionEventCallCount, 1)
     })
index 02b100d62e7ed1e6b0f7f69017a8e405f2b90587..c7033fa9e35db79a3a1eb902f98ff4452dd5f731 100644 (file)
@@ -24,7 +24,7 @@ import {
   TriggerMessageStatusEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 
@@ -438,95 +438,51 @@ await describe('F06 - TriggerMessage', async () => {
       assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
     })
 
-    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)
-    })
+    const triggerCases: {
+      name: string
+      trigger: MessageTriggerEnumType
+    }[] = [
+      {
+        name: 'BootNotification',
+        trigger: MessageTriggerEnumType.BootNotification,
+      },
+      {
+        name: 'Heartbeat',
+        trigger: MessageTriggerEnumType.Heartbeat,
+      },
+      {
+        name: 'FirmwareStatusNotification',
+        trigger: MessageTriggerEnumType.FirmwareStatusNotification,
+      },
+      {
+        name: 'LogStatusNotification',
+        trigger: MessageTriggerEnumType.LogStatusNotification,
+      },
+      {
+        name: 'MeterValues',
+        trigger: MessageTriggerEnumType.MeterValues,
+      },
+    ]
+
+    for (const { name, trigger } of triggerCases) {
+      await it(`should fire ${name} requestHandler on Accepted`, () => {
+        const request: OCPP20TriggerMessageRequest = {
+          requestedMessage: trigger,
+        }
+        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 = {
@@ -630,7 +586,7 @@ await describe('F06 - TriggerMessage', async () => {
       )
 
       // Flush microtask queue so .catch(errorHandler) executes
-      await Promise.resolve()
+      await flushMicrotasks()
 
       assert.strictEqual(rejectingMock.mock.callCount(), 1)
     })
index f3830d69d6d27a628588323882f5f023204ebc60..3d92d4519c097cb4dbbb5357e20b48da275d051e 100644 (file)
@@ -227,14 +227,12 @@ await describe('L01/L02 - UpdateFirmware', async () => {
   })
 
   await describe('UPDATE_FIRMWARE event listener', async () => {
-    await it('should register UPDATE_FIRMWARE event listener in constructor', () => {
-      const service = new OCPP20IncomingRequestService()
-      assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE), 1)
-    })
+    let service: OCPP20IncomingRequestService
+    let simulateMock: ReturnType<typeof mock.fn>
 
-    await it('should call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Accepted response', () => {
-      const service = new OCPP20IncomingRequestService()
-      const simulateMock = mock.method(
+    beforeEach(() => {
+      service = new OCPP20IncomingRequestService()
+      simulateMock = mock.method(
         service as unknown as {
           simulateFirmwareUpdateLifecycle: (
             chargingStation: ChargingStation,
@@ -245,7 +243,13 @@ await describe('L01/L02 - UpdateFirmware', async () => {
         'simulateFirmwareUpdateLifecycle',
         () => Promise.resolve()
       )
+    })
 
+    await it('should register UPDATE_FIRMWARE event listener in constructor', () => {
+      assert.strictEqual(service.listenerCount(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE), 1)
+    })
+
+    await it('should call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Accepted response', () => {
       const request: OCPP20UpdateFirmwareRequest = {
         firmware: {
           location: 'https://firmware.example.com/update.bin',
@@ -266,19 +270,6 @@ await describe('L01/L02 - UpdateFirmware', async () => {
     })
 
     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<void>
-        },
-        'simulateFirmwareUpdateLifecycle',
-        () => Promise.resolve()
-      )
-
       const request: OCPP20UpdateFirmwareRequest = {
         firmware: {
           location: 'https://firmware.example.com/update.bin',
@@ -296,7 +287,6 @@ await describe('L01/L02 - UpdateFirmware', async () => {
     })
 
     await it('should handle simulateFirmwareUpdateLifecycle rejection gracefully', async () => {
-      const service = new OCPP20IncomingRequestService()
       mock.method(
         service as unknown as {
           simulateFirmwareUpdateLifecycle: (
@@ -322,7 +312,7 @@ await describe('L01/L02 - UpdateFirmware', async () => {
 
       service.emit(OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, station, request, response)
 
-      await Promise.resolve()
+      await flushMicrotasks()
     })
 
     await it('should cancel previous firmware update when new one arrives', async t => {
index 5e98605f69d84c34c97a075679a4d550bd1c3830..38ddf2a53596c9d1a8560e9891109abad6f28ce6 100644 (file)
@@ -32,7 +32,7 @@ import {
   type OCPP20TransactionContext,
 } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants, generateUUID } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import {
index c210d14eb769ed80d33339695cc67951d155adf9..16596814b64133fcfbc33e4cb5ccd99d550a56fe 100644 (file)
@@ -1,3 +1,7 @@
+/**
+ * @file OCPP 2.0 test utilities
+ * @description Shared helpers, mock factories, and fixtures for OCPP 2.0 test suites
+ */
 import { mock } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
@@ -871,6 +875,33 @@ export function createMockOCSPRequestData (): OCSPRequestDataType {
   }
 }
 
+/**
+ * Create a mock OCPP 2.0 charging station with a spy requestHandler for listener tests.
+ * @param baseName - Base name for the mock charging station
+ * @returns The mock station and its request handler spy
+ */
+export function createOCPP20ListenerStation (baseName: string): {
+  requestHandlerMock: ReturnType<typeof mock.fn>
+  station: ChargingStation
+} {
+  const requestHandlerMock = mock.fn(async () => Promise.resolve({}))
+  const { station } = createMockChargingStation({
+    baseName,
+    connectorsCount: 3,
+    evseConfiguration: { evsesCount: 3 },
+    heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+    ocppRequestService: {
+      requestHandler: requestHandlerMock,
+    },
+    stationInfo: {
+      ocppStrictCompliance: false,
+      ocppVersion: OCPPVersion.VERSION_201,
+    },
+    websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+  })
+  return { requestHandlerMock, station }
+}
+
 /**
  * Create a mock ChargingStation with certificate manager for testing.
  * This encapsulates the type casting pattern for ChargingStationWithCertificateManager.
index 432b424fee7b136036c94227c3839261df9c930d..0958d034557d7d5b19ae6dc0fd42d78373f34900 100644 (file)
@@ -34,7 +34,7 @@ import {
 } from '../../../../src/types/index.js'
 import { StandardParametersKey } from '../../../../src/types/ocpp/Configuration.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../../tests/helpers/TestLifecycleHelpers.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import {
index c673820ef8fb50d1b26091197c0d691023bbb5ce..480f29c7a4803271e438de3f6a2eecc0ec69ad32 100644 (file)
@@ -1,8 +1,9 @@
-import { CircularBuffer } from 'mnemonist'
 /**
  * @file Tests for MessageChannelUtils
  * @description Unit tests for charging station worker message builders and performance statistics conversion
  */
+
+import { CircularBuffer } from 'mnemonist'
 import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
index c05a7b756db017438b7b85679a520e79f8445a02..87e6dacaa363f6d6b41a5ec84b2510a960cca3a0 100644 (file)
@@ -1,9 +1,10 @@
-import { hoursToMilliseconds, hoursToSeconds } from 'date-fns'
-import { CircularBuffer } from 'mnemonist'
 /**
  * @file Tests for Utils
  * @description Unit tests for general utility functions
  */
+
+import { hoursToMilliseconds, hoursToSeconds } from 'date-fns'
+import { CircularBuffer } from 'mnemonist'
 import assert from 'node:assert/strict'
 import { randomInt } from 'node:crypto'
 import process from 'node:process'