import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type React from 'react'; const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() })); const { mockGetRefillAlerts, mockListRefillLists, mockCreateRefillList, mockUpdateRefillList, mockUpdateRefillListItem, mockAddToCabinet, } = vi.hoisted(() => ({ mockGetRefillAlerts: vi.fn(), mockListRefillLists: vi.fn(), mockCreateRefillList: vi.fn(), mockUpdateRefillList: vi.fn(), mockUpdateRefillListItem: vi.fn(), mockAddToCabinet: vi.fn(), })); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/services/refills', () => ({ getRefillAlerts: mockGetRefillAlerts, listRefillLists: mockListRefillLists, createRefillList: mockCreateRefillList, updateRefillList: mockUpdateRefillList, updateRefillListItem: mockUpdateRefillListItem, addToCabinet: mockAddToCabinet, })); vi.mock('next/link', () => ({ default: (props: { href?: string; children: React.ReactNode }) => props.children, })); import RefillsPage from '../../../../src/app/(dashboard)/refills/page'; const emptyAlerts = { data: [], pagination: { cursor: null, hasMore: false } }; const emptyLists = { data: [], pagination: { cursor: null, hasMore: false } }; beforeEach(() => { vi.clearAllMocks(); mockGetRefillAlerts.mockResolvedValue(emptyAlerts); mockListRefillLists.mockResolvedValue(emptyLists); }); describe('RefillsPage', () => { it('shows loading skeleton when session loading', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: true }); render(); expect(screen.getByText('Refills')).toBeInTheDocument(); expect(screen.queryByText('New Refill List')).not.toBeInTheDocument(); }); it('shows household prompt when no householdId', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false }); render(); expect(screen.getByText(/create or join a household/)).toBeInTheDocument(); }); it('renders Refills heading when householdId exists', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); render(); expect(screen.getByText('Refills')).toBeInTheDocument(); }); it('shows empty state for alerts', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); render(); await waitFor(() => expect(screen.getByText(/No medicines running low/)).toBeInTheDocument()); }); it('shows empty state for refill lists', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); render(); await waitFor(() => expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument()); }); it('shows error when alerts fail', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRefillAlerts.mockRejectedValue(new Error('Alert error')); render(); await waitFor(() => expect(screen.getByText('Alert error')).toBeInTheDocument()); }); it('shows New List button and toggles form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); render(); await waitFor(() => screen.getByText('New List')); await userEvent.click(screen.getByText('New List')); expect(screen.getByPlaceholderText('List name')).toBeInTheDocument(); }); it('creates a new refill list', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockCreateRefillList.mockResolvedValue({ _id: 'rl-1', name: 'Test List', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }); render(); await waitFor(() => screen.getByText('New List')); await userEvent.click(screen.getByText('New List')); await waitFor(() => screen.getByPlaceholderText('List name')); fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Test List' } }); fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!); await waitFor(() => expect(mockCreateRefillList).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ name: 'Test List' }), ), ); }); it('shows refill list and can select it', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRefillLists.mockResolvedValue({ data: [ { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument()); }); it('updates a refill list status', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockUpdateRefillList.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText('Start shopping')); await userEvent.click(screen.getByText('Start shopping')); expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'shopping' }); }); it('shows error when create list fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockCreateRefillList.mockRejectedValue(new Error('Create failed')); render(); await waitFor(() => screen.getByText('New List')); await userEvent.click(screen.getByText('New List')); await waitFor(() => screen.getByPlaceholderText('List name')); fireEvent.change(screen.getByPlaceholderText('List name'), { target: { value: 'Test' } }); fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!); await waitFor(() => expect(screen.getByText('Create failed')).toBeInTheDocument()); }); it('adds checked items to cabinet', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockAddToCabinet.mockResolvedValue({ addedCount: 1 }); vi.spyOn(window, 'confirm').mockReturnValue(true); vi.spyOn(window, 'alert').mockImplementation(() => {}); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText(/Add .* to Cabinet/)); await userEvent.click(screen.getByText(/Add .* to Cabinet/)); expect(mockAddToCabinet).toHaveBeenCalledWith('hh1', 'rl-1'); }); it('generates a refill list from alerts', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRefillAlerts.mockResolvedValue({ data: [ { medicineId: 'm1', medicineName: 'Aspirin', medicineStrength: 500, medicineStrengthUnit: 'mg', currentStock: 5, dailyConsumption: 1.5, daysUntilEmpty: 3, }, ], pagination: { cursor: null, hasMore: false }, }); mockCreateRefillList.mockResolvedValue({ _id: 'rl-gen', name: 'Auto Refills', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }); render(); await waitFor(() => screen.getByText('Aspirin')); await userEvent.click(screen.getByText('Generate Refill List')); await waitFor(() => screen.getByPlaceholderText('List name, e.g. Weekly refills')); fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), { target: { value: 'Auto Refills' }, }); fireEvent.submit( screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!, ); await waitFor(() => expect(mockCreateRefillList).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ name: 'Auto Refills', fromAlerts: true }), ), ); }); it('cancels create list form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); render(); await waitFor(() => screen.getByText('New List')); await userEvent.click(screen.getByText('New List')); await waitFor(() => screen.getByPlaceholderText('List name')); await userEvent.click( screen.getAllByRole('button', { name: 'Cancel' })[ screen.getAllByRole('button', { name: 'Cancel' }).length - 1 ]!, ); expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument(); }); it('closes the refill list detail panel', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRefillLists.mockResolvedValue({ data: [ { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByTitle('Close')); await userEvent.click(screen.getByTitle('Close')); // After close, detail panel should not be visible (no Close button) expect(screen.queryByTitle('Close')).not.toBeInTheDocument(); }); it('archives a refill list', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockUpdateRefillList.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText('Archive')); await userEvent.click(screen.getByText('Archive')); expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'archived' }); }); it('shows error when generate list fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRefillAlerts.mockResolvedValue({ data: [ { medicineId: 'm1', medicineName: 'Aspirin', medicineStrength: 500, medicineStrengthUnit: 'mg', currentStock: 5, dailyConsumption: 1.5, daysUntilEmpty: 3, }, ], pagination: { cursor: null, hasMore: false }, }); mockCreateRefillList.mockRejectedValue(new Error('Generate failed')); render(); await waitFor(() => screen.getByText('Aspirin')); await userEvent.click(screen.getByText('Generate Refill List')); await waitFor(() => screen.getByPlaceholderText('List name, e.g. Weekly refills')); fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), { target: { value: 'Auto Refills' }, }); fireEvent.submit( screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!, ); await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument()); }); it('dismisses refill list panel error', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRefillLists.mockRejectedValue(new Error('List error')); render(); await waitFor(() => screen.getByText('List error')); await userEvent.click(screen.getByText('Dismiss')); expect(screen.queryByText('List error')).not.toBeInTheDocument(); }); it('toggles item checked state', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockUpdateRefillListItem.mockResolvedValue({ _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText('Aspirin')); await userEvent.click(screen.getByRole('checkbox')); expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', { checked: true, }); }); it('marks a shopping list as complete', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'shopping', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockUpdateRefillList.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText('Mark complete')); await userEvent.click(screen.getByText('Mark complete')); expect(mockUpdateRefillList).toHaveBeenCalledWith('hh1', 'rl-1', { status: 'completed' }); }); it('filters refill lists by status', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListRefillLists.mockResolvedValue({ data: [ { _id: 'rl-1', name: 'Active List', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Active List')); fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'active' } }); await waitFor(() => expect(mockListRefillLists).toHaveBeenCalledTimes(2)); }); it('shows actual price input after checking item and allows price entry', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByPlaceholderText('Actual price')); fireEvent.change(screen.getByPlaceholderText('Actual price'), { target: { value: '5.99' } }); expect(screen.getByPlaceholderText('Actual price')).toBeInTheDocument(); }); it('dismisses panel-level error', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockAddToCabinet.mockRejectedValue(new Error('Cabinet error')); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText(/Add .* to Cabinet/)); await userEvent.click(screen.getByText(/Add .* to Cabinet/)); await waitFor(() => screen.getByText('Cabinet error')); // Dismiss via inline button in the panel const dismissBtns = screen.getAllByText('Dismiss'); await userEvent.click(dismissBtns[dismissBtns.length - 1]!); expect(screen.queryByText('Cabinet error')).not.toBeInTheDocument(); }); it('shows fallback error when non-Error thrown on update item', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [ { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false, }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockUpdateRefillListItem.mockRejectedValue('update failed'); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText('Aspirin')); await userEvent.click(screen.getAllByRole('checkbox')[0]!); await waitFor(() => expect(screen.getByText('Failed to update item')).toBeInTheDocument()); }); it('shows fallback error when non-Error thrown on update status', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); const refillList = { _id: 'rl-1', name: 'Weekly List', status: 'active', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', }; mockListRefillLists.mockResolvedValue({ data: [refillList], pagination: { cursor: null, hasMore: false }, }); mockUpdateRefillList.mockRejectedValue('status failed'); render(); await waitFor(() => screen.getByText('Weekly List')); await userEvent.click(screen.getByText('Weekly List')); await waitFor(() => screen.getByText('Start shopping')); await userEvent.click(screen.getByText('Start shopping')); await waitFor(() => expect(screen.getByText('Failed to update status')).toBeInTheDocument()); }); it('shows refill alert when present', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockGetRefillAlerts.mockResolvedValue({ data: [ { medicineId: 'm1', medicineName: 'Aspirin', medicineStrength: 500, medicineStrengthUnit: 'mg', currentStock: 5, dailyConsumption: 1.5, daysUntilEmpty: 3, }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument()); }); });