From 03bcd0200254fde69542f1f6e9ec8ef425beff61 Mon Sep 17 00:00:00 2001 From: Louis Date: Sat, 12 Jul 2025 22:46:27 +0700 Subject: [PATCH] test: add missing unit tests --- web-app/src/__tests__/i18n.test.ts | 52 ++++ .../ui/__tests__/hover-card.test.tsx | 168 +++++++++++ .../components/ui/__tests__/sonner.test.tsx | 93 ++++++ .../src/constants/__tests__/windows.test.ts | 36 +++ .../containers/__tests__/AvatarEmoji.test.tsx | 124 ++++++++ web-app/src/hooks/__tests__/useTheme.test.ts | 189 +++++++++++++ .../src/services/__tests__/analytic.test.ts | 265 ++++++++++++++++++ web-app/src/services/__tests__/events.test.ts | 247 ++++++++++++++++ 8 files changed, 1174 insertions(+) create mode 100644 web-app/src/__tests__/i18n.test.ts create mode 100644 web-app/src/components/ui/__tests__/hover-card.test.tsx create mode 100644 web-app/src/components/ui/__tests__/sonner.test.tsx create mode 100644 web-app/src/constants/__tests__/windows.test.ts create mode 100644 web-app/src/containers/__tests__/AvatarEmoji.test.tsx create mode 100644 web-app/src/hooks/__tests__/useTheme.test.ts create mode 100644 web-app/src/services/__tests__/analytic.test.ts create mode 100644 web-app/src/services/__tests__/events.test.ts diff --git a/web-app/src/__tests__/i18n.test.ts b/web-app/src/__tests__/i18n.test.ts new file mode 100644 index 000000000..644bc019d --- /dev/null +++ b/web-app/src/__tests__/i18n.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock the dependencies +vi.mock('@/i18n/setup', () => ({ + default: { t: vi.fn(), init: vi.fn() }, +})) + +vi.mock('@/i18n/react-i18next-compat', () => ({ + useTranslation: vi.fn(() => ({ t: vi.fn() })), +})) + +vi.mock('@/i18n/hooks', () => ({ + useAppTranslation: vi.fn(() => ({ t: vi.fn() })), +})) + +vi.mock('@/i18n/TranslationContext', () => ({ + TranslationProvider: vi.fn(({ children }) => children), +})) + +describe('i18n module', () => { + it('should re-export default from i18n/setup', async () => { + const i18nModule = await import('../i18n') + expect(i18nModule.default).toBeDefined() + }) + + it('should re-export useTranslation', async () => { + const i18nModule = await import('../i18n') + expect(i18nModule.useTranslation).toBeDefined() + expect(typeof i18nModule.useTranslation).toBe('function') + }) + + it('should re-export useAppTranslation', async () => { + const i18nModule = await import('../i18n') + expect(i18nModule.useAppTranslation).toBeDefined() + expect(typeof i18nModule.useAppTranslation).toBe('function') + }) + + it('should re-export TranslationProvider', async () => { + const i18nModule = await import('../i18n') + expect(i18nModule.TranslationProvider).toBeDefined() + expect(typeof i18nModule.TranslationProvider).toBe('function') + }) + + it('should export all expected functions', async () => { + const i18nModule = await import('../i18n') + const expectedExports = ['default', 'useTranslation', 'useAppTranslation', 'TranslationProvider'] + + expectedExports.forEach(exportName => { + expect(i18nModule[exportName]).toBeDefined() + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/components/ui/__tests__/hover-card.test.tsx b/web-app/src/components/ui/__tests__/hover-card.test.tsx new file mode 100644 index 000000000..71e78cb7f --- /dev/null +++ b/web-app/src/components/ui/__tests__/hover-card.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { HoverCard, HoverCardTrigger, HoverCardContent } from '../hover-card' + +// Mock Radix UI +vi.mock('@radix-ui/react-hover-card', () => ({ + Root: ({ children, ...props }: any) =>
{children}
, + Trigger: ({ children, ...props }: any) => , + Portal: ({ children, ...props }: any) =>
{children}
, + Content: ({ children, className, align, sideOffset, ...props }: any) => ( +
+ {children} +
+ ), +})) + +describe('HoverCard Components', () => { + describe('HoverCard', () => { + it('should render HoverCard root component', () => { + render( + +
Test content
+
+ ) + + const hoverCard = screen.getByTestId('hover-card-root') + expect(hoverCard).toBeDefined() + expect(hoverCard).toHaveAttribute('data-slot', 'hover-card') + expect(screen.getByText('Test content')).toBeDefined() + }) + + it('should pass through props to root component', () => { + render( + +
Test content
+
+ ) + + const hoverCard = screen.getByTestId('hover-card-root') + expect(hoverCard).toHaveAttribute('openDelay', '500') + }) + }) + + describe('HoverCardTrigger', () => { + it('should render trigger component', () => { + render( + + Hover me + + ) + + const trigger = screen.getByTestId('hover-card-trigger') + expect(trigger).toBeDefined() + expect(trigger).toHaveAttribute('data-slot', 'hover-card-trigger') + expect(screen.getByText('Hover me')).toBeDefined() + }) + + it('should pass through props to trigger component', () => { + render( + + Disabled trigger + + ) + + const trigger = screen.getByTestId('hover-card-trigger') + expect(trigger).toHaveAttribute('disabled') + }) + }) + + describe('HoverCardContent', () => { + it('should render content with default props', () => { + render( + +
Content here
+
+ ) + + const portal = screen.getByTestId('hover-card-portal') + expect(portal).toHaveAttribute('data-slot', 'hover-card-portal') + + const content = screen.getByTestId('hover-card-content') + expect(content).toBeDefined() + expect(content).toHaveAttribute('data-slot', 'hover-card-content') + expect(content).toHaveAttribute('data-align', 'center') + expect(content).toHaveAttribute('data-side-offset', '4') + expect(screen.getByText('Content here')).toBeDefined() + }) + + it('should render content with custom props', () => { + render( + +
Custom content
+
+ ) + + const content = screen.getByTestId('hover-card-content') + expect(content).toHaveAttribute('data-align', 'start') + expect(content).toHaveAttribute('data-side-offset', '8') + expect(content.className).toContain('custom-class') + }) + + it('should apply default styling classes', () => { + render( + +
Content
+
+ ) + + const content = screen.getByTestId('hover-card-content') + expect(content.className).toContain('bg-main-view') + expect(content.className).toContain('text-main-view-fg/70') + expect(content.className).toContain('rounded-md') + expect(content.className).toContain('border') + expect(content.className).toContain('shadow-md') + }) + + it('should merge custom className with default classes', () => { + render( + +
Content
+
+ ) + + const content = screen.getByTestId('hover-card-content') + expect(content.className).toContain('bg-main-view') + expect(content.className).toContain('my-custom-class') + }) + + it('should pass through additional props', () => { + render( + +
Content
+
+ ) + + const content = screen.getByTestId('hover-card-content') + expect(content).toHaveAttribute('data-testprop', 'test-value') + }) + }) + + describe('Integration', () => { + it('should render complete hover card structure', () => { + render( + + + + + +
Hover content
+
+
+ ) + + expect(screen.getByTestId('hover-card-root')).toBeDefined() + expect(screen.getByTestId('hover-card-trigger')).toBeDefined() + expect(screen.getByTestId('hover-card-portal')).toBeDefined() + expect(screen.getByTestId('hover-card-content')).toBeDefined() + expect(screen.getByText('Trigger')).toBeDefined() + expect(screen.getByText('Hover content')).toBeDefined() + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/components/ui/__tests__/sonner.test.tsx b/web-app/src/components/ui/__tests__/sonner.test.tsx new file mode 100644 index 000000000..72aca5526 --- /dev/null +++ b/web-app/src/components/ui/__tests__/sonner.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Toaster } from '../sonner' + +// Mock sonner +vi.mock('sonner', () => ({ + Toaster: ({ className, expand, richColors, closeButton, ...props }: any) => ( +
+ Toaster Component +
+ ), +})) + +describe('Toaster Component', () => { + it('should render toaster component', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toBeDefined() + expect(screen.getByText('Toaster Component')).toBeDefined() + }) + + it('should apply default className', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveClass('toaster', 'group') + }) + + it('should pass through additional props', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveAttribute('position', 'top-right') + expect(toaster).toHaveAttribute('duration', '5000') + }) + + it('should maintain default className with additional props', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveClass('toaster', 'group') + expect(toaster).toHaveAttribute('position', 'bottom-left') + }) + + it('should handle custom expand prop', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveAttribute('data-expand', 'true') + }) + + it('should handle custom richColors prop', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveAttribute('data-rich-colors', 'true') + }) + + it('should handle custom closeButton prop', () => { + render() + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveAttribute('data-close-button', 'true') + }) + + it('should handle multiple props', () => { + render( + + ) + + const toaster = screen.getByTestId('toaster') + expect(toaster).toHaveClass('toaster', 'group') + expect(toaster).toHaveAttribute('position', 'top-center') + expect(toaster).toHaveAttribute('duration', '3000') + expect(toaster).toHaveAttribute('data-expand', 'true') + expect(toaster).toHaveAttribute('data-rich-colors', 'true') + expect(toaster).toHaveAttribute('data-close-button', 'true') + }) +}) \ No newline at end of file diff --git a/web-app/src/constants/__tests__/windows.test.ts b/web-app/src/constants/__tests__/windows.test.ts new file mode 100644 index 000000000..f9d388f98 --- /dev/null +++ b/web-app/src/constants/__tests__/windows.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { windowKey } from '../windows' + +describe('windows constants', () => { + it('should export correct window keys', () => { + expect(windowKey).toBeDefined() + expect(typeof windowKey).toBe('object') + }) + + it('should have logsAppWindow key', () => { + expect(windowKey.logsAppWindow).toBe('logs-app-window') + }) + + it('should have logsWindowLocalApiServer key', () => { + expect(windowKey.logsWindowLocalApiServer).toBe('logs-window-local-api-server') + }) + + it('should have systemMonitorWindow key', () => { + expect(windowKey.systemMonitorWindow).toBe('system-monitor-window') + }) + + it('should have all required keys', () => { + const expectedKeys = ['logsAppWindow', 'logsWindowLocalApiServer', 'systemMonitorWindow'] + const actualKeys = Object.keys(windowKey) + + expect(actualKeys).toEqual(expect.arrayContaining(expectedKeys)) + expect(actualKeys.length).toBe(expectedKeys.length) + }) + + it('should have string values for all keys', () => { + Object.values(windowKey).forEach(value => { + expect(typeof value).toBe('string') + expect(value.length).toBeGreaterThan(0) + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/containers/__tests__/AvatarEmoji.test.tsx b/web-app/src/containers/__tests__/AvatarEmoji.test.tsx new file mode 100644 index 000000000..ea44b95a0 --- /dev/null +++ b/web-app/src/containers/__tests__/AvatarEmoji.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { AvatarEmoji } from '../AvatarEmoji' + +describe('AvatarEmoji Component', () => { + it('should return null when no avatar is provided', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should return null when avatar is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render image when avatar is a custom image path', () => { + render() + + const img = screen.getByRole('img') + expect(img).toBeDefined() + expect(img).toHaveAttribute('src', '/images/custom-avatar.png') + expect(img).toHaveAttribute('alt', 'Custom avatar') + }) + + it('should apply default image className', () => { + render() + + const img = screen.getByRole('img') + expect(img).toHaveClass('w-5', 'h-5', 'object-contain') + }) + + it('should apply custom image className', () => { + render( + + ) + + const img = screen.getByRole('img') + expect(img).toHaveClass('w-10', 'h-10', 'rounded-full') + expect(img).not.toHaveClass('w-5', 'h-5', 'object-contain') + }) + + it('should render emoji as text span', () => { + render() + + const span = screen.getByText('🤖') + expect(span.tagName).toBe('SPAN') + }) + + it('should apply default text className for emoji', () => { + render() + + const span = screen.getByText('😊') + expect(span).toHaveClass('text-base') + }) + + it('should apply custom text className for emoji', () => { + render( + + ) + + const span = screen.getByText('🎯') + expect(span).toHaveClass('text-lg', 'font-bold') + expect(span).not.toHaveClass('text-base') + }) + + it('should render text content as span', () => { + render() + + const span = screen.getByText('AI') + expect(span.tagName).toBe('SPAN') + expect(span).toHaveClass('text-base') + }) + + it('should handle React node as avatar', () => { + const customNode =
Custom
+ render() + + const span = screen.getByText('Custom') + expect(span.closest('span')).toHaveClass('text-base') + expect(screen.getByTestId('custom-node')).toBeDefined() + }) + + it('should not treat non-image paths as custom images', () => { + render() + + const span = screen.getByText('/api/data') + expect(span.tagName).toBe('SPAN') + expect(screen.queryByRole('img')).toBeNull() + }) + + it('should not treat relative paths as custom images', () => { + render() + + const span = screen.getByText('images/avatar.png') + expect(span.tagName).toBe('SPAN') + expect(screen.queryByRole('img')).toBeNull() + }) + + it('should handle different image extensions', () => { + const extensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg'] + + extensions.forEach((ext, index) => { + const { unmount } = render() + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', `/images/avatar${ext}`) + + unmount() + }) + }) + + it('should maintain accessibility for custom images', () => { + render() + + const img = screen.getByRole('img') + expect(img).toHaveAttribute('alt', 'Custom avatar') + }) +}) \ No newline at end of file diff --git a/web-app/src/hooks/__tests__/useTheme.test.ts b/web-app/src/hooks/__tests__/useTheme.test.ts new file mode 100644 index 000000000..a2416eb03 --- /dev/null +++ b/web-app/src/hooks/__tests__/useTheme.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { checkOSDarkMode } from '../useTheme' + +// Mock Tauri API +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + setTheme: vi.fn().mockResolvedValue(undefined), + }), + Theme: { + Dark: 'dark', + Light: 'light', + }, +})) + +// Mock localStorage +vi.mock('@/constants/localStorage', () => ({ + localStorageKey: { + theme: 'theme', + }, +})) + +// Mock zustand persist +vi.mock('zustand/middleware', () => ({ + persist: (fn: any) => fn, + createJSONStorage: () => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }), +})) + +describe('useTheme', () => { + let originalMatchMedia: any + + beforeEach(() => { + vi.clearAllMocks() + // Mock window.matchMedia + originalMatchMedia = window.matchMedia + window.matchMedia = vi.fn() + }) + + afterEach(() => { + // Restore original matchMedia + window.matchMedia = originalMatchMedia + }) + + describe('checkOSDarkMode', () => { + it('should return true when OS prefers dark mode', () => { + vi.mocked(window.matchMedia).mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any) + + const result = checkOSDarkMode() + expect(result).toBe(true) + expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)') + }) + + it('should return false when OS prefers light mode', () => { + vi.mocked(window.matchMedia).mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any) + + const result = checkOSDarkMode() + expect(result).toBe(false) + }) + + it('should return falsy when matchMedia is not available', () => { + const originalMatchMedia = window.matchMedia + // @ts-ignore + window.matchMedia = null + + const result = checkOSDarkMode() + expect(result).toBeFalsy() + + // Restore + window.matchMedia = originalMatchMedia + }) + }) + + describe('useTheme hook basic functionality', () => { + beforeEach(() => { + // Default to light mode + vi.mocked(window.matchMedia).mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any) + }) + + it('should have the expected interface', async () => { + const { useTheme } = await import('../useTheme') + const { result } = renderHook(() => useTheme()) + + expect(result.current).toHaveProperty('activeTheme') + expect(result.current).toHaveProperty('isDark') + expect(result.current).toHaveProperty('setTheme') + expect(result.current).toHaveProperty('setIsDark') + expect(typeof result.current.setTheme).toBe('function') + expect(typeof result.current.setIsDark).toBe('function') + }) + + it('should initialize with auto theme', async () => { + const { useTheme } = await import('../useTheme') + const { result } = renderHook(() => useTheme()) + + expect(result.current.activeTheme).toBe('auto') + expect(typeof result.current.isDark).toBe('boolean') + }) + + it('should allow setting isDark directly', async () => { + const { useTheme } = await import('../useTheme') + const { result } = renderHook(() => useTheme()) + + act(() => { + result.current.setIsDark(true) + }) + + expect(result.current.isDark).toBe(true) + + act(() => { + result.current.setIsDark(false) + }) + + expect(result.current.isDark).toBe(false) + }) + + it('should handle theme changes', async () => { + const { useTheme } = await import('../useTheme') + const { result } = renderHook(() => useTheme()) + + await act(async () => { + await result.current.setTheme('dark') + }) + + expect(result.current.activeTheme).toBe('dark') + expect(result.current.isDark).toBe(true) + + await act(async () => { + await result.current.setTheme('light') + }) + + expect(result.current.activeTheme).toBe('light') + expect(result.current.isDark).toBe(false) + }) + + it('should handle auto theme with OS preference', async () => { + // Mock OS dark preference + vi.mocked(window.matchMedia).mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any) + + const { useTheme } = await import('../useTheme') + const { result } = renderHook(() => useTheme()) + + await act(async () => { + await result.current.setTheme('auto') + }) + + expect(result.current.activeTheme).toBe('auto') + expect(result.current.isDark).toBe(true) + }) + + it('should handle auto theme with light OS preference', async () => { + // Mock OS light preference + vi.mocked(window.matchMedia).mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any) + + const { useTheme } = await import('../useTheme') + const { result } = renderHook(() => useTheme()) + + await act(async () => { + await result.current.setTheme('auto') + }) + + expect(result.current.activeTheme).toBe('auto') + expect(result.current.isDark).toBe(false) + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/services/__tests__/analytic.test.ts b/web-app/src/services/__tests__/analytic.test.ts new file mode 100644 index 000000000..94f3fa7e9 --- /dev/null +++ b/web-app/src/services/__tests__/analytic.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { updateDistinctId, getAppDistinctId } from '../analytic' + +// Mock window.core API +const mockGetAppConfigurations = vi.fn() +const mockUpdateAppConfiguration = vi.fn() + +const mockCore = { + api: { + getAppConfigurations: mockGetAppConfigurations, + updateAppConfiguration: mockUpdateAppConfiguration, + }, +} + +// Setup global window mock +Object.defineProperty(window, 'core', { + writable: true, + value: mockCore, +}) + +describe('analytic service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('updateDistinctId', () => { + it('should update distinct id in app configuration', async () => { + const mockConfiguration = { + distinct_id: 'old-id', + other_setting: 'value', + } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + mockUpdateAppConfiguration.mockResolvedValue(undefined) + + await updateDistinctId('new-distinct-id') + + expect(mockGetAppConfigurations).toHaveBeenCalledTimes(1) + expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({ + configuration: { + distinct_id: 'new-distinct-id', + other_setting: 'value', + }, + }) + }) + + it('should handle when configuration has no existing distinct_id', async () => { + const mockConfiguration = { + other_setting: 'value', + } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + mockUpdateAppConfiguration.mockResolvedValue(undefined) + + await updateDistinctId('first-distinct-id') + + expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({ + configuration: { + distinct_id: 'first-distinct-id', + other_setting: 'value', + }, + }) + }) + + it('should handle empty string as distinct id', async () => { + const mockConfiguration = { + distinct_id: 'old-id', + } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + mockUpdateAppConfiguration.mockResolvedValue(undefined) + + await updateDistinctId('') + + expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({ + configuration: { + distinct_id: '', + }, + }) + }) + + it('should handle UUID format distinct id', async () => { + const mockConfiguration = {} + const uuidId = '550e8400-e29b-41d4-a716-446655440000' + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + mockUpdateAppConfiguration.mockResolvedValue(undefined) + + await updateDistinctId(uuidId) + + expect(mockUpdateAppConfiguration).toHaveBeenCalledWith({ + configuration: { + distinct_id: uuidId, + }, + }) + }) + + it('should handle API errors gracefully', async () => { + mockGetAppConfigurations.mockRejectedValue(new Error('API Error')) + + await expect(updateDistinctId('test-id')).rejects.toThrow('API Error') + expect(mockUpdateAppConfiguration).not.toHaveBeenCalled() + }) + + it('should handle update configuration errors', async () => { + const mockConfiguration = { distinct_id: 'old-id' } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + mockUpdateAppConfiguration.mockRejectedValue(new Error('Update Error')) + + await expect(updateDistinctId('new-id')).rejects.toThrow('Update Error') + }) + }) + + describe('getAppDistinctId', () => { + it('should return distinct id from app configuration', async () => { + const mockConfiguration = { + distinct_id: 'test-distinct-id', + other_setting: 'value', + } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + + const result = await getAppDistinctId() + + expect(result).toBe('test-distinct-id') + expect(mockGetAppConfigurations).toHaveBeenCalledTimes(1) + }) + + it('should return undefined when distinct_id is not set', async () => { + const mockConfiguration = { + other_setting: 'value', + } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + + const result = await getAppDistinctId() + + expect(result).toBeUndefined() + }) + + it('should return empty string if distinct_id is empty', async () => { + const mockConfiguration = { + distinct_id: '', + } + + mockGetAppConfigurations.mockResolvedValue(mockConfiguration) + + const result = await getAppDistinctId() + + expect(result).toBe('') + }) + + it('should handle null configuration', async () => { + mockGetAppConfigurations.mockResolvedValue(null) + + await expect(getAppDistinctId()).rejects.toThrow() + }) + + it('should handle undefined configuration', async () => { + mockGetAppConfigurations.mockResolvedValue(undefined) + + await expect(getAppDistinctId()).rejects.toThrow() + }) + + it('should handle API errors', async () => { + mockGetAppConfigurations.mockRejectedValue(new Error('Get Config Error')) + + await expect(getAppDistinctId()).rejects.toThrow('Get Config Error') + }) + + it('should handle different types of distinct_id values', async () => { + // Test with UUID + mockGetAppConfigurations.mockResolvedValue({ + distinct_id: '550e8400-e29b-41d4-a716-446655440000', + }) + + let result = await getAppDistinctId() + expect(result).toBe('550e8400-e29b-41d4-a716-446655440000') + + // Test with simple string + mockGetAppConfigurations.mockResolvedValue({ + distinct_id: 'user123', + }) + + result = await getAppDistinctId() + expect(result).toBe('user123') + + // Test with numeric string + mockGetAppConfigurations.mockResolvedValue({ + distinct_id: '12345', + }) + + result = await getAppDistinctId() + expect(result).toBe('12345') + }) + }) + + describe('integration tests', () => { + it('should update and retrieve distinct id', async () => { + const newId = 'integration-test-id' + const mockConfiguration = { other_setting: 'value' } + + // Mock get configuration for update + mockGetAppConfigurations.mockResolvedValueOnce(mockConfiguration) + mockUpdateAppConfiguration.mockResolvedValue(undefined) + + // Mock get configuration for retrieval + mockGetAppConfigurations.mockResolvedValueOnce({ + ...mockConfiguration, + distinct_id: newId, + }) + + // Update the distinct id + await updateDistinctId(newId) + + // Retrieve the distinct id + const retrievedId = await getAppDistinctId() + + expect(retrievedId).toBe(newId) + expect(mockGetAppConfigurations).toHaveBeenCalledTimes(2) + expect(mockUpdateAppConfiguration).toHaveBeenCalledTimes(1) + }) + }) + + describe('edge cases', () => { + it('should handle when window.core is undefined', async () => { + const originalCore = window.core + + // Temporarily remove core + Object.defineProperty(window, 'core', { + writable: true, + value: undefined, + }) + + await expect(updateDistinctId('test')).rejects.toThrow() + await expect(getAppDistinctId()).rejects.toThrow() + + // Restore core + Object.defineProperty(window, 'core', { + writable: true, + value: originalCore, + }) + }) + + it('should handle when window.core.api is undefined', async () => { + const originalCore = window.core + + // Set core without api + Object.defineProperty(window, 'core', { + writable: true, + value: {}, + }) + + await expect(updateDistinctId('test')).rejects.toThrow() + await expect(getAppDistinctId()).rejects.toThrow() + + // Restore core + Object.defineProperty(window, 'core', { + writable: true, + value: originalCore, + }) + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/services/__tests__/events.test.ts b/web-app/src/services/__tests__/events.test.ts new file mode 100644 index 000000000..88a6c9a8c --- /dev/null +++ b/web-app/src/services/__tests__/events.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi } from 'vitest' +import { EventEmitter } from '../events' + +describe('EventEmitter', () => { + let eventEmitter: EventEmitter + + beforeEach(() => { + eventEmitter = new EventEmitter() + }) + + describe('constructor', () => { + it('should create an instance with empty handlers map', () => { + expect(eventEmitter).toBeInstanceOf(EventEmitter) + expect(eventEmitter['handlers']).toBeInstanceOf(Map) + expect(eventEmitter['handlers'].size).toBe(0) + }) + }) + + describe('on method', () => { + it('should register a handler for a new event', () => { + const handler = vi.fn() + + eventEmitter.on('test-event', handler) + + expect(eventEmitter['handlers'].has('test-event')).toBe(true) + expect(eventEmitter['handlers'].get('test-event')).toContain(handler) + }) + + it('should add multiple handlers for the same event', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + + eventEmitter.on('test-event', handler1) + eventEmitter.on('test-event', handler2) + + const handlers = eventEmitter['handlers'].get('test-event') + expect(handlers).toHaveLength(2) + expect(handlers).toContain(handler1) + expect(handlers).toContain(handler2) + }) + + it('should handle multiple different events', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + + eventEmitter.on('event1', handler1) + eventEmitter.on('event2', handler2) + + expect(eventEmitter['handlers'].has('event1')).toBe(true) + expect(eventEmitter['handlers'].has('event2')).toBe(true) + expect(eventEmitter['handlers'].get('event1')).toContain(handler1) + expect(eventEmitter['handlers'].get('event2')).toContain(handler2) + }) + + it('should allow the same handler to be registered multiple times', () => { + const handler = vi.fn() + + eventEmitter.on('test-event', handler) + eventEmitter.on('test-event', handler) + + const handlers = eventEmitter['handlers'].get('test-event') + expect(handlers).toHaveLength(2) + expect(handlers![0]).toBe(handler) + expect(handlers![1]).toBe(handler) + }) + }) + + describe('off method', () => { + it('should remove a handler from an existing event', () => { + const handler = vi.fn() + + eventEmitter.on('test-event', handler) + expect(eventEmitter['handlers'].get('test-event')).toContain(handler) + + eventEmitter.off('test-event', handler) + expect(eventEmitter['handlers'].get('test-event')).not.toContain(handler) + }) + + it('should do nothing when trying to remove handler from non-existent event', () => { + const handler = vi.fn() + + // Should not throw an error + expect(() => { + eventEmitter.off('non-existent-event', handler) + }).not.toThrow() + }) + + it('should do nothing when trying to remove non-existent handler', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + + eventEmitter.on('test-event', handler1) + + // Should not throw an error + expect(() => { + eventEmitter.off('test-event', handler2) + }).not.toThrow() + + // Original handler should still be there + expect(eventEmitter['handlers'].get('test-event')).toContain(handler1) + }) + + it('should remove only the first occurrence of a handler', () => { + const handler = vi.fn() + + eventEmitter.on('test-event', handler) + eventEmitter.on('test-event', handler) + + expect(eventEmitter['handlers'].get('test-event')).toHaveLength(2) + + eventEmitter.off('test-event', handler) + + expect(eventEmitter['handlers'].get('test-event')).toHaveLength(1) + expect(eventEmitter['handlers'].get('test-event')).toContain(handler) + }) + + it('should remove correct handler when multiple handlers exist', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + const handler3 = vi.fn() + + eventEmitter.on('test-event', handler1) + eventEmitter.on('test-event', handler2) + eventEmitter.on('test-event', handler3) + + eventEmitter.off('test-event', handler2) + + const handlers = eventEmitter['handlers'].get('test-event') + expect(handlers).toHaveLength(2) + expect(handlers).toContain(handler1) + expect(handlers).not.toContain(handler2) + expect(handlers).toContain(handler3) + }) + }) + + describe('emit method', () => { + it('should call all handlers for an event', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + + eventEmitter.on('test-event', handler1) + eventEmitter.on('test-event', handler2) + + eventEmitter.emit('test-event', 'test-data') + + expect(handler1).toHaveBeenCalledWith('test-data') + expect(handler2).toHaveBeenCalledWith('test-data') + }) + + it('should do nothing when emitting non-existent event', () => { + // Should not throw an error + expect(() => { + eventEmitter.emit('non-existent-event', 'data') + }).not.toThrow() + }) + + it('should pass arguments to handlers', () => { + const handler = vi.fn() + const testData = { message: 'test', number: 42 } + + eventEmitter.on('test-event', handler) + eventEmitter.emit('test-event', testData) + + expect(handler).toHaveBeenCalledWith(testData) + }) + + it('should call handlers in the order they were added', () => { + const callOrder: number[] = [] + const handler1 = vi.fn(() => callOrder.push(1)) + const handler2 = vi.fn(() => callOrder.push(2)) + const handler3 = vi.fn(() => callOrder.push(3)) + + eventEmitter.on('test-event', handler1) + eventEmitter.on('test-event', handler2) + eventEmitter.on('test-event', handler3) + + eventEmitter.emit('test-event', null) + + expect(callOrder).toEqual([1, 2, 3]) + }) + + it('should handle null and undefined arguments', () => { + const handler = vi.fn() + + eventEmitter.on('test-event', handler) + + eventEmitter.emit('test-event', null) + expect(handler).toHaveBeenCalledWith(null) + + eventEmitter.emit('test-event', undefined) + expect(handler).toHaveBeenCalledWith(undefined) + }) + + it('should not affect other events', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + + eventEmitter.on('event1', handler1) + eventEmitter.on('event2', handler2) + + eventEmitter.emit('event1', 'data1') + + expect(handler1).toHaveBeenCalledWith('data1') + expect(handler2).not.toHaveBeenCalled() + }) + }) + + describe('integration tests', () => { + it('should support complete event lifecycle', () => { + const handler1 = vi.fn() + const handler2 = vi.fn() + + // Register handlers + eventEmitter.on('lifecycle-event', handler1) + eventEmitter.on('lifecycle-event', handler2) + + // Emit event + eventEmitter.emit('lifecycle-event', 'test-data') + expect(handler1).toHaveBeenCalledWith('test-data') + expect(handler2).toHaveBeenCalledWith('test-data') + + // Remove one handler + eventEmitter.off('lifecycle-event', handler1) + + // Emit again + eventEmitter.emit('lifecycle-event', 'test-data-2') + expect(handler1).toHaveBeenCalledTimes(1) // Still only called once + expect(handler2).toHaveBeenCalledTimes(2) // Called twice + expect(handler2).toHaveBeenLastCalledWith('test-data-2') + }) + + it('should handle complex data types', () => { + const handler = vi.fn() + const complexData = { + array: [1, 2, 3], + object: { nested: true }, + function: () => 'test', + symbol: Symbol('test'), + } + + eventEmitter.on('complex-event', handler) + eventEmitter.emit('complex-event', complexData) + + expect(handler).toHaveBeenCalledWith(complexData) + }) + }) +}) \ No newline at end of file