test fixes and doc work
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s
This commit is contained in:
424
src/components/NotificationBell.test.tsx
Normal file
424
src/components/NotificationBell.test.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
// src/components/NotificationBell.test.tsx
|
||||
import React from 'react';
|
||||
import { screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { NotificationBell, ConnectionStatus } from './NotificationBell';
|
||||
import { renderWithProviders } from '../tests/utils/renderWithProviders';
|
||||
|
||||
// Mock the useWebSocket hook
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useEventBus hook
|
||||
vi.mock('../hooks/useEventBus', () => ({
|
||||
useEventBus: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked modules
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
|
||||
// Type the mocked functions
|
||||
const mockUseWebSocket = useWebSocket as Mock;
|
||||
const mockUseEventBus = useEventBus as Mock;
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
let eventBusCallback: ((data?: unknown) => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
eventBusCallback = null;
|
||||
|
||||
// Default mock: connected state, no error
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Capture the callback passed to useEventBus
|
||||
mockUseEventBus.mockImplementation((_event: string, callback: (data?: unknown) => void) => {
|
||||
eventBusCallback = callback;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the notification bell button', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /notifications/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom className', () => {
|
||||
renderWithProviders(<NotificationBell className="custom-class" />);
|
||||
|
||||
const container = screen.getByRole('button').parentElement;
|
||||
expect(container).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should show connection status indicator by default', () => {
|
||||
const { container } = renderWithProviders(<NotificationBell />);
|
||||
|
||||
// The status indicator is a span with inline style containing backgroundColor
|
||||
const statusIndicator = container.querySelector('span[title="Connected"]');
|
||||
expect(statusIndicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide connection status indicator when showConnectionStatus is false', () => {
|
||||
const { container } = renderWithProviders(<NotificationBell showConnectionStatus={false} />);
|
||||
|
||||
// No status indicator should be present (no span with title Connected/Connecting/Disconnected)
|
||||
const connectedIndicator = container.querySelector('span[title="Connected"]');
|
||||
const connectingIndicator = container.querySelector('span[title="Connecting"]');
|
||||
const disconnectedIndicator = container.querySelector('span[title="Disconnected"]');
|
||||
expect(connectedIndicator).not.toBeInTheDocument();
|
||||
expect(connectingIndicator).not.toBeInTheDocument();
|
||||
expect(disconnectedIndicator).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unread count badge', () => {
|
||||
it('should not show badge when unread count is zero', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
// The badge displays numbers, check that no number badge exists
|
||||
const badge = screen.queryByText(/^\d+$/);
|
||||
expect(badge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show badge with count when notifications arrive', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
// Simulate a notification arriving via event bus
|
||||
expect(eventBusCallback).not.toBeNull();
|
||||
act(() => {
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
|
||||
});
|
||||
|
||||
const badge = screen.getByText('1');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should increment count when multiple notifications arrive', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
// Simulate multiple notifications
|
||||
act(() => {
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test 1' }] });
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test 2' }] });
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test 3' }] });
|
||||
});
|
||||
|
||||
const badge = screen.getByText('3');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display 99+ when count exceeds 99', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
// Simulate 100 notifications
|
||||
act(() => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
eventBusCallback!({ deals: [{ item_name: `Test ${i}` }] });
|
||||
}
|
||||
});
|
||||
|
||||
const badge = screen.getByText('99+');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not increment count when notification data is undefined', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
// Simulate a notification with undefined data
|
||||
act(() => {
|
||||
eventBusCallback!(undefined);
|
||||
});
|
||||
|
||||
const badge = screen.queryByText(/^\d+$/);
|
||||
expect(badge).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('click behavior', () => {
|
||||
it('should reset unread count when clicked', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
// First, add some notifications
|
||||
act(() => {
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
|
||||
});
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
|
||||
// Click the bell
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
// Badge should no longer show
|
||||
expect(screen.queryByText('1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onClick callback when provided', () => {
|
||||
const mockOnClick = vi.fn();
|
||||
renderWithProviders(<NotificationBell onClick={mockOnClick} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle click without onClick callback', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
// Should not throw
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection status', () => {
|
||||
it('should show green indicator when connected', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<NotificationBell />);
|
||||
|
||||
const statusIndicator = container.querySelector('span[title="Connected"]');
|
||||
expect(statusIndicator).toBeInTheDocument();
|
||||
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(16, 185, 129)' });
|
||||
});
|
||||
|
||||
it('should show red indicator when error occurs', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<NotificationBell />);
|
||||
|
||||
const statusIndicator = container.querySelector('span[title="Disconnected"]');
|
||||
expect(statusIndicator).toBeInTheDocument();
|
||||
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(239, 68, 68)' });
|
||||
});
|
||||
|
||||
it('should show amber indicator when connecting', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<NotificationBell />);
|
||||
|
||||
const statusIndicator = container.querySelector('span[title="Connecting"]');
|
||||
expect(statusIndicator).toBeInTheDocument();
|
||||
expect(statusIndicator).toHaveStyle({ backgroundColor: 'rgb(245, 158, 11)' });
|
||||
});
|
||||
|
||||
it('should show error tooltip when disconnected with error', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
});
|
||||
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
expect(screen.getByText('Live notifications unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show error tooltip when connected', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
expect(screen.queryByText('Live notifications unavailable')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('aria attributes', () => {
|
||||
it('should have correct aria-label without unread notifications', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Notifications');
|
||||
});
|
||||
|
||||
it('should have correct aria-label with unread notifications', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
act(() => {
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test2' }] });
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', 'Notifications (2 unread)');
|
||||
});
|
||||
|
||||
it('should have correct title when connected', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('title', 'Connected to live notifications');
|
||||
});
|
||||
|
||||
it('should have correct title when connecting', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('title', 'Connecting...');
|
||||
});
|
||||
|
||||
it('should have correct title when error occurs', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: 'Network error',
|
||||
});
|
||||
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('title', 'WebSocket error: Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bell icon styling', () => {
|
||||
it('should have default color when no unread notifications', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const svg = button.querySelector('svg');
|
||||
expect(svg).toHaveClass('text-gray-600');
|
||||
});
|
||||
|
||||
it('should have highlighted color when there are unread notifications', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
act(() => {
|
||||
eventBusCallback!({ deals: [{ item_name: 'Test' }] });
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const svg = button.querySelector('svg');
|
||||
expect(svg).toHaveClass('text-blue-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event bus subscription', () => {
|
||||
it('should subscribe to notification:deal event', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
expect(mockUseEventBus).toHaveBeenCalledWith('notification:deal', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWebSocket configuration', () => {
|
||||
it('should call useWebSocket with autoConnect: true', () => {
|
||||
renderWithProviders(<NotificationBell />);
|
||||
|
||||
expect(mockUseWebSocket).toHaveBeenCalledWith({ autoConnect: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should show "Live" text when connected', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<ConnectionStatus />);
|
||||
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Offline" text when disconnected with error', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
});
|
||||
|
||||
renderWithProviders(<ConnectionStatus />);
|
||||
|
||||
expect(screen.getByText('Offline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Connecting..." text when connecting', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<ConnectionStatus />);
|
||||
|
||||
expect(screen.getByText('Connecting...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call useWebSocket with autoConnect: true', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<ConnectionStatus />);
|
||||
|
||||
expect(mockUseWebSocket).toHaveBeenCalledWith({ autoConnect: true });
|
||||
});
|
||||
|
||||
it('should render Wifi icon when connected', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithProviders(<ConnectionStatus />);
|
||||
|
||||
const container = screen.getByText('Live').parentElement;
|
||||
const svg = container?.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveClass('text-green-600');
|
||||
});
|
||||
|
||||
it('should render WifiOff icon when disconnected', () => {
|
||||
mockUseWebSocket.mockReturnValue({
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
});
|
||||
|
||||
renderWithProviders(<ConnectionStatus />);
|
||||
|
||||
const container = screen.getByText('Offline').parentElement;
|
||||
const svg = container?.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg).toHaveClass('text-red-600');
|
||||
});
|
||||
});
|
||||
776
src/components/NotificationToastHandler.test.tsx
Normal file
776
src/components/NotificationToastHandler.test.tsx
Normal file
@@ -0,0 +1,776 @@
|
||||
// src/components/NotificationToastHandler.test.tsx
|
||||
import React from 'react';
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { NotificationToastHandler } from './NotificationToastHandler';
|
||||
import type { DealNotificationData, SystemMessageData } from '../types/websocket';
|
||||
|
||||
// Use vi.hoisted to properly hoist mock functions
|
||||
const { mockToastSuccess, mockToastError, mockToastDefault } = vi.hoisted(() => ({
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
mockToastDefault: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock('react-hot-toast', () => {
|
||||
const toastFn = (message: string, options?: unknown) => mockToastDefault(message, options);
|
||||
toastFn.success = mockToastSuccess;
|
||||
toastFn.error = mockToastError;
|
||||
return {
|
||||
default: toastFn,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useWebSocket hook
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useEventBus hook
|
||||
vi.mock('../hooks/useEventBus', () => ({
|
||||
useEventBus: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock formatCurrency
|
||||
vi.mock('../utils/formatUtils', () => ({
|
||||
formatCurrency: vi.fn((cents: number) => `$${(cents / 100).toFixed(2)}`),
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { useEventBus } from '../hooks/useEventBus';
|
||||
|
||||
const mockUseWebSocket = useWebSocket as Mock;
|
||||
const mockUseEventBus = useEventBus as Mock;
|
||||
|
||||
describe('NotificationToastHandler', () => {
|
||||
let eventBusCallbacks: Map<string, (data?: unknown) => void>;
|
||||
let onConnectCallback: (() => void) | undefined;
|
||||
let onDisconnectCallback: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Clear toast mocks
|
||||
mockToastSuccess.mockClear();
|
||||
mockToastError.mockClear();
|
||||
mockToastDefault.mockClear();
|
||||
|
||||
eventBusCallbacks = new Map();
|
||||
onConnectCallback = undefined;
|
||||
onDisconnectCallback = undefined;
|
||||
|
||||
// Default mock implementation for useWebSocket
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: true,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Capture callbacks for different event types
|
||||
mockUseEventBus.mockImplementation((event: string, callback: (data?: unknown) => void) => {
|
||||
eventBusCallbacks.set(event, callback);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render null (no visible output)', () => {
|
||||
const { container } = render(<NotificationToastHandler />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should subscribe to event bus on mount', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
expect(mockUseEventBus).toHaveBeenCalledWith('notification:deal', expect.any(Function));
|
||||
expect(mockUseEventBus).toHaveBeenCalledWith('notification:system', expect.any(Function));
|
||||
expect(mockUseEventBus).toHaveBeenCalledWith('notification:error', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection events', () => {
|
||||
it('should show success toast on connect when enabled', () => {
|
||||
render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
// Trigger onConnect callback
|
||||
onConnectCallback?.();
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'Connected to live notifications',
|
||||
expect.objectContaining({
|
||||
duration: 2000,
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show success toast on connect when disabled', () => {
|
||||
render(<NotificationToastHandler enabled={false} />);
|
||||
|
||||
onConnectCallback?.();
|
||||
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error toast on disconnect when error exists', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: 'Connection lost',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
onDisconnectCallback?.();
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
'Disconnected from live notifications',
|
||||
expect.objectContaining({
|
||||
duration: 3000,
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show disconnect toast when disabled', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: 'Connection lost',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={false} />);
|
||||
|
||||
onDisconnectCallback?.();
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show disconnect toast when no error', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
onDisconnectCallback?.();
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deal notifications', () => {
|
||||
it('should show toast for single deal notification', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal found',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
duration: 5000,
|
||||
icon: expect.any(String),
|
||||
position: 'top-right',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show toast for multiple deals notification', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Store A',
|
||||
store_id: 1,
|
||||
},
|
||||
{
|
||||
item_name: 'Bread',
|
||||
best_price_in_cents: 299,
|
||||
store_name: 'Store B',
|
||||
store_id: 2,
|
||||
},
|
||||
{
|
||||
item_name: 'Eggs',
|
||||
best_price_in_cents: 499,
|
||||
store_name: 'Store C',
|
||||
store_id: 3,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'Multiple deals found',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show toast when disabled', () => {
|
||||
render(<NotificationToastHandler enabled={false} />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal found',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show toast when data is undefined', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(undefined);
|
||||
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('system messages', () => {
|
||||
it('should show error toast for error severity', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const systemData: SystemMessageData = {
|
||||
message: 'System error occurred',
|
||||
severity: 'error',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:system');
|
||||
callback?.(systemData);
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
'System error occurred',
|
||||
expect.objectContaining({
|
||||
duration: 6000,
|
||||
position: 'top-center',
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show warning toast for warning severity', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const systemData: SystemMessageData = {
|
||||
message: 'System warning',
|
||||
severity: 'warning',
|
||||
};
|
||||
|
||||
// For warning, the default toast() is called
|
||||
const callback = eventBusCallbacks.get('notification:system');
|
||||
callback?.(systemData);
|
||||
|
||||
// Warning uses the regular toast function (mockToastDefault)
|
||||
expect(mockToastDefault).toHaveBeenCalledWith(
|
||||
'System warning',
|
||||
expect.objectContaining({
|
||||
duration: 4000,
|
||||
position: 'top-center',
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show info toast for info severity', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const systemData: SystemMessageData = {
|
||||
message: 'System info',
|
||||
severity: 'info',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:system');
|
||||
callback?.(systemData);
|
||||
|
||||
// Info uses the regular toast function (mockToastDefault)
|
||||
expect(mockToastDefault).toHaveBeenCalledWith(
|
||||
'System info',
|
||||
expect.objectContaining({
|
||||
duration: 4000,
|
||||
position: 'top-center',
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show toast when disabled', () => {
|
||||
render(<NotificationToastHandler enabled={false} />);
|
||||
|
||||
const systemData: SystemMessageData = {
|
||||
message: 'System error',
|
||||
severity: 'error',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:system');
|
||||
callback?.(systemData);
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show toast when data is undefined', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:system');
|
||||
callback?.(undefined);
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error notifications', () => {
|
||||
it('should show error toast with message and code', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const errorData = {
|
||||
message: 'Something went wrong',
|
||||
code: 'ERR_001',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:error');
|
||||
callback?.(errorData);
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
'Error: Something went wrong',
|
||||
expect.objectContaining({
|
||||
duration: 5000,
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error toast without code', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const errorData = {
|
||||
message: 'Something went wrong',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:error');
|
||||
callback?.(errorData);
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
'Error: Something went wrong',
|
||||
expect.objectContaining({
|
||||
duration: 5000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show toast when disabled', () => {
|
||||
render(<NotificationToastHandler enabled={false} />);
|
||||
|
||||
const errorData = {
|
||||
message: 'Something went wrong',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:error');
|
||||
callback?.(errorData);
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show toast when data is undefined', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:error');
|
||||
callback?.(undefined);
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sound playback', () => {
|
||||
it('should not play sound by default', () => {
|
||||
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
|
||||
const AudioMock = vi.fn().mockImplementation(() => ({
|
||||
play: audioPlayMock,
|
||||
volume: 0,
|
||||
}));
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler playSound={false} />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(AudioMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create Audio instance when playSound is true', () => {
|
||||
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
|
||||
const AudioMock = vi.fn().mockImplementation(() => ({
|
||||
play: audioPlayMock,
|
||||
volume: 0,
|
||||
}));
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler playSound={true} />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
// Verify Audio constructor was called with correct URL
|
||||
expect(AudioMock).toHaveBeenCalledWith('/notification-sound.mp3');
|
||||
});
|
||||
|
||||
it('should use custom sound URL', () => {
|
||||
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
|
||||
const AudioMock = vi.fn().mockImplementation(() => ({
|
||||
play: audioPlayMock,
|
||||
volume: 0,
|
||||
}));
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler playSound={true} soundUrl="/custom-sound.mp3" />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(AudioMock).toHaveBeenCalledWith('/custom-sound.mp3');
|
||||
});
|
||||
|
||||
it('should handle audio play failure gracefully', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const audioPlayMock = vi.fn().mockRejectedValue(new Error('Autoplay blocked'));
|
||||
const AudioMock = vi.fn().mockImplementation(() => ({
|
||||
play: audioPlayMock,
|
||||
volume: 0,
|
||||
}));
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler playSound={true} />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
// Should not throw even if play() fails
|
||||
expect(() => callback?.(dealData)).not.toThrow();
|
||||
// Audio constructor should still be called
|
||||
expect(AudioMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Audio constructor failure gracefully', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const AudioMock = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Audio not supported');
|
||||
});
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler playSound={true} />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
// Should not throw
|
||||
expect(() => callback?.(dealData)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistent connection error', () => {
|
||||
it('should show error toast after delay when connection error persists', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
// Fast-forward 5 seconds
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
'Unable to connect to live notifications. Some features may be limited.',
|
||||
expect.objectContaining({
|
||||
duration: 5000,
|
||||
icon: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show error toast before delay', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
// Advance only 4 seconds
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to connect'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show persistent error toast when disabled', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={false} />);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear timeout on unmount', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: 'Connection failed',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
// Unmount before timer fires
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// The toast should not be called because component unmounted
|
||||
expect(mockToastError).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to connect'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show persistent error toast when there is no error', () => {
|
||||
mockUseWebSocket.mockImplementation(
|
||||
(options: { onConnect?: () => void; onDisconnect?: () => void }) => {
|
||||
onConnectCallback = options?.onConnect;
|
||||
onDisconnectCallback = options?.onDisconnect;
|
||||
return {
|
||||
isConnected: false,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
render(<NotificationToastHandler enabled={true} />);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(mockToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default props', () => {
|
||||
it('should default enabled to true', () => {
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
onConnectCallback?.();
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should default playSound to false', () => {
|
||||
const AudioMock = vi.fn();
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(AudioMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should default soundUrl to /notification-sound.mp3', () => {
|
||||
const audioPlayMock = vi.fn().mockResolvedValue(undefined);
|
||||
const AudioMock = vi.fn().mockImplementation(() => ({
|
||||
play: audioPlayMock,
|
||||
volume: 0,
|
||||
}));
|
||||
vi.stubGlobal('Audio', AudioMock);
|
||||
|
||||
render(<NotificationToastHandler playSound={true} />);
|
||||
|
||||
const dealData: DealNotificationData = {
|
||||
deals: [
|
||||
{
|
||||
item_name: 'Milk',
|
||||
best_price_in_cents: 399,
|
||||
store_name: 'Test Store',
|
||||
store_id: 1,
|
||||
},
|
||||
],
|
||||
user_id: 'user-123',
|
||||
message: 'New deal',
|
||||
};
|
||||
|
||||
const callback = eventBusCallbacks.get('notification:deal');
|
||||
callback?.(dealData);
|
||||
|
||||
expect(AudioMock).toHaveBeenCalledWith('/notification-sound.mp3');
|
||||
});
|
||||
});
|
||||
});
|
||||
395
src/features/store/StoreCard.test.tsx
Normal file
395
src/features/store/StoreCard.test.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
// src/features/store/StoreCard.test.tsx
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { StoreCard } from './StoreCard';
|
||||
import { renderWithProviders } from '../../tests/utils/renderWithProviders';
|
||||
|
||||
describe('StoreCard', () => {
|
||||
const mockStoreWithLogo = {
|
||||
store_id: 1,
|
||||
name: 'Test Store',
|
||||
logo_url: 'https://example.com/logo.png',
|
||||
locations: [
|
||||
{
|
||||
address_line_1: '123 Main Street',
|
||||
city: 'Toronto',
|
||||
province_state: 'ON',
|
||||
postal_code: 'M5V 1A1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockStoreWithoutLogo = {
|
||||
store_id: 2,
|
||||
name: 'Another Store',
|
||||
logo_url: null,
|
||||
locations: [
|
||||
{
|
||||
address_line_1: '456 Oak Avenue',
|
||||
city: 'Vancouver',
|
||||
province_state: 'BC',
|
||||
postal_code: 'V6B 2M9',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockStoreWithMultipleLocations = {
|
||||
store_id: 3,
|
||||
name: 'Multi Location Store',
|
||||
logo_url: 'https://example.com/multi-logo.png',
|
||||
locations: [
|
||||
{
|
||||
address_line_1: '100 First Street',
|
||||
city: 'Montreal',
|
||||
province_state: 'QC',
|
||||
postal_code: 'H2X 1Y6',
|
||||
},
|
||||
{
|
||||
address_line_1: '200 Second Street',
|
||||
city: 'Montreal',
|
||||
province_state: 'QC',
|
||||
postal_code: 'H3A 2T1',
|
||||
},
|
||||
{
|
||||
address_line_1: '300 Third Street',
|
||||
city: 'Montreal',
|
||||
province_state: 'QC',
|
||||
postal_code: 'H4B 3C2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockStoreNoLocations = {
|
||||
store_id: 4,
|
||||
name: 'No Location Store',
|
||||
logo_url: 'https://example.com/no-loc-logo.png',
|
||||
locations: [],
|
||||
};
|
||||
|
||||
const mockStoreUndefinedLocations = {
|
||||
store_id: 5,
|
||||
name: 'Undefined Locations Store',
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('store name rendering', () => {
|
||||
it('should render the store name', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
expect(screen.getByText('Test Store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render store name with truncation class', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveClass('truncate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logo rendering', () => {
|
||||
it('should render logo image when logo_url is provided', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
const logo = screen.getByAltText('Test Store logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute('src', 'https://example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should render initials fallback when logo_url is null', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithoutLogo} />);
|
||||
|
||||
expect(screen.getByText('AN')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render initials fallback when logo_url is undefined', () => {
|
||||
const storeWithUndefinedLogo = {
|
||||
store_id: 10,
|
||||
name: 'Test Name',
|
||||
logo_url: undefined,
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={storeWithUndefinedLogo} />);
|
||||
|
||||
expect(screen.getByText('TE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should convert initials to uppercase', () => {
|
||||
const storeWithLowercase = {
|
||||
store_id: 11,
|
||||
name: 'lowercase store',
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={storeWithLowercase} />);
|
||||
|
||||
expect(screen.getByText('LO')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle single character store name', () => {
|
||||
const singleCharStore = {
|
||||
store_id: 12,
|
||||
name: 'X',
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={singleCharStore} />);
|
||||
|
||||
// Both the store name and initials will be 'X'
|
||||
// Check that there are exactly 2 elements with 'X'
|
||||
const elements = screen.getAllByText('X');
|
||||
expect(elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty string store name', () => {
|
||||
const emptyNameStore = {
|
||||
store_id: 13,
|
||||
name: '',
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
// This will render empty string for initials
|
||||
const { container } = renderWithProviders(<StoreCard store={emptyNameStore} />);
|
||||
|
||||
// The fallback div should still render
|
||||
const fallbackDiv = container.querySelector('.h-12.w-12.flex');
|
||||
expect(fallbackDiv).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('location display', () => {
|
||||
it('should not show location when showLocations is false (default)', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
expect(screen.queryByText('123 Main Street')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Toronto/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show primary location when showLocations is true', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} showLocations={true} />);
|
||||
|
||||
expect(screen.getByText('123 Main Street')).toBeInTheDocument();
|
||||
expect(screen.getByText('Toronto, ON M5V 1A1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "No location data" when showLocations is true but no locations exist', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={true} />);
|
||||
|
||||
expect(screen.getByText('No location data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "No location data" when locations is undefined', () => {
|
||||
renderWithProviders(
|
||||
<StoreCard
|
||||
store={mockStoreUndefinedLocations as typeof mockStoreWithLogo}
|
||||
showLocations={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No location data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "No location data" message when showLocations is false', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={false} />);
|
||||
|
||||
expect(screen.queryByText('No location data')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple locations', () => {
|
||||
it('should show additional locations count for 2 locations', () => {
|
||||
const storeWith2Locations = {
|
||||
...mockStoreWithLogo,
|
||||
locations: [
|
||||
mockStoreWithMultipleLocations.locations[0],
|
||||
mockStoreWithMultipleLocations.locations[1],
|
||||
],
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={storeWith2Locations} showLocations={true} />);
|
||||
|
||||
expect(screen.getByText('+ 1 more location')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show additional locations count for 3+ locations', () => {
|
||||
renderWithProviders(
|
||||
<StoreCard store={mockStoreWithMultipleLocations} showLocations={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('+ 2 more locations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show primary location from multiple locations', () => {
|
||||
renderWithProviders(
|
||||
<StoreCard store={mockStoreWithMultipleLocations} showLocations={true} />,
|
||||
);
|
||||
|
||||
// Should show first location
|
||||
expect(screen.getByText('100 First Street')).toBeInTheDocument();
|
||||
expect(screen.getByText('Montreal, QC H2X 1Y6')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show secondary locations directly
|
||||
expect(screen.queryByText('200 Second Street')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show additional locations count for single location', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} showLocations={true} />);
|
||||
|
||||
expect(screen.queryByText(/more location/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have proper alt text for logo', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
const logo = screen.getByAltText('Test Store logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use heading level 3 for store name', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent('Test Store');
|
||||
});
|
||||
});
|
||||
|
||||
describe('styling', () => {
|
||||
it('should apply flex layout to container', () => {
|
||||
const { container } = renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
const mainDiv = container.firstChild;
|
||||
expect(mainDiv).toHaveClass('flex', 'items-start', 'space-x-3');
|
||||
});
|
||||
|
||||
it('should apply proper styling to logo image', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreWithLogo} />);
|
||||
|
||||
const logo = screen.getByAltText('Test Store logo');
|
||||
expect(logo).toHaveClass(
|
||||
'h-12',
|
||||
'w-12',
|
||||
'object-contain',
|
||||
'rounded-md',
|
||||
'bg-gray-100',
|
||||
'dark:bg-gray-700',
|
||||
'p-1',
|
||||
'flex-shrink-0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply proper styling to initials fallback', () => {
|
||||
const { container } = renderWithProviders(<StoreCard store={mockStoreWithoutLogo} />);
|
||||
|
||||
const initialsDiv = container.querySelector('.h-12.w-12.flex.items-center.justify-center');
|
||||
expect(initialsDiv).toHaveClass(
|
||||
'h-12',
|
||||
'w-12',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-gray-200',
|
||||
'dark:bg-gray-700',
|
||||
'rounded-md',
|
||||
'text-gray-400',
|
||||
'text-xs',
|
||||
'flex-shrink-0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply italic style to "No location data" text', () => {
|
||||
renderWithProviders(<StoreCard store={mockStoreNoLocations} showLocations={true} />);
|
||||
|
||||
const noLocationText = screen.getByText('No location data');
|
||||
expect(noLocationText).toHaveClass('italic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle store with special characters in name', () => {
|
||||
const specialCharStore = {
|
||||
store_id: 20,
|
||||
name: "Store & Co's <Best>",
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={specialCharStore} />);
|
||||
|
||||
expect(screen.getByText("Store & Co's <Best>")).toBeInTheDocument();
|
||||
expect(screen.getByText('ST')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle store with unicode characters', () => {
|
||||
const unicodeStore = {
|
||||
store_id: 21,
|
||||
name: 'Cafe Le Cafe',
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={unicodeStore} />);
|
||||
|
||||
expect(screen.getByText('Cafe Le Cafe')).toBeInTheDocument();
|
||||
expect(screen.getByText('CA')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle location with long address', () => {
|
||||
const longAddressStore = {
|
||||
store_id: 22,
|
||||
name: 'Long Address Store',
|
||||
logo_url: 'https://example.com/logo.png',
|
||||
locations: [
|
||||
{
|
||||
address_line_1: '1234567890 Very Long Street Name That Exceeds Normal Length',
|
||||
city: 'Vancouver',
|
||||
province_state: 'BC',
|
||||
postal_code: 'V6B 2M9',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderWithProviders(<StoreCard store={longAddressStore} showLocations={true} />);
|
||||
|
||||
const addressElement = screen.getByText(
|
||||
'1234567890 Very Long Street Name That Exceeds Normal Length',
|
||||
);
|
||||
expect(addressElement).toHaveClass('truncate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data types', () => {
|
||||
it('should accept store_id as number', () => {
|
||||
const store = {
|
||||
store_id: 12345,
|
||||
name: 'Numeric ID Store',
|
||||
logo_url: null,
|
||||
};
|
||||
|
||||
// This should compile and render without errors
|
||||
renderWithProviders(<StoreCard store={store} />);
|
||||
|
||||
expect(screen.getByText('Numeric ID Store')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty logo_url string', () => {
|
||||
const storeWithEmptyLogo = {
|
||||
store_id: 30,
|
||||
name: 'Empty Logo Store',
|
||||
logo_url: '',
|
||||
};
|
||||
|
||||
// Empty string is truthy check, but might cause issues with img src
|
||||
// The component checks for truthy logo_url, so empty string will render initials
|
||||
// Actually, empty string '' is falsy in JavaScript, so this would show initials
|
||||
renderWithProviders(<StoreCard store={storeWithEmptyLogo} />);
|
||||
|
||||
// Empty string is falsy, so initials should show
|
||||
expect(screen.getByText('EM')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
311
src/hooks/useEventBus.test.ts
Normal file
311
src/hooks/useEventBus.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// src/hooks/useEventBus.test.ts
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { useEventBus } from './useEventBus';
|
||||
|
||||
// Mock the eventBus service
|
||||
vi.mock('../services/eventBus', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { eventBus } from '../services/eventBus';
|
||||
|
||||
const mockEventBus = eventBus as {
|
||||
on: Mock;
|
||||
off: Mock;
|
||||
dispatch: Mock;
|
||||
};
|
||||
|
||||
describe('useEventBus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('subscription', () => {
|
||||
it('should subscribe to the event on mount', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('test-event', callback));
|
||||
|
||||
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should unsubscribe from the event on unmount', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const { unmount } = renderHook(() => useEventBus('test-event', callback));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockEventBus.off).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.off).toHaveBeenCalledWith('test-event', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should pass the same callback reference to on and off', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const { unmount } = renderHook(() => useEventBus('test-event', callback));
|
||||
|
||||
const onCallback = mockEventBus.on.mock.calls[0][1];
|
||||
|
||||
unmount();
|
||||
|
||||
const offCallback = mockEventBus.off.mock.calls[0][1];
|
||||
|
||||
expect(onCallback).toBe(offCallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback execution', () => {
|
||||
it('should call the callback when event is dispatched', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('test-event', callback));
|
||||
|
||||
// Get the registered callback and call it
|
||||
const registeredCallback = mockEventBus.on.mock.calls[0][1];
|
||||
registeredCallback({ message: 'hello' });
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith({ message: 'hello' });
|
||||
});
|
||||
|
||||
it('should call the callback with undefined data', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('test-event', callback));
|
||||
|
||||
const registeredCallback = mockEventBus.on.mock.calls[0][1];
|
||||
registeredCallback(undefined);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should call the callback with null data', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('test-event', callback));
|
||||
|
||||
const registeredCallback = mockEventBus.on.mock.calls[0][1];
|
||||
registeredCallback(null);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback ref updates', () => {
|
||||
it('should use the latest callback when event is dispatched', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(({ callback }) => useEventBus('test-event', callback), {
|
||||
initialProps: { callback: callback1 },
|
||||
});
|
||||
|
||||
// Rerender with new callback
|
||||
rerender({ callback: callback2 });
|
||||
|
||||
// Get the registered callback and call it
|
||||
const registeredCallback = mockEventBus.on.mock.calls[0][1];
|
||||
registeredCallback({ message: 'hello' });
|
||||
|
||||
// Should call the new callback, not the old one
|
||||
expect(callback1).not.toHaveBeenCalled();
|
||||
expect(callback2).toHaveBeenCalledTimes(1);
|
||||
expect(callback2).toHaveBeenCalledWith({ message: 'hello' });
|
||||
});
|
||||
|
||||
it('should not re-subscribe when callback changes', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(({ callback }) => useEventBus('test-event', callback), {
|
||||
initialProps: { callback: callback1 },
|
||||
});
|
||||
|
||||
// Clear mock counts
|
||||
mockEventBus.on.mockClear();
|
||||
mockEventBus.off.mockClear();
|
||||
|
||||
// Rerender with new callback
|
||||
rerender({ callback: callback2 });
|
||||
|
||||
// Should NOT unsubscribe and re-subscribe
|
||||
expect(mockEventBus.off).not.toHaveBeenCalled();
|
||||
expect(mockEventBus.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event name changes', () => {
|
||||
it('should re-subscribe when event name changes', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(({ event }) => useEventBus(event, callback), {
|
||||
initialProps: { event: 'event-1' },
|
||||
});
|
||||
|
||||
// Initial subscription
|
||||
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('event-1', expect.any(Function));
|
||||
|
||||
// Clear mock
|
||||
mockEventBus.on.mockClear();
|
||||
|
||||
// Rerender with different event
|
||||
rerender({ event: 'event-2' });
|
||||
|
||||
// Should unsubscribe from old event
|
||||
expect(mockEventBus.off).toHaveBeenCalledWith('event-1', expect.any(Function));
|
||||
|
||||
// Should subscribe to new event
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('event-2', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple hooks', () => {
|
||||
it('should allow multiple subscriptions to same event', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('shared-event', callback1));
|
||||
renderHook(() => useEventBus('shared-event', callback2));
|
||||
|
||||
expect(mockEventBus.on).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Both should be subscribed to same event
|
||||
expect(mockEventBus.on.mock.calls[0][0]).toBe('shared-event');
|
||||
expect(mockEventBus.on.mock.calls[1][0]).toBe('shared-event');
|
||||
});
|
||||
|
||||
it('should allow subscriptions to different events', () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('event-a', callback1));
|
||||
renderHook(() => useEventBus('event-b', callback2));
|
||||
|
||||
expect(mockEventBus.on).toHaveBeenCalledTimes(2);
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('event-a', expect.any(Function));
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('event-b', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should correctly type the callback data', () => {
|
||||
interface TestData {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const callback = vi.fn<[TestData?], void>();
|
||||
|
||||
renderHook(() => useEventBus<TestData>('typed-event', callback));
|
||||
|
||||
const registeredCallback = mockEventBus.on.mock.calls[0][1];
|
||||
registeredCallback({ id: 1, name: 'test' });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({ id: 1, name: 'test' });
|
||||
});
|
||||
|
||||
it('should handle callback with optional parameter', () => {
|
||||
const callback = vi.fn<[string?], void>();
|
||||
|
||||
renderHook(() => useEventBus<string>('optional-event', callback));
|
||||
|
||||
const registeredCallback = mockEventBus.on.mock.calls[0][1];
|
||||
|
||||
// Call with data
|
||||
registeredCallback('hello');
|
||||
expect(callback).toHaveBeenCalledWith('hello');
|
||||
|
||||
// Call without data
|
||||
registeredCallback();
|
||||
expect(callback).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string event name', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('', callback));
|
||||
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should handle event names with special characters', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
renderHook(() => useEventBus('event:with:colons', callback));
|
||||
|
||||
expect(mockEventBus.on).toHaveBeenCalledWith('event:with:colons', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should handle rapid mount/unmount cycles', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const { unmount: unmount1 } = renderHook(() => useEventBus('rapid-event', callback));
|
||||
unmount1();
|
||||
|
||||
const { unmount: unmount2 } = renderHook(() => useEventBus('rapid-event', callback));
|
||||
unmount2();
|
||||
|
||||
const { unmount: unmount3 } = renderHook(() => useEventBus('rapid-event', callback));
|
||||
unmount3();
|
||||
|
||||
// Should have 3 subscriptions and 3 unsubscriptions
|
||||
expect(mockEventBus.on).toHaveBeenCalledTimes(3);
|
||||
expect(mockEventBus.off).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stable callback reference', () => {
|
||||
it('should use useCallback for stable reference', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(() => useEventBus('stable-event', callback));
|
||||
|
||||
const firstCallbackRef = mockEventBus.on.mock.calls[0][1];
|
||||
|
||||
// Force a rerender
|
||||
rerender();
|
||||
|
||||
// The callback passed to eventBus.on should remain the same
|
||||
// (no re-subscription means the same callback is used)
|
||||
expect(mockEventBus.on).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the callback still works after rerender
|
||||
firstCallbackRef({ data: 'test' });
|
||||
expect(callback).toHaveBeenCalledWith({ data: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup timing', () => {
|
||||
it('should unsubscribe before component is fully unmounted', () => {
|
||||
const callback = vi.fn();
|
||||
const cleanupOrder: string[] = [];
|
||||
|
||||
// Override off to track when it's called
|
||||
mockEventBus.off.mockImplementation(() => {
|
||||
cleanupOrder.push('eventBus.off');
|
||||
});
|
||||
|
||||
const { unmount } = renderHook(() => useEventBus('cleanup-event', callback));
|
||||
|
||||
cleanupOrder.push('before unmount');
|
||||
unmount();
|
||||
cleanupOrder.push('after unmount');
|
||||
|
||||
expect(cleanupOrder).toEqual(['before unmount', 'eventBus.off', 'after unmount']);
|
||||
});
|
||||
});
|
||||
});
|
||||
560
src/hooks/useOnboardingTour.test.ts
Normal file
560
src/hooks/useOnboardingTour.test.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
// src/hooks/useOnboardingTour.test.ts
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { useOnboardingTour } from './useOnboardingTour';
|
||||
|
||||
// Mock driver.js
|
||||
const mockDrive = vi.fn();
|
||||
const mockDestroy = vi.fn();
|
||||
const mockDriverInstance = {
|
||||
drive: mockDrive,
|
||||
destroy: mockDestroy,
|
||||
};
|
||||
|
||||
vi.mock('driver.js', () => ({
|
||||
driver: vi.fn(() => mockDriverInstance),
|
||||
Driver: vi.fn(),
|
||||
DriveStep: vi.fn(),
|
||||
}));
|
||||
|
||||
import { driver } from 'driver.js';
|
||||
|
||||
const mockDriver = driver as Mock;
|
||||
|
||||
describe('useOnboardingTour', () => {
|
||||
const STORAGE_KEY = 'flyer_crawler_onboarding_completed';
|
||||
|
||||
// Mock localStorage
|
||||
let mockLocalStorage: { [key: string]: string };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Reset mock driver instance methods
|
||||
mockDrive.mockClear();
|
||||
mockDestroy.mockClear();
|
||||
|
||||
// Reset localStorage mock
|
||||
mockLocalStorage = {};
|
||||
|
||||
// Mock localStorage
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(
|
||||
(key: string) => mockLocalStorage[key] || null,
|
||||
);
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => {
|
||||
mockLocalStorage[key] = value;
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => {
|
||||
delete mockLocalStorage[key];
|
||||
});
|
||||
|
||||
// Mock document.getElementById for style injection check
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should return startTour, skipTour, and replayTour functions', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true'; // Prevent auto-start
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
expect(result.current.startTour).toBeInstanceOf(Function);
|
||||
expect(result.current.skipTour).toBeInstanceOf(Function);
|
||||
expect(result.current.replayTour).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should auto-start tour if not completed', async () => {
|
||||
// Don't set the storage key - tour not completed
|
||||
|
||||
renderHook(() => useOnboardingTour());
|
||||
|
||||
// Fast-forward past the 500ms delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(mockDriver).toHaveBeenCalled();
|
||||
expect(mockDrive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not auto-start tour if already completed', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
renderHook(() => useOnboardingTour());
|
||||
|
||||
// Fast-forward past the 500ms delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(mockDrive).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startTour', () => {
|
||||
it('should create and start the driver tour', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true'; // Prevent auto-start
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
expect(mockDriver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showProgress: true,
|
||||
steps: expect.any(Array),
|
||||
nextBtnText: 'Next',
|
||||
prevBtnText: 'Previous',
|
||||
doneBtnText: 'Done',
|
||||
progressText: 'Step {{current}} of {{total}}',
|
||||
onDestroyed: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mockDrive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should inject custom CSS styles', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
// Track the created style element
|
||||
const createdStyleElement = document.createElement('style');
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = vi
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === 'style') {
|
||||
return createdStyleElement;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
const appendChildSpy = vi.spyOn(document.head, 'appendChild');
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('style');
|
||||
expect(appendChildSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not inject styles if they already exist', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
// Mock that the style element already exists
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue({
|
||||
id: 'driver-js-custom-styles',
|
||||
} as HTMLElement);
|
||||
|
||||
const createElementSpy = vi.spyOn(document, 'createElement');
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
// createElement should not be called for the style element
|
||||
const styleCreateCalls = createElementSpy.mock.calls.filter((call) => call[0] === 'style');
|
||||
expect(styleCreateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should destroy existing tour before starting new one', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
// Start tour twice
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
mockDestroy.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark tour complete when onDestroyed is called', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
// Get the onDestroyed callback
|
||||
const driverConfig = mockDriver.mock.calls[0][0];
|
||||
const onDestroyed = driverConfig.onDestroyed;
|
||||
|
||||
act(() => {
|
||||
onDestroyed();
|
||||
});
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipTour', () => {
|
||||
it('should destroy the tour if active', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
// Start the tour first
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
mockDestroy.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.skipTour();
|
||||
});
|
||||
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark tour as complete', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.skipTour();
|
||||
});
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
|
||||
});
|
||||
|
||||
it('should handle skip when no tour is active', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
// Skip without starting
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.skipTour();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replayTour', () => {
|
||||
it('should start the tour', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.replayTour();
|
||||
});
|
||||
|
||||
expect(mockDriver).toHaveBeenCalled();
|
||||
expect(mockDrive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work even if tour was previously completed', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.replayTour();
|
||||
});
|
||||
|
||||
expect(mockDrive).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should destroy tour on unmount', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result, unmount } = renderHook(() => useOnboardingTour());
|
||||
|
||||
// Start the tour
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
mockDestroy.mockClear();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear timeout on unmount if tour not started yet', () => {
|
||||
// Don't set storage key - tour will try to auto-start
|
||||
|
||||
const { unmount } = renderHook(() => useOnboardingTour());
|
||||
|
||||
// Unmount before the 500ms delay
|
||||
unmount();
|
||||
|
||||
// Now advance timers - tour should NOT start
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(mockDrive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw on unmount when no tour is active', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { unmount } = renderHook(() => useOnboardingTour());
|
||||
|
||||
// Unmount without starting tour
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-start delay', () => {
|
||||
it('should wait 500ms before auto-starting tour', () => {
|
||||
// Don't set storage key
|
||||
|
||||
renderHook(() => useOnboardingTour());
|
||||
|
||||
// Tour should not have started yet
|
||||
expect(mockDrive).not.toHaveBeenCalled();
|
||||
|
||||
// Advance 499ms
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(499);
|
||||
});
|
||||
|
||||
expect(mockDrive).not.toHaveBeenCalled();
|
||||
|
||||
// Advance 1 more ms
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(mockDrive).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tour steps configuration', () => {
|
||||
it('should configure tour with 6 steps', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
const driverConfig = mockDriver.mock.calls[0][0];
|
||||
expect(driverConfig.steps).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should have correct step elements', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
const driverConfig = mockDriver.mock.calls[0][0];
|
||||
const steps = driverConfig.steps;
|
||||
|
||||
expect(steps[0].element).toBe('[data-tour="flyer-uploader"]');
|
||||
expect(steps[1].element).toBe('[data-tour="extracted-data-table"]');
|
||||
expect(steps[2].element).toBe('[data-tour="watch-button"]');
|
||||
expect(steps[3].element).toBe('[data-tour="watched-items"]');
|
||||
expect(steps[4].element).toBe('[data-tour="price-chart"]');
|
||||
expect(steps[5].element).toBe('[data-tour="shopping-list"]');
|
||||
});
|
||||
|
||||
it('should have popover configuration for each step', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
const driverConfig = mockDriver.mock.calls[0][0];
|
||||
const steps = driverConfig.steps;
|
||||
|
||||
steps.forEach(
|
||||
(step: {
|
||||
popover: { title: string; description: string; side: string; align: string };
|
||||
}) => {
|
||||
expect(step.popover).toBeDefined();
|
||||
expect(step.popover.title).toBeDefined();
|
||||
expect(step.popover.description).toBeDefined();
|
||||
expect(step.popover.side).toBeDefined();
|
||||
expect(step.popover.align).toBeDefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function stability', () => {
|
||||
it('should maintain stable function references across rerenders', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result, rerender } = renderHook(() => useOnboardingTour());
|
||||
|
||||
const initialStartTour = result.current.startTour;
|
||||
const initialSkipTour = result.current.skipTour;
|
||||
const initialReplayTour = result.current.replayTour;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.startTour).toBe(initialStartTour);
|
||||
expect(result.current.skipTour).toBe(initialSkipTour);
|
||||
expect(result.current.replayTour).toBe(initialReplayTour);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage key', () => {
|
||||
it('should use correct storage key', () => {
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.skipTour();
|
||||
});
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'flyer_crawler_onboarding_completed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('should read from correct storage key on mount', () => {
|
||||
mockLocalStorage['flyer_crawler_onboarding_completed'] = 'true';
|
||||
|
||||
renderHook(() => useOnboardingTour());
|
||||
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith('flyer_crawler_onboarding_completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiple startTour calls gracefully', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
result.current.startTour();
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
// Each startTour destroys the previous one
|
||||
expect(mockDestroy).toHaveBeenCalledTimes(2); // Called before 2nd and 3rd startTour
|
||||
expect(mockDrive).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should handle skipTour after startTour', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
mockDestroy.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.skipTour();
|
||||
});
|
||||
|
||||
expect(mockDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'true');
|
||||
});
|
||||
|
||||
it('should handle replayTour multiple times', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.replayTour();
|
||||
});
|
||||
|
||||
mockDriver.mockClear();
|
||||
mockDrive.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.replayTour();
|
||||
});
|
||||
|
||||
expect(mockDriver).toHaveBeenCalled();
|
||||
expect(mockDrive).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS injection', () => {
|
||||
it('should set correct id on style element', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
// Track the created style element
|
||||
const createdStyleElement = document.createElement('style');
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||
if (tagName === 'style') {
|
||||
return createdStyleElement;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
expect(createdStyleElement.id).toBe('driver-js-custom-styles');
|
||||
});
|
||||
|
||||
it('should inject CSS containing custom styles', () => {
|
||||
mockLocalStorage[STORAGE_KEY] = 'true';
|
||||
|
||||
// Track the created style element
|
||||
const createdStyleElement = document.createElement('style');
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
|
||||
if (tagName === 'style') {
|
||||
return createdStyleElement;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOnboardingTour());
|
||||
|
||||
act(() => {
|
||||
result.current.startTour();
|
||||
});
|
||||
|
||||
// Check that textContent contains expected CSS rules
|
||||
expect(createdStyleElement.textContent).toContain('.driver-popover');
|
||||
expect(createdStyleElement.textContent).toContain('background-color');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe('Worker Service Lifecycle', () => {
|
||||
cleanupWorker = workerService.cleanupWorker;
|
||||
weeklyAnalyticsWorker = workerService.weeklyAnalyticsWorker;
|
||||
tokenCleanupWorker = workerService.tokenCleanupWorker;
|
||||
});
|
||||
}, 15000); // Increase timeout for module re-import which can be slow
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up all event listeners on the mock connection to prevent open handles.
|
||||
@@ -144,8 +144,8 @@ describe('Worker Service Lifecycle', () => {
|
||||
});
|
||||
|
||||
it('should log a success message when Redis connects', () => {
|
||||
// Re-import redis.server to trigger its event listeners with the mock
|
||||
import('./redis.server');
|
||||
// redis.server is already imported via workers.server in beforeEach,
|
||||
// which attaches event listeners to mockRedisConnection.
|
||||
// Act: Simulate the 'connect' event on the mock Redis connection
|
||||
mockRedisConnection.emit('connect');
|
||||
|
||||
@@ -154,7 +154,8 @@ describe('Worker Service Lifecycle', () => {
|
||||
});
|
||||
|
||||
it('should log an error message when Redis connection fails', () => {
|
||||
import('./redis.server');
|
||||
// redis.server is already imported via workers.server in beforeEach,
|
||||
// which attaches event listeners to mockRedisConnection.
|
||||
const redisError = new Error('Connection refused');
|
||||
mockRedisConnection.emit('error', redisError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.');
|
||||
|
||||
@@ -77,22 +77,22 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
// Calculate checksum (required by the API)
|
||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// 4. Upload the flyer
|
||||
// 4. Upload the flyer (uses /ai/upload-and-process endpoint with flyerFile field)
|
||||
const uploadResponse = await getRequest()
|
||||
.post('/api/v1/flyers/upload')
|
||||
.post('/api/v1/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('flyer', fileBuffer, fileName)
|
||||
.attach('flyerFile', fileBuffer, fileName)
|
||||
.field('checksum', checksum);
|
||||
|
||||
expect(uploadResponse.status).toBe(202);
|
||||
const jobId = uploadResponse.body.data.jobId;
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
// 5. Poll for job completion using the new utility
|
||||
// 5. Poll for job completion using the new utility (endpoint is /ai/jobs/:jobId/status)
|
||||
const jobStatusResponse = await poll(
|
||||
async () => {
|
||||
const statusResponse = await getRequest()
|
||||
.get(`/api/v1/jobs/${jobId}`)
|
||||
.get(`/api/v1/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user