Files
babymonitor/frontend/tests/unit/components/stats/SessionList.test.tsx
T

317 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SessionList } from '../../../../src/components/stats/SessionList';
import { useSessions } from '../../../../src/hooks/useSleepSession';
import { useSettings } from '../../../../src/hooks/useSettings';
vi.mock('../../../../src/hooks/useSleepSession');
vi.mock('../../../../src/hooks/useSettings');
// Mock Pagination component to simplify integration testing
vi.mock('../../../../src/components/stats/Pagination', () => ({
Pagination: vi.fn(({ currentPage, totalPages, onPageChange }) => (
<div data-testid="pagination-mock">
<span>{currentPage} / {totalPages}</span>
<button onClick={() => onPageChange(currentPage + 1)}>Next Page</button>
</div>
)),
}));
const mockSession = {
id: 1,
start_time: '2026-06-04T11:58:00', // UTC
end_time: '2026-06-04T13:58:00', // UTC
notes: null,
created_at: '2026-06-04T11:58:00',
updated_at: '2026-06-04T11:58:00',
};
describe('SessionList', () => {
const mockUpdateSession = vi.fn();
const mockDeleteSession = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation for useSettings
vi.mocked(useSettings).mockReturnValue({
settings: { sessionsPerPage: 10 } as any,
});
// Mock window.confirm and alert to prevent actual popups during tests
vi.spyOn(window, 'confirm').mockImplementation(() => true);
vi.spyOn(window, 'alert').mockImplementation(() => {});
});
it('displays loading state', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [],
isLoading: true,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
expect(screen.getByText(/Loading sessions.../i)).toBeInTheDocument();
});
it('displays empty state when no sessions are recorded', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
expect(screen.getByText(/No sessions recorded./i)).toBeInTheDocument();
});
it('renders session rows with correct start time and duration', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// Check that the session row is rendered
const row = screen.getByTestId('session-row');
expect(row).toBeInTheDocument();
// Check for presence of duration (13:58 - 11:58 = 2h 0m)
// Note: formatMinutes(120) -> "2h 0m"
// Using getAllByText because duration is rendered twice (desktop and mobile views)
const durations = screen.getAllByText(/2h 0m/i);
expect(durations.length).toBeGreaterThan(0);
});
it('handles editing flow: start, cancel and save', async () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// 1. Start Editing
const row = screen.getByTestId('session-row');
fireEvent.click(row);
// Verify edit inputs are visible
expect(screen.getByLabelText(/Start Time/i)).toBeInTheDocument();
expect(screen.getByLabelText(/End Time/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
// 2. Cancel Edit
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(mockUpdateSession).not.toHaveBeenCalled();
// Should be back in view mode (row should be visible again)
expect(screen.getByTestId('session-row')).toBeInTheDocument();
// 3. Save Edit
fireEvent.click(screen.getByTestId('session-row')); // Re-enter edit mode
const startInput = screen.getByLabelText(/Start Time/i) as HTMLInputElement;
const endInput = screen.getByLabelText(/End Time/i) as HTMLInputElement;
const newStart = '2026-06-04T14:00';
const newEnd = '2026-06-04T15:00';
fireEvent.change(startInput, { target: { value: newStart } });
fireEvent.change(endInput, { target: { value: newEnd } });
fireEvent.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(mockUpdateSession).toHaveBeenCalledWith({
sessionId: 1,
payload: expect.objectContaining({
start_time: expect.any(String),
end_time: expect.any(String)
})
});
});
// Check if it exited edit mode (row is back)
expect(screen.getByTestId('session-row')).toBeInTheDocument();
});
it('handles deletion flow', async () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// Clicking the delete button (Trash2 icon)
const deleteButtons = screen.getAllByTitle('Delete session');
fireEvent.click(deleteButtons[0]);
// Verify confirmation was called
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this sleep session?');
// If confirmed (mockImplementation returns true)
await waitFor(() => {
expect(mockDeleteSession).toHaveBeenCalledWith(1);
});
});
it('disables delete button when isDeleting is true', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: true, // DELETING!
} as any);
render(<SessionList />);
const deleteButtons = screen.getAllByTitle('Delete session');
expect(deleteButtons[0]).toBeDisabled();
});
it('integrates with pagination when sessions exceed page size', () => {
const manySessions = Array.from({ length: 15 }, (_, i) => ({
...mockSession,
id: i + 1,
}));
vi.mocked(useSessions).mockReturnValue({
sessions: manySessions,
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// Pagination should be rendered (using the mocked test-id)
expect(screen.getByTestId('pagination-mock')).toBeInTheDocument();
});
it('verifies timezone accuracy for Stockholm/UTC', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
const clickableRow = screen.getByTestId('session-row');
fireEvent.click(clickableRow);
const startTimeInput = screen.getByLabelText(/Start Time/i) as HTMLInputElement;
const endTimeInput = screen.getByLabelText(/End Time/i) as HTMLInputElement;
// Stockholm is UTC+2 in June (daylight savings).
// mockSession start_time: '2026-06-04T11:58:00' (UTC)
// Stockholm local time: 2026-06-04T13:58
expect(startTimeInput.value).toBe('2026-06-04T13:58');
expect(endTimeInput.value).toBe('2026-06-04T15:58');
});
it('handles updateSession error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: vi.fn().mockRejectedValue(new Error('Update failed')),
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
const row = screen.getByTestId('session-row');
fireEvent.click(row);
const saveButton = screen.getByRole('button', { name: /Save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Failed to update session');
expect(consoleSpy).toHaveBeenCalled();
});
consoleSpy.mockRestore();
alertSpy.mockRestore();
});
it('handles deleteSession error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: vi.fn().mockRejectedValue(new Error('Delete failed')),
isDeleting: false,
} as any);
render(<SessionList />);
const deleteButtons = screen.getAllByTitle('Delete session');
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Failed to delete session');
expect(consoleSpy).toHaveBeenCalled();
});
consoleSpy.mockRestore();
alertSpy.mockRestore();
});
it('renders in-progress session correctly (without end time)', () => {
const inProgressSession = { ...mockSession, end_time: null } as any;
vi.mocked(useSessions).mockReturnValue({
sessions: [inProgressSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
expect(screen.getByText(/In progress/i)).toBeInTheDocument();
expect(screen.getAllByText(/—/i).length).toBeGreaterThan(0);
});
it('handles deletion cancellation', async () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
vi.spyOn(window, 'confirm').mockImplementation(() => false);
render(<SessionList />);
const deleteButtons = screen.getAllByTitle('Delete session');
fireEvent.click(deleteButtons[0]);
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this sleep session?');
await waitFor(() => {
expect(mockDeleteSession).not.toHaveBeenCalled();
});
});
});