]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp2): add RequestStopTransaction command
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 5 Nov 2025 16:15:19 +0000 (17:15 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 5 Nov 2025 16:15:19 +0000 (17:15 +0100)
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
13 files changed:
src/charging-station/ChargingStation.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/types/index.ts
src/types/ocpp/2.0/Requests.ts
src/types/ocpp/2.0/Responses.ts
src/types/ocpp/2.0/Transaction.ts
src/utils/Utils.ts
tests/ChargingStationFactory.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts [new file with mode: 0644]
tests/utils/Utils.test.ts

index 38bb259d7cda2d35dce63881d7594ab36f829fa7..cf1a9f81f1b27fcb4ffb5aa632c8da9f2e57729d 100644 (file)
@@ -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(
index 006203b1038f972989f88c2fef4f14625ab7d5b9..2e2cdf676d642c1d019fe61ffdc8fbf39f4aadc7 100644 (file)
@@ -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<OCPP20RequestStopTransactionRequest>(
+            '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<OCPP20RequestStopTransactionResponse> {
+    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
index 926d4d17aeda8f7364c3f05f6f1dde14875fea92..a72b3a019be21114704b6258d299e9c61d234826 100644 (file)
@@ -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
index ec76b2f8c77ac96d5a0b4c605c25a656a109502e..b74628ff00642930546513664a56d626d44ffd58 100644 (file)
@@ -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<OCPP20RequestStartTransactionResponse>(
+            'assets/json-schemas/ocpp/2.0/RequestStartTransactionResponse.json',
+            moduleName,
+            'constructor'
+          )
+        ),
+      ],
+      [
+        OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+        this.ajvIncomingRequest.compile(
+          OCPP20ServiceUtils.parseJsonSchemaFile<OCPP20RequestStopTransactionResponse>(
+            'assets/json-schemas/ocpp/2.0/RequestStopTransactionResponse.json',
+            moduleName,
+            'constructor'
+          )
+        ),
+      ],
     ])
     this.validatePayload = this.validatePayload.bind(this)
   }
index 424d675841231c694db3d64ee1c3ffa73c9ab8a8..0f817843aebe74bb4c91b48362fb3d4663898edc 100644 (file)
@@ -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<GenericResponse> => {
+    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
+  }
 }
index 0884adb367532131ae09fe532d44ddf08989f8c5..be3cb70c41695e272cda2027e8126dda33040beb 100644 (file)
@@ -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,
index 6971261ff60ce053698d0a0d53754fb1c88d97af..1b7d3521de104d0f092d104fc7daece88df56a1d 100644 (file)
@@ -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
index 095d4b7d74188a762330f7b5da431b8d8ea3b76e..f85cefd57723cdf40a7b1d4e2098dcaaf55f246a 100644 (file)
@@ -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 {
index e80b0acbacca3acfde9d685879db025acb77e1fe..4cc2149673c1d758da5072c6ee0de94febca4ac8 100644 (file)
@@ -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 {
index fc20974bb893ba0348fd91aafd800ba19e38d08e..87adf4e6a4fac1be14c1c94b3411b8787b9866c9 100644 (file)
@@ -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
   )
index b42839cb321557ab44ef0a2a36071d8578fd6d8f..05a0f1a5a90c0654ed6b84b114d02c253b178028 100644 (file)
@@ -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 (file)
index 0000000..dcf17b5
--- /dev/null
@@ -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<string> {
+    // 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
+  })
+})
index 9d1ce242714f52b8b1fb05717569d446d5f6e6bd..2d01002d2a49a26c2b5cf5aa7fb6a827f607b68c 100644 (file)
@@ -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 () => {