All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
// src/pages/admin/ActivityLog.test.tsx
|
|
import React from 'react';
|
|
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ActivityLog } from './ActivityLog';
|
|
import * as apiClient from '../../services/apiClient';
|
|
import type { ActivityLogItem, UserProfile } from '../../types';
|
|
import { createMockActivityLogItem, createMockUserProfile } from '../../tests/utils/mockFactories';
|
|
|
|
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
|
|
// We can cast it to its mocked type to get type safety and autocompletion.
|
|
const mockedApiClient = vi.mocked(apiClient);
|
|
|
|
// Mock date-fns to return a consistent value for snapshots
|
|
vi.mock('date-fns', () => {
|
|
return {
|
|
// Only mock the specific function used in the component.
|
|
// This avoids potential issues with `importOriginal` in complex mocking scenarios.
|
|
formatDistanceToNow: vi.fn(() => 'about 5 hours ago'),
|
|
};
|
|
});
|
|
|
|
const mockUserProfile: UserProfile = createMockUserProfile({
|
|
user: { user_id: 'user-123', email: 'test@example.com' },
|
|
});
|
|
|
|
const mockLogs: ActivityLogItem[] = [
|
|
createMockActivityLogItem({
|
|
activity_log_id: 1,
|
|
user_id: 'user-123',
|
|
action: 'flyer_processed',
|
|
display_text: 'Processed a new flyer for Walmart.',
|
|
user_avatar_url: 'https://example.com/avatar.png',
|
|
user_full_name: 'Test User',
|
|
details: { flyer_id: 1, store_name: 'Walmart' },
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 2,
|
|
user_id: 'user-456',
|
|
action: 'recipe_created',
|
|
display_text: 'Jane Doe added a new recipe: Pasta Carbonara',
|
|
user_full_name: 'Jane Doe',
|
|
details: { recipe_id: 1, recipe_name: 'Pasta Carbonara' },
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 3,
|
|
user_id: 'user-789',
|
|
action: 'list_shared',
|
|
display_text: 'John Smith shared a list.',
|
|
user_full_name: 'John Smith',
|
|
details: { list_name: 'Weekly Groceries', shopping_list_id: 10, shared_with_name: 'Test User' },
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 4,
|
|
user_id: 'user-101',
|
|
action: 'user_registered',
|
|
display_text: 'New user joined',
|
|
details: { full_name: 'Newbie User' }, // No avatar provided to test fallback
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 5,
|
|
user_id: 'user-102',
|
|
action: 'recipe_favorited',
|
|
display_text: 'User favorited a recipe',
|
|
user_full_name: 'Pizza Lover',
|
|
user_avatar_url: 'https://example.com/pizza.png',
|
|
details: { recipe_name: 'Best Pizza' },
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 6,
|
|
user_id: 'user-103',
|
|
action: 'unknown_action' as any, // Force unknown action to test default case
|
|
display_text: 'Something happened',
|
|
details: {} as any,
|
|
}),
|
|
];
|
|
|
|
describe('ActivityLog', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should not render if userProfile is null', () => {
|
|
const { container } = render(<ActivityLog userProfile={null} onLogClick={vi.fn()} />);
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it('should show a loading state initially', async () => {
|
|
let resolvePromise: (value: Response) => void;
|
|
const mockPromise = new Promise<Response>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
// Cast to any to bypass strict type checking for the mock return value vs Promise
|
|
mockedApiClient.fetchActivityLog.mockReturnValue(mockPromise as any);
|
|
|
|
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
|
|
|
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
resolvePromise!(new Response(JSON.stringify([])));
|
|
});
|
|
});
|
|
|
|
it('should display an error message if fetching logs fails', async () => {
|
|
mockedApiClient.fetchActivityLog.mockRejectedValue(new Error('API is down'));
|
|
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('API is down')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display a message when there are no logs', async () => {
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify([])));
|
|
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should render a list of activities successfully covering all types', async () => {
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
|
render(<ActivityLog userProfile={mockUserProfile} />);
|
|
await waitFor(() => {
|
|
// Check for specific text from different log types
|
|
expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed
|
|
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); // From recipe_created
|
|
expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); // From list_shared
|
|
expect(screen.getByText('Newbie User')).toBeInTheDocument(); // From user_registered
|
|
expect(screen.getByText('Best Pizza')).toBeInTheDocument(); // From recipe_favorited
|
|
expect(screen.getByText('An unknown activity occurred.')).toBeInTheDocument(); // From unknown_action
|
|
|
|
// Check for user names
|
|
expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument();
|
|
|
|
// Check for avatar
|
|
const avatar = screen.getByAltText('Test User');
|
|
expect(avatar).toBeInTheDocument();
|
|
expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
|
|
|
|
// Check for fallback avatar (Newbie User has no avatar)
|
|
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
|
|
// The container for fallback has specific classes.
|
|
// We can look for the container associated with the "Newbie User" item.
|
|
const newbieItem = screen.getByText('Newbie User').closest('li');
|
|
const fallbackIcon = newbieItem?.querySelector('svg');
|
|
expect(fallbackIcon).toBeInTheDocument();
|
|
|
|
// Check for the mocked date
|
|
expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length);
|
|
});
|
|
});
|
|
|
|
it('should call onLogClick when a clickable log item is clicked', async () => {
|
|
const onLogClickMock = vi.fn();
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
|
render(<ActivityLog userProfile={mockUserProfile} onLogClick={onLogClickMock} />);
|
|
|
|
await waitFor(() => {
|
|
// Recipe Created
|
|
const clickableRecipe = screen.getByText('Pasta Carbonara');
|
|
fireEvent.click(clickableRecipe);
|
|
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]);
|
|
|
|
// List Shared
|
|
const clickableList = screen.getByText('Weekly Groceries');
|
|
fireEvent.click(clickableList);
|
|
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]);
|
|
|
|
// Recipe Favorited
|
|
const clickableFav = screen.getByText('Best Pizza');
|
|
fireEvent.click(clickableFav);
|
|
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[4]);
|
|
});
|
|
|
|
expect(onLogClickMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('should not render clickable styling if onLogClick is undefined', async () => {
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
|
render(<ActivityLog userProfile={mockUserProfile} />); // onLogClick is undefined
|
|
|
|
await waitFor(() => {
|
|
const recipeName = screen.getByText('Pasta Carbonara');
|
|
expect(recipeName).not.toHaveClass('cursor-pointer');
|
|
expect(recipeName).not.toHaveClass('text-blue-500');
|
|
|
|
const listName = screen.getByText('Weekly Groceries');
|
|
expect(listName).not.toHaveClass('cursor-pointer');
|
|
});
|
|
});
|
|
|
|
it('should handle missing details in logs gracefully (fallback values)', async () => {
|
|
const logsWithMissingDetails: ActivityLogItem[] = [
|
|
createMockActivityLogItem({
|
|
activity_log_id: 101,
|
|
user_id: 'u1',
|
|
action: 'flyer_processed',
|
|
display_text: '...',
|
|
details: { flyer_id: 1, store_name: '' } as any, // Missing store_name, explicit empty to override mock default
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 102,
|
|
user_id: 'u2',
|
|
action: 'recipe_created',
|
|
display_text: '...',
|
|
details: { recipe_id: 1, recipe_name: '' } as any, // Missing recipe_name
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 103,
|
|
user_id: 'u3',
|
|
action: 'user_registered',
|
|
display_text: '...',
|
|
details: { full_name: '' } as any, // Missing full_name
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 104,
|
|
user_id: 'u4',
|
|
action: 'recipe_favorited',
|
|
display_text: '...',
|
|
details: { recipe_id: 2, recipe_name: '' } as any, // Missing recipe_name
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 105,
|
|
user_id: 'u5',
|
|
action: 'list_shared',
|
|
display_text: '...',
|
|
details: { shopping_list_id: 1, list_name: '', shared_with_name: '' } as any, // Missing list_name and shared_with_name
|
|
}),
|
|
createMockActivityLogItem({
|
|
activity_log_id: 106,
|
|
user_id: 'u6',
|
|
action: 'flyer_processed',
|
|
display_text: '...',
|
|
user_avatar_url: 'http://img.com/a.png', // FIX: Moved from details
|
|
user_full_name: '', // FIX: Moved from details to test fallback alt text
|
|
details: { flyer_id: 2, store_name: 'Mock Store' } as any,
|
|
}),
|
|
];
|
|
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(
|
|
new Response(JSON.stringify(logsWithMissingDetails)),
|
|
);
|
|
|
|
// Debug: verify structure of logs to ensure defaults are overridden
|
|
console.log(
|
|
'Testing fallback rendering with logs:',
|
|
JSON.stringify(logsWithMissingDetails, null, 2),
|
|
);
|
|
|
|
const { container } = render(<ActivityLog userProfile={mockUserProfile} />);
|
|
|
|
await waitFor(() => {
|
|
console.log('[TEST DEBUG] Waiting for UI to update...');
|
|
// Use screen.debug to log the current state of the DOM, which is invaluable for debugging.
|
|
screen.debug(undefined, 30000);
|
|
|
|
console.log('[TEST DEBUG] Checking for fallback text elements...');
|
|
expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
|
|
expect(screen.getByText('Untitled Recipe')).toBeInTheDocument();
|
|
expect(screen.getByText('A new user')).toBeInTheDocument();
|
|
expect(screen.getByText('a recipe')).toBeInTheDocument();
|
|
expect(screen.getByText('a shopping list')).toBeInTheDocument();
|
|
expect(screen.getByText('another user')).toBeInTheDocument();
|
|
console.log('[TEST DEBUG] All fallback text elements found!');
|
|
|
|
console.log('[TEST DEBUG] Checking for avatar with fallback alt text...');
|
|
// Check for empty alt text on avatar (item 106)
|
|
const avatars = screen.getAllByRole('img');
|
|
console.log(
|
|
'[TEST DEBUG] Found avatars with alts:',
|
|
avatars.map((img) => img.getAttribute('alt')),
|
|
);
|
|
const avatarWithFallbackAlt = avatars.find(
|
|
(img) => img.getAttribute('alt') === 'User Avatar',
|
|
);
|
|
expect(avatarWithFallbackAlt).toBeInTheDocument();
|
|
console.log('[TEST DEBUG] Fallback avatar with correct alt text found!');
|
|
});
|
|
});
|
|
|
|
it('should display error message from API response when not OK', async () => {
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(
|
|
new Response(JSON.stringify({ message: 'Server says no' }), { status: 500 }),
|
|
);
|
|
render(<ActivityLog userProfile={mockUserProfile} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Server says no')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display default error message from API response when not OK and no message provided', async () => {
|
|
mockedApiClient.fetchActivityLog.mockResolvedValue(
|
|
new Response(JSON.stringify({}), { status: 500 }),
|
|
);
|
|
render(<ActivityLog userProfile={mockUserProfile} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Failed to fetch logs')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display generic error message when fetch throws non-Error object', async () => {
|
|
mockedApiClient.fetchActivityLog.mockRejectedValue('String error');
|
|
render(<ActivityLog userProfile={mockUserProfile} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Failed to load activity.')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|