From: Jérôme Benoit Date: Wed, 5 Nov 2025 16:15:19 +0000 (+0100) Subject: feat(ocpp2): add RequestStopTransaction command X-Git-Tag: v2~51^2~22 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=8eb9f228230854bb45afd1296ff034c57e8c2f01;p=e-mobility-charging-stations-simulator.git feat(ocpp2): add RequestStopTransaction command Signed-off-by: Jérôme Benoit --- diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 38bb259d..cf1a9f81 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -396,7 +396,9 @@ export class ChargingStation extends EventEmitter { return Constants.DEFAULT_CONNECTION_TIMEOUT } - public getConnectorIdByTransactionId (transactionId: number | undefined): number | undefined { + public getConnectorIdByTransactionId ( + transactionId: number | string | undefined + ): number | undefined { if (transactionId == null) { return undefined } else if (this.hasEvses) { @@ -475,7 +477,7 @@ export class ChargingStation extends EventEmitter { } public getEnergyActiveImportRegisterByTransactionId ( - transactionId: number | undefined, + transactionId: number | string | undefined, rounded = false ): number { return this.getEnergyActiveImportRegister( diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 006203b1..2e2cdf67 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -16,6 +16,7 @@ import { DataEnumType, ErrorType, GenericDeviceModelStatusEnumType, + GenericStatus, GetVariableStatusEnumType, type IncomingRequestHandler, type JsonType, @@ -33,6 +34,8 @@ import { OCPP20RequestCommand, type OCPP20RequestStartTransactionRequest, type OCPP20RequestStartTransactionResponse, + type OCPP20RequestStopTransactionRequest, + type OCPP20RequestStopTransactionResponse, OCPP20RequiredVariableName, type OCPP20ResetRequest, type OCPP20ResetResponse, @@ -49,7 +52,13 @@ import { StopTransactionReason, } from '../../../types/index.js' import { StandardParametersKey } from '../../../types/ocpp/Configuration.js' -import { convertToIntOrNaN, generateUUID, isAsyncFunction, logger } from '../../../utils/index.js' +import { + convertToIntOrNaN, + generateUUID, + isAsyncFunction, + logger, + validateUUID, +} from '../../../utils/index.js' import { getConfigurationKey } from '../../ConfigurationKeyUtils.js' import { resetConnectorStatus } from '../../Helpers.js' import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js' @@ -93,6 +102,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, this.handleRequestRequestStartTransaction.bind(this) as unknown as IncomingRequestHandler, ], + [ + OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, + this.handleRequestRequestStopTransaction.bind(this) as unknown as IncomingRequestHandler, + ], [ OCPP20IncomingRequestCommand.RESET, this.handleRequestReset.bind(this) as unknown as IncomingRequestHandler, @@ -136,6 +149,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ) ), ], + [ + OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, + this.ajv.compile( + OCPP20ServiceUtils.parseJsonSchemaFile( + 'assets/json-schemas/ocpp/2.0/RequestStopTransactionRequest.json', + moduleName, + 'constructor' + ) + ), + ], [ OCPP20IncomingRequestCommand.RESET, this.ajv.compile( @@ -1062,6 +1085,67 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } } + private async handleRequestRequestStopTransaction ( + chargingStation: ChargingStation, + commandPayload: OCPP20RequestStopTransactionRequest + ): Promise { + const { transactionId } = commandPayload + logger.info( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestRequestStopTransaction: Remote stop transaction request received for transaction ID ${transactionId}` + ) + + if (!validateUUID(transactionId)) { + logger.warn( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${chargingStation.logPrefix()} ${moduleName}.handleRequestRequestStopTransaction: Invalid transaction ID format (expected UUID): ${transactionId}` + ) + return { + status: RequestStartStopStatusEnumType.Rejected, + } + } + + const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) + if (connectorId == null) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestRequestStopTransaction: Transaction ID ${transactionId} not found on any connector` + ) + return { + status: RequestStartStopStatusEnumType.Rejected, + } + } + + try { + const stopResponse = await OCPP20ServiceUtils.requestStopTransaction( + chargingStation, + connectorId + ) + + if (stopResponse.status === GenericStatus.Accepted) { + logger.info( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestRequestStopTransaction: Remote stop transaction accepted for transaction ID ${transactionId} on connector ${connectorId.toString()}` + ) + return { + status: RequestStartStopStatusEnumType.Accepted, + } + } + + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestRequestStopTransaction: Remote stop transaction rejected for transaction ID ${transactionId} on connector ${connectorId.toString()}` + ) + return { + status: RequestStartStopStatusEnumType.Rejected, + } + } catch (error) { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestRequestStopTransaction: Error occurred during remote stop transaction for transaction ID ${transactionId} on connector ${connectorId.toString()}:`, + error + ) + return { + status: RequestStartStopStatusEnumType.Rejected, + } + } + } + private handleRequestReset ( chargingStation: ChargingStation, commandPayload: OCPP20ResetRequest diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 926d4d17..a72b3a01 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -130,7 +130,6 @@ export class OCPP20RequestService extends OCPPRequestService { // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError(). throw new OCPPError( ErrorType.NOT_SUPPORTED, - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Unsupported OCPP command ${commandName}`, commandName, commandParams diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index ec76b2f8..b74628ff 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -16,6 +16,8 @@ import { type OCPP20NotifyReportResponse, OCPP20OptionalVariableName, OCPP20RequestCommand, + type OCPP20RequestStartTransactionResponse, + type OCPP20RequestStopTransactionResponse, type OCPP20StatusNotificationResponse, OCPPVersion, RegistrationStatusEnumType, @@ -116,6 +118,26 @@ export class OCPP20ResponseService extends OCPPResponseService { ) ), ], + [ + OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, + this.ajvIncomingRequest.compile( + OCPP20ServiceUtils.parseJsonSchemaFile( + 'assets/json-schemas/ocpp/2.0/RequestStartTransactionResponse.json', + moduleName, + 'constructor' + ) + ), + ], + [ + OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION, + this.ajvIncomingRequest.compile( + OCPP20ServiceUtils.parseJsonSchemaFile( + 'assets/json-schemas/ocpp/2.0/RequestStopTransactionResponse.json', + moduleName, + 'constructor' + ) + ), + ], ]) this.validatePayload = this.validatePayload.bind(this) } diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 424d6758..0f817843 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -2,8 +2,22 @@ import type { JSONSchemaType } from 'ajv' -import { type JsonType, OCPPVersion } from '../../../types/index.js' -import { OCPPServiceUtils } from '../OCPPServiceUtils.js' +import type { ChargingStation } from '../../../charging-station/index.js' + +import { + ConnectorStatusEnum, + type GenericResponse, + type JsonType, + OCPP20RequestCommand, + OCPP20TransactionEventEnumType, + type OCPP20TransactionEventRequest, + OCPP20TriggerReasonEnumType, + OCPPVersion, +} from '../../../types/index.js' +import { OCPP20ReasonEnumType } from '../../../types/ocpp/2.0/Transaction.js' +import { logger, validateUUID } from '../../../utils/index.js' +import { OCPPServiceUtils, sendAndSetConnectorStatus } from '../OCPPServiceUtils.js' +import { OCPP20Constants } from './OCPP20Constants.js' export class OCPP20ServiceUtils extends OCPPServiceUtils { public static enforceMessageLimits< @@ -94,4 +108,54 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils { methodName ) } + + public static requestStopTransaction = async ( + chargingStation: ChargingStation, + connectorId: number + ): Promise => { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + if (connectorStatus?.transactionStarted && connectorStatus.transactionId != null) { + // OCPP 2.0 validation: transactionId should be a valid UUID format + let transactionId: string + if (typeof connectorStatus.transactionId === 'string') { + transactionId = connectorStatus.transactionId + } else { + transactionId = connectorStatus.transactionId.toString() + logger.warn( + `${chargingStation.logPrefix()} OCPP20ServiceUtils.remoteStopTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0` + ) + } + + if (!validateUUID(transactionId)) { + logger.error( + `${chargingStation.logPrefix()} OCPP20ServiceUtils.remoteStopTransaction: Invalid transaction ID format (expected UUID): ${transactionId}` + ) + return OCPP20Constants.OCPP_RESPONSE_REJECTED + } + + const transactionEventRequest: OCPP20TransactionEventRequest = { + eventType: OCPP20TransactionEventEnumType.Ended, + evse: { + id: connectorId, + }, + seqNo: 0, // This should be managed by the transaction sequence + timestamp: new Date(), + transactionInfo: { + stoppedReason: OCPP20ReasonEnumType.Remote, + transactionId, + }, + triggerReason: OCPP20TriggerReasonEnumType.RemoteStop, + } + + await chargingStation.ocppRequestService.requestHandler< + OCPP20TransactionEventRequest, + OCPP20TransactionEventRequest + >(chargingStation, OCPP20RequestCommand.TRANSACTION_EVENT, transactionEventRequest) + + await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available) + + return OCPP20Constants.OCPP_RESPONSE_ACCEPTED + } + return OCPP20Constants.OCPP_RESPONSE_REJECTED + } } diff --git a/src/types/index.ts b/src/types/index.ts index 0884adb3..be3cb70c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -175,6 +175,7 @@ export { type OCPP20NotifyReportRequest, OCPP20RequestCommand, type OCPP20RequestStartTransactionRequest, + type OCPP20RequestStopTransactionRequest, type OCPP20ResetRequest, type OCPP20SetVariablesRequest, type OCPP20StatusNotificationRequest, @@ -187,6 +188,7 @@ export type { OCPP20HeartbeatResponse, OCPP20NotifyReportResponse, OCPP20RequestStartTransactionResponse, + OCPP20RequestStopTransactionResponse, OCPP20ResetResponse, OCPP20SetVariablesResponse, OCPP20StatusNotificationResponse, diff --git a/src/types/ocpp/2.0/Requests.ts b/src/types/ocpp/2.0/Requests.ts index 6971261f..1b7d3521 100644 --- a/src/types/ocpp/2.0/Requests.ts +++ b/src/types/ocpp/2.0/Requests.ts @@ -34,6 +34,7 @@ export enum OCPP20RequestCommand { HEARTBEAT = 'Heartbeat', NOTIFY_REPORT = 'NotifyReport', STATUS_NOTIFICATION = 'StatusNotification', + TRANSACTION_EVENT = 'TransactionEvent', } export interface OCPP20BootNotificationRequest extends JsonObject { @@ -81,6 +82,11 @@ export interface OCPP20RequestStartTransactionRequest extends JsonObject { remoteStartId: number } +export interface OCPP20RequestStopTransactionRequest extends JsonObject { + customData?: CustomDataType + transactionId: `${string}-${string}-${string}-${string}-${string}` +} + export interface OCPP20ResetRequest extends JsonObject { customData?: CustomDataType evseId?: number diff --git a/src/types/ocpp/2.0/Responses.ts b/src/types/ocpp/2.0/Responses.ts index 095d4b7d..f85cefd5 100644 --- a/src/types/ocpp/2.0/Responses.ts +++ b/src/types/ocpp/2.0/Responses.ts @@ -54,7 +54,13 @@ export interface OCPP20RequestStartTransactionResponse extends JsonObject { customData?: CustomDataType status: RequestStartStopStatusEnumType statusInfo?: StatusInfoType - transactionId?: string + transactionId?: `${string}-${string}-${string}-${string}-${string}` +} + +export interface OCPP20RequestStopTransactionResponse extends JsonObject { + customData?: CustomDataType + status: RequestStartStopStatusEnumType + statusInfo?: StatusInfoType } export interface OCPP20ResetResponse extends JsonObject { diff --git a/src/types/ocpp/2.0/Transaction.ts b/src/types/ocpp/2.0/Transaction.ts index e80b0acb..4cc21496 100644 --- a/src/types/ocpp/2.0/Transaction.ts +++ b/src/types/ocpp/2.0/Transaction.ts @@ -252,7 +252,7 @@ export interface OCPP20TransactionType extends JsonObject { remoteStartId?: number stoppedReason?: OCPP20ReasonEnumType timeSpentCharging?: number - transactionId: string + transactionId: `${string}-${string}-${string}-${string}-${string}` } export interface RelativeTimeIntervalType extends JsonObject { diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index fc20974b..87adf4e6 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -127,8 +127,11 @@ export const generateUUID = (): `${string}-${string}-${string}-${string}-${strin } export const validateUUID = ( - uuid: `${string}-${string}-${string}-${string}-${string}` + uuid: unknown ): uuid is `${string}-${string}-${string}-${string}-${string}` => { + if (typeof uuid !== 'string') { + return false + } return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test( uuid ) diff --git a/tests/ChargingStationFactory.ts b/tests/ChargingStationFactory.ts index b42839cb..05a0f1a5 100644 --- a/tests/ChargingStationFactory.ts +++ b/tests/ChargingStationFactory.ts @@ -78,6 +78,25 @@ export function createChargingStation (options: ChargingStationOptions = {}): Ch }, evses, getConnectionTimeout: () => connectionTimeout, + getConnectorIdByTransactionId: (transactionId: string) => { + // Search through connectors to find one with matching transaction ID + if (chargingStation.hasEvses) { + for (const evseStatus of chargingStation.evses.values()) { + for (const [connectorId, connectorStatus] of evseStatus.connectors.entries()) { + if (connectorStatus.transactionId === transactionId) { + return connectorId + } + } + } + } else { + for (const [connectorId, connectorStatus] of chargingStation.connectors.entries()) { + if (connectorStatus.transactionId === transactionId) { + return connectorId + } + } + } + return undefined + }, getConnectorStatus: (connectorId: number) => { if (chargingStation.hasEvses) { for (const evseStatus of chargingStation.evses.values()) { @@ -89,6 +108,19 @@ export function createChargingStation (options: ChargingStationOptions = {}): Ch } return chargingStation.connectors.get(connectorId) }, + getEvseIdByTransactionId: (transactionId: string) => { + // Search through EVSEs to find one with matching transaction ID + if (chargingStation.hasEvses) { + for (const [evseId, evseStatus] of chargingStation.evses.entries()) { + for (const connectorStatus of evseStatus.connectors.values()) { + if (connectorStatus.transactionId === transactionId) { + return evseId + } + } + } + } + return undefined + }, getHeartbeatInterval: () => heartbeatInterval, getWebSocketPingInterval: () => websocketPingInterval, hasEvses: useEvses, @@ -98,6 +130,13 @@ export function createChargingStation (options: ChargingStationOptions = {}): Ch chargingStation.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED ) }, + isConnectorAvailable: (connectorId: number) => { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + return ( + connectorStatus?.availability === AvailabilityType.Operative && + connectorStatus.status === ConnectorStatusEnum.Available + ) + }, logPrefix: (): string => { const stationId = chargingStation.stationInfo?.chargingStationId ?? diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts new file mode 100644 index 00000000..dcf17b50 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts @@ -0,0 +1,454 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { expect } from '@std/expect' +import { describe, it } from 'node:test' + +import type { + OCPP20RequestStartTransactionRequest, + OCPP20RequestStopTransactionRequest, + OCPP20TransactionEventRequest, +} from '../../../../src/types/index.js' + +import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' +import { + OCPP20RequestCommand, + OCPP20TransactionEventEnumType, + OCPP20TriggerReasonEnumType, + OCPPVersion, + RequestStartStopStatusEnumType, +} from '../../../../src/types/index.js' +import { + OCPP20IdTokenEnumType, + OCPP20ReasonEnumType, +} from '../../../../src/types/ocpp/2.0/Transaction.js' +import { Constants } from '../../../../src/utils/index.js' +import { createChargingStation } from '../../../ChargingStationFactory.js' +import { TEST_CHARGING_STATION_BASE_NAME } from './OCPP20TestConstants.js' +import { resetLimits, resetReportingValueSize } from './OCPP20TestUtils.js' + +await describe('E02 - Remote Stop Transaction', async () => { + // Track sent TransactionEvent requests for verification + let sentTransactionEvents: OCPP20TransactionEventRequest[] = [] + + const mockChargingStation = createChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: async (chargingStation: any, commandName: any, commandPayload: any) => { + // Mock successful OCPP request responses + if (commandName === OCPP20RequestCommand.TRANSACTION_EVENT) { + // Capture the TransactionEvent for test verification + sentTransactionEvents.push(commandPayload as OCPP20TransactionEventRequest) + return Promise.resolve({}) // OCPP 2.0 TransactionEvent response is empty object + } + // Mock other requests (StatusNotification, etc.) + return Promise.resolve({}) + }, + }, + stationInfo: { + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + + const incomingRequestService = new OCPP20IncomingRequestService() + + // Reset limits before each test + resetLimits(mockChargingStation) + resetReportingValueSize(mockChargingStation) + + /** + * Helper function to reset all connector transaction states + */ + function resetConnectorTransactionStates (): void { + // Reset all connectors across all EVSEs + for (const [, evse] of mockChargingStation.evses.entries()) { + for (const [connectorId] of evse.connectors.entries()) { + const status = mockChargingStation.getConnectorStatus(connectorId) + if (status) { + status.transactionStarted = false + status.transactionId = undefined + status.transactionIdTag = undefined + status.transactionStart = undefined + status.transactionEnergyActiveImportRegisterValue = undefined + status.remoteStartId = undefined + status.chargingProfiles = undefined + // Keep status as Available and availability as Operative + } + } + } + } + + /** + * Helper function to start a transaction and return the transaction ID + * @param evseId - The EVSE ID to start transaction on + * @param remoteStartId - The remote start ID for the transaction + * @param skipReset - Whether to skip resetting connector states + * @returns The transaction ID of the started transaction + */ + async function startTransaction ( + evseId = 1, + remoteStartId = 1, + skipReset = false + ): Promise { + // Reset all connector states first to ensure clean state (unless skipped for multiple transactions) + if (!skipReset) { + resetConnectorTransactionStates() + } + + const startRequest: OCPP20RequestStartTransactionRequest = { + evseId, + idToken: { + idToken: `TEST_TOKEN_${evseId.toString()}`, + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId, + } + + const startResponse = await ( + incomingRequestService as any + ).handleRequestRequestStartTransaction(mockChargingStation, startRequest) + + expect(startResponse.status).toBe(RequestStartStopStatusEnumType.Accepted) + expect(startResponse.transactionId).toBeDefined() + return startResponse.transactionId as string + } + + await it('Should successfully stop an active transaction', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Start a transaction first + const transactionId = await startTransaction(1, 100) + + // Create stop transaction request + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: transactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + // Execute stop transaction + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequest + ) + + // Verify response + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Accepted) + + // Verify TransactionEvent was sent + expect(sentTransactionEvents).toHaveLength(1) + const transactionEvent = sentTransactionEvents[0] + + expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended) + expect(transactionEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStop) + expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId) + expect(transactionEvent.transactionInfo.stoppedReason).toBe(OCPP20ReasonEnumType.Remote) + expect(transactionEvent.evse?.id).toBe(1) + }) + + await it('Should handle multiple active transactions correctly', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Reset once before starting multiple transactions + resetConnectorTransactionStates() + + // Start transactions on different EVSEs (skip reset for subsequent transactions) + const transactionId1 = await startTransaction(1, 200, true) // Skip reset since we just did it + const transactionId2 = await startTransaction(2, 201, true) // Skip reset to keep transaction 1 + const transactionId3 = await startTransaction(3, 202, true) // Skip reset to keep transactions 1 & 2 + + // Stop the second transaction + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: transactionId2 as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequest + ) + + // Verify response + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Accepted) + + // Verify correct TransactionEvent was sent + expect(sentTransactionEvents).toHaveLength(1) + const transactionEvent = sentTransactionEvents[0] + + expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId2) + expect(transactionEvent.evse?.id).toBe(2) + + // Verify other transactions are still active (test implementation dependent) + expect(mockChargingStation.getConnectorIdByTransactionId(transactionId1)).toBe(1) + expect(mockChargingStation.getConnectorIdByTransactionId(transactionId3)).toBe(3) + }) + + await it('Should reject stop transaction for non-existent transaction ID', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + const nonExistentTransactionId = 'non-existent-transaction-id' + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: + nonExistentTransactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequest + ) + + // Verify rejection + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Rejected) + + // Verify no TransactionEvent was sent + expect(sentTransactionEvents).toHaveLength(0) + }) + + await it('Should reject stop transaction for invalid transaction ID format - empty string', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + const invalidRequest: OCPP20RequestStopTransactionRequest = { + transactionId: '' as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + invalidRequest + ) + + // Verify rejection + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Rejected) + + // Verify no TransactionEvent was sent + expect(sentTransactionEvents).toHaveLength(0) + }) + + await it('Should reject stop transaction for invalid transaction ID format - too long', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Create a transaction ID longer than 36 characters + const tooLongTransactionId = 'a'.repeat(37) + const invalidRequest: OCPP20RequestStopTransactionRequest = { + transactionId: tooLongTransactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + invalidRequest + ) + + // Verify rejection + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Rejected) + + // Verify no TransactionEvent was sent + expect(sentTransactionEvents).toHaveLength(0) + }) + + await it('Should accept valid transaction ID format - exactly 36 characters', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Start a transaction first + const transactionId = await startTransaction(1, 300) + + // Ensure the transaction ID is exactly 36 characters (pad if necessary for test) + let testTransactionId = transactionId + if (testTransactionId.length < 36) { + testTransactionId = testTransactionId.padEnd(36, '0') + } else if (testTransactionId.length > 36) { + testTransactionId = testTransactionId.substring(0, 36) + } + + // Update the connector's transaction ID for testing + const connectorId = mockChargingStation.getConnectorIdByTransactionId(transactionId) + if (connectorId != null) { + const connectorStatus = mockChargingStation.getConnectorStatus(connectorId) + if (connectorStatus) { + connectorStatus.transactionId = testTransactionId + } + } + + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: testTransactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequest + ) + + // Verify acceptance (format is valid) + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Accepted) + + // Verify TransactionEvent was sent + expect(sentTransactionEvents).toHaveLength(1) + }) + + await it('Should handle TransactionEvent request failure gracefully', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Create a mock charging station that fails TransactionEvent requests + const failingChargingStation = createChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME + '-FAIL', + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + ocppRequestService: { + requestHandler: async (chargingStation: any, commandName: any, commandPayload: any) => { + if (commandName === OCPP20RequestCommand.TRANSACTION_EVENT) { + // Simulate server rejection + throw new Error('TransactionEvent rejected by server') + } + return Promise.resolve({}) + }, + }, + stationInfo: { + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL, + }) + + // Start a transaction on the failing station + const startRequest: OCPP20RequestStartTransactionRequest = { + evseId: 1, + idToken: { + idToken: 'FAIL_TEST_TOKEN', + type: OCPP20IdTokenEnumType.ISO14443, + }, + remoteStartId: 999, + } + + const startResponse = await ( + incomingRequestService as any + ).handleRequestRequestStartTransaction(failingChargingStation, startRequest) + + const transactionId = startResponse.transactionId as string + + // Attempt to stop the transaction + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: transactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + failingChargingStation, + stopRequest + ) + + // Should be rejected due to TransactionEvent failure + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Rejected) + }) + + await it('Should return proper response structure', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Start a transaction first + const transactionId = await startTransaction(1, 400) + + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: transactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequest + ) + + // Verify response structure + expect(response).toBeDefined() + expect(typeof response).toBe('object') + expect(response).toHaveProperty('status') + + // Verify status is valid enum value + expect(Object.values(RequestStartStopStatusEnumType)).toContain(response.status) + + // OCPP 2.0 RequestStopTransaction response should only contain status + expect(Object.keys(response as object)).toEqual(['status']) + }) + + await it('Should handle custom data in request payload', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Start a transaction first + const transactionId = await startTransaction(1, 500) + + const stopRequestWithCustomData: OCPP20RequestStopTransactionRequest = { + customData: { + data: 'Custom stop transaction data', + vendorId: 'TestVendor', + }, + transactionId: transactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequestWithCustomData + ) + + // Verify response + expect(response).toBeDefined() + expect(response.status).toBe(RequestStartStopStatusEnumType.Accepted) + + // Verify TransactionEvent was sent despite custom data + expect(sentTransactionEvents).toHaveLength(1) + }) + + await it('Should validate TransactionEvent content correctly', async () => { + // Clear previous transaction events + sentTransactionEvents = [] + + // Start a transaction first + const transactionId = await startTransaction(2, 600) // Use EVSE 2 + + const stopRequest: OCPP20RequestStopTransactionRequest = { + transactionId: transactionId as `${string}-${string}-${string}-${string}-${string}`, + } + + const response = await (incomingRequestService as any).handleRequestRequestStopTransaction( + mockChargingStation, + stopRequest + ) + + expect(response.status).toBe(RequestStartStopStatusEnumType.Accepted) + + // Verify TransactionEvent structure and content + expect(sentTransactionEvents).toHaveLength(1) + const transactionEvent = sentTransactionEvents[0] + + // Validate required fields + expect(transactionEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended) + expect(transactionEvent.timestamp).toBeDefined() + expect(transactionEvent.timestamp).toBeInstanceOf(Date) + expect(transactionEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.RemoteStop) + expect(transactionEvent.seqNo).toBeDefined() + expect(typeof transactionEvent.seqNo).toBe('number') + + // Validate transaction info + expect(transactionEvent.transactionInfo).toBeDefined() + expect(transactionEvent.transactionInfo.transactionId).toBe(transactionId) + expect(transactionEvent.transactionInfo.stoppedReason).toBe(OCPP20ReasonEnumType.Remote) + + // Validate EVSE info + expect(transactionEvent.evse).toBeDefined() + expect(transactionEvent.evse?.id).toBe(2) // Should match the EVSE we used + }) +}) diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index 9d1ce242..2d01002d 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -50,6 +50,13 @@ await describe('Utils test suite', async () => { // Shall invalidate Nil UUID expect(validateUUID('00000000-0000-0000-0000-000000000000')).toBe(false) expect(validateUUID('987FBC9-4BED-3078-CF07A-9141BA07C9F3')).toBe(false) + // Shall invalidate non-string inputs + expect(validateUUID(123)).toBe(false) + expect(validateUUID(null)).toBe(false) + expect(validateUUID(undefined)).toBe(false) + expect(validateUUID({})).toBe(false) + expect(validateUUID([])).toBe(false) + expect(validateUUID(true)).toBe(false) }) await it('Verify sleep()', async () => {