- Add edge case tests for BaseError, OCPPError, ElectricUtils, AsyncLock, StatisticUtils
- Replace real timers with withMockTimers in UIServerSecurity rate limiter tests
- Add 'Date' to MockableTimerAPI type for Date.now() mocking support
- Factor duplicated OCPP 2.0 beforeEach into createOCPP20RequestTestContext factory
- Remove verbose 29-line JSDoc block in Utils.test.ts
import type { ChargingStation } from '../../../../src/charging-station/index.js'
-import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
-import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import {
BootReasonEnumType,
type OCPP20BootNotificationRequest,
OCPP20RequestCommand,
- OCPPVersion,
} from '../../../../src/types/index.js'
import { type ChargingStationType } from '../../../../src/types/ocpp/2.0/Common.js'
-import { Constants } from '../../../../src/utils/index.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import {
TEST_CHARGE_POINT_MODEL,
TEST_CHARGE_POINT_SERIAL_NUMBER,
TEST_CHARGE_POINT_VENDOR,
- TEST_CHARGING_STATION_BASE_NAME,
TEST_FIRMWARE_VERSION,
} from '../../ChargingStationTestConstants.js'
-import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
import {
- createTestableOCPP20RequestService,
+ createOCPP20RequestTestContext,
type TestableOCPP20RequestService,
} from './OCPP20TestUtils.js'
await describe('B01 - Cold Boot Charging Station', async () => {
- let mockResponseService: OCPP20ResponseService
- let requestService: OCPP20RequestService
let testableRequestService: TestableOCPP20RequestService
let station: ChargingStation
beforeEach(() => {
- mockResponseService = new OCPP20ResponseService()
- requestService = new OCPP20RequestService(mockResponseService)
- testableRequestService = createTestableOCPP20RequestService(requestService)
- const { station: createdStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 3,
- evseConfiguration: { evsesCount: 3 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ const context = createOCPP20RequestTestContext({
stationInfo: {
chargePointModel: TEST_CHARGE_POINT_MODEL,
chargePointSerialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
chargePointVendor: TEST_CHARGE_POINT_VENDOR,
firmwareVersion: TEST_FIRMWARE_VERSION,
- ocppStrictCompliance: false,
- ocppVersion: OCPPVersion.VERSION_201,
},
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
})
- station = createdStation
+ testableRequestService = context.testableRequestService
+ station = context.station
})
afterEach(() => {
import type { ChargingStation } from '../../../../src/charging-station/index.js'
-import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
-import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import {
type OCPP20HeartbeatRequest,
OCPP20RequestCommand,
OCPPVersion,
} from '../../../../src/types/index.js'
-import { Constants, has } from '../../../../src/utils/index.js'
+import { has } from '../../../../src/utils/index.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import {
TEST_CHARGE_POINT_MODEL,
TEST_CHARGE_POINT_SERIAL_NUMBER,
TEST_CHARGE_POINT_VENDOR,
- TEST_CHARGING_STATION_BASE_NAME,
TEST_FIRMWARE_VERSION,
} from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
import {
- createTestableOCPP20RequestService,
+ createOCPP20RequestTestContext,
type TestableOCPP20RequestService,
} from './OCPP20TestUtils.js'
await describe('G02 - Heartbeat', async () => {
- let mockResponseService: OCPP20ResponseService
- let requestService: OCPP20RequestService
let testableRequestService: TestableOCPP20RequestService
let station: ChargingStation
beforeEach(() => {
- mockResponseService = new OCPP20ResponseService()
- requestService = new OCPP20RequestService(mockResponseService)
- testableRequestService = createTestableOCPP20RequestService(requestService)
- const { station: createdStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 3,
- evseConfiguration: { evsesCount: 3 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ const context = createOCPP20RequestTestContext({
stationInfo: {
chargePointModel: TEST_CHARGE_POINT_MODEL,
chargePointSerialNumber: TEST_CHARGE_POINT_SERIAL_NUMBER,
chargePointVendor: TEST_CHARGE_POINT_VENDOR,
firmwareVersion: TEST_FIRMWARE_VERSION,
- ocppStrictCompliance: false,
- ocppVersion: OCPPVersion.VERSION_201,
},
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
})
- station = createdStation
+ testableRequestService = context.testableRequestService
+ station = context.station
})
afterEach(() => {
import type { ChargingStation } from '../../../../src/charging-station/index.js'
-import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
-import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import {
OCPP20ConnectorStatusEnumType,
OCPP20RequestCommand,
type OCPP20StatusNotificationRequest,
- OCPPVersion,
} from '../../../../src/types/index.js'
-import { Constants } from '../../../../src/utils/index.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import {
TEST_FIRMWARE_VERSION,
TEST_STATUS_CHARGE_POINT_VENDOR,
TEST_STATUS_CHARGING_STATION_BASE_NAME,
} from '../../ChargingStationTestConstants.js'
-import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
import {
- createTestableOCPP20RequestService,
+ createOCPP20RequestTestContext,
type TestableOCPP20RequestService,
} from './OCPP20TestUtils.js'
await describe('G01 - Status Notification', async () => {
- let mockResponseService: OCPP20ResponseService
- let requestService: OCPP20RequestService
let testableRequestService: TestableOCPP20RequestService
let station: ChargingStation
beforeEach(() => {
- mockResponseService = new OCPP20ResponseService()
- requestService = new OCPP20RequestService(mockResponseService)
- testableRequestService = createTestableOCPP20RequestService(requestService)
- const { station: createdStation } = createMockChargingStation({
+ const context = createOCPP20RequestTestContext({
baseName: TEST_STATUS_CHARGING_STATION_BASE_NAME,
- connectorsCount: 3,
- evseConfiguration: { evsesCount: 3 },
- heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
stationInfo: {
chargePointModel: TEST_STATUS_CHARGE_POINT_MODEL,
chargePointSerialNumber: TEST_STATUS_CHARGE_POINT_SERIAL_NUMBER,
chargePointVendor: TEST_STATUS_CHARGE_POINT_VENDOR,
firmwareVersion: TEST_FIRMWARE_VERSION,
- ocppStrictCompliance: false,
- ocppVersion: OCPPVersion.VERSION_201,
},
- websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
})
- station = createdStation
+ testableRequestService = context.testableRequestService
+ station = context.station
})
afterEach(() => {
import type { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
import type { ChargingStationWithCertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js'
-import type { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
import type { ConfigurationKey } from '../../../../src/types/ChargingStationOcppConfiguration.js'
import type { EmptyObject } from '../../../../src/types/EmptyObject.js'
import type {
OCPP20TransactionContext,
} from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import {
ConnectorStatusEnum,
HashAlgorithmEnumType,
station: ChargingStation
}
+/**
+ * Result of creating an OCPP 2.0 request test context.
+ * Contains all objects typically needed in beforeEach for request service tests.
+ */
+export interface OCPP20RequestTestContext {
+ readonly requestService: OCPP20RequestService
+ readonly station: ChargingStation
+ readonly testableRequestService: TestableOCPP20RequestService
+}
+
+/**
+ * Options for creating an OCPP 2.0 request test context.
+ */
+export interface OCPP20RequestTestContextOptions {
+ readonly baseName?: string
+ readonly stationInfo?: Record<string, unknown>
+}
+
/**
* Interface exposing private methods of OCPP20RequestService for testing.
* This allows type-safe testing without `as any` casts.
}
}
+/**
+ * Create a standard OCPP 2.0 request test context with response service, request service,
+ * testable wrapper, and mock charging station.
+ *
+ * Eliminates duplicated beforeEach setup across BootNotification, Heartbeat,
+ * and StatusNotification test files.
+ * @param options - Optional overrides for base name and station info
+ * @returns OCPP20RequestTestContext with all objects needed for testing
+ */
+export function createOCPP20RequestTestContext (
+ options: OCPP20RequestTestContextOptions = {}
+): OCPP20RequestTestContext {
+ const { baseName = TEST_CHARGING_STATION_BASE_NAME, stationInfo = {} } = options
+
+ const mockResponseService = new OCPP20ResponseService()
+ const requestService = new OCPP20RequestService(mockResponseService)
+ const testableRequestService = createTestableOCPP20RequestService(requestService)
+ const { station } = createMockChargingStation({
+ baseName,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ ...stationInfo,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+
+ return { requestService, station, testableRequestService }
+}
+
/**
* Create a testable wrapper for OCPP20RequestService that exposes private methods.
*
isValidCredential,
isValidNumberOfStations,
} from '../../../src/charging-station/ui-server/UIServerSecurity.js'
-import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
-import { waitForStreamFlush } from './UIServerTestUtils.js'
-
-const RATE_WINDOW_EXPIRY_DELAY_MS = 110
+import { standardCleanup, withMockTimers } from '../../helpers/TestLifecycleHelpers.js'
await describe('UIServerSecurity', async () => {
afterEach(() => {
expect(limiter('192.168.1.1')).toBe(false)
})
- await it('should reset window after time expires', async () => {
- limiter = createRateLimiter(2, 100)
- limiter('10.0.0.1')
- limiter('10.0.0.1')
- expect(limiter('10.0.0.1')).toBe(false)
-
- await waitForStreamFlush(RATE_WINDOW_EXPIRY_DELAY_MS)
-
- expect(limiter('10.0.0.1')).toBe(true)
+ await it('should reset window after time expires', async t => {
+ await withMockTimers(t, ['Date', 'setTimeout'], () => {
+ limiter = createRateLimiter(2, 100)
+ limiter('10.0.0.1')
+ limiter('10.0.0.1')
+ expect(limiter('10.0.0.1')).toBe(false)
+ t.mock.timers.tick(101)
+ expect(limiter('10.0.0.1')).toBe(true)
+ })
})
await it('should reject new IPs when at max tracked capacity', () => {
expect(limiter('192.168.1.2')).toBe(true)
})
- await it('should cleanup expired entries when at capacity', async () => {
- limiter = createRateLimiter(10, 50, 2)
- expect(limiter('192.168.1.1')).toBe(true)
- expect(limiter('192.168.1.2')).toBe(true)
-
- await waitForStreamFlush(60)
-
- expect(limiter('192.168.1.3')).toBe(true)
+ await it('should cleanup expired entries when at capacity', async t => {
+ await withMockTimers(t, ['Date', 'setTimeout'], () => {
+ limiter = createRateLimiter(10, 50, 2)
+ expect(limiter('192.168.1.1')).toBe(true)
+ expect(limiter('192.168.1.2')).toBe(true)
+ t.mock.timers.tick(51)
+ expect(limiter('192.168.1.3')).toBe(true)
+ })
})
})
expect(baseError).toBeInstanceOf(BaseError)
expect(baseError.message).toBe('Test message')
})
+
+ await it('should be an instance of Error', () => {
+ const baseError = new BaseError()
+ expect(baseError instanceof Error).toBe(true)
+ })
+
+ await it('should not set cause since constructor only accepts message', () => {
+ const baseError = new BaseError('wrapper')
+ expect(baseError.cause).toBeUndefined()
+ })
+
+ await it('should contain stack trace with class name', () => {
+ const baseError = new BaseError()
+ expect(baseError.stack?.includes('BaseError')).toBe(true)
+ })
+
+ await it('should set date close to current time', () => {
+ const beforeNow = Date.now()
+ const baseError = new BaseError()
+ const afterNow = Date.now()
+ expect(baseError.date.getTime() >= beforeNow - 1000).toBe(true)
+ expect(baseError.date.getTime() <= afterNow + 1000).toBe(true)
+ })
+
+ await it('should set name to subclass name when extended', () => {
+ class TestSubError extends BaseError {}
+
+ const testSubError = new TestSubError()
+ expect(testSubError.name).toBe('TestSubError')
+ expect(testSubError).toBeInstanceOf(BaseError)
+ expect(testSubError).toBeInstanceOf(Error)
+ })
})
import { expect } from '@std/expect'
import { afterEach, describe, it } from 'node:test'
+import { BaseError } from '../../src/exception/BaseError.js'
import { OCPPError } from '../../src/exception/OCPPError.js'
-import { ErrorType } from '../../src/types/index.js'
+import { ErrorType, RequestCommand } from '../../src/types/index.js'
import { Constants } from '../../src/utils/Constants.js'
import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
expect(ocppError.cause).toBeUndefined()
expect(ocppError.date).toBeInstanceOf(Date)
})
+
+ await it('should be an instance of BaseError and Error', () => {
+ const ocppError = new OCPPError(ErrorType.GENERIC_ERROR, 'test')
+ expect(ocppError).toBeInstanceOf(BaseError)
+ expect(ocppError).toBeInstanceOf(Error)
+ })
+
+ await it('should create instance with custom command', () => {
+ const ocppError = new OCPPError(ErrorType.GENERIC_ERROR, 'test', RequestCommand.HEARTBEAT)
+ expect(ocppError.command).toBe(RequestCommand.HEARTBEAT)
+ })
+
+ await it('should create instance with custom details', () => {
+ const details = { key: 'value' }
+ const ocppError = new OCPPError(ErrorType.GENERIC_ERROR, 'test', undefined, details)
+ expect(ocppError.details).toStrictEqual({ key: 'value' })
+ })
+
+ await it('should handle different error types', () => {
+ const ocppError = new OCPPError(ErrorType.NOT_IMPLEMENTED, 'test')
+ expect(ocppError.code).toBe(ErrorType.NOT_IMPLEMENTED)
+ })
+
+ await it('should propagate cause through BaseError', () => {
+ const cause = new Error('root')
+ const ocppError = new OCPPError(ErrorType.INTERNAL_ERROR, 'wrapped')
+ expect(cause.message).toBe('root')
+ expect(ocppError.cause).toBeUndefined()
+ })
+
+ await it('should set name to OCPPError', () => {
+ const ocppError = new OCPPError(ErrorType.GENERIC_ERROR, 'test')
+ expect(ocppError.name).toBe('OCPPError')
+ })
})
/**
* Timer APIs that can be mocked in tests
*/
-export type MockableTimerAPI = 'setImmediate' | 'setInterval' | 'setTimeout'
+export type MockableTimerAPI = 'Date' | 'setImmediate' | 'setInterval' | 'setTimeout'
/**
* Configuration options for TestTimerHelper
await Promise.all(promises)
expect(executed).toStrictEqual(new Array(runs).fill(0).map((_, i) => ++i))
})
+
+ await it('should propagate error thrown in exclusive function', async () => {
+ await expect(
+ AsyncLock.runExclusive(AsyncLockType.configuration, () => {
+ throw new Error('test error')
+ })
+ ).rejects.toThrow('test error')
+ })
+
+ await it('should release lock after error and allow subsequent runs', async () => {
+ await expect(
+ AsyncLock.runExclusive(AsyncLockType.configuration, () => {
+ throw new Error('first fails')
+ })
+ ).rejects.toThrow('first fails')
+
+ let recovered = false
+ await AsyncLock.runExclusive(AsyncLockType.configuration, () => {
+ recovered = true
+ })
+ expect(recovered).toBe(true)
+ })
+
+ await it('should isolate locks across different lock types', async () => {
+ const order: string[] = []
+ const configPromise = AsyncLock.runExclusive(AsyncLockType.configuration, async () => {
+ await new Promise(resolve => {
+ setTimeout(resolve, 50)
+ })
+ order.push('configuration')
+ })
+ const perfPromise = AsyncLock.runExclusive(AsyncLockType.performance, () => {
+ order.push('performance')
+ })
+ await Promise.all([configPromise, perfPromise])
+ expect(order[0]).toBe('performance')
+ expect(order[1]).toBe('configuration')
+ })
+
+ await it('should return value from exclusive function', async () => {
+ const result = await AsyncLock.runExclusive(AsyncLockType.configuration, () => 42)
+ expect(result).toBe(42)
+ })
})
await it('should calculate AC amperage per phase from power', () => {
expect(ACElectricUtils.amperagePerPhaseFromPower(3, 690, 230)).toBe(1)
})
+ await it('should return 0 for DC amperage when voltage is zero', () => {
+ expect(DCElectricUtils.amperage(1000, 0)).toBe(0)
+ })
+ await it('should return 0 for AC amperage when voltage is zero', () => {
+ expect(ACElectricUtils.amperageTotalFromPower(1000, 0)).toBe(0)
+ })
+ await it('should return 0 for AC amperage when cosPhi is zero', () => {
+ expect(ACElectricUtils.amperageTotalFromPower(1000, 230, 0)).toBe(0)
+ })
+ await it('should return 0 for AC amperage per phase when phases is zero or negative', () => {
+ expect(ACElectricUtils.amperagePerPhaseFromPower(0, 690, 230)).toBe(0)
+ expect(ACElectricUtils.amperagePerPhaseFromPower(-1, 690, 230)).toBe(0)
+ })
+ await it('should round AC power per phase with non-unity cosPhi', () => {
+ expect(ACElectricUtils.powerPerPhase(230, 10, 0.85)).toBe(1955)
+ })
+ await it('should round DC amperage when power is not evenly divisible by voltage', () => {
+ expect(DCElectricUtils.amperage(100, 3)).toBe(33)
+ })
+ await it('should calculate DC power as voltage times current', () => {
+ expect(DCElectricUtils.power(0, 10)).toBe(0)
+ expect(DCElectricUtils.power(400, 0)).toBe(0)
+ })
})
await it('should calculate standard deviation of array', () => {
expect(std([0.25, 4.75, 3.05, 6.04, 1.01, 2.02, 5.03])).toBe(2.1879050645374383)
})
+ await it('should return 0 for standard deviation of empty or single-element array', () => {
+ expect(std([])).toBe(0)
+ expect(std([42])).toBe(0)
+ })
+ await it('should throw TypeError for non-array input to std', () => {
+ expect(() => std(null as unknown as number[])).toThrow(TypeError)
+ expect(() => std(undefined as unknown as number[])).toThrow(TypeError)
+ })
+ await it('should throw TypeError for non-array input to percentile', () => {
+ expect(() => percentile(null as unknown as number[], 50)).toThrow(TypeError)
+ })
+ await it('should throw RangeError for out-of-range percentile', () => {
+ expect(() => percentile([1, 2, 3], -1)).toThrow(RangeError)
+ expect(() => percentile([1, 2, 3], 101)).toThrow(RangeError)
+ })
+ await it('should accept pre-computed average parameter', () => {
+ const data = [0.25, 4.75, 3.05, 6.04, 1.01, 2.02, 5.03]
+ const avg = average(data)
+ expect(std(data, avg)).toBe(std(data))
+ })
})
})
await it('should sleep for specified milliseconds using timer mock', async t => {
- /**
- * Timer mock pattern for testing asynchronous timer-based operations.
- * Uses Node.js test module's built-in timer mocking API.
- * @example
- * // Enable timer mocking with setTimeout API
- * t.mock.timers.enable({ apis: ['setTimeout'] })
- *
- * try {
- * const delay = 10
- * const sleepPromise = sleep(delay)
- * // Advance mocked timers by specified milliseconds
- * t.mock.timers.tick(delay)
- * const timeout = await sleepPromise
- * expect(timeout).toBeDefined()
- * } finally {
- * // Always reset timers after test to prevent side effects
- * t.mock.timers.reset()
- * }
- * @description
- * This pattern demonstrates:
- * - `t.mock.timers.enable()`: Activates timer mocking for specified APIs (e.g., setTimeout)
- * - `t.mock.timers.tick()`: Advances the mocked internal clock by the given milliseconds
- * - `t.mock.timers.reset()`: Clears mocked timers; use in finally block to ensure cleanup
- *
- * Useful for testing:
- * - Timeout and interval-based logic
- * - Async functions that depend on timing
- * - Avoiding slow tests caused by actual delays
- */
await withMockTimers(t, ['setTimeout'], async () => {
const delay = 10
const sleepPromise = sleep(delay)