317 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|