Repository: tjtanjin/react-chatbotify
Pull request #211 (2024-10-15 17:08:20)

Test: Add unit test cases for ThemeService
#### Description

This PR adds unit tests for ThemeService and includes a configuration update to address issues with Jest not properly handling Vite environment variables. The configuration change allows Jest to mock Vite environment variables, resolving issues that prevented the tests from running.

Closes #(166)

#### What change does this PR introduce?

Please select the relevant option(s).

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] Documentation update (changes to docs/code comments)

#### What is the proposed approach?

Adding unit tests for ThemeService to ensure its functionality is thoroughly tested. Creating a separate configuration module (viteconfig.ts) for Vite environment variables, allowing them to be mocked in Jest tests, overcoming compatibility issues between Jest and import.meta in Vite.

#### 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/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/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