From: Jérôme Benoit Date: Mon, 16 Mar 2026 21:36:24 +0000 (+0100) Subject: test: harmonize event listener test pattern across OCPP command test files (#1730) X-Git-Tag: ocpp-server@v3.1.1~7 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=8eb7710860a4cdb00e0a4ac86d6b633abf604488;p=e-mobility-charging-stations-simulator.git test: harmonize event listener test pattern across OCPP command test files (#1730) * 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) --- diff --git a/tests/TEST_STYLE_GUIDE.md b/tests/TEST_STYLE_GUIDE.md index 01c7c5aa..b263e7d7 100644 --- a/tests/TEST_STYLE_GUIDE.md +++ b/tests/TEST_STYLE_GUIDE.md @@ -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 + 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, '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 diff --git a/tests/charging-station/AutomaticTransactionGenerator.test.ts b/tests/charging-station/AutomaticTransactionGenerator.test.ts index e6eb6943..1fa13994 100644 --- a/tests/charging-station/AutomaticTransactionGenerator.test.ts +++ b/tests/charging-station/AutomaticTransactionGenerator.test.ts @@ -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 @@ -120,7 +121,7 @@ function mockInternalStartConnector (atg: AutomaticTransactionGenerator): void { internalStartConnector: (...args: unknown[]) => Promise } atgPrivate.internalStartConnector = async () => { - await Promise.resolve() + await flushMicrotasks() } } diff --git a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts index 902dcd08..8a7eb717 100644 --- a/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts +++ b/tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts @@ -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) diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts index b1056702..d5038ac4 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Firmware.test.ts @@ -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 + + beforeEach(() => { + listenerService = new OCPP16IncomingRequestService() + updateFirmwareMock = mock.method( + listenerService as unknown as { + updateFirmwareSimulation: (chargingStation: unknown) => Promise + }, + '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 + }, + '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 + }) + }) }) diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts index e39be637..6abb6702 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts @@ -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 + 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 + } + ).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) + }) + }) }) diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts index 16c34915..9a4e4a8d 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts @@ -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 + }) + }) }) diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts index f60a0e6f..fa4d612c 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts @@ -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['station'] + let requestHandlerMock: ReturnType + + 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) + }) + }) }) diff --git a/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts b/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts index a0bc79a4..ea99cf8b 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16TestUtils.ts @@ -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 + 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. diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts index 9965b580..22f82e65 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts @@ -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 - }, - '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 - }, - '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 - }, - 'sendNotifyCustomerInformation', - () => Promise.resolve() - ) + await describe('CUSTOMER_INFORMATION event listener', async () => { + let incomingRequestService: OCPP20IncomingRequestService + let notifyMock: ReturnType + + beforeEach(() => { + incomingRequestService = new OCPP20IncomingRequestService() + notifyMock = mock.method( + incomingRequestService as unknown as { + sendNotifyCustomerInformation: ( + chargingStation: ChargingStation, + requestId: number + ) => Promise + }, + 'sendNotifyCustomerInformation', + () => Promise.resolve() + ) + }) - const request: OCPP20CustomerInformationRequest = { - clear: false, - report: false, - requestId: 22, - } - const response: OCPP20CustomerInformationResponse = { - status: CustomerInformationStatusEnumType.Rejected, - } + 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 - }, - '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 + }, + '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() + }) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts index cd25ca4d..c84ae05d 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts @@ -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 + + beforeEach(() => { + listenerService = new OCPP20IncomingRequestService() + sendNotifyMock = mock.method( + listenerService as unknown as { + sendNotifyReportRequest: ( + chargingStation: ChargingStation, + request: OCPP20GetBaseReportRequest, + response: OCPP20GetBaseReportResponse + ) => Promise + }, + '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 + }, + '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() + }) + }) }) 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 316e93fd..12c16ed3 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts @@ -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 - 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 - }, - '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 () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts index fe078261..95cabf84 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetVariables.test.ts @@ -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, diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts index 257fde13..ee1e0164 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RemoteStartAuth.test.ts @@ -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 diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts index d77ee04d..338d0e78 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts @@ -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 + 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) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts index 8052900f..edee1bc9 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts @@ -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 - 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 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) }) 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 02b100d6..c7033fa9 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts @@ -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) }) 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 f3830d69..3d92d451 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts @@ -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 - 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 - }, - '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 => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 5e98605f..38ddf2a5 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -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 { diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index c210d14e..16596814 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -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 + 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. diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts index 432b424f..0958d034 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts @@ -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 { diff --git a/tests/utils/MessageChannelUtils.test.ts b/tests/utils/MessageChannelUtils.test.ts index c673820e..480f29c7 100644 --- a/tests/utils/MessageChannelUtils.test.ts +++ b/tests/utils/MessageChannelUtils.test.ts @@ -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' diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index c05a7b75..87e6daca 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -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'