import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { WorkSession } from '@solelog/shared'; import Live from './Live'; import { apiFetch } from '../lib/api'; vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), })); const mockApiFetch = vi.mocked(apiFetch); function makeSession(over: Partial): WorkSession { return { id: 1, user_id: 'u1', activity_id: 10, activity_name: 'Frezen', user_name: 'Jan', insole_type: 'Kurk', pair_count: 4, start_time: new Date(Date.now() - 65_000).toISOString(), end_time: null, duration_seconds: null, paused_seconds: 0, paused_at: null, status: 'active', source: 'app', notes: null, created_at: new Date().toISOString(), ...over, }; } function renderLive() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); } describe('Live', () => { beforeEach(() => { mockApiFetch.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); it('renders a card per active session with header count', async () => { mockApiFetch.mockResolvedValue([ makeSession({ id: 1, user_name: 'Jan', activity_name: 'Frezen', insole_type: 'Kurk' }), makeSession({ id: 2, user_name: 'Piet', activity_name: 'Lijmen', insole_type: 'Berk' }), ]); renderLive(); expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument(); expect(screen.getByText('Jan')).toBeInTheDocument(); expect(screen.getByText('Frezen')).toBeInTheDocument(); expect(screen.getByText('Piet')).toBeInTheDocument(); expect(screen.getByText('Lijmen')).toBeInTheDocument(); }); it('shows the empty state when nobody is working', async () => { mockApiFetch.mockResolvedValue([]); renderLive(); expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument(); expect(screen.getByText('Actief nu (0)')).toBeInTheDocument(); }); it('shows a Gepauzeerd badge with a frozen worked timer when paused_at is set', async () => { vi.useFakeTimers(); const start = Date.now() - 100_000; const pausedAt = Date.now() - 40_000; // paused 40s ago mockApiFetch.mockResolvedValue([ makeSession({ id: 1, user_name: 'Jan', start_time: new Date(start).toISOString(), paused_at: new Date(pausedAt).toISOString(), paused_seconds: 10, }), ]); renderLive(); // The fake clock needs to advance for react-query's async resolution. await vi.advanceTimersByTimeAsync(0); expect(screen.getByText('Gepauzeerd')).toBeInTheDocument(); // worked = (paused_at - start)/1000 - paused_seconds = 60 - 10 = 50s = 00:00:50 const frozen = screen.getByText('00:00:50'); expect(frozen).toBeInTheDocument(); // Advance the 1s tick: the worked timer must stay frozen (does not count up). await vi.advanceTimersByTimeAsync(3000); expect(screen.getByText('00:00:50')).toBeInTheDocument(); }); it('shows the paused total when paused_seconds > 0', async () => { mockApiFetch.mockResolvedValue([ makeSession({ id: 1, user_name: 'Jan', paused_seconds: 125, paused_at: new Date().toISOString(), }), ]); renderLive(); // 125s = 00:02:05 expect(await screen.findByText('Pauze 00:02:05')).toBeInTheDocument(); }); it('shows Stop and Annuleer buttons on an active card', async () => { mockApiFetch.mockResolvedValue([makeSession({ id: 7, user_name: 'Jan' })]); renderLive(); expect(await screen.findByText('Jan')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Annuleer' })).toBeInTheDocument(); }); it('clicking Stop calls the stop endpoint for that session', async () => { const user = userEvent.setup(); mockApiFetch.mockImplementation((path: string) => { if (path === '/api/admin/sessions/active') { return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]); } return Promise.resolve({}); }); renderLive(); await user.click(await screen.findByRole('button', { name: 'Stop' })); await waitFor(() => { expect(mockApiFetch).toHaveBeenCalledWith( '/api/admin/sessions/7/stop', expect.objectContaining({ method: 'POST' }), ); }); }); it('clicking Annuleer calls the discard endpoint for that session', async () => { const user = userEvent.setup(); mockApiFetch.mockImplementation((path: string) => { if (path === '/api/admin/sessions/active') { return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]); } return Promise.resolve({}); }); renderLive(); await user.click(await screen.findByRole('button', { name: 'Annuleer' })); await waitFor(() => { expect(mockApiFetch).toHaveBeenCalledWith( '/api/admin/sessions/7/discard', expect.objectContaining({ method: 'POST' }), ); }); }); it('keeps the timer counting (no Gepauzeerd badge) when not paused', async () => { vi.useFakeTimers(); mockApiFetch.mockResolvedValue([ makeSession({ id: 1, user_name: 'Jan', start_time: new Date(Date.now() - 10_000).toISOString(), paused_at: null, paused_seconds: 0, }), ]); renderLive(); await vi.advanceTimersByTimeAsync(0); expect(screen.queryByText('Gepauzeerd')).not.toBeInTheDocument(); expect(screen.getByText('00:00:10')).toBeInTheDocument(); await vi.advanceTimersByTimeAsync(2000); expect(screen.getByText('00:00:12')).toBeInTheDocument(); }); it('shows the loading state before the first response resolves', () => { mockApiFetch.mockReturnValue(new Promise(() => {})); // never resolves → stays loading renderLive(); expect(screen.getByText('Laden…')).toBeInTheDocument(); }); it('shows the error state when the query fails', async () => { mockApiFetch.mockRejectedValue(new Error('boom')); renderLive(); expect(await screen.findByText('Kon gegevens niet laden.')).toBeInTheDocument(); expect(screen.queryByText(/Actief nu/)).not.toBeInTheDocument(); }); it('treats a non-array payload as no active sessions', async () => { mockApiFetch.mockResolvedValue({} as unknown as WorkSession[]); renderLive(); expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument(); expect(screen.getByText('Actief nu (0)')).toBeInTheDocument(); }); it('falls back to placeholder labels and hides the pill when name/activity/type are missing', async () => { mockApiFetch.mockResolvedValue([ makeSession({ id: 1, user_name: undefined, activity_name: undefined, insole_type: null }), ]); renderLive(); expect(await screen.findByText('Onbekend')).toBeInTheDocument(); expect(screen.getByText('Onbekende handeling')).toBeInTheDocument(); expect(screen.queryByText('Kurk')).not.toBeInTheDocument(); }); it('disables both action buttons while a stop is in flight', async () => { const user = userEvent.setup(); mockApiFetch.mockImplementation((path: string) => { if (path === '/api/admin/sessions/active') { return Promise.resolve([makeSession({ id: 7, user_name: 'Jan' })]); } return new Promise(() => {}); // stop mutation never resolves → stays pending }); renderLive(); await user.click(await screen.findByRole('button', { name: 'Stop' })); await waitFor(() => { expect(screen.getByRole('button', { name: 'Stop' })).toBeDisabled(); }); expect(screen.getByRole('button', { name: 'Annuleer' })).toBeDisabled(); }); // Lifecycle: active-sessions data renders cards, and a paused session shows the paused treatment. it('lifecycle: renders a running card and a paused card side by side', async () => { const start = Date.now() - 100_000; const pausedAt = Date.now() - 40_000; mockApiFetch.mockResolvedValue([ makeSession({ id: 1, user_name: 'Jan', activity_name: 'Frezen', insole_type: 'Kurk', pair_count: 4, paused_at: null, paused_seconds: 0, }), makeSession({ id: 2, user_name: 'Piet', activity_name: 'Lijmen', insole_type: 'Berk', pair_count: 6, start_time: new Date(start).toISOString(), paused_at: new Date(pausedAt).toISOString(), paused_seconds: 90, // 00:01:30 }), ]); renderLive(); // Both cards render under the header count. expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument(); expect(screen.getByText('Jan')).toBeInTheDocument(); expect(screen.getByText('Frezen')).toBeInTheDocument(); expect(screen.getByText('Piet')).toBeInTheDocument(); expect(screen.getByText('Lijmen')).toBeInTheDocument(); // Exactly the paused session shows the paused treatment. expect(screen.getAllByText('Gepauzeerd')).toHaveLength(1); expect(screen.getByText('Pauze 00:01:30')).toBeInTheDocument(); }); });