From bcaca7f15365ab0c41b83c1ba9ebba1e4fa2bbde Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 27 Feb 2026 20:33:44 +0100 Subject: [PATCH] refactor(tests): modularize ChargingStationTestUtils Split monolithic 1122-line utility file into focused modules: - mocks/MockWebSocket.ts (188 lines) - WebSocket mock class - mocks/MockCaches.ts (110 lines) - Cache mocks - helpers/StationHelpers.ts (834 lines) - Station creation/cleanup - ChargingStationTestUtils.ts (32 lines) - Barrel export All existing imports preserved via re-exports All 291 tests passing --- .../ChargingStationTestUtils.ts | 1126 +---------------- .../helpers/StationHelpers.ts | 834 ++++++++++++ tests/charging-station/mocks/MockCaches.ts | 110 ++ tests/charging-station/mocks/MockWebSocket.ts | 188 +++ 4 files changed, 1150 insertions(+), 1108 deletions(-) create mode 100644 tests/charging-station/helpers/StationHelpers.ts create mode 100644 tests/charging-station/mocks/MockCaches.ts create mode 100644 tests/charging-station/mocks/MockWebSocket.ts diff --git a/tests/charging-station/ChargingStationTestUtils.ts b/tests/charging-station/ChargingStationTestUtils.ts index 1dd952ee..8f88d154 100644 --- a/tests/charging-station/ChargingStationTestUtils.ts +++ b/tests/charging-station/ChargingStationTestUtils.ts @@ -12,1111 +12,21 @@ * @see tests/charging-station/ChargingStationTestConstants.ts for test constants */ -import type { RawData } from 'ws' - -import { EventEmitter } from 'node:events' - -import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' -import type { - ChargingStationConfiguration, - ChargingStationTemplate, - ConnectorStatus, - EvseStatus, - StopTransactionReason, -} from '../../src/types/index.js' - -import { - AvailabilityType, - ConnectorStatusEnum, - OCPPVersion, - RegistrationStatusEnumType, -} from '../../src/types/index.js' -import { - TEST_CHARGING_STATION_BASE_NAME, - TEST_CHARGING_STATION_HASH_ID, - TEST_HEARTBEAT_INTERVAL_SECONDS, -} from './ChargingStationTestConstants.js' - -/** - * WebSocket ready states matching ws module - */ -export enum WebSocketReadyState { - CONNECTING = 0, - OPEN = 1, - CLOSING = 2, - CLOSED = 3, -} - -/** - * Collection of all mocks used in a real ChargingStation instance - */ -export interface ChargingStationMocks { - /** Mock file system operations */ - fileSystem: { - readFiles: Map - writtenFiles: Map - } - - /** Mock IdTagsCache */ - idTagsCache: MockIdTagsCache - - /** Mock parentPort messages */ - parentPortMessages: unknown[] - - /** Mock SharedLRUCache */ - sharedLRUCache: MockSharedLRUCache - - /** Mock WebSocket connection */ - webSocket: MockWebSocket -} - -/** - * Options for creating a mock ChargingStation instance - */ -export interface MockChargingStationOptions { - /** Auto-start the station on creation */ - autoStart?: boolean - - /** Station base name */ - baseName?: string - - /** Initial boot notification status */ - bootNotificationStatus?: RegistrationStatusEnumType - - /** Number of connectors (default: 2) */ - connectorsCount?: number - - /** Number of EVSEs (enables EVSE mode if > 0) */ - evsesCount?: number - - /** Heartbeat interval in seconds */ - heartbeatInterval?: number - - /** Station index (default: 1) */ - index?: number - - /** OCPP version (default: '1.6') */ - ocppVersion?: OCPPVersion - - /** Whether station is started */ - started?: boolean - - /** Template file path (mocked) */ - templateFile?: string -} - -/** - * Result of creating a mock ChargingStation instance - */ -export interface MockChargingStationResult { - /** All mocks used by the station for assertion */ - mocks: ChargingStationMocks - - /** The actual ChargingStation instance */ - station: ChargingStation -} - -/** - * Mock IdTagsCache for testing - * - * Provides mock RFID tag management without file system access. - */ -export class MockIdTagsCache { - private static instance: MockIdTagsCache | null = null - private readonly idTagsMap = new Map() - - public static getInstance (): MockIdTagsCache { - MockIdTagsCache.instance ??= new MockIdTagsCache() - return MockIdTagsCache.instance - } - - public static resetInstance (): void { - MockIdTagsCache.instance = null - } - - public clear (): void { - this.idTagsMap.clear() - } - - public deleteIdTags (file: string): boolean { - return this.idTagsMap.delete(file) - } - - public getIdTag (): string { - return 'TEST-TAG-001' - } - - public getIdTags (file: string): string[] | undefined { - return this.idTagsMap.get(file) - } - - public setIdTags (file: string, idTags: string[]): void { - this.idTagsMap.set(file, idTags) - } -} - -/** - * Mock SharedLRUCache for testing - * - * Provides in-memory caching without requiring Bootstrap initialization. - */ -export class MockSharedLRUCache { - private static instance: MockSharedLRUCache | null = null - private readonly configurations = new Map() - private readonly templates = new Map() - - public static getInstance (): MockSharedLRUCache { - MockSharedLRUCache.instance ??= new MockSharedLRUCache() - return MockSharedLRUCache.instance - } - - public static resetInstance (): void { - MockSharedLRUCache.instance = null - } - - public clear (): void { - this.templates.clear() - this.configurations.clear() - } - - public deleteChargingStationConfiguration (hash: string): void { - this.configurations.delete(hash) - } - - public deleteChargingStationTemplate (hash: string): void { - this.templates.delete(hash) - } - - public getChargingStationConfiguration (hash: string): ChargingStationConfiguration | undefined { - return this.configurations.get(hash) - } - - public getChargingStationTemplate (hash: string): ChargingStationTemplate | undefined { - return this.templates.get(hash) - } - - public hasChargingStationConfiguration (hash: string): boolean { - return this.configurations.has(hash) - } - - public hasChargingStationTemplate (hash: string): boolean { - return this.templates.has(hash) - } - - public setChargingStationConfiguration (config: ChargingStationConfiguration): void { - if (config.configurationHash != null) { - this.configurations.set(config.configurationHash, config) - } - } - - public setChargingStationTemplate (template: ChargingStationTemplate): void { - if (template.templateHash != null) { - this.templates.set(template.templateHash, template) - } - } -} - -/** - * MockWebSocket class with message capture capability - * - * Simulates a WebSocket connection for testing without actual network I/O. - * Captures all sent messages for assertion in tests. - * @example - * ```typescript - * const mockWs = new MockWebSocket('ws://localhost:8080') - * mockWs.send('["2","uuid","BootNotification",{}]') - * expect(mockWs.sentMessages).toContain('["2","uuid","BootNotification",{}]') - * ``` - */ -export class MockWebSocket extends EventEmitter { - /** Close code received */ - public closeCode?: number - - /** Close reason received */ - public closeReason?: string - - /** Negotiated protocol */ - public protocol = 'ocpp1.6' - - /** WebSocket ready state */ - public readyState: WebSocketReadyState = WebSocketReadyState.OPEN - - /** Binary messages sent via send() */ - public sentBinaryMessages: Buffer[] = [] - - /** All messages sent via send() */ - public sentMessages: string[] = [] - - /** URL this socket was connected to */ - public readonly url: string - - constructor (url: string | URL, _protocols?: string | string[]) { - super() - this.url = typeof url === 'string' ? url : url.toString() - } - - /** - * Clear all captured messages - */ - public clearMessages (): void { - this.sentMessages = [] - this.sentBinaryMessages = [] - } - - /** - * Close the WebSocket connection - * @param code - Close status code - * @param reason - Close reason string - */ - public close (code?: number, reason?: string): void { - this.closeCode = code - this.closeReason = reason - this.readyState = WebSocketReadyState.CLOSING - // Emit close event asynchronously like real WebSocket - setImmediate(() => { - this.readyState = WebSocketReadyState.CLOSED - this.emit('close', code ?? 1000, Buffer.from(reason ?? '')) - }) - } - - /** - * Get the last message sent - * @returns The last sent message or undefined if none - */ - public getLastSentMessage (): string | undefined { - return this.sentMessages[this.sentMessages.length - 1] - } - - /** - * Get all sent messages parsed as JSON - * @returns Array of parsed JSON messages - */ - public getSentMessagesAsJson (): unknown[] { - return this.sentMessages.map(msg => JSON.parse(msg) as unknown) - } - - /** - * Ping the server (no-op in mock) - */ - public ping (): void { - // No-op for tests - } - - /** - * Pong response (no-op in mock) - */ - public pong (): void { - // No-op for tests - } - - /** - * Send a message through the WebSocket - * @param data - Message to send - */ - public send (data: Buffer | string): void { - if (this.readyState !== WebSocketReadyState.OPEN) { - throw new Error('WebSocket is not open') - } - if (typeof data === 'string') { - this.sentMessages.push(data) - } else { - this.sentBinaryMessages.push(data) - } - } - - /** - * Simulate connection close from server - * @param code - Close code - * @param reason - Close reason - */ - public simulateClose (code = 1000, reason = ''): void { - this.readyState = WebSocketReadyState.CLOSED - this.emit('close', code, Buffer.from(reason)) - } - - /** - * Simulate a WebSocket error - * @param error - Error to emit - */ - public simulateError (error: Error): void { - this.emit('error', error) - } - - /** - * Simulate receiving a message from the server - * @param data - Message data to receive - */ - public simulateMessage (data: RawData | string): void { - const buffer = typeof data === 'string' ? Buffer.from(data) : data - this.emit('message', buffer, false) - } - - /** - * Simulate the connection opening - */ - public simulateOpen (): void { - this.readyState = WebSocketReadyState.OPEN - this.emit('open') - } - - /** - * Simulate a ping from the server - * @param data - Optional ping data buffer - */ - public simulatePing (data?: Buffer): void { - this.emit('ping', data ?? Buffer.alloc(0)) - } - - /** - * Simulate a pong from the server - * @param data - Optional pong data buffer - */ - public simulatePong (data?: Buffer): void { - this.emit('pong', data ?? Buffer.alloc(0)) - } - - /** - * Terminate the connection immediately - */ - public terminate (): void { - this.readyState = WebSocketReadyState.CLOSED - this.emit('close', 1006, Buffer.from('Connection terminated')) - } -} - -/** - * Cleanup a ChargingStation instance to prevent test pollution - * - * Stops all timers, removes event listeners, and clears state. - * Call this in test afterEach() hooks. - * @param station - ChargingStation instance to clean up - * @example - * ```typescript - * afterEach(() => { - * cleanupChargingStation(station) - * }) - * ``` - */ -export function cleanupChargingStation (station: ChargingStation): void { - // Stop heartbeat timer - if (station.heartbeatSetInterval != null) { - clearInterval(station.heartbeatSetInterval) - station.heartbeatSetInterval = undefined - } - - // Close WebSocket connection - if (station.wsConnection != null) { - try { - station.closeWSConnection() - } catch { - // Ignore errors during cleanup - } - } - - // Clear all event listeners - try { - station.removeAllListeners() - } catch { - // Ignore errors during cleanup - } - - // Clear connector transaction state - for (const connectorStatus of station.connectors.values()) { - if (connectorStatus.transactionSetInterval != null) { - clearInterval(connectorStatus.transactionSetInterval) - connectorStatus.transactionSetInterval = undefined - } - } - - // Clear EVSE connector transaction state - for (const evseStatus of station.evses.values()) { - for (const connectorStatus of evseStatus.connectors.values()) { - if (connectorStatus.transactionSetInterval != null) { - clearInterval(connectorStatus.transactionSetInterval) - connectorStatus.transactionSetInterval = undefined - } - } - } - - // Clear requests map - station.requests.clear() - - // Reset mock singleton instances - MockSharedLRUCache.resetInstance() - MockIdTagsCache.resetInstance() -} - -/** - * Creates a minimal mock ChargingStation-like object for testing - * - * Due to the complexity of the ChargingStation class and its deep dependencies - * (Bootstrap singleton, file system, WebSocket, worker threads), this factory - * creates a lightweight stub that implements the essential ChargingStation interface - * without requiring the full initialization chain. - * - * This is useful for testing code that depends on ChargingStation methods - * without needing the full OCPP protocol stack. - * @param options - Configuration options for the mock charging station - * @returns Object with mock station instance and mocks for assertion - * @example - * ```typescript - * const { station, mocks } = createMockChargingStation({ connectorsCount: 2 }) - * expect(station.connectors.size).toBe(3) // 0 + 2 connectors - * station.wsConnection = mocks.webSocket - * mocks.webSocket.simulateMessage('["3","uuid",{}]') - * ``` - */ -export function createMockChargingStation ( - options: MockChargingStationOptions = {} -): MockChargingStationResult { - const { - autoStart = false, - baseName = TEST_CHARGING_STATION_BASE_NAME, - bootNotificationStatus = RegistrationStatusEnumType.ACCEPTED, - connectorsCount = 2, - evsesCount = 0, - heartbeatInterval = TEST_HEARTBEAT_INTERVAL_SECONDS, - index = 1, - ocppVersion = OCPPVersion.VERSION_16, - started = false, - templateFile = 'test-template.json', - } = options - - // Initialize mocks - const mockWebSocket = new MockWebSocket(`ws://localhost:8080/${baseName}-${String(index)}`) - const mockSharedLRUCache = MockSharedLRUCache.getInstance() - const mockIdTagsCache = MockIdTagsCache.getInstance() - const parentPortMessages: unknown[] = [] - const writtenFiles = new Map() - const readFiles = new Map() - - // Create connectors map - const connectors = new Map() - const useEvses = evsesCount > 0 - - // Connector 0 always exists - connectors.set(0, createConnectorStatus(0)) - - // Add numbered connectors - for (let i = 1; i <= connectorsCount; i++) { - connectors.set(i, createConnectorStatus(i)) - } - - // Create EVSEs map if applicable - const evses = new Map() - if (useEvses) { - // EVSE 0 contains connector 0 (station-level status for availability checks) - const evse0Connectors = new Map() - const connector0Status = connectors.get(0) - if (connector0Status != null) { - evse0Connectors.set(0, connector0Status) - } - evses.set(0, { - availability: AvailabilityType.Operative, - connectors: evse0Connectors, - }) - - // Create EVSEs 1..N with their respective connectors - const connectorsPerEvse = Math.ceil(connectorsCount / evsesCount) - for (let evseId = 1; evseId <= evsesCount; evseId++) { - const evseConnectors = new Map() - const startId = (evseId - 1) * connectorsPerEvse + 1 - const endId = Math.min(startId + connectorsPerEvse - 1, connectorsCount) - - for (let connId = startId; connId <= endId; connId++) { - const connectorStatus = connectors.get(connId) - if (connectorStatus != null) { - evseConnectors.set(connId, connectorStatus) - } - } - - evses.set(evseId, { - availability: AvailabilityType.Operative, - connectors: evseConnectors, - }) - } - } - - // Create requests map - const requests = new Map() - - // Create the station object that mimics ChargingStation - const station = { - // Reservation methods (mock implementations - eslint disabled for test utilities) - - addReservation (reservation: Record): void { - // Check if reservation with same ID exists and remove it - const existingReservation = this.getReservationBy( - 'reservationId', - (reservation as Record).reservationId - ) - if (existingReservation != null) { - this.removeReservation(existingReservation, 'REPLACE_EXISTING') - } - const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) - if (connectorStatus != null) { - connectorStatus.reservation = reservation - } - }, - automaticTransactionGenerator: undefined, - bootNotificationRequest: undefined, - - bootNotificationResponse: { - currentTime: new Date(), - interval: heartbeatInterval, - status: bootNotificationStatus, - }, - - bufferMessage (message: string): void { - this.messageQueue.push(message) - }, - closeWSConnection (): void { - if (this.wsConnection != null) { - this.wsConnection.close() - this.wsConnection = null - } - }, - connectors, - - async delete (deleteConfiguration = true): Promise { - if (this.started) { - await this.stop() - } - this.requests.clear() - this.connectors.clear() - this.evses.clear() - // Note: deleteConfiguration controls file deletion in real implementation - // Mock doesn't have file system access, so parameter is unused - }, - // Event emitter methods (minimal implementation) - emit: () => true, - // Empty implementations for interface compatibility - // eslint-disable-next-line @typescript-eslint/no-empty-function - emitChargingStationEvent: () => {}, - evses, - getAuthorizeRemoteTxRequests (): boolean { - return false // Default to false in mock - }, - getConnectionTimeout (): number { - return 30000 - }, - getConnectorIdByTransactionId (transactionId: number | string | undefined): number | undefined { - if (transactionId == null) { - return undefined - } else if (useEvses) { - for (const evseStatus of evses.values()) { - for (const [connectorId, connectorStatus] of evseStatus.connectors) { - if (connectorStatus.transactionId === transactionId) { - return connectorId - } - } - } - } else { - for (const connectorId of connectors.keys()) { - if (this.getConnectorStatus(connectorId)?.transactionId === transactionId) { - return connectorId - } - } - } - return undefined - }, - // Methods - getConnectorStatus (connectorId: number): ConnectorStatus | undefined { - if (useEvses) { - for (const evseStatus of evses.values()) { - if (evseStatus.connectors.has(connectorId)) { - return evseStatus.connectors.get(connectorId) - } - } - return undefined - } - return connectors.get(connectorId) - }, - getEnergyActiveImportRegisterByConnectorId (connectorId: number, rounded = false): number { - const connectorStatus = this.getConnectorStatus(connectorId) - if (connectorStatus == null) { - return 0 - } - const value = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 - return rounded ? Math.round(value) : value - }, - getEnergyActiveImportRegisterByTransactionId ( - transactionId: number | string | undefined, - rounded = false - ): number { - const connectorId = this.getConnectorIdByTransactionId(transactionId) - if (connectorId == null) { - return 0 - } - return this.getEnergyActiveImportRegisterByConnectorId(connectorId, rounded) - }, - getEvseIdByConnectorId (connectorId: number): number | undefined { - if (!useEvses) { - return undefined - } - for (const [evseId, evseStatus] of evses) { - if (evseStatus.connectors.has(connectorId)) { - return evseId - } - } - return undefined - }, - getEvseIdByTransactionId (transactionId: number | string | undefined): number | undefined { - if (transactionId == null) { - return undefined - } else if (useEvses) { - for (const [evseId, evseStatus] of evses) { - for (const connectorStatus of evseStatus.connectors.values()) { - if (connectorStatus.transactionId === transactionId) { - return evseId - } - } - } - } - return undefined - }, - getEvseStatus (evseId: number): EvseStatus | undefined { - return evses.get(evseId) - }, - getHeartbeatInterval (): number { - return heartbeatInterval * 1000 // Return in ms - }, - getLocalAuthListEnabled (): boolean { - return false // Default to false in mock - }, - getNumberOfConnectors (): number { - if (useEvses) { - let numberOfConnectors = 0 - for (const [evseId, evseStatus] of evses) { - if (evseId > 0) { - numberOfConnectors += evseStatus.connectors.size - } - } - return numberOfConnectors - } - return connectors.has(0) ? connectors.size - 1 : connectors.size - }, - getNumberOfEvses (): number { - return evses.has(0) ? evses.size - 1 : evses.size - }, - getNumberOfRunningTransactions (): number { - let numberOfRunningTransactions = 0 - if (useEvses) { - for (const [evseId, evseStatus] of evses) { - if (evseId === 0) { - continue - } - for (const connectorStatus of evseStatus.connectors.values()) { - if (connectorStatus.transactionStarted === true) { - ++numberOfRunningTransactions - } - } - } - } else { - for (const connectorId of connectors.keys()) { - if ( - connectorId > 0 && - this.getConnectorStatus(connectorId)?.transactionStarted === true - ) { - ++numberOfRunningTransactions - } - } - } - return numberOfRunningTransactions - }, - getReservationBy (filterKey: string, value: unknown): Record | undefined { - if (useEvses) { - for (const evseStatus of evses.values()) { - for (const connectorStatus of evseStatus.connectors.values()) { - if (connectorStatus.reservation?.[filterKey] === value) { - return connectorStatus.reservation - } - } - } - } else { - for (const connectorStatus of connectors.values()) { - if (connectorStatus.reservation?.[filterKey] === value) { - return connectorStatus.reservation - } - } - } - return undefined - }, - getTransactionIdTag (transactionId: number): string | undefined { - if (useEvses) { - for (const evseStatus of evses.values()) { - for (const connectorStatus of evseStatus.connectors.values()) { - if (connectorStatus.transactionId === transactionId) { - return connectorStatus.transactionIdTag - } - } - } - } else { - for (const connectorId of connectors.keys()) { - if (this.getConnectorStatus(connectorId)?.transactionId === transactionId) { - return this.getConnectorStatus(connectorId)?.transactionIdTag - } - } - } - return undefined - }, - getWebSocketPingInterval (): number { - return 30 - }, - hasConnector (connectorId: number): boolean { - if (useEvses) { - for (const evseStatus of evses.values()) { - if (evseStatus.connectors.has(connectorId)) { - return true - } - } - return false - } - return connectors.has(connectorId) - }, - - // Getters - get hasEvses (): boolean { - return useEvses - }, - - heartbeatSetInterval: undefined as NodeJS.Timeout | undefined, - - idTagsCache: mockIdTagsCache as unknown, - - inAcceptedState (): boolean { - return this.bootNotificationResponse.status === RegistrationStatusEnumType.ACCEPTED - }, - - // Core properties - index, - - inPendingState (): boolean { - return this.bootNotificationResponse.status === RegistrationStatusEnumType.PENDING - }, - inRejectedState (): boolean { - return this.bootNotificationResponse.status === RegistrationStatusEnumType.REJECTED - }, - - inUnknownState (): boolean { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return this.bootNotificationResponse?.status == null - }, - - isChargingStationAvailable (): boolean { - return this.getConnectorStatus(0)?.availability === AvailabilityType.Operative - }, - - isConnectorAvailable (connectorId: number): boolean { - return ( - connectorId > 0 && - this.getConnectorStatus(connectorId)?.availability === AvailabilityType.Operative - ) - }, - - isConnectorReservable (reservationId: number, idTag?: string, connectorId?: number): boolean { - if (connectorId === 0) { - return false - } - const reservation = this.getReservationBy('reservationId', reservationId) - return reservation == null - }, - - isWebSocketConnectionOpened (): boolean { - return this.wsConnection?.readyState === WebSocketReadyState.OPEN - }, - - listenerCount: () => 0, - - logPrefix (): string { - return `${this.stationInfo.chargingStationId} |` - }, - - messageQueue: [] as string[], - - ocppConfiguration: { - configurationKey: [], - }, - - on: () => station, - - once: () => station, - - performanceStatistics: undefined, - - powerDivider: 1, - - removeAllListeners: () => station, - - removeListener: () => station, - - removeReservation (reservation: Record, _reason?: string): void { - const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) - if (connectorStatus != null) { - delete connectorStatus.reservation - } - }, - requests, - - restartHeartbeat (): void { - this.stopHeartbeat() - this.startHeartbeat() - }, - - restartMeterValues (connectorId: number, interval: number): void { - this.stopMeterValues(connectorId) - this.startMeterValues(connectorId, interval) - }, - - restartWebSocketPing (): void { - /* empty */ - }, - - saveOcppConfiguration (): void { - /* empty */ - }, - start (): void { - this.started = true - this.starting = false - }, - started, - - startHeartbeat (): void { - this.heartbeatSetInterval ??= setInterval(() => { - /* empty */ - }, 30000) - }, - starting: false, - - startMeterValues (connectorId: number, interval: number): void { - const connector = this.getConnectorStatus(connectorId) - if (connector != null) { - connector.transactionSetInterval = setInterval(() => { - /* empty */ - }, interval) - } - }, - - startTxUpdatedInterval (connectorId: number, interval: number): void { - if ( - this.stationInfo.ocppVersion === OCPPVersion.VERSION_20 || - this.stationInfo.ocppVersion === OCPPVersion.VERSION_201 - ) { - const connector = this.getConnectorStatus(connectorId) - if (connector != null) { - connector.transactionTxUpdatedSetInterval = setInterval(() => { - /* empty */ - }, interval) - } - } - }, - - startWebSocketPing (): void { - /* empty */ - }, - // Station info - stationInfo: { - autoStart, - baseName, - chargingStationId: `${baseName}-${index.toString().padStart(5, '0')}`, - hashId: TEST_CHARGING_STATION_HASH_ID, - maximumAmperage: 32, - maximumPower: 22000, - ocppVersion, - remoteAuthorization: true, - templateIndex: index, - templateName: templateFile, - }, - - async stop (reason?: StopTransactionReason, stopTransactions?: boolean): Promise { - if (this.started && !this.stopping) { - this.stopping = true - // Simulate async stop behavior (immediate resolution for tests) - await Promise.resolve() - this.closeWSConnection() - delete this.bootNotificationResponse - this.started = false - this.stopping = false - } - }, - - stopHeartbeat (): void { - if (this.heartbeatSetInterval != null) { - clearInterval(this.heartbeatSetInterval) - delete this.heartbeatSetInterval - } - }, - stopMeterValues (connectorId: number): void { - const connector = this.getConnectorStatus(connectorId) - if (connector?.transactionSetInterval != null) { - clearInterval(connector.transactionSetInterval) - delete connector.transactionSetInterval - } - }, - stopping: false, - - stopTxUpdatedInterval (connectorId: number): void { - const connector = this.getConnectorStatus(connectorId) - if (connector?.transactionTxUpdatedSetInterval != null) { - clearInterval(connector.transactionTxUpdatedSetInterval) - delete connector.transactionTxUpdatedSetInterval - } - }, - templateFile, - wsConnection: null as MockWebSocket | null, - wsConnectionRetryCount: 0, - } - - // Set up mock WebSocket connection - station.wsConnection = mockWebSocket - - const mocks: ChargingStationMocks = { - fileSystem: { - readFiles, - writtenFiles, - }, - idTagsCache: mockIdTagsCache, - parentPortMessages, - sharedLRUCache: mockSharedLRUCache, - webSocket: mockWebSocket, - } - - return { - mocks, - station: station as unknown as ChargingStation, - } -} - -/** - * Create a mock template for testing - * @param overrides - Template properties to override - * @returns ChargingStationTemplate for testing - */ -export function createMockTemplate ( - overrides: Partial = {} -): ChargingStationTemplate { - return { - baseName: TEST_CHARGING_STATION_BASE_NAME, - chargePointModel: 'Test Model', - chargePointVendor: 'Test Vendor', - numberOfConnectors: 2, - ocppVersion: OCPPVersion.VERSION_16, - ...overrides, - } as ChargingStationTemplate -} - -/** - * Reset a ChargingStation to its initial state - * - * Resets all connector statuses, clears transactions, and restores defaults. - * Useful between test cases when reusing a station instance. - * @param station - ChargingStation instance to reset - * @example - * ```typescript - * beforeEach(() => { - * resetChargingStationState(station) - * }) - * ``` - */ -export function resetChargingStationState (station: ChargingStation): void { - // Reset station state - station.started = false - station.starting = false - - // Reset boot notification response - if (station.bootNotificationResponse != null) { - station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED - station.bootNotificationResponse.currentTime = new Date() - } - - // Reset connector statuses - for (const [connectorId, connectorStatus] of station.connectors) { - resetConnectorStatus(connectorStatus, connectorId === 0) - } - - // Reset EVSE connector statuses - for (const evseStatus of station.evses.values()) { - evseStatus.availability = AvailabilityType.Operative - for (const connectorStatus of evseStatus.connectors.values()) { - resetConnectorStatus(connectorStatus, false) - } - } - - // Clear requests - station.requests.clear() - - // Clear WebSocket messages if using MockWebSocket - const ws = station.wsConnection as unknown as MockWebSocket | null - if (ws != null && 'clearMessages' in ws) { - ws.clearMessages() - } -} - -/** - * Wait for a condition to be true with timeout - * @param condition - Function that returns true when condition is met - * @param timeout - Maximum time to wait in milliseconds - * @param interval - Check interval in milliseconds - */ -export async function waitForCondition ( - condition: () => boolean, - timeout = 1000, - interval = 10 -): Promise { - const startTime = Date.now() - while (!condition()) { - if (Date.now() - startTime > timeout) { - throw new Error('Timeout waiting for condition') - } - await new Promise(resolve => setTimeout(resolve, interval)) - } -} - -/** - * Create a connector status object with default values - * @param connectorId - Connector ID - * @returns Default connector status - */ -function createConnectorStatus (connectorId: number): ConnectorStatus { - return { - availability: AvailabilityType.Operative, - bootStatus: ConnectorStatusEnum.Available, - chargingProfiles: [], - energyActiveImportRegisterValue: 0, - idTagAuthorized: false, - idTagLocalAuthorized: false, - MeterValues: [], - status: ConnectorStatusEnum.Available, - transactionEnergyActiveImportRegisterValue: 0, - transactionId: undefined, - transactionIdTag: undefined, - transactionRemoteStarted: false, - transactionStart: undefined, - transactionStarted: false, - } as unknown as ConnectorStatus -} - -/** - * Reset a single connector status to default values - * @param status - Connector status object to reset - * @param isConnectorZero - Whether this is connector 0 (station-level) - */ -function resetConnectorStatus (status: ConnectorStatus, isConnectorZero: boolean): void { - status.availability = AvailabilityType.Operative - status.status = isConnectorZero ? undefined : ConnectorStatusEnum.Available - status.transactionId = undefined - status.transactionIdTag = undefined - status.transactionStart = undefined - status.transactionStarted = false - status.transactionRemoteStarted = false - status.idTagAuthorized = false - status.idTagLocalAuthorized = false - status.energyActiveImportRegisterValue = 0 - status.transactionEnergyActiveImportRegisterValue = 0 - - // Clear transaction interval - if (status.transactionSetInterval != null) { - clearInterval(status.transactionSetInterval) - status.transactionSetInterval = undefined - } -} +// Re-export all mock classes +export { MockWebSocket, WebSocketReadyState } from './mocks/MockWebSocket.js' +export { MockIdTagsCache, MockSharedLRUCache } from './mocks/MockCaches.js' + +// Re-export all helper functions and types +export type { + ChargingStationMocks, + MockChargingStationOptions, + MockChargingStationResult, +} from './helpers/StationHelpers.js' + +export { + cleanupChargingStation, + createMockChargingStation, + createMockTemplate, + resetChargingStationState, + waitForCondition, +} from './helpers/StationHelpers.js' diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts new file mode 100644 index 00000000..34c40c3c --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -0,0 +1,834 @@ +/** + * Station helper functions for testing + * + * Factory functions to create mock ChargingStation instances with isolated dependencies. + */ + +import type { ChargingStation } from '../../../src/charging-station/ChargingStation.js' +import type { + ChargingStationConfiguration, + ChargingStationTemplate, + ConnectorStatus, + EvseStatus, + StopTransactionReason, +} from '../../../src/types/index.js' + +import { + AvailabilityType, + ConnectorStatusEnum, + OCPPVersion, + RegistrationStatusEnumType, +} from '../../../src/types/index.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_CHARGING_STATION_HASH_ID, + TEST_HEARTBEAT_INTERVAL_SECONDS, +} from '../ChargingStationTestConstants.js' +import { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js' +import { MockWebSocket, WebSocketReadyState } from '../mocks/MockWebSocket.js' + +/** + * Collection of all mocks used in a real ChargingStation instance + */ +export interface ChargingStationMocks { + /** Mock file system operations */ + fileSystem: { + readFiles: Map + writtenFiles: Map + } + + /** Mock IdTagsCache */ + idTagsCache: MockIdTagsCache + + /** Mock parentPort messages */ + parentPortMessages: unknown[] + + /** Mock SharedLRUCache */ + sharedLRUCache: MockSharedLRUCache + + /** Mock WebSocket connection */ + webSocket: MockWebSocket +} + +/** + * Options for creating a mock ChargingStation instance + */ +export interface MockChargingStationOptions { + /** Auto-start the station on creation */ + autoStart?: boolean + + /** Station base name */ + baseName?: string + + /** Initial boot notification status */ + bootNotificationStatus?: RegistrationStatusEnumType + + /** Number of connectors (default: 2) */ + connectorsCount?: number + + /** Number of EVSEs (enables EVSE mode if > 0) */ + evsesCount?: number + + /** Heartbeat interval in seconds */ + heartbeatInterval?: number + + /** Station index (default: 1) */ + index?: number + + /** OCPP version (default: '1.6') */ + ocppVersion?: OCPPVersion + + /** Whether station is started */ + started?: boolean + + /** Template file path (mocked) */ + templateFile?: string +} + +/** + * Result of creating a mock ChargingStation instance + */ +export interface MockChargingStationResult { + /** All mocks used by the station for assertion */ + mocks: ChargingStationMocks + + /** The actual ChargingStation instance */ + station: ChargingStation +} + +/** + * Cleanup a ChargingStation instance to prevent test pollution + * + * Stops all timers, removes event listeners, and clears state. + * Call this in test afterEach() hooks. + * @param station - ChargingStation instance to clean up + * @example + * ```typescript + * afterEach(() => { + * cleanupChargingStation(station) + * }) + * ``` + */ +export function cleanupChargingStation (station: ChargingStation): void { + // Stop heartbeat timer + if (station.heartbeatSetInterval != null) { + clearInterval(station.heartbeatSetInterval) + station.heartbeatSetInterval = undefined + } + + // Close WebSocket connection + if (station.wsConnection != null) { + try { + station.closeWSConnection() + } catch { + // Ignore errors during cleanup + } + } + + // Clear all event listeners + try { + station.removeAllListeners() + } catch { + // Ignore errors during cleanup + } + + // Clear connector transaction state + for (const connectorStatus of station.connectors.values()) { + if (connectorStatus.transactionSetInterval != null) { + clearInterval(connectorStatus.transactionSetInterval) + connectorStatus.transactionSetInterval = undefined + } + } + + // Clear EVSE connector transaction state + for (const evseStatus of station.evses.values()) { + for (const connectorStatus of evseStatus.connectors.values()) { + if (connectorStatus.transactionSetInterval != null) { + clearInterval(connectorStatus.transactionSetInterval) + connectorStatus.transactionSetInterval = undefined + } + } + } + + // Clear requests map + station.requests.clear() + + // Reset mock singleton instances + MockSharedLRUCache.resetInstance() + MockIdTagsCache.resetInstance() +} + +/** + * Creates a minimal mock ChargingStation-like object for testing + * + * Due to the complexity of the ChargingStation class and its deep dependencies + * (Bootstrap singleton, file system, WebSocket, worker threads), this factory + * creates a lightweight stub that implements the essential ChargingStation interface + * without requiring the full initialization chain. + * + * This is useful for testing code that depends on ChargingStation methods + * without needing the full OCPP protocol stack. + * @param options - Configuration options for the mock charging station + * @returns Object with mock station instance and mocks for assertion + * @example + * ```typescript + * const { station, mocks } = createMockChargingStation({ connectorsCount: 2 }) + * expect(station.connectors.size).toBe(3) // 0 + 2 connectors + * station.wsConnection = mocks.webSocket + * mocks.webSocket.simulateMessage('["3","uuid",{}]') + * ``` + */ +export function createMockChargingStation ( + options: MockChargingStationOptions = {} +): MockChargingStationResult { + const { + autoStart = false, + baseName = TEST_CHARGING_STATION_BASE_NAME, + bootNotificationStatus = RegistrationStatusEnumType.ACCEPTED, + connectorsCount = 2, + evsesCount = 0, + heartbeatInterval = TEST_HEARTBEAT_INTERVAL_SECONDS, + index = 1, + ocppVersion = OCPPVersion.VERSION_16, + started = false, + templateFile = 'test-template.json', + } = options + + // Initialize mocks + const mockWebSocket = new MockWebSocket(`ws://localhost:8080/${baseName}-${String(index)}`) + const mockSharedLRUCache = MockSharedLRUCache.getInstance() + const mockIdTagsCache = MockIdTagsCache.getInstance() + const parentPortMessages: unknown[] = [] + const writtenFiles = new Map() + const readFiles = new Map() + + // Create connectors map + const connectors = new Map() + const useEvses = evsesCount > 0 + + // Connector 0 always exists + connectors.set(0, createConnectorStatus(0)) + + // Add numbered connectors + for (let i = 1; i <= connectorsCount; i++) { + connectors.set(i, createConnectorStatus(i)) + } + + // Create EVSEs map if applicable + const evses = new Map() + if (useEvses) { + // EVSE 0 contains connector 0 (station-level status for availability checks) + const evse0Connectors = new Map() + const connector0Status = connectors.get(0) + if (connector0Status != null) { + evse0Connectors.set(0, connector0Status) + } + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: evse0Connectors, + }) + + // Create EVSEs 1..N with their respective connectors + const connectorsPerEvse = Math.ceil(connectorsCount / evsesCount) + for (let evseId = 1; evseId <= evsesCount; evseId++) { + const evseConnectors = new Map() + const startId = (evseId - 1) * connectorsPerEvse + 1 + const endId = Math.min(startId + connectorsPerEvse - 1, connectorsCount) + + for (let connId = startId; connId <= endId; connId++) { + const connectorStatus = connectors.get(connId) + if (connectorStatus != null) { + evseConnectors.set(connId, connectorStatus) + } + } + + evses.set(evseId, { + availability: AvailabilityType.Operative, + connectors: evseConnectors, + }) + } + } + + // Create requests map + const requests = new Map() + + // Create the station object that mimics ChargingStation + const station = { + // Reservation methods (mock implementations - eslint disabled for test utilities) + + addReservation (reservation: Record): void { + // Check if reservation with same ID exists and remove it + const existingReservation = this.getReservationBy( + 'reservationId', + (reservation as Record).reservationId + ) + if (existingReservation != null) { + this.removeReservation(existingReservation, 'REPLACE_EXISTING') + } + const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) + if (connectorStatus != null) { + connectorStatus.reservation = reservation + } + }, + automaticTransactionGenerator: undefined, + bootNotificationRequest: undefined, + + bootNotificationResponse: { + currentTime: new Date(), + interval: heartbeatInterval, + status: bootNotificationStatus, + }, + + bufferMessage (message: string): void { + this.messageQueue.push(message) + }, + closeWSConnection (): void { + if (this.wsConnection != null) { + this.wsConnection.close() + this.wsConnection = null + } + }, + connectors, + + async delete (deleteConfiguration = true): Promise { + if (this.started) { + await this.stop() + } + this.requests.clear() + this.connectors.clear() + this.evses.clear() + // Note: deleteConfiguration controls file deletion in real implementation + // Mock doesn't have file system access, so parameter is unused + }, + // Event emitter methods (minimal implementation) + emit: () => true, + // Empty implementations for interface compatibility + // eslint-disable-next-line @typescript-eslint/no-empty-function + emitChargingStationEvent: () => {}, + evses, + getAuthorizeRemoteTxRequests (): boolean { + return false // Default to false in mock + }, + getConnectionTimeout (): number { + return 30000 + }, + getConnectorIdByTransactionId (transactionId: number | string | undefined): number | undefined { + if (transactionId == null) { + return undefined + } else if (useEvses) { + for (const evseStatus of evses.values()) { + for (const [connectorId, connectorStatus] of evseStatus.connectors) { + if (connectorStatus.transactionId === transactionId) { + return connectorId + } + } + } + } else { + for (const connectorId of connectors.keys()) { + if (this.getConnectorStatus(connectorId)?.transactionId === transactionId) { + return connectorId + } + } + } + return undefined + }, + // Methods + getConnectorStatus (connectorId: number): ConnectorStatus | undefined { + if (useEvses) { + for (const evseStatus of evses.values()) { + if (evseStatus.connectors.has(connectorId)) { + return evseStatus.connectors.get(connectorId) + } + } + return undefined + } + return connectors.get(connectorId) + }, + getEnergyActiveImportRegisterByConnectorId (connectorId: number, rounded = false): number { + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + return 0 + } + const value = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 + return rounded ? Math.round(value) : value + }, + getEnergyActiveImportRegisterByTransactionId ( + transactionId: number | string | undefined, + rounded = false + ): number { + const connectorId = this.getConnectorIdByTransactionId(transactionId) + if (connectorId == null) { + return 0 + } + return this.getEnergyActiveImportRegisterByConnectorId(connectorId, rounded) + }, + getEvseIdByConnectorId (connectorId: number): number | undefined { + if (!useEvses) { + return undefined + } + for (const [evseId, evseStatus] of evses) { + if (evseStatus.connectors.has(connectorId)) { + return evseId + } + } + return undefined + }, + getEvseIdByTransactionId (transactionId: number | string | undefined): number | undefined { + if (transactionId == null) { + return undefined + } else if (useEvses) { + for (const [evseId, evseStatus] of evses) { + for (const connectorStatus of evseStatus.connectors.values()) { + if (connectorStatus.transactionId === transactionId) { + return evseId + } + } + } + } + return undefined + }, + getEvseStatus (evseId: number): EvseStatus | undefined { + return evses.get(evseId) + }, + getHeartbeatInterval (): number { + return heartbeatInterval * 1000 // Return in ms + }, + getLocalAuthListEnabled (): boolean { + return false // Default to false in mock + }, + getNumberOfConnectors (): number { + if (useEvses) { + let numberOfConnectors = 0 + for (const [evseId, evseStatus] of evses) { + if (evseId > 0) { + numberOfConnectors += evseStatus.connectors.size + } + } + return numberOfConnectors + } + return connectors.has(0) ? connectors.size - 1 : connectors.size + }, + getNumberOfEvses (): number { + return evses.has(0) ? evses.size - 1 : evses.size + }, + getNumberOfRunningTransactions (): number { + let numberOfRunningTransactions = 0 + if (useEvses) { + for (const [evseId, evseStatus] of evses) { + if (evseId === 0) { + continue + } + for (const connectorStatus of evseStatus.connectors.values()) { + if (connectorStatus.transactionStarted === true) { + ++numberOfRunningTransactions + } + } + } + } else { + for (const connectorId of connectors.keys()) { + if ( + connectorId > 0 && + this.getConnectorStatus(connectorId)?.transactionStarted === true + ) { + ++numberOfRunningTransactions + } + } + } + return numberOfRunningTransactions + }, + getReservationBy (filterKey: string, value: unknown): Record | undefined { + if (useEvses) { + for (const evseStatus of evses.values()) { + for (const connectorStatus of evseStatus.connectors.values()) { + if (connectorStatus.reservation?.[filterKey] === value) { + return connectorStatus.reservation + } + } + } + } else { + for (const connectorStatus of connectors.values()) { + if (connectorStatus.reservation?.[filterKey] === value) { + return connectorStatus.reservation + } + } + } + return undefined + }, + getTransactionIdTag (transactionId: number): string | undefined { + if (useEvses) { + for (const evseStatus of evses.values()) { + for (const connectorStatus of evseStatus.connectors.values()) { + if (connectorStatus.transactionId === transactionId) { + return connectorStatus.transactionIdTag + } + } + } + } else { + for (const connectorId of connectors.keys()) { + if (this.getConnectorStatus(connectorId)?.transactionId === transactionId) { + return this.getConnectorStatus(connectorId)?.transactionIdTag + } + } + } + return undefined + }, + getWebSocketPingInterval (): number { + return 30 + }, + hasConnector (connectorId: number): boolean { + if (useEvses) { + for (const evseStatus of evses.values()) { + if (evseStatus.connectors.has(connectorId)) { + return true + } + } + return false + } + return connectors.has(connectorId) + }, + + // Getters + get hasEvses (): boolean { + return useEvses + }, + + heartbeatSetInterval: undefined as NodeJS.Timeout | undefined, + + idTagsCache: mockIdTagsCache as unknown, + + inAcceptedState (): boolean { + return this.bootNotificationResponse.status === RegistrationStatusEnumType.ACCEPTED + }, + + // Core properties + index, + + inPendingState (): boolean { + return this.bootNotificationResponse.status === RegistrationStatusEnumType.PENDING + }, + inRejectedState (): boolean { + return this.bootNotificationResponse.status === RegistrationStatusEnumType.REJECTED + }, + + inUnknownState (): boolean { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return this.bootNotificationResponse?.status == null + }, + + isChargingStationAvailable (): boolean { + return this.getConnectorStatus(0)?.availability === AvailabilityType.Operative + }, + + isConnectorAvailable (connectorId: number): boolean { + return ( + connectorId > 0 && + this.getConnectorStatus(connectorId)?.availability === AvailabilityType.Operative + ) + }, + + isConnectorReservable (reservationId: number, idTag?: string, connectorId?: number): boolean { + if (connectorId === 0) { + return false + } + const reservation = this.getReservationBy('reservationId', reservationId) + return reservation == null + }, + + isWebSocketConnectionOpened (): boolean { + return this.wsConnection?.readyState === WebSocketReadyState.OPEN + }, + + listenerCount: () => 0, + + logPrefix (): string { + return `${this.stationInfo.chargingStationId} |` + }, + + messageQueue: [] as string[], + + ocppConfiguration: { + configurationKey: [], + }, + + on: () => station, + + once: () => station, + + performanceStatistics: undefined, + + powerDivider: 1, + + removeAllListeners: () => station, + + removeListener: () => station, + + removeReservation (reservation: Record, _reason?: string): void { + const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) + if (connectorStatus != null) { + delete connectorStatus.reservation + } + }, + requests, + + restartHeartbeat (): void { + this.stopHeartbeat() + this.startHeartbeat() + }, + + restartMeterValues (connectorId: number, interval: number): void { + this.stopMeterValues(connectorId) + this.startMeterValues(connectorId, interval) + }, + + restartWebSocketPing (): void { + /* empty */ + }, + + saveOcppConfiguration (): void { + /* empty */ + }, + start (): void { + this.started = true + this.starting = false + }, + started, + + startHeartbeat (): void { + this.heartbeatSetInterval ??= setInterval(() => { + /* empty */ + }, 30000) + }, + starting: false, + + startMeterValues (connectorId: number, interval: number): void { + const connector = this.getConnectorStatus(connectorId) + if (connector != null) { + connector.transactionSetInterval = setInterval(() => { + /* empty */ + }, interval) + } + }, + + startTxUpdatedInterval (connectorId: number, interval: number): void { + if ( + this.stationInfo.ocppVersion === OCPPVersion.VERSION_20 || + this.stationInfo.ocppVersion === OCPPVersion.VERSION_201 + ) { + const connector = this.getConnectorStatus(connectorId) + if (connector != null) { + connector.transactionTxUpdatedSetInterval = setInterval(() => { + /* empty */ + }, interval) + } + } + }, + + startWebSocketPing (): void { + /* empty */ + }, + // Station info + stationInfo: { + autoStart, + baseName, + chargingStationId: `${baseName}-${index.toString().padStart(5, '0')}`, + hashId: TEST_CHARGING_STATION_HASH_ID, + maximumAmperage: 32, + maximumPower: 22000, + ocppVersion, + remoteAuthorization: true, + templateIndex: index, + templateName: templateFile, + }, + + async stop (reason?: StopTransactionReason, stopTransactions?: boolean): Promise { + if (this.started && !this.stopping) { + this.stopping = true + // Simulate async stop behavior (immediate resolution for tests) + await Promise.resolve() + this.closeWSConnection() + delete this.bootNotificationResponse + this.started = false + this.stopping = false + } + }, + + stopHeartbeat (): void { + if (this.heartbeatSetInterval != null) { + clearInterval(this.heartbeatSetInterval) + delete this.heartbeatSetInterval + } + }, + stopMeterValues (connectorId: number): void { + const connector = this.getConnectorStatus(connectorId) + if (connector?.transactionSetInterval != null) { + clearInterval(connector.transactionSetInterval) + delete connector.transactionSetInterval + } + }, + stopping: false, + + stopTxUpdatedInterval (connectorId: number): void { + const connector = this.getConnectorStatus(connectorId) + if (connector?.transactionTxUpdatedSetInterval != null) { + clearInterval(connector.transactionTxUpdatedSetInterval) + delete connector.transactionTxUpdatedSetInterval + } + }, + templateFile, + wsConnection: null as MockWebSocket | null, + wsConnectionRetryCount: 0, + } + + // Set up mock WebSocket connection + station.wsConnection = mockWebSocket + + const mocks: ChargingStationMocks = { + fileSystem: { + readFiles, + writtenFiles, + }, + idTagsCache: mockIdTagsCache, + parentPortMessages, + sharedLRUCache: mockSharedLRUCache, + webSocket: mockWebSocket, + } + + return { + mocks, + station: station as unknown as ChargingStation, + } +} + +/** + * Create a mock template for testing + * @param overrides - Template properties to override + * @returns ChargingStationTemplate for testing + */ +export function createMockTemplate ( + overrides: Partial = {} +): ChargingStationTemplate { + return { + baseName: TEST_CHARGING_STATION_BASE_NAME, + chargePointModel: 'Test Model', + chargePointVendor: 'Test Vendor', + numberOfConnectors: 2, + ocppVersion: OCPPVersion.VERSION_16, + ...overrides, + } as ChargingStationTemplate +} + +/** + * Reset a ChargingStation to its initial state + * + * Resets all connector statuses, clears transactions, and restores defaults. + * Useful between test cases when reusing a station instance. + * @param station - ChargingStation instance to reset + * @example + * ```typescript + * beforeEach(() => { + * resetChargingStationState(station) + * }) + * ``` + */ +export function resetChargingStationState (station: ChargingStation): void { + // Reset station state + station.started = false + station.starting = false + + // Reset boot notification response + if (station.bootNotificationResponse != null) { + station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED + station.bootNotificationResponse.currentTime = new Date() + } + + // Reset connector statuses + for (const [connectorId, connectorStatus] of station.connectors) { + resetConnectorStatus(connectorStatus, connectorId === 0) + } + + // Reset EVSE connector statuses + for (const evseStatus of station.evses.values()) { + evseStatus.availability = AvailabilityType.Operative + for (const connectorStatus of evseStatus.connectors.values()) { + resetConnectorStatus(connectorStatus, false) + } + } + + // Clear requests + station.requests.clear() + + // Clear WebSocket messages if using MockWebSocket + const ws = station.wsConnection as unknown as MockWebSocket | null + if (ws != null && 'clearMessages' in ws) { + ws.clearMessages() + } +} + +/** + * Wait for a condition to be true with timeout + * @param condition - Function that returns true when condition is met + * @param timeout - Maximum time to wait in milliseconds + * @param interval - Check interval in milliseconds + */ +export async function waitForCondition ( + condition: () => boolean, + timeout = 1000, + interval = 10 +): Promise { + const startTime = Date.now() + while (!condition()) { + if (Date.now() - startTime > timeout) { + throw new Error('Timeout waiting for condition') + } + await new Promise(resolve => setTimeout(resolve, interval)) + } +} + +/** + * Create a connector status object with default values + * @param connectorId - Connector ID + * @returns Default connector status + */ +function createConnectorStatus (connectorId: number): ConnectorStatus { + return { + availability: AvailabilityType.Operative, + bootStatus: ConnectorStatusEnum.Available, + chargingProfiles: [], + energyActiveImportRegisterValue: 0, + idTagAuthorized: false, + idTagLocalAuthorized: false, + MeterValues: [], + status: ConnectorStatusEnum.Available, + transactionEnergyActiveImportRegisterValue: 0, + transactionId: undefined, + transactionIdTag: undefined, + transactionRemoteStarted: false, + transactionStart: undefined, + transactionStarted: false, + } as unknown as ConnectorStatus +} + +/** + * Reset a single connector status to default values + * @param status - Connector status object to reset + * @param isConnectorZero - Whether this is connector 0 (station-level) + */ +function resetConnectorStatus (status: ConnectorStatus, isConnectorZero: boolean): void { + status.availability = AvailabilityType.Operative + status.status = isConnectorZero ? undefined : ConnectorStatusEnum.Available + status.transactionId = undefined + status.transactionIdTag = undefined + status.transactionStart = undefined + status.transactionStarted = false + status.transactionRemoteStarted = false + status.idTagAuthorized = false + status.idTagLocalAuthorized = false + status.energyActiveImportRegisterValue = 0 + status.transactionEnergyActiveImportRegisterValue = 0 + + // Clear transaction interval + if (status.transactionSetInterval != null) { + clearInterval(status.transactionSetInterval) + status.transactionSetInterval = undefined + } +} diff --git a/tests/charging-station/mocks/MockCaches.ts b/tests/charging-station/mocks/MockCaches.ts new file mode 100644 index 00000000..b21ed2ac --- /dev/null +++ b/tests/charging-station/mocks/MockCaches.ts @@ -0,0 +1,110 @@ +/** + * Mock cache implementations for testing + * + * Provides in-memory caching without requiring Bootstrap initialization. + */ + +import type { + ChargingStationConfiguration, + ChargingStationTemplate, +} from '../../../src/types/index.js' + +/** + * Mock IdTagsCache for testing + * + * Provides mock RFID tag management without file system access. + */ +export class MockIdTagsCache { + private static instance: MockIdTagsCache | null = null + private readonly idTagsMap = new Map() + + public static getInstance (): MockIdTagsCache { + MockIdTagsCache.instance ??= new MockIdTagsCache() + return MockIdTagsCache.instance + } + + public static resetInstance (): void { + MockIdTagsCache.instance = null + } + + public clear (): void { + this.idTagsMap.clear() + } + + public deleteIdTags (file: string): boolean { + return this.idTagsMap.delete(file) + } + + public getIdTag (): string { + return 'TEST-TAG-001' + } + + public getIdTags (file: string): string[] | undefined { + return this.idTagsMap.get(file) + } + + public setIdTags (file: string, idTags: string[]): void { + this.idTagsMap.set(file, idTags) + } +} + +/** + * Mock SharedLRUCache for testing + * + * Provides in-memory caching without requiring Bootstrap initialization. + */ +export class MockSharedLRUCache { + private static instance: MockSharedLRUCache | null = null + private readonly configurations = new Map() + private readonly templates = new Map() + + public static getInstance (): MockSharedLRUCache { + MockSharedLRUCache.instance ??= new MockSharedLRUCache() + return MockSharedLRUCache.instance + } + + public static resetInstance (): void { + MockSharedLRUCache.instance = null + } + + public clear (): void { + this.templates.clear() + this.configurations.clear() + } + + public deleteChargingStationConfiguration (hash: string): void { + this.configurations.delete(hash) + } + + public deleteChargingStationTemplate (hash: string): void { + this.templates.delete(hash) + } + + public getChargingStationConfiguration (hash: string): ChargingStationConfiguration | undefined { + return this.configurations.get(hash) + } + + public getChargingStationTemplate (hash: string): ChargingStationTemplate | undefined { + return this.templates.get(hash) + } + + public hasChargingStationConfiguration (hash: string): boolean { + return this.configurations.has(hash) + } + + public hasChargingStationTemplate (hash: string): boolean { + return this.templates.has(hash) + } + + public setChargingStationConfiguration (config: ChargingStationConfiguration): void { + if (config.configurationHash != null) { + this.configurations.set(config.configurationHash, config) + } + } + + public setChargingStationTemplate (template: ChargingStationTemplate): void { + if (template.templateHash != null) { + this.templates.set(template.templateHash, template) + } + } +} diff --git a/tests/charging-station/mocks/MockWebSocket.ts b/tests/charging-station/mocks/MockWebSocket.ts new file mode 100644 index 00000000..04e75bc7 --- /dev/null +++ b/tests/charging-station/mocks/MockWebSocket.ts @@ -0,0 +1,188 @@ +/** + * WebSocket mock for testing + * + * Simulates a WebSocket connection for testing without actual network I/O. + * Captures all sent messages for assertion in tests. + */ + +import type { RawData } from 'ws' + +import { EventEmitter } from 'node:events' + +/** + * WebSocket ready states matching ws module + */ +export enum WebSocketReadyState { + CONNECTING = 0, + OPEN = 1, + CLOSING = 2, + CLOSED = 3, +} + +/** + * MockWebSocket class with message capture capability + * + * Simulates a WebSocket connection for testing without actual network I/O. + * Captures all sent messages for assertion in tests. + * @example + * ```typescript + * const mockWs = new MockWebSocket('ws://localhost:8080') + * mockWs.send('["2","uuid","BootNotification",{}]') + * expect(mockWs.sentMessages).toContain('["2","uuid","BootNotification",{}]') + * ``` + */ +export class MockWebSocket extends EventEmitter { + /** Close code received */ + public closeCode?: number + + /** Close reason received */ + public closeReason?: string + + /** Negotiated protocol */ + public protocol = 'ocpp1.6' + + /** WebSocket ready state */ + public readyState: WebSocketReadyState = WebSocketReadyState.OPEN + + /** Binary messages sent via send() */ + public sentBinaryMessages: Buffer[] = [] + + /** All messages sent via send() */ + public sentMessages: string[] = [] + + /** URL this socket was connected to */ + public readonly url: string + + constructor (url: string | URL, _protocols?: string | string[]) { + super() + this.url = typeof url === 'string' ? url : url.toString() + } + + /** + * Clear all captured messages + */ + public clearMessages (): void { + this.sentMessages = [] + this.sentBinaryMessages = [] + } + + /** + * Close the WebSocket connection + * @param code - Close status code + * @param reason - Close reason string + */ + public close (code?: number, reason?: string): void { + this.closeCode = code + this.closeReason = reason + this.readyState = WebSocketReadyState.CLOSING + // Emit close event asynchronously like real WebSocket + setImmediate(() => { + this.readyState = WebSocketReadyState.CLOSED + this.emit('close', code ?? 1000, Buffer.from(reason ?? '')) + }) + } + + /** + * Get the last message sent + * @returns The last sent message or undefined if none + */ + public getLastSentMessage (): string | undefined { + return this.sentMessages[this.sentMessages.length - 1] + } + + /** + * Get all sent messages parsed as JSON + * @returns Array of parsed JSON messages + */ + public getSentMessagesAsJson (): unknown[] { + return this.sentMessages.map(msg => JSON.parse(msg) as unknown) + } + + /** + * Ping the server (no-op in mock) + */ + public ping (): void { + // No-op for tests + } + + /** + * Pong response (no-op in mock) + */ + public pong (): void { + // No-op for tests + } + + /** + * Send a message through the WebSocket + * @param data - Message to send + */ + public send (data: Buffer | string): void { + if (this.readyState !== WebSocketReadyState.OPEN) { + throw new Error('WebSocket is not open') + } + if (typeof data === 'string') { + this.sentMessages.push(data) + } else { + this.sentBinaryMessages.push(data) + } + } + + /** + * Simulate connection close from server + * @param code - Close code + * @param reason - Close reason + */ + public simulateClose (code = 1000, reason = ''): void { + this.readyState = WebSocketReadyState.CLOSED + this.emit('close', code, Buffer.from(reason)) + } + + /** + * Simulate a WebSocket error + * @param error - Error to emit + */ + public simulateError (error: Error): void { + this.emit('error', error) + } + + /** + * Simulate receiving a message from the server + * @param data - Message data to receive + */ + public simulateMessage (data: RawData | string): void { + const buffer = typeof data === 'string' ? Buffer.from(data) : data + this.emit('message', buffer, false) + } + + /** + * Simulate the connection opening + */ + public simulateOpen (): void { + this.readyState = WebSocketReadyState.OPEN + this.emit('open') + } + + /** + * Simulate a ping from the server + * @param data - Optional ping data buffer + */ + public simulatePing (data?: Buffer): void { + this.emit('ping', data ?? Buffer.alloc(0)) + } + + /** + * Simulate a pong from the server + * @param data - Optional pong data buffer + */ + public simulatePong (data?: Buffer): void { + this.emit('pong', data ?? Buffer.alloc(0)) + } + + /** + * Terminate the connection immediately + */ + public terminate (): void { + this.readyState = WebSocketReadyState.CLOSED + this.emit('close', 1006, Buffer.from('Connection terminated')) + } +} -- 2.43.0