feat: add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider, DashboardLayout

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-29 00:00:08 +00:00
parent 7dd0b5f600
commit 63839c3886
9 changed files with 966 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { AuthGuard } from './AuthGuard';
// Mock next/navigation
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));
// Mock userApi
const mockGetCurrentUser = jest.fn();
jest.mock('@/lib/api', () => ({
userApi: {
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
},
}));
// Mock authStore
const mockSetUser = jest.fn();
const mockSetLoading = jest.fn();
let mockUser: Record<string, unknown> | null = null;
let mockIsLoading = true;
jest.mock('@/store/authStore', () => ({
useAuthStore: () => ({
user: mockUser,
isLoading: mockIsLoading,
setUser: mockSetUser,
setLoading: mockSetLoading,
}),
}));
const mockUserData = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
is_active: true,
is_superuser: false,
subscription_tier: 'free',
subscription_status: 'active',
created_at: '2024-01-01T00:00:00Z',
};
describe('AuthGuard', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
mockUser = null;
mockIsLoading = true;
});
it('should show loading spinner while isLoading is true', () => {
mockIsLoading = true;
localStorage.setItem('access_token', 'test-token');
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
// Should show spinner (via animate-spin class)
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render children when user is authenticated', () => {
mockUser = mockUserData;
mockIsLoading = false;
localStorage.setItem('access_token', 'test-token');
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
it('should render nothing when not loading and no user', () => {
mockUser = null;
mockIsLoading = false;
const { container } = render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
expect(container.innerHTML).toBe('');
});
it('should redirect to /login when no token exists', async () => {
// No token in localStorage
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockSetLoading).toHaveBeenCalledWith(false);
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
it('should fetch user data when token exists', async () => {
localStorage.setItem('access_token', 'valid-token');
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockGetCurrentUser).toHaveBeenCalled();
expect(mockSetUser).toHaveBeenCalledWith(mockUserData);
});
});
it('should redirect to /login when API call fails', async () => {
localStorage.setItem('access_token', 'expired-token');
mockGetCurrentUser.mockRejectedValue(new Error('Unauthorized'));
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(null);
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
});
@@ -0,0 +1,246 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { DashboardLayout } from './DashboardLayout';
// Mock next/navigation
const mockPush = jest.fn();
let mockPathname = '/dashboard';
jest.mock('next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({ push: mockPush }),
}));
// Mock next/link to render a simple anchor
jest.mock('next/link', () => {
return ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
);
});
// Mock @tanstack/react-query
let mockVersionInfo: Record<string, string> | null = null;
jest.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: mockVersionInfo }),
}));
// Mock lucide-react icons as simple spans
jest.mock('lucide-react', () => {
const iconNames = [
'LayoutDashboard', 'Mail', 'Settings', 'LogOut', 'Menu', 'X',
'User', 'Shield', 'Users', 'CreditCard', 'Bell', 'Inbox', 'Activity',
];
const icons: Record<string, React.FC<{ className?: string }>> = {};
iconNames.forEach((name) => {
icons[name] = ({ className }: { className?: string }) => (
<span data-testid={`icon-${name}`} className={className} />
);
});
return icons;
});
// Mock authStore
const mockLogout = jest.fn();
let mockUser: Record<string, unknown> | null = null;
jest.mock('@/store/authStore', () => ({
useAuthStore: () => ({
user: mockUser,
logout: mockLogout,
}),
}));
// Mock versionApi
jest.mock('@/lib/api', () => ({
versionApi: {
get: jest.fn(),
},
}));
const regularUser = {
id: 1,
email: 'user@example.com',
full_name: 'Regular User',
is_active: true,
is_superuser: false,
subscription_tier: 'free',
subscription_status: 'active',
created_at: '2024-01-01T00:00:00Z',
};
const adminUser = {
...regularUser,
id: 2,
email: 'admin@example.com',
full_name: 'Admin User',
is_superuser: true,
};
describe('DashboardLayout', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPathname = '/dashboard';
mockUser = regularUser;
mockVersionInfo = null;
});
it('should render the InboxConverge branding', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// Desktop sidebar has the branding
expect(screen.getAllByText('InboxConverge').length).toBeGreaterThan(0);
});
it('should render main navigation items', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mail Accounts').length).toBeGreaterThan(0);
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mailbox Activity').length).toBeGreaterThan(0);
expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
});
it('should render children in main content area', () => {
render(
<DashboardLayout>
<div data-testid="page-content">Page Content</div>
</DashboardLayout>
);
expect(screen.getByTestId('page-content')).toBeInTheDocument();
expect(screen.getByText('Page Content')).toBeInTheDocument();
});
it('should display user info in the top bar', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('Regular User')).toBeInTheDocument();
expect(screen.getByText('user@example.com')).toBeInTheDocument();
});
it('should not show admin navigation for regular users', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText('Admin Overview')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Users')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Plans')).not.toBeInTheDocument();
expect(screen.queryByText('Activity Logs')).not.toBeInTheDocument();
});
it('should show admin navigation for superusers', () => {
mockUser = adminUser;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getAllByText('Admin Overview').length).toBeGreaterThan(0);
expect(screen.getAllByText('Manage Users').length).toBeGreaterThan(0);
expect(screen.getAllByText('Manage Plans').length).toBeGreaterThan(0);
expect(screen.getAllByText('Activity Logs').length).toBeGreaterThan(0);
});
it('should show Admin badge for superusers', () => {
mockUser = adminUser;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The Admin badge has a specific class for styling
const adminBadges = screen.getAllByText('Admin');
const badge = adminBadges.find((el) => el.classList.contains('bg-purple-100'));
expect(badge).toBeInTheDocument();
});
it('should not show Admin badge for regular users', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
it('should call logout and redirect on Logout button click', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// Click the first Logout button (desktop sidebar)
const logoutButtons = screen.getAllByText('Logout');
fireEvent.click(logoutButtons[0]);
expect(mockLogout).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith('/login');
});
it('should display the current page title based on pathname', () => {
mockPathname = '/accounts';
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The top bar should show "Mail Accounts" as the h2 heading
const headings = screen.getAllByText('Mail Accounts');
// At least one should be a heading in the top bar
expect(headings.length).toBeGreaterThan(0);
});
it('should show version info in the footer when available', () => {
mockVersionInfo = { version: '1.2.3', build_date: '2024-06-15T12:00:00Z' };
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('should not show version info when not available', () => {
mockVersionInfo = null;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
});
it('should render footer links', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('Impressum')).toBeInTheDocument();
expect(screen.getByText('Datenschutz')).toBeInTheDocument();
});
it('should open mobile sidebar on menu button click', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The mobile menu button has a Menu icon
const menuButton = screen.getByTestId('icon-Menu').closest('button');
expect(menuButton).toBeInTheDocument();
fireEvent.click(menuButton!);
// After clicking, the close (X) button should appear
expect(screen.getByTestId('icon-X')).toBeInTheDocument();
});
});
@@ -0,0 +1,26 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryProvider } from './QueryProvider';
describe('QueryProvider', () => {
it('should render children', () => {
render(
<QueryProvider>
<div data-testid="child">Hello</div>
</QueryProvider>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('should render multiple children', () => {
render(
<QueryProvider>
<div data-testid="first">First</div>
<div data-testid="second">Second</div>
</QueryProvider>
);
expect(screen.getByTestId('first')).toBeInTheDocument();
expect(screen.getByTestId('second')).toBeInTheDocument();
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests for the Axios API instance configuration — interceptors and
* default headers. We use jest.mock to stub axios.create so we can
* inspect the interceptor callbacks that api.ts registers.
*/
// Capture interceptor callbacks registered by api.ts
type InterceptorFn = (config: Record<string, unknown>) => unknown;
type ErrorFn = (error: unknown) => unknown;
let requestInterceptor: InterceptorFn | null = null;
let responseSuccessInterceptor: InterceptorFn | null = null;
let responseErrorInterceptor: ErrorFn | null = null;
const mockCreate = jest.fn();
const mockAxiosInstance = {
interceptors: {
request: {
use: jest.fn((fn: InterceptorFn) => {
requestInterceptor = fn;
}),
},
response: {
use: jest.fn((successFn: InterceptorFn, errorFn: ErrorFn) => {
responseSuccessInterceptor = successFn;
responseErrorInterceptor = errorFn;
}),
},
},
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
};
mockCreate.mockReturnValue(mockAxiosInstance);
jest.mock('axios', () => ({
__esModule: true,
default: {
create: mockCreate,
},
}));
// Force module initialization to capture interceptors
require('./api');
describe('API module setup', () => {
it('should create an axios instance with correct baseURL', () => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: '/api/v1',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
it('should register request and response interceptors', () => {
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
});
describe('Request interceptor', () => {
beforeEach(() => {
localStorage.clear();
});
it('should attach Authorization header when access_token exists', () => {
localStorage.setItem('access_token', 'test-jwt-token');
const config = { headers: {} as Record<string, string> };
const result = requestInterceptor!(config) as typeof config;
expect(result.headers.Authorization).toBe('Bearer test-jwt-token');
});
it('should not attach Authorization header when no token exists', () => {
const config = { headers: {} as Record<string, string> };
const result = requestInterceptor!(config) as typeof config;
expect(result.headers.Authorization).toBeUndefined();
});
});
describe('Response interceptor', () => {
beforeEach(() => {
localStorage.clear();
});
it('should pass through successful responses', () => {
const response = { data: { ok: true }, status: 200 };
const result = responseSuccessInterceptor!(response);
expect(result).toBe(response);
});
it('should clear auth state on 401', async () => {
localStorage.setItem('access_token', 'expired-token');
localStorage.setItem('user', '{"id":1}');
const error = { response: { status: 401 } };
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
expect(localStorage.getItem('access_token')).toBeNull();
expect(localStorage.getItem('user')).toBeNull();
});
it('should not clear auth state for non-401 errors', async () => {
localStorage.setItem('access_token', 'valid-token');
const error = { response: { status: 500 } };
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
expect(localStorage.getItem('access_token')).toBe('valid-token');
});
it('should reject with the error for non-401 errors', async () => {
const error = { response: { status: 403 } };
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
});
it('should handle errors without a response object', async () => {
const error = new Error('Network error');
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { parseUTC, formatRelative, formatDate, formatDuration } from './date-utils';
describe('parseUTC', () => {
it('should parse ISO string with Z suffix as UTC', () => {
const date = parseUTC('2024-01-15T10:30:00Z');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO string with positive timezone offset', () => {
const date = parseUTC('2024-01-15T12:30:00+02:00');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO string with negative timezone offset', () => {
const date = parseUTC('2024-01-15T05:30:00-05:00');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should append Z to timezone-naive ISO string', () => {
const date = parseUTC('2024-01-15T10:30:00');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should handle ISO string with milliseconds and Z', () => {
const date = parseUTC('2024-01-15T10:30:00.123Z');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.123Z');
});
it('should handle ISO string with compact offset (no colon)', () => {
const date = parseUTC('2024-01-15T12:30:00+0200');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
});
describe('formatRelative', () => {
it('should return "Never" for undefined input', () => {
expect(formatRelative(undefined)).toBe('Never');
});
it('should return "Never" for null input', () => {
expect(formatRelative(null)).toBe('Never');
});
it('should return "Never" for empty string', () => {
expect(formatRelative('')).toBe('Never');
});
it('should return "Just now" for timestamps less than 1 minute ago', () => {
const now = new Date().toISOString();
expect(formatRelative(now)).toBe('Just now');
});
it('should return minutes ago for timestamps less than 1 hour ago', () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(formatRelative(fiveMinutesAgo)).toBe('5m ago');
});
it('should return hours ago for timestamps less than 1 day ago', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
expect(formatRelative(threeHoursAgo)).toBe('3h ago');
});
it('should return days ago for timestamps 1+ days ago', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(formatRelative(twoDaysAgo)).toBe('2d ago');
});
it('should return "1m ago" for exactly 1 minute ago', () => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
expect(formatRelative(oneMinuteAgo)).toBe('1m ago');
});
it('should return "59m ago" for 59 minutes ago', () => {
const fiftyNineMinutesAgo = new Date(Date.now() - 59 * 60 * 1000).toISOString();
expect(formatRelative(fiftyNineMinutesAgo)).toBe('59m ago');
});
it('should return "1h ago" for exactly 60 minutes ago', () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
expect(formatRelative(oneHourAgo)).toBe('1h ago');
});
it('should return "23h ago" for 23 hours ago', () => {
const twentyThreeHoursAgo = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString();
expect(formatRelative(twentyThreeHoursAgo)).toBe('23h ago');
});
it('should return "1d ago" for exactly 24 hours ago', () => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
expect(formatRelative(oneDayAgo)).toBe('1d ago');
});
it('should handle timezone-naive timestamps correctly', () => {
// Create a timestamp without Z suffix
const now = new Date();
const naive = now.toISOString().replace('Z', '');
// parseUTC will append Z, so it should be interpreted as UTC
const result = formatRelative(naive);
expect(result).toBe('Just now');
});
});
describe('formatDate', () => {
it('should return a locale-formatted date string', () => {
const result = formatDate('2024-01-15T10:30:00Z');
// The exact format depends on locale, but it should contain key parts
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('should handle timezone-naive ISO strings', () => {
const result = formatDate('2024-01-15T10:30:00');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
});
describe('formatDuration', () => {
it('should return em dash for null', () => {
expect(formatDuration(null)).toBe('—');
});
it('should return em dash for undefined', () => {
expect(formatDuration(undefined)).toBe('—');
});
it('should format 0 seconds', () => {
expect(formatDuration(0)).toBe('0.0s');
});
it('should format sub-minute durations with one decimal', () => {
expect(formatDuration(3.14)).toBe('3.1s');
});
it('should format exactly 59.9 seconds', () => {
expect(formatDuration(59.9)).toBe('59.9s');
});
it('should format exactly 60 seconds as minutes', () => {
expect(formatDuration(60)).toBe('1m 0s');
});
it('should format 125 seconds as 2m 5s', () => {
expect(formatDuration(125)).toBe('2m 5s');
});
it('should format large durations', () => {
expect(formatDuration(3661)).toBe('61m 1s');
});
it('should format fractional seconds above 60', () => {
expect(formatDuration(65.7)).toBe('1m 5s');
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';