From 84fad8272fe91b8ec3d9ad2162139ab8d87b2f92 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 24 Jun 2026 22:11:56 +0200 Subject: [PATCH] fix(ui-server): classify broadcast responses without handlers (#1924) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(ui-server): classify broadcast responses without handlers Signed-off-by: Jérôme Benoit * fix(ui-server): preserve internal broadcast origin Signed-off-by: Jérôme Benoit * fix(ui-server): cleanup broadcast aggregation reliably Signed-off-by: Jérôme Benoit * test(ui-server): ensure late response cleanup Signed-off-by: Jérôme Benoit * refactor(types): introduce UIRequestOrigin enum and widen ProtocolRequestHandler - Promote 'internal' | 'transport' string-literal union to enum UIRequestOrigin { INTERNAL, TRANSPORT } hosted in src/types/UIProtocol.ts. Matches SCREAMING_SNAKE_CASE convention used across the file (ProcedureName, ResponseStatus, BroadcastChannelProcedureName). - Promote UIRequestContext interface to src/types/UIProtocol.ts. - Widen canonical ProtocolRequestHandler with optional context? parameter (backwards-compatible). - Re-export UIRequestOrigin and UIRequestContext from src/types/index.ts. - Drop the local UIServiceProtocolRequestHandler shadow in AbstractUIService.ts; consume the canonical ProtocolRequestHandler. - Replace inline 'internal' / 'transport' literals at the two AbstractUIService defaults and the AbstractUIServer.sendInternalRequest call site. Signed-off-by: Jérôme Benoit * refactor(ui-server): tighten handleProtocolRequest, rename log context, align terminology - Tighten the canonical ProtocolRequestHandler in src/types/UIProtocol.ts: required params (uuid, procedureName, payload) match the runtime invariant guaranteed by the ProtocolRequest tuple destructure in requestHandler(); return union collapses to Promise | ... | undefined. - Tighten handleProtocolRequest signature accordingly and drop the dead 'Invalid protocol request' runtime throw (unreachable: requestHandler only calls it after the tuple has been destructured into non-null values). - Rename interface BroadcastResponseLogContext to BroadcastChannelResponseLogContext for symmetry with the BroadcastChannel{ProcedureName, RequestContext, ...} family, and export it so test helpers can type-check the structured log payload. - Align log strings in logBroadcastResponseWithoutHandler from 'transport handler' to 'response handler' to match the public API (hasResponseHandler / responseHandlers). Test names and regex matchers updated atomically. Signed-off-by: Jérôme Benoit * style(ui-server): rename active to stopped and document rollback invariants - Rename AbstractUIService.active boolean to stopped (default false, flipped by stop()). The branch label 'late broadcast response after UI service stop' now matches the field name; the read site at logBroadcastResponseWithoutHandler reads as 'if (this.stopped) debug, else warn' without needing a comment. - Invert the two log branches under requestContext == null accordingly: the stopped branch leads with debug, the active branch with warn. - Add a 1-line rationale comment on the try/catch in sendBroadcastChannelRequest documenting the bookkeeping/dispatch atomicity invariant (rollback the just-recorded context if the worker dispatch throws). - Add a 1-line rationale comment on the try/finally in UIServiceWorkerBroadcastChannel.responseHandler documenting the aggregation-state release invariant under a sendResponse throw. Signed-off-by: Jérôme Benoit * test(ui-server): extract expectSingleLog, assert log context, regress dispatch rollback - Add expectSingleLog(mocks, level, pattern, contextShape?) to UIServerTestUtils.ts. Collapses the repeated 'warnMock.length === 0 / debugMock.length === 1 + typeof guard + assert.match' block previously duplicated across six broadcast-response tests. Accepts an optional structured contextShape that, when provided, deep-equals the second logger argument against the BroadcastChannelResponseLogContext payload. - Rewrite the six broadcast-response classification tests to invoke the helper. Each test now asserts the full structured log context (origin, procedureName, status, uuid, hashIds{Failed,Succeeded}), not just the message string — catching regressions that swap origin, drop procedureName, or omit hashIds propagation into the log payload. - Add a new regression test 'should rollback expected responses when broadcast dispatch throws' that stubs uiServiceWorkerBroadcastChannel .sendRequest to throw, calls service.requestHandler with a broadcast procedure, and asserts service.getBroadcastChannelExpectedResponses rolled back to 0 — locking the try/catch invariant introduced in this PR. Signed-off-by: Jérôme Benoit * refactor(ui-server): address post-design-pass nits - tests: replace Reflect.get + double 'as never' chain in the broadcast dispatch rollback regression test with a typed cast to UIServiceWorkerBroadcastChannel. Kills both 'as never' casts and the '(): never' return annotation. Field rename now fails the test loudly via TS instead of silently mis-testing. - tests: rename 'should warn on untracked broadcast responses while service is active' to '... before service stop' so the test name matches the post-rename 'stopped' field vocabulary. - types: add JSDoc on the new public surface introduced by this PR - UIRequestOrigin, UIRequestContext, ProtocolRequestHandler widening (UIProtocol.ts), and BroadcastChannelResponseLogContext (AbstractUIService.ts). Docs describe contract, not mechanism. Signed-off-by: Jérôme Benoit --------- Signed-off-by: Jérôme Benoit --- .../UIServiceWorkerBroadcastChannel.ts | 10 +- .../ui-server/AbstractUIServer.ts | 3 +- .../ui-services/AbstractUIService.ts | 132 ++++++++--- .../ui-server/ui-services/UIService001.ts | 7 +- src/types/UIProtocol.ts | 33 ++- src/types/index.ts | 2 + .../ui-server/UIServerTestUtils.ts | 38 +++ .../ui-services/AbstractUIService.test.ts | 223 +++++++++++++++++- 8 files changed, 407 insertions(+), 41 deletions(-) diff --git a/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts index 07dc1274..36a13f67 100644 --- a/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts @@ -111,9 +111,13 @@ export class UIServiceWorkerBroadcastChannel extends WorkerBroadcastChannel { } const responses = this.responses.get(uuid) if (responses != null && responses.responsesReceived >= responses.responsesExpected) { - this.uiService.sendResponse(uuid, this.buildResponsePayload(uuid)) - this.responses.delete(uuid) - this.uiService.deleteBroadcastChannelRequest(uuid) + // Always release aggregation state, even if downstream sendResponse throws. + try { + this.uiService.sendResponse(uuid, this.buildResponsePayload(uuid)) + } finally { + this.responses.delete(uuid) + this.uiService.deleteBroadcastChannelRequest(uuid) + } } } } diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index 802d6860..32766830 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -22,6 +22,7 @@ import { ProtocolVersion, type RequestPayload, type ResponsePayload, + UIRequestOrigin, type UIServerConfiguration, type UUIDv4, } from '../../types/index.js' @@ -300,7 +301,7 @@ export abstract class AbstractUIServer { this.registerProtocolVersionUIService(protocolVersion) return await (this.uiServices .get(protocolVersion) - ?.requestHandler(request) as Promise) + ?.requestHandler(request, { origin: UIRequestOrigin.INTERNAL }) as Promise) } public abstract sendRequest (request: ProtocolRequest): void diff --git a/src/charging-station/ui-server/ui-services/AbstractUIService.ts b/src/charging-station/ui-server/ui-services/AbstractUIService.ts index 4d39ad4a..5deeade3 100644 --- a/src/charging-station/ui-server/ui-services/AbstractUIService.ts +++ b/src/charging-station/ui-server/ui-services/AbstractUIService.ts @@ -18,13 +18,14 @@ import { type ResponsePayload, ResponseStatus, type StorageConfiguration, + type UIRequestContext, + UIRequestOrigin, type UUIDv4, } from '../../../types/index.js' import { Configuration, ensureError, getErrorMessage, - isAsyncFunction, isNotEmptyArray, logger, } from '../../../utils/index.js' @@ -33,12 +34,33 @@ import { DEFAULT_MAX_STATIONS, isValidNumberOfStations } from '../UIServerSecuri const moduleName = 'AbstractUIService' +/** + * Structured payload of the `logger.{debug,warn}` calls emitted by + * {@link AbstractUIService.logBroadcastResponseWithoutHandler}. Fields are + * optional when the corresponding context is unavailable (untracked or + * late responses, payload without hashIds). + */ +export interface BroadcastChannelResponseLogContext { + readonly hashIdsFailed?: string[] + readonly hashIdsSucceeded?: string[] + readonly origin?: UIRequestOrigin + readonly procedureName?: BroadcastChannelProcedureName + readonly status: ResponseStatus + readonly uuid: UUIDv4 +} + interface AddChargingStationsRequestPayload extends RequestPayload { numberOfStations: number options?: ChargingStationOptions template: string } +interface BroadcastChannelRequestContext { + readonly expectedResponses: number + readonly origin: UIRequestOrigin + readonly procedureName: BroadcastChannelProcedureName +} + export abstract class AbstractUIService { protected static readonly ProcedureNameToBroadCastChannelProcedureNameMapping = new Map< ProcedureName, @@ -99,7 +121,8 @@ export abstract class AbstractUIService { ]) protected readonly requestHandlers: Map - private readonly broadcastChannelRequests: Map + private readonly broadcastChannelRequests: Map + private stopped = false private readonly uiServer: AbstractUIServer private readonly uiServiceWorkerBroadcastChannel: UIServiceWorkerBroadcastChannel @@ -118,7 +141,7 @@ export abstract class AbstractUIService { [ProcedureName.STOP_SIMULATOR, this.handleStopSimulator.bind(this)], ]) this.uiServiceWorkerBroadcastChannel = new UIServiceWorkerBroadcastChannel(this) - this.broadcastChannelRequests = new Map() + this.broadcastChannelRequests = new Map() } public deleteBroadcastChannelRequest (uuid: UUIDv4): void { @@ -126,14 +149,17 @@ export abstract class AbstractUIService { } public getBroadcastChannelExpectedResponses (uuid: UUIDv4): number { - return this.broadcastChannelRequests.get(uuid) ?? 0 + return this.broadcastChannelRequests.get(uuid)?.expectedResponses ?? 0 } public logPrefix = (modName: string, methodName: string): string => { return this.uiServer.logPrefix(modName, methodName, this.version) } - public async requestHandler (request: ProtocolRequest): Promise { + public async requestHandler ( + request: ProtocolRequest, + context: UIRequestContext = { origin: UIRequestOrigin.TRANSPORT } + ): Promise { let uuid: undefined | UUIDv4 let command: ProcedureName | undefined let requestPayload: RequestPayload | undefined @@ -156,17 +182,7 @@ export abstract class AbstractUIService { if (requestHandler == null) { throw new BaseError(`'${command}' request handler not found`) } - if (isAsyncFunction(requestHandler)) { - responsePayload = await requestHandler(uuid, command, requestPayload) - } else { - responsePayload = ( - requestHandler as ( - uuid?: string, - procedureName?: ProcedureName, - payload?: RequestPayload - ) => ResponsePayload | undefined - )(uuid, command, requestPayload) - } + responsePayload = await requestHandler(uuid, command, requestPayload, context) } catch (error) { // Log logger.error(`${this.logPrefix(moduleName, 'requestHandler')} Handle request error:`, error) @@ -206,15 +222,13 @@ export abstract class AbstractUIService { public sendResponse (uuid: UUIDv4, responsePayload: ResponsePayload): void { if (this.uiServer.hasResponseHandler(uuid)) { this.uiServer.sendResponse(this.uiServer.buildProtocolResponse(uuid, responsePayload)) - } else { - logger.warn(`${this.logPrefix(moduleName, 'sendResponse')} Response handler not found:`, { - responsePayload, - uuid, - }) + return } + this.logBroadcastResponseWithoutHandler(uuid, responsePayload) } public stop (): void { + this.stopped = true this.broadcastChannelRequests.clear() this.uiServiceWorkerBroadcastChannel.close() } @@ -222,14 +236,37 @@ export abstract class AbstractUIService { protected handleProtocolRequest ( uuid: UUIDv4, procedureName: ProcedureName, - payload: RequestPayload - ): void { + payload: RequestPayload, + context: UIRequestContext = { origin: UIRequestOrigin.TRANSPORT } + ): undefined { const broadCastChannelProcedureName = AbstractUIService.ProcedureNameToBroadCastChannelProcedureNameMapping.get(procedureName) if (broadCastChannelProcedureName == null) { throw new BaseError(`No broadcast channel mapping for procedure '${procedureName}'`) } - this.sendBroadcastChannelRequest(uuid, broadCastChannelProcedureName, payload) + this.sendBroadcastChannelRequest(uuid, broadCastChannelProcedureName, payload, context) + return undefined + } + + private buildBroadcastResponseLogContext ( + uuid: UUIDv4, + responsePayload: ResponsePayload, + requestContext?: BroadcastChannelRequestContext + ): BroadcastChannelResponseLogContext { + return { + ...(responsePayload.hashIdsFailed != null && { + hashIdsFailed: responsePayload.hashIdsFailed, + }), + ...(responsePayload.hashIdsSucceeded != null && { + hashIdsSucceeded: responsePayload.hashIdsSucceeded, + }), + ...(requestContext != null && { + origin: requestContext.origin, + procedureName: requestContext.procedureName, + }), + status: responsePayload.status, + uuid, + } } private async handleAddChargingStations ( @@ -386,10 +423,41 @@ export abstract class AbstractUIService { } } + private logBroadcastResponseWithoutHandler (uuid: UUIDv4, responsePayload: ResponsePayload): void { + const requestContext = this.broadcastChannelRequests.get(uuid) + const logContext = this.buildBroadcastResponseLogContext(uuid, responsePayload, requestContext) + if (requestContext == null) { + if (this.stopped) { + logger.debug( + `${this.logPrefix(moduleName, 'sendResponse')} Dropping late broadcast response after UI service stop:`, + logContext + ) + } else { + logger.warn( + `${this.logPrefix(moduleName, 'sendResponse')} Dropping untracked broadcast response:`, + logContext + ) + } + return + } + if (responsePayload.status === ResponseStatus.SUCCESS) { + logger.debug( + `${this.logPrefix(moduleName, 'sendResponse')} Broadcast response completed without response handler:`, + logContext + ) + return + } + logger.warn( + `${this.logPrefix(moduleName, 'sendResponse')} Failed broadcast response completed without response handler:`, + logContext + ) + } + private sendBroadcastChannelRequest ( uuid: UUIDv4, procedureName: BroadcastChannelProcedureName, - payload: BroadcastChannelRequestPayload + payload: BroadcastChannelRequestPayload, + context: UIRequestContext ): void { if (isNotEmptyArray(payload.hashIds)) { payload.hashIds = payload.hashIds @@ -417,7 +485,17 @@ export abstract class AbstractUIService { 'hashIds array in the request payload does not contain any valid charging station hashId' ) } - this.uiServiceWorkerBroadcastChannel.sendRequest([uuid, procedureName, payload]) - this.broadcastChannelRequests.set(uuid, expectedNumberOfResponses) + this.broadcastChannelRequests.set(uuid, { + expectedResponses: expectedNumberOfResponses, + origin: context.origin, + procedureName, + }) + // Rollback expected-response accounting if dispatch to the worker channel throws. + try { + this.uiServiceWorkerBroadcastChannel.sendRequest([uuid, procedureName, payload]) + } catch (error) { + this.broadcastChannelRequests.delete(uuid) + throw error + } } } diff --git a/src/charging-station/ui-server/ui-services/UIService001.ts b/src/charging-station/ui-server/ui-services/UIService001.ts index 51553669..b777fe15 100644 --- a/src/charging-station/ui-server/ui-services/UIService001.ts +++ b/src/charging-station/ui-server/ui-services/UIService001.ts @@ -1,16 +1,13 @@ import type { AbstractUIServer } from '../AbstractUIServer.js' -import { type ProtocolRequestHandler, ProtocolVersion } from '../../../types/index.js' +import { ProtocolVersion } from '../../../types/index.js' import { AbstractUIService } from './AbstractUIService.js' export class UIService001 extends AbstractUIService { constructor (uiServer: AbstractUIServer) { super(uiServer, ProtocolVersion['0.0.1']) for (const procedureName of AbstractUIService.ProcedureNameToBroadCastChannelProcedureNameMapping.keys()) { - this.requestHandlers.set( - procedureName, - this.handleProtocolRequest.bind(this) as ProtocolRequestHandler - ) + this.requestHandlers.set(procedureName, this.handleProtocolRequest.bind(this)) } } } diff --git a/src/types/UIProtocol.ts b/src/types/UIProtocol.ts index da5e4db3..a08b833d 100644 --- a/src/types/UIProtocol.ts +++ b/src/types/UIProtocol.ts @@ -71,15 +71,32 @@ export enum ServerNotification { REFRESH = 'refresh', } +/** + * Origin of a UI service request. Drives broadcast-response classification: + * `INTERNAL` requests originate from `AbstractUIServer.sendInternalRequest` + * (e.g. `Bootstrap.doStop`) and have no transport-side response handler. + * `TRANSPORT` requests originate from a UI client (WebSocket/HTTP/MCP). + */ +export enum UIRequestOrigin { + INTERNAL = 'internal', + TRANSPORT = 'transport', +} + export type ProtocolNotification = [ServerNotification] export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload] +/** + * Signature of any UI service request handler stored in the dispatch map. + * Sync or async; may return a payload or nothing. The optional `context` + * carries the request origin so broadcast handlers can classify late responses. + */ export type ProtocolRequestHandler = ( - uuid?: UUIDv4, - procedureName?: ProcedureName, - payload?: RequestPayload -) => Promise | Promise | ResponsePayload | undefined + uuid: UUIDv4, + procedureName: ProcedureName, + payload: RequestPayload, + context?: UIRequestContext +) => Promise | ResponsePayload | undefined export type ProtocolResponse = [UUIDv4, ResponsePayload] @@ -94,3 +111,11 @@ export interface ResponsePayload extends JsonObject { responsesFailed?: BroadcastChannelResponsePayload[] status: ResponseStatus } + +/** + * Context carried alongside a UI protocol request. Currently records only the + * request origin; additive fields are non-breaking. + */ +export interface UIRequestContext { + readonly origin: UIRequestOrigin +} diff --git a/src/types/index.ts b/src/types/index.ts index 039ba113..8685f3f6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -458,6 +458,8 @@ export { type ResponsePayload, ResponseStatus, ServerNotification, + type UIRequestContext, + UIRequestOrigin, } from './UIProtocol.js' export type { UUIDv4 } from './UUID.js' export { diff --git a/tests/charging-station/ui-server/UIServerTestUtils.ts b/tests/charging-station/ui-server/UIServerTestUtils.ts index fca39ea9..a0625499 100644 --- a/tests/charging-station/ui-server/UIServerTestUtils.ts +++ b/tests/charging-station/ui-server/UIServerTestUtils.ts @@ -6,9 +6,11 @@ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import type { mock } from 'node:test' +import assert from 'node:assert/strict' import { EventEmitter, once } from 'node:events' import type { IBootstrap } from '../../../src/charging-station/IBootstrap.js' +import type { BroadcastChannelResponseLogContext } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js' import type { ChargingStationData, ProcedureName, @@ -529,3 +531,39 @@ export const createMockChargingStationDataWithVersion = ( templateName: 'test-template', }, }) + +interface LogMock { + readonly mock: { + readonly calls: readonly { readonly arguments: readonly unknown[] }[] + } +} + +/** + * Assert that exactly one of the two `logger` levels was invoked with a + * message matching `pattern` and, optionally, a structured second argument + * deep-equal to `contextShape`. + * @param mocks - Tracked `logger.debug` and `logger.warn` mocks. + * @param mocks.debug - Mock tracking `logger.debug` invocations. + * @param mocks.warn - Mock tracking `logger.warn` invocations. + * @param level - Expected level for the single invocation. + * @param pattern - Regular expression the message must match. + * @param contextShape - Optional deep-equal shape for the second log argument. + */ +export const expectSingleLog = ( + mocks: { readonly debug: LogMock; readonly warn: LogMock }, + level: 'debug' | 'warn', + pattern: RegExp, + contextShape?: BroadcastChannelResponseLogContext +): void => { + const [hit, miss] = level === 'debug' ? [mocks.debug, mocks.warn] : [mocks.warn, mocks.debug] + assert.strictEqual(miss.mock.calls.length, 0) + assert.strictEqual(hit.mock.calls.length, 1) + const [message, context] = hit.mock.calls[0]?.arguments ?? [] + if (typeof message !== 'string') { + assert.fail(`Expected ${level} log message to be a string`) + } + assert.match(message, pattern) + if (contextShape != null) { + assert.deepStrictEqual(context, contextShape) + } +} diff --git a/tests/charging-station/ui-server/ui-services/AbstractUIService.test.ts b/tests/charging-station/ui-server/ui-services/AbstractUIService.test.ts index d42ae92e..0fd3a017 100644 --- a/tests/charging-station/ui-server/ui-services/AbstractUIService.test.ts +++ b/tests/charging-station/ui-server/ui-services/AbstractUIService.test.ts @@ -6,16 +6,54 @@ import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' -import { ProcedureName, ProtocolVersion, ResponseStatus } from '../../../../src/types/index.js' +import type { UIServiceWorkerBroadcastChannel } from '../../../../src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.js' +import type { AbstractUIService } from '../../../../src/charging-station/ui-server/ui-services/AbstractUIService.js' + +import { + BroadcastChannelProcedureName, + ProcedureName, + ProtocolVersion, + ResponseStatus, + UIRequestOrigin, +} from '../../../../src/types/index.js' +import { logger } from '../../../../src/utils/Logger.js' import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' import { TEST_HASH_ID, TEST_UUID } from '../UIServerTestConstants.js' import { createMockChargingStationData, createMockUIServerConfiguration, createProtocolRequest, + expectSingleLog, TestableUIWebSocketServer, } from '../UIServerTestUtils.js' +const createServiceContext = (): { + readonly server: TestableUIWebSocketServer + readonly service: AbstractUIService +} => { + const config = createMockUIServerConfiguration() + const server = new TestableUIWebSocketServer(config) + server.testRegisterProtocolVersionUIService(ProtocolVersion['0.0.1']) + server.setChargingStationData(TEST_HASH_ID, createMockChargingStationData(TEST_HASH_ID)) + const service = server.getUIService(ProtocolVersion['0.0.1']) + if (service == null) { + assert.fail('Expected UI service to be registered') + } + return { server, service } +} + +const registerInternalStopRequest = async (server: TestableUIWebSocketServer): Promise => { + await server.sendInternalRequest( + server.buildProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {}) + ) +} + +const registerTransportStopRequest = async (service: AbstractUIService): Promise => { + await service.requestHandler( + createProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {}) + ) +} + await describe('AbstractUIService', async () => { afterEach(() => { standardCleanup() @@ -148,6 +186,189 @@ await describe('AbstractUIService', async () => { } }) + await it('should log internal successful broadcast responses without response handler at debug', async t => { + const mocks = { + debug: t.mock.method(logger, 'debug', () => undefined), + warn: t.mock.method(logger, 'warn', () => undefined), + } + const { server, service } = createServiceContext() + + try { + await registerInternalStopRequest(server) + service.sendResponse(TEST_UUID, { + hashIdsSucceeded: [TEST_HASH_ID], + status: ResponseStatus.SUCCESS, + }) + + expectSingleLog(mocks, 'debug', /Broadcast response completed without response handler/, { + hashIdsSucceeded: [TEST_HASH_ID], + origin: UIRequestOrigin.INTERNAL, + procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION, + status: ResponseStatus.SUCCESS, + uuid: TEST_UUID, + }) + } finally { + service.stop() + } + }) + + await it('should warn on internal failed broadcast responses without response handler', async t => { + const mocks = { + debug: t.mock.method(logger, 'debug', () => undefined), + warn: t.mock.method(logger, 'warn', () => undefined), + } + const { server, service } = createServiceContext() + + try { + await registerInternalStopRequest(server) + service.sendResponse(TEST_UUID, { + hashIdsFailed: [TEST_HASH_ID], + status: ResponseStatus.FAILURE, + }) + + expectSingleLog( + mocks, + 'warn', + /Failed broadcast response completed without response handler/, + { + hashIdsFailed: [TEST_HASH_ID], + origin: UIRequestOrigin.INTERNAL, + procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION, + status: ResponseStatus.FAILURE, + uuid: TEST_UUID, + } + ) + } finally { + service.stop() + } + }) + + await it('should log transport successful broadcast responses without response handler at debug', async t => { + const mocks = { + debug: t.mock.method(logger, 'debug', () => undefined), + warn: t.mock.method(logger, 'warn', () => undefined), + } + const { service } = createServiceContext() + + try { + await registerTransportStopRequest(service) + service.sendResponse(TEST_UUID, { + hashIdsSucceeded: [TEST_HASH_ID], + status: ResponseStatus.SUCCESS, + }) + + expectSingleLog(mocks, 'debug', /Broadcast response completed without response handler/, { + hashIdsSucceeded: [TEST_HASH_ID], + origin: UIRequestOrigin.TRANSPORT, + procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION, + status: ResponseStatus.SUCCESS, + uuid: TEST_UUID, + }) + } finally { + service.stop() + } + }) + + await it('should warn on transport failed broadcast responses without response handler', async t => { + const mocks = { + debug: t.mock.method(logger, 'debug', () => undefined), + warn: t.mock.method(logger, 'warn', () => undefined), + } + const { service } = createServiceContext() + + try { + await registerTransportStopRequest(service) + service.sendResponse(TEST_UUID, { + hashIdsFailed: [TEST_HASH_ID], + status: ResponseStatus.FAILURE, + }) + + expectSingleLog( + mocks, + 'warn', + /Failed broadcast response completed without response handler/, + { + hashIdsFailed: [TEST_HASH_ID], + origin: UIRequestOrigin.TRANSPORT, + procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION, + status: ResponseStatus.FAILURE, + uuid: TEST_UUID, + } + ) + } finally { + service.stop() + } + }) + + await it('should warn on untracked broadcast responses before service stop', t => { + const mocks = { + debug: t.mock.method(logger, 'debug', () => undefined), + warn: t.mock.method(logger, 'warn', () => undefined), + } + const { service } = createServiceContext() + + try { + service.sendResponse(TEST_UUID, { status: ResponseStatus.SUCCESS }) + + expectSingleLog(mocks, 'warn', /Dropping untracked broadcast response/, { + status: ResponseStatus.SUCCESS, + uuid: TEST_UUID, + }) + } finally { + service.stop() + } + }) + + await it('should log late broadcast responses after service stop at debug', async t => { + const mocks = { + debug: t.mock.method(logger, 'debug', () => undefined), + warn: t.mock.method(logger, 'warn', () => undefined), + } + const { service } = createServiceContext() + + try { + await registerTransportStopRequest(service) + service.stop() + service.sendResponse(TEST_UUID, { + hashIdsSucceeded: [TEST_HASH_ID], + status: ResponseStatus.SUCCESS, + }) + + expectSingleLog(mocks, 'debug', /Dropping late broadcast response/, { + hashIdsSucceeded: [TEST_HASH_ID], + status: ResponseStatus.SUCCESS, + uuid: TEST_UUID, + }) + } finally { + service.stop() + } + }) + + await it('should rollback expected responses when broadcast dispatch throws', async t => { + const { service } = createServiceContext() + const channel = Reflect.get( + service, + 'uiServiceWorkerBroadcastChannel' + ) as UIServiceWorkerBroadcastChannel + t.mock.method(channel, 'sendRequest', () => { + throw new Error('dispatch failed') + }) + + try { + const response = await service.requestHandler( + createProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {}) + ) + + assert.notStrictEqual(response, undefined) + if (response != null) { + assert.strictEqual(response[1].status, ResponseStatus.FAILURE) + } + assert.strictEqual(service.getBroadcastChannelExpectedResponses(TEST_UUID), 0) + } finally { + service.stop() + } + }) + await it('should return failure response when request handler throws', async () => { const config = createMockUIServerConfiguration() const server = new TestableUIWebSocketServer(config) -- 2.53.0