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 { mockListPurchases, mockCreatePurchase, mockReceivePurchase, mockDeletePurchase } = vi.hoisted(() => ({ mockListPurchases: vi.fn(), mockCreatePurchase: vi.fn(), mockReceivePurchase: vi.fn(), mockDeletePurchase: vi.fn(), })); const { mockListStores } = vi.hoisted(() => ({ mockListStores: vi.fn() })); const { mockListMedicines, mockListMedicineProducts } = vi.hoisted(() => ({ mockListMedicines: vi.fn(), mockListMedicineProducts: vi.fn(), })); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/services/purchases', () => ({ listPurchases: mockListPurchases, createPurchase: mockCreatePurchase, receivePurchase: mockReceivePurchase, deletePurchase: mockDeletePurchase, })); vi.mock('@/services/stores', () => ({ listStores: mockListStores })); vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines, listMedicineProducts: mockListMedicineProducts, })); vi.mock('next/link', () => ({ default: (props: { href?: string; children: React.ReactNode }) => props.children, })); import PurchasesPage from '../../../../src/app/(dashboard)/purchases/page'; const emptyResponse = { data: [], pagination: { cursor: null, hasMore: false } }; beforeEach(() => { vi.clearAllMocks(); mockListStores.mockResolvedValue(emptyResponse); mockListMedicines.mockResolvedValue(emptyResponse); }); describe('PurchasesPage', () => { it('shows loading skeleton when session loading', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: true }); render(); expect(screen.getByText('Purchases')).toBeInTheDocument(); expect(screen.queryByText('Record Purchase')).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('shows empty state when no purchases', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument()); }); it('shows Record Purchase button', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => expect(screen.getByText('Record Purchase')).toBeInTheDocument()); }); it('shows error when purchases fail to load', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockRejectedValue(new Error('Network failure')); render(); await waitFor(() => expect(screen.getByText('Network failure')).toBeInTheDocument()); }); it('renders ordered and received sections', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [ { _id: 'p1', status: 'ordered', purchasedAt: '2026-01-01T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, { _id: 'p2', status: 'in_cabinet', purchasedAt: '2026-01-02T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => expect(screen.getByText('Pending arrival')).toBeInTheDocument()); // "Received" appears as a section heading (h2) — use getAllByText since PurchaseCard may also render it expect(screen.getAllByText('Received').length).toBeGreaterThan(0); }); it('toggles the create form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); // After clicking, both the header toggle and the form show "Cancel" expect(screen.getAllByRole('button', { name: 'Cancel' }).length).toBeGreaterThan(0); }); it('shows Load more button when hasMore', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [], pagination: { cursor: 'cur1', hasMore: true }, }); render(); await waitFor(() => expect(screen.getByText('Load more')).toBeInTheDocument()); }); it('shows validation error when no store selected', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByText('Save Purchase')); // Submit without selecting a store fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!); await waitFor(() => expect(screen.getByText('Please select a store.')).toBeInTheDocument()); }); it('shows validation error when no items', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByText('Save Purchase')); fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } }); fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!); await waitFor(() => expect( screen.getByText('Add at least one item with a name and quantity.'), ).toBeInTheDocument(), ); }); it('receives a purchase after confirmation', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [ { _id: 'p1', status: 'ordered', purchasedAt: '2026-01-01T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); mockReceivePurchase.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('Mark as received')); await userEvent.click(screen.getByText('Mark as received')); expect(mockReceivePurchase).toHaveBeenCalledWith('hh1', 'p1'); }); it('deletes a purchase after confirmation', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [ { _id: 'p1', status: 'ordered', purchasedAt: '2026-01-01T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); mockDeletePurchase.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('Cancel order')); await userEvent.click(screen.getByText('Cancel order')); expect(mockDeletePurchase).toHaveBeenCalledWith('hh1', 'p1'); }); it('shows error when receive fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [ { _id: 'p1', status: 'ordered', purchasedAt: '2026-01-01T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); mockReceivePurchase.mockRejectedValue(new Error('Receive failed')); render(); await waitFor(() => screen.getByText('Mark as received')); await userEvent.click(screen.getByText('Mark as received')); await waitFor(() => expect(screen.getByText('Receive failed')).toBeInTheDocument()); }); it('shows error when delete fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [ { _id: 'p1', status: 'ordered', purchasedAt: '2026-01-01T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); mockDeletePurchase.mockRejectedValue(new Error('Delete failed')); render(); await waitFor(() => screen.getByText('Cancel order')); await userEvent.click(screen.getByText('Cancel order')); await waitFor(() => expect(screen.getByText('Delete failed')).toBeInTheDocument()); }); it('creates a purchase successfully', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); mockListMedicineProducts.mockResolvedValue(emptyResponse); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); mockCreatePurchase.mockResolvedValue({ _id: 'p-new', status: 'in_cabinet', purchasedAt: '2026-01-01T00:00:00.000Z', items: [], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByText('Save Purchase')); fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } }); fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' }, }); fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } }); fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!); await waitFor(() => expect(mockCreatePurchase).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ storeId: 'st-1' }), ), ); // Form hides after success await waitFor(() => expect(screen.queryByText('Save Purchase')).not.toBeInTheDocument()); }); it('cancels the create form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getAllByRole('button', { name: 'Cancel' })); // Click the form's Cancel button (inside the form) const cancelBtns = screen.getAllByRole('button', { name: 'Cancel' }); await userEvent.click(cancelBtns[cancelBtns.length - 1]!); expect(screen.queryByText('Save Purchase')).not.toBeInTheDocument(); }); it('renders purchase items with price data', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [ { _id: 'p1', status: 'in_cabinet', storeName: 'Walgreens', purchasedAt: '2026-01-01T00:00:00.000Z', items: [ { name: 'Aspirin', quantity: 30, unit: 'tablet', actualPrice: 5.99, currency: 'USD', }, ], householdId: 'hh1', createdBy: 'u1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => expect(screen.getByText('Aspirin')).toBeInTheDocument()); expect(screen.getByText(/5.99/)).toBeInTheDocument(); }); it('loads more purchases when Load more is clicked', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue({ data: [], pagination: { cursor: 'cur1', hasMore: true }, }); render(); await waitFor(() => screen.getByText('Load more')); await userEvent.click(screen.getByText('Load more')); expect(mockListPurchases).toHaveBeenCalledTimes(2); }); it('toggles online order checkbox and changes notes', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByLabelText('Online order (pending arrival)')); fireEvent.click(screen.getByLabelText('Online order (pending arrival)')); fireEvent.change(document.querySelector('input[maxLength="1000"]') as HTMLElement, { target: { value: 'Some notes' }, }); expect(screen.getByLabelText('Online order (pending arrival)')).toBeChecked(); }); it('adds and removes purchase items', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByText('Save Purchase')); const addItemBtn = screen.getByRole('button', { name: 'Add item' }); await userEvent.click(addItemBtn); // Now 2 items — remove button shows await waitFor(() => screen.getAllByText('Remove').length > 0); await userEvent.click(screen.getAllByText('Remove')[0]!); expect(screen.queryByText('Remove')).not.toBeInTheDocument(); }); it('changes item price and currency fields', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByPlaceholderText('9.99')); fireEvent.change(screen.getByPlaceholderText('9.99'), { target: { value: '5.99' } }); fireEvent.change(screen.getByPlaceholderText('USD'), { target: { value: 'EUR' } }); expect(screen.getByPlaceholderText('9.99')).toBeInTheDocument(); }); it('changes item name and unit fields', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByPlaceholderText('Brand / product name')); fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' }, }); fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } }); fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } }); expect(screen.getByPlaceholderText('Brand / product name')).toBeInTheDocument(); }); it('selects a medicine and product in the form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); mockListMedicines.mockResolvedValue({ data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg' }], pagination: { cursor: null, hasMore: false }, }); mockListMedicineProducts.mockResolvedValue({ data: [{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' }], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByText('Save Purchase')); // Select a medicine fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } }); // Wait for products to load await waitFor(() => screen.getByDisplayValue('Select product')); // Select a product fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } }); // Product details should auto-fill the form expect(screen.getByDisplayValue('60')).toBeInTheDocument(); }); it('handles product list failure gracefully', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); mockListMedicines.mockResolvedValue({ data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg' }], pagination: { cursor: null, hasMore: false }, }); mockListMedicineProducts.mockRejectedValue(new Error('Products failed')); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => screen.getByText('Save Purchase')); fireEvent.change(screen.getByDisplayValue('Select medicine'), { target: { value: 'med-1' } }); // Wait for products loading to finish (error is swallowed) await waitFor(() => expect(mockListMedicineProducts).toHaveBeenCalled()); }); it('shows no stores message when store list is empty in form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockResolvedValue(emptyResponse); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Record Purchase')); await userEvent.click(screen.getByText('Record Purchase')); await waitFor(() => expect(screen.getByText(/No stores yet/)).toBeInTheDocument()); }); it('dismisses error', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListPurchases.mockRejectedValue(new Error('Oops')); render(); await waitFor(() => screen.getByText('Oops')); await userEvent.click(screen.getByText('Dismiss')); expect(screen.queryByText('Oops')).not.toBeInTheDocument(); }); });