Repository: tjtanjin/react-chatbotify
Pull request #162 (2024-10-07 15:46:04)

test:added mediaDisplay Unit test
#### Description

Added unit test for the MediaDisplay component

Closes #(issue)

#### [#149](https://github.com/tjtanjin/react-chatbotify/issues/149)

Please select the relevant option(s).

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update (changes to docs/code comments)

#### What is the proposed approach?

 Tested for image rendering correctly, video rendering correctly if type is video and also audio if type is audio

#### Checklist:

- [x] The commit message follows our adopted [guidelines](https://www.conventionalcommits.org/en/v1.0.0/)
- [x] Testing has been done for the change(s) added (for bug fixes/features)
- [x] Relevant comments/docs have been added/updated (for bug fixes/features)


Patch:
diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml
index f3aafb2..8d1cab4 100644
--- a/.github/workflows/lint-and-build.yml
+++ b/.github/workflows/lint-and-build.yml
@@ -35,7 +35,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [16.x, 17.x, 18.x, 19.x, 20.x, 21.x, 22.x]
+        node-version: [18.x, 20.x, 22.x]
 
     steps:
       - name: Checkout code
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ffb79d..ae77767 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # CHANGELOG.md
 
+## v2.0.0-beta.16 (08-10-2024)
+
+**Fixed:**
+- Fixed an issue where themes may not load properly
+- Buttons now properly inherit base styles even in different states (e.g. disabled, hovered)
+
 ## v2.0.0-beta.15 (06-10-2024)
 
 **Fixed:**
diff --git a/package-lock.json b/package-lock.json
index 76ac27a..f304ba3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "react-chatbotify",
-  "version": "2.0.0-beta.15",
+  "version": "2.0.0-beta.16",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "react-chatbotify",
-      "version": "2.0.0-beta.15",
+      "version": "2.0.0-beta.16",
       "license": "MIT",
       "devDependencies": {
         "@testing-library/jest-dom": "^6.5.0",
diff --git a/package.json b/package.json
index 1aec58a..30028fb 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
   "files": [
     "./dist"
   ],
-  "version": "2.0.0-beta.15",
+  "version": "2.0.0-beta.16",
   "description": "A modern React library for creating flexible and extensible chatbots.",
   "type": "module",
   "main": "./dist/index.cjs",
diff --git a/src/components/Buttons/AudioButton/AudioButton.tsx b/src/components/Buttons/AudioButton/AudioButton.tsx
index 9fa9d15..55bf0f1 100644
--- a/src/components/Buttons/AudioButton/AudioButton.tsx
+++ b/src/components/Buttons/AudioButton/AudioButton.tsx
@@ -28,6 +28,7 @@ const AudioButton = () => {
 	// styles for audio disabled icon
 	const audioIconDisabledStyle: React.CSSProperties = {
 		backgroundImage: `url(${settings.audio?.icon})`,
+		...styles.audioIconStyle, // by default inherit the base style
 		...styles.audioIconDisabledStyle
 	};
 
@@ -39,7 +40,10 @@ const AudioButton = () => {
 				event.preventDefault();
 				toggleAudio();
 			}}
-			style={audioToggledOn ? styles.audioButtonStyle : styles.audioButtonDisabledStyle}
+			style={audioToggledOn
+				? styles.audioButtonStyle
+				: {...styles.audioButtonStyle, ...styles.audioButtonDisabledStyle}
+			}
 		>
 			<span
 				className={`rcb-audio-icon-${audioToggledOn ? "on" : "off"}`}
diff --git a/src/components/Buttons/EmojiButton/EmojiButton.tsx b/src/components/Buttons/EmojiButton/EmojiButton.tsx
index 4f41d73..21f8f6b 100644
--- a/src/components/Buttons/EmojiButton/EmojiButton.tsx
+++ b/src/components/Buttons/EmojiButton/EmojiButton.tsx
@@ -36,6 +36,7 @@ const EmojiButton = () => {
 	// styles for emoji disabled button
 	const emojiButtonDisabledStyle: React.CSSProperties = {
 		cursor: `url(${settings.general?.actionDisabledIcon}), auto`,
+		...styles.emojiButtonStyle, // by default inherit the base style
 		...styles.emojiButtonDisabledStyle
 	};
 
@@ -48,6 +49,7 @@ const EmojiButton = () => {
 	// styles for emoji disabled icon
 	const emojiIconDisabledStyle: React.CSSProperties = {
 		backgroundImage: `url(${settings.emoji?.icon})`,
+		...styles.emojiIconStyle, // by default inherit the base style
 		...styles.emojiIconDisabledStyle
 	};
 
diff --git a/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx b/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx
index 11b4144..d4487ba 100644
--- a/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx
+++ b/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx
@@ -55,6 +55,7 @@ const FileAttachmentButton = () => {
 	// styles for file attachment disabled button
 	const fileAttachmentButtonDisabledStyle = {
 		cursor: `url(${settings.general?.actionDisabledIcon}), auto`,
+		...styles.fileAttachmentButtonStyle, // by default inherit the base style
 		...styles.fileAttachmentButtonDisabledStyle
 	}
 
@@ -67,6 +68,7 @@ const FileAttachmentButton = () => {
 	// styles for file attachment disabled icon
 	const fileAttachmentIconDisabledStyle: React.CSSProperties = {
 		backgroundImage: `url(${settings.fileAttachment?.icon})`,
+		...styles.fileAttachmentIconStyle, // by default inherit the base style
 		...styles.fileAttachmentIconDisabledStyle
 	};
 
diff --git a/src/components/Buttons/NotificationButton/NotificationButton.tsx b/src/components/Buttons/NotificationButton/NotificationButton.tsx
index 284a745..c27cda5 100644
--- a/src/components/Buttons/NotificationButton/NotificationButton.tsx
+++ b/src/components/Buttons/NotificationButton/NotificationButton.tsx
@@ -28,6 +28,7 @@ const NotificationButton = () => {
 	// styles for notification disabled icon
 	const notificationIconDisabledStyle: React.CSSProperties = {
 		backgroundImage: `url(${settings.notification?.icon})`,
+		...styles.notificationIconStyle, // by default inherit the base style
 		...styles.notificationIconDisabledStyle
 	};
 
@@ -39,7 +40,10 @@ const NotificationButton = () => {
 				event.preventDefault();
 				toggleNotifications();
 			}}
-			style={notificationsToggledOn ? styles.notificationButtonStyle : styles.notificationButtonDisabledStyle}
+			style={notificationsToggledOn
+				? styles.notificationButtonStyle
+				: {...styles.notificationButtonStyle, ...styles.notificationButtonDisabledStyle}
+			}
 		>
 			<span
 				className={`rcb-notification-icon-${
diff --git a/src/components/Buttons/SendButton/SendButton.tsx b/src/components/Buttons/SendButton/SendButton.tsx
index 7b55a44..ea6cf8e 100644
--- a/src/components/Buttons/SendButton/SendButton.tsx
+++ b/src/components/Buttons/SendButton/SendButton.tsx
@@ -36,12 +36,14 @@ const SendButton = () => {
 	const sendButtonDisabledStyle: React.CSSProperties = {
 		cursor: `url(${settings.general?.actionDisabledIcon}), auto`,
 		backgroundColor: settings.general?.primaryColor,
+		...styles.sendButtonStyle, // by default inherit the base style
 		...styles.sendButtonDisabledStyle
 	};
 
 	// styles for hovered send button
 	const sendButtonHoveredStyle: React.CSSProperties = {
 		backgroundColor: settings.general?.secondaryColor,
+		...styles.sendButtonStyle, // by default inherit the base style
 		...styles.sendButtonHoveredStyle
 	};
 	
diff --git a/src/components/Buttons/VoiceButton/VoiceButton.tsx b/src/components/Buttons/VoiceButton/VoiceButton.tsx
index da5e0ce..ab3aff5 100644
--- a/src/components/Buttons/VoiceButton/VoiceButton.tsx
+++ b/src/components/Buttons/VoiceButton/VoiceButton.tsx
@@ -72,6 +72,7 @@ const VoiceButton = () => {
 	// styles for voice disabled icon
 	const voiceIconDisabledStyle: React.CSSProperties = {
 		backgroundImage: `url(${settings.voice?.icon})`,
+		...styles.voiceIconStyle, // by default inherit the base style
 		...styles.voiceIconDisabledStyle
 	};
 
@@ -109,7 +110,10 @@ const VoiceButton = () => {
 				}
 				toggleVoice();
 			}}
-			style={voiceToggledOn && !textAreaDisabled ? styles.voiceButtonStyle : styles.voiceButtonDisabledStyle}
+			style={voiceToggledOn && !textAreaDisabled
+				? styles.voiceButtonStyle
+				: {...styles.voiceButtonStyle, ...styles.voiceButtonDisabledStyle}
+			}
 			className={voiceToggledOn && !textAreaDisabled ? "rcb-voice-button-enabled" : "rcb-voice-button-disabled"}
 		>
 			<span
diff --git a/src/components/ChatBot.tsx b/src/components/ChatBot.tsx
index dbdffae..22c32e4 100644
--- a/src/components/ChatBot.tsx
+++ b/src/components/ChatBot.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useMemo, useRef, useState } from "react";
 
 import ChatBotContainer from "./ChatBotContainer";
 import ChatBotLoader from "./ChatBotLoader";
@@ -9,8 +9,6 @@ import { Styles } from "../types/Styles";
 import { Flow } from "../types/Flow";
 import { Theme } from "../types/Theme";
 import { Plugin } from "../types/Plugin";
-import { DefaultSettings } from "../constants/internal/DefaultSettings";
-import { DefaultStyles } from "../constants/internal/DefaultStyles";
 import { WelcomeFlow } from "../constants/internal/WelcomeFlow";
 
 /**
@@ -24,10 +22,10 @@ import { WelcomeFlow } from "../constants/internal/WelcomeFlow";
  * @param plugins plugins to initialize
  */
 const ChatBot = ({
-	id = generateSecureUUID(),
-	flow = WelcomeFlow,
-	settings = DefaultSettings,
-	styles = DefaultStyles,
+	id,
+	flow,
+	settings,
+	styles,
 	themes = [],
 	plugins = []
 }: {
@@ -38,20 +36,12 @@ const ChatBot = ({
 	themes?: Theme | Array<Theme>;
 	plugins?: Array<Plugin>;
 }) => {
-	// handles case where flow is empty
-	if (Object.keys(flow).length === 0) {
-		flow = WelcomeFlow;
-	}
 
-	// handles case where settings is empty
-	if (Object.keys(settings).length === 0) {
-		settings = DefaultSettings;
-	}
-
-	// handles case where styles is empty
-	if (Object.keys(styles).length === 0) {
-		styles = DefaultStyles;
-	}
+	// handles cases where any props are empty
+	const finalBotId = useMemo(() => id || "rcb-" + generateSecureUUID(), []);
+	const finalFlow = (!flow || Object.keys(flow).length === 0) ? WelcomeFlow : flow;
+	const finalSettings = !settings ? {} : settings;
+	const finalStyles = !styles ? {} : styles;
 
 	// handles loading of chatbot only when config is loaded
 	const [configLoaded, setConfigLoaded] = useState<boolean>(false);
@@ -67,8 +57,8 @@ const ChatBot = ({
 	 */
 	const renderChatBot = () => (
 		<>
-			<ChatBotLoader styleRootRef={styleRootRef} id={id} flow={flow} settings={settings}
-				styles={styles} themes={themes} plugins={plugins} setConfigLoaded={setConfigLoaded}
+			<ChatBotLoader styleRootRef={styleRootRef} id={finalBotId} flow={finalFlow} settings={finalSettings}
+				styles={finalStyles} themes={themes} plugins={plugins} setConfigLoaded={setConfigLoaded}
 			/>
 			{configLoaded && <ChatBotContainer plugins={plugins} />}
 		</>
@@ -78,12 +68,12 @@ const ChatBot = ({
 		chatBotContext == null ? ( 
 			<ChatBotProvider>
 				<style ref={styleRootRef}/>
-				<div id={id}>{renderChatBot()}</div>
+				<div id={finalBotId}>{renderChatBot()}</div>
 			</ChatBotProvider>
 		) : (
 			<>
 				<style ref={styleRootRef}/>
-				<div id={id}>{renderChatBot()}</div>
+				<div id={finalBotId}>{renderChatBot()}</div>
 			</>
 		)
 	)
diff --git a/src/components/ChatBotBody/MediaDisplay/MediaDisplay.tsx b/src/components/ChatBotBody/MediaDisplay/MediaDisplay.tsx
index bee4d91..a3fcea0 100644
--- a/src/components/ChatBotBody/MediaDisplay/MediaDisplay.tsx
+++ b/src/components/ChatBotBody/MediaDisplay/MediaDisplay.tsx
@@ -43,6 +43,7 @@ const MediaDisplay = ({
 						<div
 							style={mediaDisplayContainerStyle}
 							className="rcb-media-display-image-container rcb-media-entry"
+							data-testid="media-display-image-container"
 						>
 							<img src={fileUrl} alt="Image Display" className="rcb-media-display-image" />
 						</div>
@@ -51,6 +52,7 @@ const MediaDisplay = ({
 						<div 
 							style={mediaDisplayContainerStyle}
 							className="rcb-media-display-video-container rcb-media-entry"
+							data-testid="media-display-video-container"
 						>
 							<video controls className="rcb-media-display-video">
 								<source src={fileUrl} type={file.type} />
@@ -60,6 +62,7 @@ const MediaDisplay = ({
 					)}
 					{fileType === "audio" && fileUrl && (
 						<audio
+							data-testid="media-display-audio-container" 
 							style={{
 								maxWidth: settings.userBubble?.showAvatar ? "65%" : "70%",
 							}}
diff --git a/src/components/ChatBotLoader.tsx b/src/components/ChatBotLoader.tsx
index f96d0ec..7cba752 100644
--- a/src/components/ChatBotLoader.tsx
+++ b/src/components/ChatBotLoader.tsx
@@ -1,4 +1,4 @@
-import { Dispatch, MutableRefObject, SetStateAction, useEffect } from "react";
+import { Dispatch, MutableRefObject, SetStateAction, useEffect, useMemo } from "react";
 
 import { emitRcbEvent } from "../services/RcbEventService";
 import { useChatBotContext } from "../context/ChatBotContext";
@@ -40,6 +40,9 @@ const ChatBotLoader = ({
 	setConfigLoaded: Dispatch<SetStateAction<boolean>>;
 	styleRootRef: MutableRefObject<HTMLStyleElement | null>;
 }) => {
+	// memoized chatbot id
+	const memoizedId = useMemo(() => id, []);
+
 	// used to load config to the provider
 	const chatBotContext = useChatBotContext();
 
@@ -58,7 +61,7 @@ const ChatBotLoader = ({
 	const runLoadConfig = async () => {
 		// handles pre load chatbot event
 		if (settings.event?.rcbPreLoadChatBot) {
-			const event = emitRcbEvent(RcbEvent.PRE_LOAD_CHATBOT, {botId: id, currPath: null, prevPath: null}, 
+			const event = emitRcbEvent(RcbEvent.PRE_LOAD_CHATBOT, {botId: memoizedId, currPath: null, prevPath: null}, 
 				{
 					flow,
 					settings,
@@ -73,13 +76,13 @@ const ChatBotLoader = ({
 		}
 
 		if (chatBotContext?.loadConfig) {
-			await chatBotContext.loadConfig(id, flow, settings, styles, themes, styleRootRef);
+			await chatBotContext.loadConfig(memoizedId, flow, settings, styles, themes, styleRootRef);
 			setConfigLoaded(true);
 		}
 
 		// handles post load chatbot event
 		if (settings.event?.rcbPostLoadChatBot) {
-			emitRcbEvent(RcbEvent.POST_LOAD_CHATBOT, {botId: id, currPath: null, prevPath: null}, 
+			emitRcbEvent(RcbEvent.POST_LOAD_CHATBOT, {botId: memoizedId, currPath: null, prevPath: null}, 
 				{
 					flow,
 					settings,
@@ -92,9 +95,8 @@ const ChatBotLoader = ({
 	}
 
 	useEffect(() => {
-		console.log("I'm running!");
 		runLoadConfig();
-	}, [flow, settings, styles, themes, plugins]);
+	}, [themes]);
 
 	return null;
 };
diff --git a/src/components/ChatHistoryButton/ChatHistoryButton.tsx b/src/components/ChatHistoryButton/ChatHistoryButton.tsx
index e749c0b..08bc89c 100644
--- a/src/components/ChatHistoryButton/ChatHistoryButton.tsx
+++ b/src/components/ChatHistoryButton/ChatHistoryButton.tsx
@@ -26,6 +26,7 @@ const ChatHistoryButton = () => {
 	const chatHistoryButtonHoveredStyle: React.CSSProperties = {
 		color: settings.general?.primaryColor,
 		borderColor: settings.general?.primaryColor,
+		...styles.chatHistoryButtonStyle, // by default inherit the base style
 		...styles.chatHistoryButtonHoveredStyle
 	};
 


Test patch:
diff --git a/__tests__/components/ChatBotBody/MediaDisplay.test.tsx b/__tests__/components/ChatBotBody/MediaDisplay.test.tsx
new file mode 100644
index 0000000..8a69660
--- /dev/null
+++ b/__tests__/components/ChatBotBody/MediaDisplay.test.tsx
@@ -0,0 +1,89 @@
+
+import React from "react";
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import MediaDisplay from "../../../src/components/ChatBotBody/MediaDisplay/MediaDisplay";
+import { useSettingsContext } from "../../../src/context/SettingsContext";
+import { useStylesContext } from "../../../src/context/StylesContext";
+
+// Mock the contexts
+jest.mock("../../../src/context/SettingsContext", () => ({
+	useSettingsContext: jest.fn(),
+}));
+
+jest.mock("../../../src/context/StylesContext", () => ({
+	useStylesContext: jest.fn(),
+}));
+
+const mockSettingsContext = {
+	settings: {
+		general: { primaryColor: "#000000" },
+		userBubble: { showAvatar: true },
+	}
+};
+
+const mockStylesContext = {
+	styles: {
+		mediaDisplayContainerStyle: { border: "1px solid red" }
+	}
+};
+
+// Helper function to render MediaDisplay with mocked contexts
+const renderMediaDisplay = (file: File, fileType: string, fileUrl: string | null) => {
+	(useSettingsContext as jest.Mock).mockReturnValue(mockSettingsContext);
+	(useStylesContext as jest.Mock).mockReturnValue(mockStylesContext);
+	render(<MediaDisplay file={file} fileType={fileType} fileUrl={fileUrl} />);
+};
+
+describe("MediaDisplay", () => {
+	it("renders an image correctly when fileType is 'image'", () => {
+		const file = new File(["dummy content"], "tanjin.png", { type: "image/png" });
+		renderMediaDisplay(file, "image", "https://react-chatbot.com/tanjin.png");
+
+		const imageElement = screen.getByRole("img");
+		expect(imageElement).toBeInTheDocument();
+		expect(imageElement).toHaveAttribute("src", "https://react-chatbot.com/tanjin.png");
+
+		const container = screen.getByTestId("media-display-image-container");
+		expect(container).toHaveStyle({
+			backgroundColor: "#000000",
+			maxWidth: "65%",
+			border: "1px solid red",
+		});
+	});
+
+	it("renders a video correctly when fileType is 'video'", () => {
+		const file = new File(["dummy content"], "tanjin.mp4", { type: "video/mp4" });
+		renderMediaDisplay(file, "video", "https://react-chatbot.com/tanjin.mp4");
+
+		const videoContainer = screen.getByTestId("media-display-video-container");
+		expect(videoContainer).toBeInTheDocument();
+
+		const sourceElement = videoContainer.querySelector("source");
+		expect(sourceElement).toBeInTheDocument();
+		expect(sourceElement).toHaveAttribute("src", "https://react-chatbot.com/tanjin.mp4");
+		expect(sourceElement).toHaveAttribute("type", "video/mp4");
+	});
+
+	it("renders audio correctly when fileType is 'audio'", () => {
+		const file = new File(["dummy content"], "tanjin.mp3", { type: "audio/mp3" });
+		renderMediaDisplay(file, "audio", "https://react-chatbot.com/tanjin.mp3");
+
+		const audioContainer = screen.getByTestId("media-display-audio-container");
+		expect(audioContainer).toBeInTheDocument();
+
+		const sourceElement = audioContainer.querySelector("source");
+		expect(sourceElement).toBeInTheDocument();
+		expect(sourceElement).toHaveAttribute("src", "https://react-chatbot.com/tanjin.mp3");
+		expect(sourceElement).toHaveAttribute("type", "audio/mp3");
+	});
+
+	it("renders nothing if fileUrl is null", () => {
+		const file = new File(["dummy content"], "tanjin.png", { type: "image/png" });
+		renderMediaDisplay(file, "image", null);
+
+		expect(screen.queryByRole("img")).not.toBeInTheDocument();
+		expect(screen.queryByRole("video")).not.toBeInTheDocument();
+		expect(screen.queryByRole("audio")).not.toBeInTheDocument();
+	});
+});
\ No newline at end of file
diff --git a/__tests__/components/buttons/CloseChatButton.test.tsx b/__tests__/components/buttons/CloseChatButton.test.tsx
new file mode 100644
index 0000000..71bf39c
--- /dev/null
+++ b/__tests__/components/buttons/CloseChatButton.test.tsx
@@ -0,0 +1,187 @@
+import React from "react";
+
+import { expect } from "@jest/globals";
+import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom/jest-globals";
+
+import CloseChatButton from "../../../src/components/Buttons/CloseChatButton/CloseChatButton";
+import { DefaultSettings } from "../../../src/constants/internal/DefaultSettings";
+
+import { useChatWindowInternal } from "../../../src/hooks/internal/useChatWindowInternal";
+import { useSettingsContext } from "../../../src/context/SettingsContext";
+import { useStylesContext } from "../../../src/context/StylesContext";
+
+// Mock the hooks used in the component
+jest.mock("../../../src/hooks/internal/useChatWindowInternal");
+jest.mock("../../../src/context/SettingsContext");
+jest.mock("../../../src/context/StylesContext");
+
+/**
+ * Test for CloseChatButton component.
+ */
+describe("CloseChatButton", () => {
+	const openChatMock = jest.fn();
+
+	beforeEach(() => {
+		// Mock the return value of useChatWindowInternal hook
+		(useChatWindowInternal as jest.Mock).mockReturnValue({
+			openChat: openChatMock,
+		});
+
+		// Mock the return value of useSettingsContext hook
+		(useSettingsContext as jest.Mock).mockReturnValue({
+			settings: {
+				header: { closeChatIcon: DefaultSettings.header?.closeChatIcon },
+				ariaLabel: { closeChatButton: DefaultSettings.ariaLabel?.closeChatButton },
+			},
+		});
+
+		// Mock the return value of useStylesContext hook
+		(useStylesContext as jest.Mock).mockReturnValue({
+			styles: {
+				closeChatIconStyle: { color: "red" }, 
+				closeChatButtonStyle: { backgroundColor: "gray" },
+			},
+		});
+	});
+
+	it("renders the CloseChatButton component", () => {
+		// Render the CloseChatButton component
+		render(<CloseChatButton />);
+
+		// Get the button element by its role and name using default settings
+		const button = screen.getByRole("button", { name: DefaultSettings.ariaLabel?.closeChatButton });
+
+		// Assert that the button is in the document
+		expect(button).toBeInTheDocument();
+	});
+
+	it("displays the correct aria-label", () => {
+		// Render the CloseChatButton component
+		render(<CloseChatButton />);
+		// Get the button element by its role and name using default settings
+		const button = screen.getByRole("button", { name: DefaultSettings.ariaLabel?.closeChatButton });
+
+		// Assert that the button has the correct aria-label attribute
+		expect(button).toHaveAttribute("aria-label", DefaultSettings.ariaLabel?.closeChatButton);
+	});
+
+	it("applies the correct background image to the close chat icon", () => {
+		// Render the CloseChatButton component
+		render(<CloseChatButton />);
+		// Get the button element by its role
+		const button = screen.getByRole("button");
+		// Get the span element inside the button (assumed to be the icon)
+		const icon = button.querySelector("span");
+
+		// Check if the background image is set correctly
+		expect(icon).toHaveStyle(`background-image: url(${DefaultSettings.header?.closeChatIcon})`);
+	});
+
+	it("applies the default aria-label when none is provided in settings", () => {
+		// Mock settings without ariaLabel.closeChatButton
+		(useSettingsContext as jest.Mock).mockReturnValue({
+			settings: {
+				header: { closeChatIcon: DefaultSettings.header?.closeChatIcon },
+				ariaLabel: {},
+			},
+		});
+
+		render(<CloseChatButton />);
+
+		// Check if the aria-label falls back to "close chat"
+		const button = screen.getByRole("button", { name: DefaultSettings.ariaLabel?.closeChatButton });
+		expect(button).toHaveAttribute("aria-label", DefaultSettings.ariaLabel?.closeChatButton);
+	});
+
+	it("applies the correct styles to the button and icon", () => {
+		// Render the CloseChatButton component
+		render(<CloseChatButton />);
+		// Get the button element by its role
+		const button = screen.getByRole("button");
+		// Get the span element inside the button (assumed to be the icon)
+		const icon = button.querySelector("span");
+
+		// Assert that the button has the correct background color
+		expect(button).toHaveStyle("background-color: gray");
+		// Assert that the icon has the correct color
+		expect(icon).toHaveStyle("color: red");
+		// Assert that the icon has the correct background image
+		expect(icon).toHaveStyle(`background-image: url(${DefaultSettings.header?.closeChatIcon})`);
+	});
+
+	it("calls openChat(false) when the button is clicked", () => {
+		// Render the CloseChatButton component
+		render(<CloseChatButton />);
+		// Get the button element by its role
+		const button = screen.getByRole("button");
+		// Fire the mouseDown event on the button
+		fireEvent.mouseDown(button);
+		// Assert that openChatMock was called with false
+		expect(openChatMock).toHaveBeenCalledWith(false);
+	});
+
+	it("stops propagation of mouse down event", () => {
+		// Render the component
+		render(<CloseChatButton />);
+		const button = screen.getByRole("button");
+
+		// Spy on the stopPropagation method of the event
+		const stopPropagationSpy = jest.spyOn(MouseEvent.prototype, "stopPropagation");
+
+		// Fire the mouseDown event
+		fireEvent.mouseDown(button);
+
+		// Assert that stopPropagation was called
+		expect(stopPropagationSpy).toHaveBeenCalled();
+
+		// Clean up the spy
+		stopPropagationSpy.mockRestore();
+	});
+
+	it("handles undefined settings.header gracefully", () => {
+		// Mock settings with undefined header
+		(useSettingsContext as jest.Mock).mockReturnValue({
+			settings: {
+				header: undefined, // Simulate missing header
+				ariaLabel: { closeChatButton: DefaultSettings.ariaLabel?.closeChatButton },
+			},
+		});
+
+		(useStylesContext as jest.Mock).mockReturnValue({
+			styles: {
+				closeChatIconStyle: { color: "red" },
+				closeChatButtonStyle: { backgroundColor: "gray" },
+			},
+		});
+
+		render(<CloseChatButton />);
+
+		// Check that it renders without crashing
+		const button = screen.getByRole("button", { name: DefaultSettings.ariaLabel?.closeChatButton });
+		expect(button).toBeInTheDocument();
+	});
+
+	it("handles undefined settings.ariaLabel gracefully", () => {
+		// Mock settings without ariaLabel
+		(useSettingsContext as jest.Mock).mockReturnValue({
+			settings: {
+				header: { closeChatIcon: DefaultSettings.header?.closeChatIcon },
+				ariaLabel: undefined, // Simulate missing ariaLabel
+			},
+		});
+
+		(useStylesContext as jest.Mock).mockReturnValue({
+			styles: {
+				closeChatIconStyle: { color: "red" },
+				closeChatButtonStyle: { backgroundColor: "gray" },
+			},
+		});
+
+		render(<CloseChatButton />);
+
+		// Check that it falls back to the default aria-label
+		const button = screen.getByRole("button", { name: DefaultSettings.ariaLabel?.closeChatButton });
+		expect(button).toHaveAttribute("aria-label", DefaultSettings.ariaLabel?.closeChatButton);
+	});
+});
diff --git a/__tests__/hooks/internal/useFirstInteractionInternal.test.ts b/__tests__/hooks/internal/useFirstInteractionInternal.test.ts
index 4ebefe9..76fd19c 100644
--- a/__tests__/hooks/internal/useFirstInteractionInternal.test.ts
+++ b/__tests__/hooks/internal/useFirstInteractionInternal.test.ts
@@ -22,24 +22,24 @@ jest.mock("../../../src/context/SettingsContext", () => ({
 	})),
 }));
 class MockSpeechSynthesisUtterance {
-		text = "";
-		onend: () => void = () => {};
-	
-		constructor() {
-			setTimeout(() => {
-				this.onend();
-			}, 0);
-		}
+	text = "";
+	onend: () => void = () => {};
+
+	constructor() {
+		setTimeout(() => {
+			this.onend();
+		}, 0);
 	}
+}
 	
-	const MockSpeechSynthesis = {
-		speak: jest.fn().mockImplementation((utterance: MockSpeechSynthesisUtterance) => {
-			utterance.onend();
-		}),
-	};
-	
-	global.SpeechSynthesisUtterance = MockSpeechSynthesisUtterance as any;
-	global.speechSynthesis = MockSpeechSynthesis as any;
+const MockSpeechSynthesis = {
+	speak: jest.fn().mockImplementation((utterance: MockSpeechSynthesisUtterance) => {
+		utterance.onend();
+	}),
+};
+
+global.SpeechSynthesisUtterance = MockSpeechSynthesisUtterance as any;
+global.speechSynthesis = MockSpeechSynthesis as any;
 
 describe("useFirstInteractionInternal", () => {
 	let setHasInteractedPage: jest.Mock;
diff --git a/__tests__/services/AudioService.test.ts b/__tests__/services/AudioService.test.ts
new file mode 100644
index 0000000..4171b5e
--- /dev/null
+++ b/__tests__/services/AudioService.test.ts
@@ -0,0 +1,251 @@
+import React from "react";
+
+import { processAudio } from "../../src/services/AudioService";
+import { generateSecureUUID } from "../../src/utils/idGenerator";
+import { Settings } from "../../src/types/Settings";
+import { Message } from "../../src/types/Message";
+
+// mocks SpeechSynthesisUtterance
+class MockSpeechSynthesisUtterance {
+	text: string = "";
+	lang: string = "";
+	rate: number = 1;
+	volume: number = 1;
+	voice: SpeechSynthesisVoice | null = null;
+}
+  
+// sample voices
+const availableVoices: SpeechSynthesisVoice[] = [
+	{
+		name: "Google US English",
+		lang: "en-US",
+		voiceURI: "Google US English",
+		default: true,
+		localService: true,
+	},
+	{
+		name: "Google UK English",
+		lang: "en-GB",
+		voiceURI: "Google UK English",
+		default: false,
+		localService: true,
+	},
+];
+
+// mocks speechSynthesis methods
+const mockSpeak = jest.fn();
+const mockGetVoices = jest.fn().mockReturnValue(availableVoices);
+
+/**
+ * Test for AudioService.
+ */
+describe("AudioService.processAudio (Inline Mocks)", () => {
+	beforeAll(() => {
+		(global as any).SpeechSynthesisUtterance = MockSpeechSynthesisUtterance;
+		(global as any).speechSynthesis = {
+			speak: mockSpeak,
+			getVoices: mockGetVoices,
+		};
+	});
+
+	beforeEach(() => {
+		jest.clearAllMocks();
+		mockGetVoices.mockReturnValue(availableVoices);
+	});
+  
+	it("does not call speak when audio is disabled", () => {
+		// settings for audio
+		const settings: Settings = {
+			audio: {
+				disabled: true,
+				voiceNames: ["Google US English"]
+			}
+		};
+
+		// calls process audio
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "user",
+			content: "Hello, how can I assist you today?",
+			type: "string",
+			timestamp: Date.now().toString(),
+		};
+		const voiceToggledOn = true;
+		const isChatWindowOpen = true;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is not called
+		expect(mockSpeak).not.toHaveBeenCalled();
+	});
+
+	it("does not call speak when message is from the user", () => {
+		// settings for audio
+		const settings: Settings = {
+			audio: {
+				disabled: false,
+				voiceNames: ["Google US English"]
+			}
+		};
+
+		// defines message to be from user
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "user",
+			content: "I need help with my account.",
+			type: "string",
+			timestamp: Date.now().toString(),
+		};
+
+		// calls process audio
+		const voiceToggledOn = true;
+		const isChatWindowOpen = true;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is not called
+		expect(mockSpeak).not.toHaveBeenCalled();
+	});
+
+	it("does not call speak when message content is not a string", () => {
+		// settings for audio
+		const settings: Settings = {
+			audio: {
+				disabled: false,
+				voiceNames: ["Google US English"]
+			}
+		};
+
+		// defines message content as an object
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "bot",
+			content: React.createElement("div"),
+			type: "object",
+			timestamp: Date.now().toString(),
+		};
+
+		// calls process audio
+		const voiceToggledOn = true;
+		const isChatWindowOpen = true;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is not called
+		expect(mockSpeak).not.toHaveBeenCalled();
+	});
+
+	it("does not call speak when chat window is closed and not embedded", () => {
+		// settings for audio and general
+		const settings: Settings = {
+			audio: {
+				disabled: false,
+				voiceNames: ["Google US English"]
+			}, 
+			general: {
+				embedded: false
+			}
+		};
+
+		// calls process audio
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "bot",
+			content: "Welcome back! How can I help you today?",
+			type: "string",
+			timestamp: Date.now().toString(),
+		};
+		const voiceToggledOn = true;
+		const isChatWindowOpen = false;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is not called
+		expect(mockSpeak).not.toHaveBeenCalled();
+	});
+
+	it("does not call speak when voice is toggled off", () => {
+		// settings for audio
+		const settings: Settings = {
+			audio: {
+				disabled: false,
+				voiceNames: ["Google US English"]
+			}
+		};
+
+		// calls process audio, but voice is toggled off
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "bot",
+			content: "Let me know if you need any assistance.",
+			type: "string",
+			timestamp: Date.now().toString(),
+		};
+		const voiceToggledOn = false;
+		const isChatWindowOpen = true;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is not called
+		expect(mockSpeak).not.toHaveBeenCalled();
+	});
+
+	it("calls speak with the specified voice when all conditions are met and voice is found", () => {
+		// settings for audio
+		const settings: Settings = {
+			audio: {
+				disabled: false,
+				voiceNames: ["Google US English"]
+			}
+		};
+
+		// calls process audio
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "bot",
+			content: "Hello! How can I assist you today?",
+			type: "string",
+			timestamp: Date.now().toString(),
+		};
+		const voiceToggledOn = true;
+		const isChatWindowOpen = true;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is called correctly
+		expect(mockSpeak).toHaveBeenCalledTimes(1);
+		expect(mockSpeak).toHaveBeenCalledWith(expect.any(SpeechSynthesisUtterance));
+		const utterance = mockSpeak.mock.calls[0][0] as SpeechSynthesisUtterance;
+		expect(utterance.text).toBe(message.content);
+		expect(utterance.lang).toBe(settings.audio?.language);
+		expect(utterance.rate).toBe(settings.audio?.rate);
+		expect(utterance.volume).toBe(settings.audio?.volume);
+		expect(utterance.voice?.name).toEqual(availableVoices[0].name);
+	});
+
+	it("calls speak without a specified voice when all conditions are met but voice is not found", () => {
+		// settings for audio
+		const settings: Settings = {
+			audio: {
+				disabled: false,
+				voiceNames: ["Non-Existent Voice"],
+			}
+		};
+
+		// calls process audio
+		const message: Message = {
+			id: generateSecureUUID(),
+			sender: "bot",
+			content: "This is a test message without a specified voice.",
+			type: "string",
+			timestamp: Date.now().toString(),
+		};
+		const voiceToggledOn = true;
+		const isChatWindowOpen = true;
+		processAudio(settings, voiceToggledOn, isChatWindowOpen, message);
+
+		// checks that speak is called correctly
+		expect(mockSpeak).toHaveBeenCalledTimes(1);
+		expect(mockSpeak).toHaveBeenCalledWith(expect.any(SpeechSynthesisUtterance));
+		const utterance = mockSpeak.mock.calls[0][0] as SpeechSynthesisUtterance;
+		expect(utterance.text).toBe(message.content);
+		expect(utterance.lang).toBe(settings.audio?.language);
+		expect(utterance.rate).toBe(settings.audio?.rate);
+		expect(utterance.volume).toBe(settings.audio?.volume);
+		expect(utterance.voice).toBeNull();
+	});
+});
\ No newline at end of file