Repository: tjtanjin/react-chatbotify (ts)

Issue:
[Task] Add unit test cases for IsSensitiveProcessor
**Task Description:**
Currently, we run unit tests using jest which can be found [**here**](https://github.com/tjtanjin/react-chatbotify/tree/main/__tests__). 

However, the test cases are not yet comprehensive enough and work can be done to cover more grounds. Specifically for this issue, we hope to add test cases for the `IsSensitiveProcessor`.

**Deliverable(s):**
New unit test cases that cover the testing of the `IsSensitiveProcessor`.

**Additional Context:**
- The `IsSensitiveProcessor` can be found [**here**](https://github.com/tjtanjin/react-chatbotify/tree/main/src/services/BlockService/IsSensitiveProcessor.ts)
- An example for `AudioService` can be found [**here**](https://github.com/tjtanjin/react-chatbotify/blob/main/__tests__/services/AudioService.test.ts) which may serve as a good source for reference


Pull request description:
Test: Add unit test cases for IsSensitiveProcessor
#### Description

This PR adds unit tests for the `processIsSensitive` function within the `BlockService`. Additionally, it includes mocks for `params` and handles scenarios where `isSensitive` can be undefined, a boolean, a function, or a Promise.

Closes #183
#### What change does this PR introduce?

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?

This PR introduces unit tests for the `processIsSensitive` function, focusing on testing how the function handles different types for the isSensitive property (boolean, function, Promise, and undefined). The approach includes mocking params and ensuring that asynchronous and synchronous behaviors are properly tested. The function's logic is validated to ensure it correctly sets the `setTextAreaSensitiveMode` based on various return types from `isSensitive`.

#### 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/src/components/ChatBotBody/UserCheckboxes/UserCheckboxes.tsx b/src/components/ChatBotBody/UserCheckboxes/UserCheckboxes.tsx
index 4e8035d..b89d4db 100644
--- a/src/components/ChatBotBody/UserCheckboxes/UserCheckboxes.tsx
+++ b/src/components/ChatBotBody/UserCheckboxes/UserCheckboxes.tsx
@@ -142,7 +142,7 @@ const UserCheckboxes = ({
 					const userInput = Array.from(checkedItems).join(", ");
 					setDisabled(!checkboxes.reusable as boolean);
 					let sendInChat: boolean;
-					if (checkboxes.sendOutput) {
+					if (checkboxes.sendOutput != null) {
 						sendInChat = checkboxes.sendOutput;
 					} else {
 						sendInChat = settings.chatInput?.sendCheckboxOutput ?? true;
diff --git a/src/components/ChatBotBody/UserOptions/UserOptions.tsx b/src/components/ChatBotBody/UserOptions/UserOptions.tsx
index 27f8578..dbe1cad 100644
--- a/src/components/ChatBotBody/UserOptions/UserOptions.tsx
+++ b/src/components/ChatBotBody/UserOptions/UserOptions.tsx
@@ -107,7 +107,7 @@ const UserOptions = ({
 							}
 							setDisabled(!options.reusable as boolean);
 							let sendInChat: boolean;
-							if (options.sendOutput) {
+							if (options.sendOutput != null) {
 								sendInChat = options.sendOutput;
 							} else {
 								sendInChat = settings.chatInput?.sendOptionOutput ?? true;
diff --git a/src/components/ChatBotContainer.css b/src/components/ChatBotContainer.css
index 05df8b9..06b0d85 100644
--- a/src/components/ChatBotContainer.css
+++ b/src/components/ChatBotContainer.css
@@ -4,6 +4,7 @@
 	-webkit-font-smoothing: antialiased;
 	-moz-osx-font-smoothing: grayscale;
 	line-height: 1.5;
+	z-index: 9999;
 }
 
 /* ChatBot Commons */
diff --git a/src/services/ThemeService.ts b/src/services/ThemeService.ts
index 4ec38c8..b293b9c 100644
--- a/src/services/ThemeService.ts
+++ b/src/services/ThemeService.ts
@@ -2,10 +2,15 @@ import { Settings } from "../types/Settings";
 import { Styles } from "../types/Styles";
 import { Theme } from "../types/Theme";
 import { ThemeCacheData } from "../types/internal/ThemeCacheData";
+import { viteConfig } from "../../viteconfig";
 
-const DEFAULT_URL = import.meta.env?.VITE_THEME_BASE_CDN_URL;
-const DEFAULT_EXPIRATION = import.meta.env?.VITE_THEME_DEFAULT_CACHE_EXPIRATION;
-const CACHE_KEY_PREFIX = import.meta.env?.VITE_THEME_CACHE_KEY_PREFIX;
+// The configuration values like DEFAULT_URL, DEFAULT_EXPIRATION, and CACHE_KEY_PREFIX
+// were previously accessed using `import.meta.env` directly. To centralize and 
+// simplify configuration management, they have been moved to a separate Vite config file 
+// (viteconfig.ts) and are imported here.
+const DEFAULT_URL = viteConfig.DEFAULT_URL;
+const DEFAULT_EXPIRATION = viteConfig.DEFAULT_EXPIRATION;
+const CACHE_KEY_PREFIX = viteConfig.CACHE_KEY_PREFIX;
 
 /**
  * Fetches the cached theme if it exist and checks for expiry.
@@ -59,6 +64,8 @@ export const setCachedTheme = (id: string, version: string, settings: Settings,
 		cacheDate: currentTimeInSeconds
 	};
 
+	console.log(themeCacheData);
+
 	localStorage.setItem(`${CACHE_KEY_PREFIX}_${id}_${version}`, JSON.stringify(themeCacheData));
 }
 
diff --git a/viteconfig.ts b/viteconfig.ts
new file mode 100644
index 0000000..dee181b
--- /dev/null
+++ b/viteconfig.ts
@@ -0,0 +1,5 @@
+export const viteConfig = {
+    DEFAULT_URL: import.meta.env?.VITE_THEME_BASE_CDN_URL,
+    DEFAULT_EXPIRATION: import.meta.env?.VITE_THEME_DEFAULT_CACHE_EXPIRATION,
+    CACHE_KEY_PREFIX: import.meta.env?.VITE_THEME_CACHE_KEY_PREFIX,
+  };
\ No newline at end of file


Test patch:
diff --git a/__tests__/services/BlockService/IsSensitiveProcessor.test.ts b/__tests__/services/BlockService/IsSensitiveProcessor.test.ts
new file mode 100644
index 0000000..4ba85ac
--- /dev/null
+++ b/__tests__/services/BlockService/IsSensitiveProcessor.test.ts
@@ -0,0 +1,90 @@
+import { processIsSensitive } from "../../../src/services/BlockService/IsSensitiveProcessor";
+import { Block } from "../../../src/types/Block";
+import { Params } from "../../../src/types/Params";
+
+describe("processIsSensitive", () => {
+    let mockSetTextAreaSensitiveMode: jest.Mock;
+    let mockBlock: Block;
+    let mockParams: Params;
+
+    beforeEach(() => {
+        mockSetTextAreaSensitiveMode = jest.fn();
+        mockBlock = {
+            isSensitive: false,
+        };
+        mockParams = {} as Params;
+    });
+
+    it("should disable sensitive mode if block.isSensitive is undefined", async () => {
+        mockBlock.isSensitive = undefined;
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(false);
+    });
+
+    it("should disable sensitive mode if block.isSensitive is false", async () => {
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(false);
+    });
+
+    it("should enable sensitive mode if block.isSensitive is true", async () => {
+        mockBlock.isSensitive = true;
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(true);
+    });
+
+    it("should enable sensitive mode if isSensitive is a function returning a boolean true", async () => {
+        mockBlock.isSensitive = jest.fn().mockReturnValue(true);
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(true);
+        expect(mockBlock.isSensitive).toHaveBeenCalledWith(mockParams);
+    });
+
+    it("should disable sensitive mode if isSensitive is a function returning a boolean false", async () => {
+        mockBlock.isSensitive = jest.fn().mockReturnValue(false);
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(false);
+        expect(mockBlock.isSensitive).toHaveBeenCalledWith(mockParams);
+    });
+
+    it("should enbale sensitive mode if isSensitive is a function returning a Promise with true", async () => {
+        mockBlock.isSensitive = jest.fn().mockResolvedValue(true);
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(true);
+        expect(mockBlock.isSensitive).toHaveBeenCalledWith(mockParams);
+    });
+
+    it("should disable sensitive mode if isSensitive is a function returning a Promise with false", async () => {
+        mockBlock.isSensitive = jest.fn().mockResolvedValue(false);
+        await processIsSensitive(
+            mockBlock,
+            mockSetTextAreaSensitiveMode,
+            mockParams
+        );
+        expect(mockSetTextAreaSensitiveMode).toHaveBeenCalledWith(false);
+        expect(mockBlock.isSensitive).toHaveBeenCalledWith(mockParams);
+    });
+});
diff --git a/__tests__/services/ThemeService.test.ts b/__tests__/services/ThemeService.test.ts
new file mode 100644
index 0000000..7a3fddc
--- /dev/null
+++ b/__tests__/services/ThemeService.test.ts
@@ -0,0 +1,191 @@
+// Import the functions to be tested and types needed for the theme data
+import {
+  getCachedTheme,
+  setCachedTheme,
+} from "../../src/services/ThemeService";
+import { ThemeCacheData } from "../../src/types/internal/ThemeCacheData";
+import { viteConfig } from "../../viteconfig";
+
+// Mock the vite import so that the environment variables can be controlled in tests
+jest.mock("../../viteconfig", () => ({
+  viteConfig: {
+    DEFAULT_URL: "http://localhost:mock",
+    DEFAULT_EXPIRATION: "60",
+    CACHE_KEY_PREFIX: "VITE_THEME_CACHE_KEY_PREFIX",
+  },
+}));
+
+// Create a mock for localStorage, using a closure to simulate localStorage behavior
+const mockLocalStorage = (() => {
+  let store: { [key: string]: string } = {};
+  return {
+    getItem: (key: string) => store[key] || null,
+    setItem: (key: string, value: string) => {
+      store[key] = value.toString();
+    },
+    clear: () => {
+      store = {};
+    },
+  };
+})();
+
+// Override the global window's localStorage with the mock version
+Object.defineProperty(window, "localStorage", {
+  value: mockLocalStorage,
+});
+
+/**
+ * Tests for ThemeService.
+ */
+describe("ThemeService", () => {
+  // Clears localStorage after each test to ensure isolation between tests
+  afterEach(() => {
+    localStorage.clear();
+  });
+
+  // Test that a theme can be cached and stored in localStorage correctly
+  test("should cache a theme correctly", () => {
+    const themeData: ThemeCacheData = {
+      settings: {
+        general: {
+          primaryColor: 'blue',
+        }
+      },
+      inlineStyles: {
+        tooltipStyle: { backgroundColor: '#f0f0f0', padding: '10px', borderRadius: '5px' },
+      },
+      cssStylesText: ".example { color: red; }",
+      cacheDate: Math.floor(Date.now() / 1000),
+    };
+
+    const id = "theme1";
+    const version = "1.0";
+    const cacheKey = `${viteConfig.CACHE_KEY_PREFIX}_${id}_${version}`;
+
+    // Call setCachedTheme to save theme data into localStorage
+    setCachedTheme(
+      id,
+      version,
+      themeData.settings,
+      themeData.inlineStyles,
+      themeData.cssStylesText
+    );
+
+    // Verify that the theme was saved to localStorage using the correct cache key
+    const cachedTheme = localStorage.getItem(cacheKey);
+    expect(cachedTheme).not.toBeNull();
+  });
+
+  // Test that a cached theme can be retrieved before it expires
+  test("should retrieve cached theme before expiration", () => {
+    const themeData: ThemeCacheData = {
+      settings: {},
+      inlineStyles: {},
+      cssStylesText: ".example { color: red; }",
+      cacheDate: Math.floor(Date.now() / 1000),
+    };
+
+    const id = "theme1";
+    const version = "1.0";
+    const cacheDuration = 60;
+
+    // Cache theme data for later retrieval
+    setCachedTheme(
+      id,
+      version,
+      themeData.settings,
+      themeData.inlineStyles,
+      themeData.cssStylesText
+    );
+
+    // Retrieve the cached theme using getCachedTheme and ensure it's not expired
+    const cachedTheme = getCachedTheme(id, version, cacheDuration);
+    expect(cachedTheme).not.toBeNull();
+    expect(cachedTheme?.cssStylesText).toBe(themeData.cssStylesText);
+  });
+
+  // Test that invalid JSON in cache results in a null value when retrieved
+  test("should handle invalid JSON in cache", () => {
+    const id = "theme1";
+    const version = "1.0";
+    const cacheKey = `${viteConfig.CACHE_KEY_PREFIX}_${id}_${version}`;
+  
+    // Store invalid JSON in localStorage directly to simulate a corrupted cache
+    localStorage.setItem(cacheKey, "invalid json");
+  
+    // Attempt to retrieve the corrupted cache and expect a null result
+    const cachedTheme = getCachedTheme(id, version, 60);
+    expect(cachedTheme).toBeNull();
+  });
+  
+  // Test that caching a theme uses the correct cache key format
+  test("should cache theme with the correct key", () => {
+    const themeData: ThemeCacheData = {
+      settings: {
+        general: {
+          primaryColor: 'blue',
+        }
+      },
+      inlineStyles: {
+        tooltipStyle: { backgroundColor: '#f0f0f0', padding: '10px', borderRadius: '5px' },
+      },
+      cssStylesText: ".example { color: red; }",
+      cacheDate: Math.floor(Date.now() / 1000),
+    };
+  
+    const id = "theme1";
+    const version = "1.0";
+    const cacheKey = `${viteConfig.CACHE_KEY_PREFIX}_${id}_${version}`;
+  
+    // Call setCachedTheme to save the theme data into localStorage
+    setCachedTheme(
+      id,
+      version,
+      themeData.settings,
+      themeData.inlineStyles,
+      themeData.cssStylesText
+    );
+  
+    // Verify that the cached theme matches the original theme data as a JSON string
+    const cachedTheme = localStorage.getItem(cacheKey);
+    expect(cachedTheme).not.toBeNull();
+    
+    expect(cachedTheme).toBe(JSON.stringify(themeData)); 
+  });
+  
+  // Test that caching themes with different versions uses separate cache keys
+  test("should cache theme with different versions separately", () => {
+    const themeDataV1: ThemeCacheData = {
+      settings: {},
+      inlineStyles: {},
+      cssStylesText: ".example { color: red; }",
+      cacheDate: Math.floor(Date.now() / 1000),
+    };
+  
+    const themeDataV2: ThemeCacheData = {
+      settings: {},
+      inlineStyles: {},
+      cssStylesText: ".example { color: blue; }",
+      cacheDate: Math.floor(Date.now() / 1000),
+    };
+  
+    const id = "theme1";
+    const version1 = "1.0";
+    const version2 = "2.0";
+  
+    // Cache two themes with different versions
+    setCachedTheme(id, version1, themeDataV1.settings, themeDataV1.inlineStyles, themeDataV1.cssStylesText);
+    setCachedTheme(id, version2, themeDataV2.settings, themeDataV2.inlineStyles, themeDataV2.cssStylesText);
+  
+    // Retrieve each version of the theme and ensure they are stored separately
+    const cachedThemeV1 = getCachedTheme(id, version1, 60);
+    const cachedThemeV2 = getCachedTheme(id, version2, 60);
+  
+    // Check that each version's CSS is stored correctly and they are different
+    expect(cachedThemeV1?.cssStylesText).toBe(themeDataV1.cssStylesText);
+    expect(cachedThemeV2?.cssStylesText).toBe(themeDataV2.cssStylesText);
+    expect(cachedThemeV1?.cssStylesText).not.toBe(cachedThemeV2?.cssStylesText); // Ensure they are different
+  });
+  
+
+});
diff --git a/__tests__/utils/displayChecker.test.ts b/__tests__/utils/displayChecker.test.ts
new file mode 100644
index 0000000..aefd6e7
--- /dev/null
+++ b/__tests__/utils/displayChecker.test.ts
@@ -0,0 +1,171 @@
+import { expect, it, describe, beforeEach, jest } from "@jest/globals";
+import { isChatBotVisible } from "../../src/utils/displayChecker";
+
+describe("isChatBotVisible", () => {
+	beforeAll(() => {
+		// Set the viewport size for testing
+		Object.defineProperty(window, 'innerHeight', { value: 768 });
+		Object.defineProperty(window, 'innerWidth', { value: 1024 });
+	});
+
+	beforeEach(() => {
+		// Reset all mocks before each test
+		jest.clearAllMocks();
+	});
+
+	it("should return false when element is null", () => {
+		// Call the function with the mocked element as null
+		const result = isChatBotVisible(null as unknown as HTMLDivElement);
+		expect(result).toBe(false);
+	});
+
+	it("should return false when element is undefined", () => {
+		// Call the function with the mocked element as undefined
+		const result = isChatBotVisible(undefined as unknown as HTMLDivElement);
+		expect(result).toBe(false);
+	});
+
+	it("should return true when element is fully visible in the viewport", () => {
+		// Mock div element
+		const element = document.createElement('div');
+
+		// Mock getBoundingClientRect to return values indicating the element is fully visible
+		element.getBoundingClientRect = jest.fn(() => ({
+			top: 100,        // Element's top edge is below the viewport top
+			left: 100,       // Element's left edge is right of the viewport left
+			bottom: 200,     // Element's bottom edge is within viewport height
+			right: 200,      // Element's right edge is within viewport width
+			width: 100,
+			height: 100,
+			x: 100,         // Left position relative to the viewport
+			y: 100,         // Top position relative to the viewport
+			toJSON: () => ({
+				top: 100,
+				left: 100,
+				bottom: 200,
+				right: 200,
+				width: 100,
+				height: 100,
+			}),             // toJSON method mocked
+		}));
+
+		// Call the function with the mocked element
+		const result = isChatBotVisible(element);
+		expect(result).toBe(true);
+	});
+
+	it("should return false when element's top is less than 0", () => {
+		// Mock div element
+		const element = document.createElement('div');
+
+		// Mock getBoundingClientRect to return "top" value as negative
+		element.getBoundingClientRect = jest.fn(() => ({
+			top: -1,        // Element's top edge is out of view
+			left: 100,       
+			bottom: 200,     
+			right: 200,      
+			width: 100,
+			height: 100,
+			x: 100,  
+			y: 100,  
+			toJSON: () => ({
+				top: 100,
+				left: 100,
+				bottom: 200,
+				right: 200,
+				width: 100,
+				height: 100,
+			}), 
+		}));
+
+		// Call the function with the mocked element
+		const result = isChatBotVisible(element);
+		expect(result).toBe(false);
+	});
+
+	it("should return false when element's left is less than 0", () => {
+		// Mock div element
+		const element = document.createElement('div');
+
+		// Mock getBoundingClientRect to return "left" value as negative
+		element.getBoundingClientRect = jest.fn(() => ({
+			top: 100,        
+			left: -1,       // Element's left edge is out of view
+			bottom: 200,    
+			right: 200,      
+			width: 100,
+			height: 100,
+			x: 100,  
+			y: 100,  
+			toJSON: () => ({
+				top: 100,
+				left: 100,
+				bottom: 200,
+				right: 200,
+				width: 100,
+				height: 100,
+			}), 
+		}));
+		// Call the function with the mocked element
+		const result = isChatBotVisible(element);
+		expect(result).toBe(false);
+	});
+    
+	it("should return false when element's bottom exceeds window height", () => {
+		// Mock div element
+		const element = document.createElement('div');
+
+		// Mock getBoundingClientRect to return "bottom" value larger than the value of vh
+		element.getBoundingClientRect = jest.fn(() => ({
+			top: 100,        
+			left: 100,      
+			bottom: 800,     // Element's bottom edge exceeds viewport height
+			right: 200,      
+			width: 100,
+			height: 100,
+			x: 100,  
+			y: 100,  
+			toJSON: () => ({
+				top: 100,
+				left: 100,
+				bottom: 200,
+				right: 200,
+				width: 100,
+				height: 100,
+			}), 
+		}));
+
+		// Call the function with the mocked element
+		const result = isChatBotVisible(element);
+		expect(result).toBe(false);
+	});
+
+	it("should return false when element's right exceeds window width", () => {
+		// Mock div element
+		const element = document.createElement('div');
+
+		// Mock getBoundingClientRect to return "right" value larger than the value of vw
+		element.getBoundingClientRect = jest.fn(() => ({
+			top: 100,        
+			left: 100,       
+			bottom: 200,     
+			right: 1100,      // Element's right edge exceeds viewport width
+			width: 100,
+			height: 100,
+			x: 100,  
+			y: 100,  
+			toJSON: () => ({
+				top: 100,
+				left: 100,
+				bottom: 200,
+				right: 200,
+				width: 100,
+				height: 100,
+			}), 
+		}));
+
+		// Call the function with the mocked element
+		const result = isChatBotVisible(element);
+		expect(result).toBe(false);
+	});
+});
\ No newline at end of file