test fixes and doc work
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m50s

This commit is contained in:
2026-01-28 15:33:48 -08:00
parent e548d1b0cc
commit 4f06698dfd
18 changed files with 3210 additions and 48 deletions

View 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');
});
});

View 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');
});
});
});

View 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();
});
});
});

View 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']);
});
});
});

View 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');
});
});
});

View File

@@ -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.');

View File

@@ -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;
},