diff --git a/sdk/communication/communication-call-automation/test/public/callAutomationClient.spec.ts b/sdk/communication/communication-call-automation/test/callAutomationClient.spec.ts similarity index 71% rename from sdk/communication/communication-call-automation/test/public/callAutomationClient.spec.ts rename to sdk/communication/communication-call-automation/test/callAutomationClient.spec.ts index 0de84b43357e..dc0121e04a4a 100644 --- a/sdk/communication/communication-call-automation/test/public/callAutomationClient.spec.ts +++ b/sdk/communication/communication-call-automation/test/callAutomationClient.spec.ts @@ -2,10 +2,14 @@ // Licensed under the MIT license. import { Recorder } from "@azure-tools/test-recorder"; -import { CommunicationUserIdentifier } from "@azure/communication-common"; +import Sinon, { SinonStubbedInstance } from "sinon"; +import { CallConnectionProperties } from "../src/models/models"; +import { CreateCallResult } from "../src/models/responses"; +import { CALL_CALLBACK_URL, CALL_TARGET_ID } from "./utils/connectionUtils"; +import { CommunicationIdentifier, CommunicationUserIdentifier } from "@azure/communication-common"; import { assert } from "chai"; import { Context } from "mocha"; -import { CallAutomationClient, CallInvite, CallConnection } from "../../src"; +import { CallAutomationClient, CallInvite, CallConnection } from "../src"; import { createRecorder, createTestUser, @@ -14,8 +18,6 @@ import { createCallAutomationClient, waitForIncomingCallContext, waitForEvent, -} from "./utils/recordedClient"; -import { events, serviceBusReceivers, incomingCallContexts, @@ -23,6 +25,49 @@ import { persistEvents, } from "./utils/recordedClient"; +describe("Call Automation Client Unit Tests", () => { + let targets: CommunicationIdentifier[]; + let client: SinonStubbedInstance & CallAutomationClient; + + beforeEach(() => { + // set up + targets = [ + { + communicationUserId: CALL_TARGET_ID, + }, + ]; + // stub CallAutomationClient + client = Sinon.createStubInstance( + CallAutomationClient + ) as SinonStubbedInstance & CallAutomationClient; + }); + + it("CreateCall", async () => { + // mocks + const createCallResultMock: CreateCallResult = { + callConnectionProperties: {} as CallConnectionProperties, + callConnection: {} as CallConnection, + }; + client.createCall.returns( + new Promise((resolve) => { + resolve(createCallResultMock); + }) + ); + + const promiseResult = client.createCall(targets, CALL_CALLBACK_URL); + + // asserts + promiseResult + .then((result: CreateCallResult) => { + assert.isNotNull(result); + assert.isTrue(client.createCall.calledWith(targets, CALL_CALLBACK_URL)); + assert.equal(result, createCallResultMock); + return; + }) + .catch((error) => console.error(error)); + }); +}); + describe("Call Automation Main Client Live Tests", function () { let recorder: Recorder; let callAutomationClient: CallAutomationClient; diff --git a/sdk/communication/communication-call-automation/test/internal/callMediaClient.spec.ts b/sdk/communication/communication-call-automation/test/callMediaClient.spec.ts similarity index 93% rename from sdk/communication/communication-call-automation/test/internal/callMediaClient.spec.ts rename to sdk/communication/communication-call-automation/test/callMediaClient.spec.ts index 0cb84ef5ff92..752aadea881f 100644 --- a/sdk/communication/communication-call-automation/test/internal/callMediaClient.spec.ts +++ b/sdk/communication/communication-call-automation/test/callMediaClient.spec.ts @@ -6,11 +6,11 @@ import { serializeCommunicationIdentifier, } from "@azure/communication-common"; import Sinon, { SinonStubbedInstance } from "sinon"; -import { CallMedia } from "../../src/callMedia"; -import { FileSource, RecognizeInputType } from "../../src/models/models"; +import { CallMedia } from "../src/callMedia"; +import { FileSource, RecognizeInputType } from "../src/models/models"; import { assert } from "chai"; -import { CallMediaImpl } from "../../src/generated/src/operations"; -import { CallMediaRecognizeDtmfOptions } from "../../src"; +import { CallMediaImpl } from "../src/generated/src/operations"; +import { CallMediaRecognizeDtmfOptions } from "../src"; describe("CallMedia Unit Tests", () => { let callConnectionId: string; diff --git a/sdk/communication/communication-call-automation/test/internal/callAutomationClient.mocked.spec.ts b/sdk/communication/communication-call-automation/test/internal/callAutomationClient.mocked.spec.ts deleted file mode 100644 index 2fe3beed1633..000000000000 --- a/sdk/communication/communication-call-automation/test/internal/callAutomationClient.mocked.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { CommunicationIdentifier } from "@azure/communication-common"; -import { fail } from "assert"; -import { assert } from "chai"; -import Sinon, { SinonStubbedInstance } from "sinon"; -import { CallAutomationClient } from "../../src/callAutomationClient"; -import { CallConnection } from "../../src/callConnection"; -import { CallConnectionProperties } from "../../src/models/models"; -import { CreateCallResult } from "../../src/models/responses"; -import { CALL_CALLBACK_URL, CALL_TARGET_ID } from "./utils/mockUtils"; - -describe("Call Automation Client Unit Tests", () => { - let targets: CommunicationIdentifier[]; - let client: SinonStubbedInstance & CallAutomationClient; - - beforeEach(() => { - // set up - targets = [ - { - communicationUserId: CALL_TARGET_ID, - }, - ]; - // stub CallAutomationClient - client = Sinon.createStubInstance( - CallAutomationClient - ) as SinonStubbedInstance & CallAutomationClient; - }); - - it("CreateCall", async () => { - // mocks - const createCallResultMock: CreateCallResult = { - callConnectionProperties: {} as CallConnectionProperties, - callConnection: {} as CallConnection, - }; - client.createCall.returns( - new Promise((resolve) => { - resolve(createCallResultMock); - }) - ); - - const promiseResult = client.createCall(targets, CALL_CALLBACK_URL); - - // asserts - promiseResult - .then((result: CreateCallResult) => { - assert.isNotNull(result); - assert.isTrue(client.createCall.calledWith(targets, CALL_CALLBACK_URL)); - assert.equal(result, createCallResultMock); - return; - }) - .catch((reject) => { - fail(reject); // should not reach here - }); - }); -}); diff --git a/sdk/communication/communication-call-automation/test/public/callConnection.spec.ts b/sdk/communication/communication-call-automation/test/public/callConnection.spec.ts deleted file mode 100644 index 8a6d01226229..000000000000 --- a/sdk/communication/communication-call-automation/test/public/callConnection.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { Recorder } from "@azure-tools/test-recorder"; -import { CommunicationUserIdentifier } from "@azure/communication-common"; -import { assert } from "chai"; -import { Context } from "mocha"; -import { CallAutomationClient, CallInvite, CallConnection } from "../../src"; -import { - createRecorder, - createTestUser, - dispatcherCallback, - serviceBusWithNewCall, - createCallAutomationClient, - waitForIncomingCallContext, - waitForEvent, - events, - serviceBusReceivers, - incomingCallContexts, - persistEvents, - loadPersistedEvents, -} from "./utils/recordedClient"; - -describe("CallConnection Live Tests", function () { - let recorder: Recorder; - let callAutomationClient: CallAutomationClient; - let callConnection: CallConnection; - let testUser: CommunicationUserIdentifier; - let testUser2: CommunicationUserIdentifier; - let callConnectionId: string; - let testName: string; - - beforeEach(async function (this: Context) { - recorder = await createRecorder(this.currentTest); - testUser = await createTestUser(recorder); - testUser2 = await createTestUser(recorder); - callAutomationClient = createCallAutomationClient(recorder, testUser); - }); - - afterEach(async function (this: Context) { - persistEvents(testName); - if (callConnection) { - try { - await callConnection.hangUp(true); - } catch (e) { - console.log("Call is terminated"); - } - } - serviceBusReceivers.forEach((receiver) => { - receiver.close(); - }); - events.forEach((callConnectionEvents) => { - callConnectionEvents.clear(); - }); - events.clear(); - serviceBusReceivers.clear(); - incomingCallContexts.clear(); - await recorder.stop(); - }); - - it("List all participants", async function () { - testName = this.test?.fullTitle() - ? this.test?.fullTitle().replace(/ /g, "_") - : "list_all_participants"; - await loadPersistedEvents(testName); - - const callInvite = new CallInvite(testUser2); - const uniqueId = await serviceBusWithNewCall(testUser, testUser2); - const callBackUrl: string = dispatcherCallback + `?q=${uniqueId}`; - const result = await callAutomationClient.createCall(callInvite, callBackUrl); - const incomingCallContext = await waitForIncomingCallContext(uniqueId, 8000); - callConnectionId = result.callConnectionProperties.callConnectionId - ? result.callConnectionProperties.callConnectionId - : ""; - assert.isDefined(incomingCallContext); - if (incomingCallContext) { - await callAutomationClient.answerCall(incomingCallContext, callBackUrl); - } - const callConnectedEvent = await waitForEvent("CallConnected", callConnectionId, 8000); - assert.isDefined(callConnectedEvent); - callConnection = result.callConnection; - const allParticipants = await callConnection.listParticipants(); - assert.isDefined(allParticipants); - assert.isDefined(allParticipants.values); - }).timeout(60000); - - it("Add a participant and get call properties", async function () { - testName = this.test?.fullTitle() - ? this.test?.fullTitle().replace(/ /g, "_") - : "add_participant_and_get_call_props"; - await loadPersistedEvents(testName); - - const callInvite = new CallInvite(testUser2); - const uniqueId = await serviceBusWithNewCall(testUser, testUser2); - const callBackUrl: string = dispatcherCallback + `?q=${uniqueId}`; - const result = await callAutomationClient.createCall(callInvite, callBackUrl); - const incomingCallContext = await waitForIncomingCallContext(uniqueId, 10000); - callConnectionId = result.callConnectionProperties.callConnectionId - ? result.callConnectionProperties.callConnectionId - : ""; - assert.isDefined(incomingCallContext); - if (incomingCallContext) { - await callAutomationClient.answerCall(incomingCallContext, callBackUrl); - } - const callConnectedEvent = await waitForEvent("CallConnected", callConnectionId, 10000); - assert.isDefined(callConnectedEvent); - callConnection = result.callConnection; - const testUser3: CommunicationUserIdentifier = await createTestUser(recorder); - const participantInvite = new CallInvite(testUser3); - const uniqueId2 = await serviceBusWithNewCall(testUser, testUser3); - const callBackUrl2: string = dispatcherCallback + `?q=${uniqueId2}`; - - const addResult = await callConnection.addParticipant(participantInvite); - assert.isDefined(addResult); - - const anotherIncomingCallContext = await waitForIncomingCallContext(uniqueId2, 20000); - if (anotherIncomingCallContext) { - await callAutomationClient.answerCall(anotherIncomingCallContext, callBackUrl2); - } - const participantAddedEvent = await waitForEvent( - "AddParticipantSucceeded", - callConnectionId, - 10000 - ); - assert.isDefined(participantAddedEvent); - - const callProperties = await callConnection.getCallConnectionProperties(); - assert.isDefined(callProperties); - }).timeout(90000); - - it("Remove a participant", async function () { - testName = this.test?.fullTitle() - ? this.test?.fullTitle().replace(/ /g, "_") - : "remove_a_participant"; - await loadPersistedEvents(testName); - - const callInvite = new CallInvite(testUser2); - const uniqueId = await serviceBusWithNewCall(testUser, testUser2); - const callBackUrl: string = dispatcherCallback + `?q=${uniqueId}`; - const result = await callAutomationClient.createCall(callInvite, callBackUrl); - const incomingCallContext = await waitForIncomingCallContext(uniqueId, 8000); - callConnectionId = result.callConnectionProperties.callConnectionId - ? result.callConnectionProperties.callConnectionId - : ""; - assert.isDefined(incomingCallContext); - if (incomingCallContext) { - await callAutomationClient.answerCall(incomingCallContext, callBackUrl); - } - const callConnectedEvent = await waitForEvent("CallConnected", callConnectionId, 8000); - assert.isDefined(callConnectedEvent); - callConnection = result.callConnection; - const removeResult = await callConnection.removeParticipant(testUser2); - assert.isDefined(removeResult); - - // A call needs at least 2 participants, removing one of the only 2 participants would end the call. - const callEndedEvent = await waitForEvent("CallDisconnected", callConnectionId, 8000); - assert.isDefined(callEndedEvent); - }).timeout(60000); -}); diff --git a/sdk/communication/communication-call-automation/test/public/utils/connectionUtils.ts b/sdk/communication/communication-call-automation/test/public/utils/connectionUtils.ts deleted file mode 100644 index 829a6230cfdb..000000000000 --- a/sdk/communication/communication-call-automation/test/public/utils/connectionUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { isNode } from "@azure/core-util"; - -export const baseUri = "https://contoso.api.fake"; - -declare function btoa(stringToEncode: string): string; - -export const generateToken = (): string => { - const validForMinutes = 60; - const expiresOn = (Date.now() + validForMinutes * 60 * 1000) / 1000; - const tokenString = JSON.stringify({ exp: expiresOn }); - const base64Token = isNode ? Buffer.from(tokenString).toString("base64") : btoa(tokenString); - return `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${base64Token}.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs`; -}; diff --git a/sdk/communication/communication-call-automation/test/public/utils/recordedClient.ts b/sdk/communication/communication-call-automation/test/public/utils/recordedClient.ts deleted file mode 100644 index 85da737a5e5b..000000000000 --- a/sdk/communication/communication-call-automation/test/public/utils/recordedClient.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import * as dotenv from "dotenv"; -import { isNode } from "@azure/core-util"; -import fs from "fs"; -import { - Recorder, - RecorderStartOptions, - env, - assertEnvironmentVariable, - isRecordMode, - isPlaybackMode, - relativeRecordingsPath, -} from "@azure-tools/test-recorder"; -import { Test } from "mocha"; -import { generateToken } from "./connectionUtils"; -import { - CommunicationIdentityClient, - CommunicationIdentityClientOptions, -} from "@azure/communication-identity"; -import { - CommunicationUserIdentifier, - CommunicationIdentifier, - serializeCommunicationIdentifier, -} from "@azure/communication-common"; -import { - CallAutomationClient, - CallAutomationClientOptions, - CallAutomationEvent, - CallAutomationEventParser, -} from "../../../src"; -import { CommunicationIdentifierModel } from "../../../src/generated/src"; -import { assert } from "chai"; -import fetch from "node-fetch"; -import { - ServiceBusClient, - ServiceBusReceiver, - ServiceBusReceivedMessage, - ProcessErrorArgs, -} from "@azure/service-bus"; - -if (isNode) { - dotenv.config(); -} - -const envSetupForPlayback: { [k: string]: string } = { - COMMUNICATION_LIVETEST_STATIC_CONNECTION_STRING: "endpoint=https://endpoint/;accesskey=redacted", - DISPATCHER_ENDPOINT: "https://incomingcalldispatcher.azurewebsites.net", - SERVICEBUS_STRING: - "Endpoint=sb://REDACTED.servicebus.windows.net/;SharedAccessKeyName=REDACTED;SharedAccessKey=REDACTED", -}; - -const fakeToken = generateToken(); -const dispatcherEndpoint: string = - env["DISPATCHER_ENDPOINT"] ?? envSetupForPlayback["DISPATCHER_ENDPOINT"]; -const serviceBusConnectionString: string = - env["SERVICEBUS_STRING"] ?? envSetupForPlayback["SERVICEBUS_STRING"]; - -export const dispatcherCallback: string = dispatcherEndpoint + "/api/servicebuscallback/events"; -export const serviceBusReceivers: Map = new Map< - string, - ServiceBusReceiver ->(); -export const incomingCallContexts: Map = new Map(); -export const events: Map> = new Map< - string, - Map ->(); -export const eventsToPersist: string[] = []; -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - -function removeAllNonChar(input: string): string { - const regex = new RegExp("[^a-zA-Z0-9_-]", "g"); - return input.replace(regex, ""); -} - -export function parseIdsFromIdentifier(identifier: CommunicationIdentifier): string { - const communicationIdentifierModel: CommunicationIdentifierModel = - serializeCommunicationIdentifier(identifier); - assert.isDefined(communicationIdentifierModel?.rawId); - return communicationIdentifierModel?.rawId - ? removeAllNonChar(communicationIdentifierModel.rawId) - : ""; -} - -function createServiceBusClient(): ServiceBusClient { - return new ServiceBusClient(serviceBusConnectionString); -} - -export const recorderOptions: RecorderStartOptions = { - envSetupForPlayback, - sanitizerOptions: { - connectionStringSanitizers: [ - { - fakeConnString: envSetupForPlayback["COMMUNICATION_LIVETEST_STATIC_CONNECTION_STRING"], - actualConnString: env["COMMUNICATION_LIVETEST_STATIC_CONNECTION_STRING"] || undefined, - }, - ], - bodyKeySanitizers: [{ jsonPath: "$.accessToken.token", value: fakeToken }], - }, -}; - -export async function createRecorder(context: Test | undefined): Promise { - const recorder = new Recorder(context); - await recorder.start(recorderOptions); - await recorder.setMatcher("HeaderlessMatcher"); - return recorder; -} - -export async function createTestUser(recorder: Recorder): Promise { - const identityClient = new CommunicationIdentityClient( - assertEnvironmentVariable("COMMUNICATION_LIVETEST_STATIC_CONNECTION_STRING"), - recorder.configureClientOptions({}) as CommunicationIdentityClientOptions - ); - return identityClient.createUser(); -} - -export function createCallAutomationClient( - recorder: Recorder, - sourceIdentity: CommunicationUserIdentifier -): CallAutomationClient { - const connectionString = assertEnvironmentVariable( - "COMMUNICATION_LIVETEST_STATIC_CONNECTION_STRING" - ); - const options: CallAutomationClientOptions = { - sourceIdentity: sourceIdentity, - }; - return new CallAutomationClient(connectionString, recorder.configureClientOptions(options)); -} - -async function eventBodyHandler(body: any): Promise { - if (body.incomingCallContext) { - const incomingCallContext: string = body.incomingCallContext; - const callerRawId: string = body.from.rawId; - const calleeRawId: string = body.to.rawId; - const key: string = removeAllNonChar(callerRawId + calleeRawId); - incomingCallContexts.set(key, incomingCallContext); - } else { - const eventParser: CallAutomationEventParser = new CallAutomationEventParser(); - const event: CallAutomationEvent = await eventParser.parse(body); - if (event.callConnectionId) { - if (events.has(event.callConnectionId)) { - events.get(event.callConnectionId)?.set(event.kind, event); - } else { - const temp: Map = new Map(); - temp.set(event.kind, event); - events.set(event.callConnectionId, temp); - } - } - } -} - -export async function serviceBusWithNewCall( - caller: CommunicationIdentifier, - receiver: CommunicationIdentifier -): Promise { - const callerId: string = parseIdsFromIdentifier(caller); - const receiverId: string = parseIdsFromIdentifier(receiver); - const uniqueId: string = callerId + receiverId; - - if (!isPlaybackMode()) { - // subscribe to event dispatcher - const dispatcherUrl: string = - dispatcherEndpoint + `/api/servicebuscallback/subscribe?q=${uniqueId}`; - - try { - await fetch(dispatcherUrl, { - method: "POST", - body: JSON.stringify({}), - headers: { - "Content-type": "application/json; charset=UTF-8", - }, - }); - } catch (e) { - console.log("Error occurred", e); - } - - // create a service bus processor - const serviceBusClient = createServiceBusClient(); - const serviceBusReceiver: ServiceBusReceiver = serviceBusClient.createReceiver(uniqueId); - - // function to handle messages - const messageHandler = async (messageReceived: ServiceBusReceivedMessage): Promise => { - if (isRecordMode()) { - const messageInString: string = JSON.stringify(messageReceived.body); - eventsToPersist.push(messageInString); - } - await eventBodyHandler(messageReceived.body); - }; - - // function to handle any errors - const errorHandler = async (error: ProcessErrorArgs): Promise => { - console.log(error); - }; - - // subscribe and specify the message and error handlers - serviceBusReceiver.subscribe({ - processMessage: messageHandler, - processError: errorHandler, - }); - - serviceBusReceivers.set(uniqueId, serviceBusReceiver); - } - return uniqueId; -} - -export async function waitForIncomingCallContext( - uniqueId: string, - timeOut: number -): Promise { - let currentTime = new Date().getTime(); - const timeOutTime = currentTime + timeOut; - while (currentTime < timeOutTime) { - const incomingCallContext = incomingCallContexts.get(uniqueId); - if (incomingCallContext) { - return incomingCallContext; - } - await sleep(1000); - currentTime += 1000; - } - return ""; -} - -export async function waitForEvent( - eventName: string, - callConnectionId: string, - timeOut: number -): Promise { - let currentTime = new Date().getTime(); - const timeOutTime = currentTime + timeOut; - while (currentTime < timeOutTime) { - const eventGroup = events.get(callConnectionId); - if (eventGroup && eventGroup.has(eventName)) { - return eventGroup.get(eventName); - } - await sleep(1000); - currentTime += 1000; - } - return undefined; -} - -export function persistEvents(testName: string): void { - if (isRecordMode()) { - fs.writeFile(`recordings\\${testName}.txt`, eventsToPersist.join("\n"), (err) => { - if (err) throw err; - }); - // Clear the array for next test to use - while (eventsToPersist.length > 0) { - eventsToPersist.pop(); - } - } -} - -export async function loadPersistedEvents(testName: string): Promise { - if (isPlaybackMode()) { - let data: string = ""; - console.log("path is: " + relativeRecordingsPath()); - try { - data = fs.readFileSync(`recordings\\${testName}.txt`, "utf-8"); - } catch (e) { - console.log("original path doesn't work"); - data = fs.readFileSync(`recordings/${testName}.txt`, "utf-8"); - } - const eventStrings = data.split("\n"); - - eventStrings.forEach(async (eventString) => { - const event: any = JSON.parse(eventString); - await eventBodyHandler(event); - }); - } -} diff --git a/sdk/communication/communication-call-automation/test/internal/utils/mockUtils.ts b/sdk/communication/communication-call-automation/test/utils/connectionUtils.ts similarity index 59% rename from sdk/communication/communication-call-automation/test/internal/utils/mockUtils.ts rename to sdk/communication/communication-call-automation/test/utils/connectionUtils.ts index 869e82650ae6..1464b139e494 100644 --- a/sdk/communication/communication-call-automation/test/internal/utils/mockUtils.ts +++ b/sdk/communication/communication-call-automation/test/utils/connectionUtils.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { isNode } from "@azure/core-util"; + +export const baseUri = "https://contoso.api.fake"; + export const MOCK_ENDPOINT = "https://REDACTED.communication.azure.com/"; export const MOCK_CONNECTION_STRING = `endpoint=${MOCK_ENDPOINT};accesskey=eyJhbG`; export const CALL_CONNECTION_ID = "callConnectionId"; @@ -14,3 +18,13 @@ export const CALL_CALLBACK_URL = "https://REDACTED.com/events"; export const CALL_INCOMING_CALL_CONTEXT = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.REDACTED"; export const CALL_OPERATION_CONTEXT = "operationContext"; export const MEDIA_SUBSCRIPTION_ID = "mediaSubscriptionId"; + +declare function btoa(stringToEncode: string): string; + +export const generateToken = (): string => { + const validForMinutes = 60; + const expiresOn = (Date.now() + validForMinutes * 60 * 1000) / 1000; + const tokenString = JSON.stringify({ exp: expiresOn }); + const base64Token = isNode ? Buffer.from(tokenString).toString("base64") : btoa(tokenString); + return `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${base64Token}.adM-ddBZZlQ1WlN3pdPBOF5G4Wh9iZpxNP_fSvpF4cWs`; +};