import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn(), })); const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted( () => ({ mockListStores: vi.fn(), mockCreateStore: vi.fn(), mockUpdateStore: vi.fn(), mockDeactivateStore: vi.fn(), }), ); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/services/stores', () => ({ listStores: mockListStores, createStore: mockCreateStore, updateStore: mockUpdateStore, deactivateStore: mockDeactivateStore, })); vi.mock('next/link', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return { default: (props: any) => props.children }; }); import StoresPage from '../../../../src/app/(dashboard)/stores/page'; beforeEach(() => vi.clearAllMocks()); describe('StoresPage', () => { it('shows loading skeleton when session is loading', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: true }); render(); expect(screen.getByText('Stores')).toBeInTheDocument(); expect(screen.queryByText('Add Store')).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 store list when household exists', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Walgreens', tags: ['pharmacy'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('Walgreens')).toBeInTheDocument(); }); }); it('shows empty state when no stores', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText(/No stores yet/)).toBeInTheDocument(); }); }); it('shows Add Store form when button clicked', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => { expect(screen.getByText('Add Store')).toBeInTheDocument(); }); await userEvent.click(screen.getByText('Add Store')); expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument(); }); it('shows error when store list fails to load', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockRejectedValue(new Error('Network error')); render(); await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); }); it('deactivates store after confirmation', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); mockDeactivateStore.mockResolvedValue({}); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => { expect(screen.getByText('CVS')).toBeInTheDocument(); }); await userEvent.click(screen.getByTitle('Deactivate')); expect(mockDeactivateStore).toHaveBeenCalledWith('hh1', 'st-1'); }); it('creates a store on form submit', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); mockCreateStore.mockResolvedValue({ _id: 'st-new', name: 'Walmart', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens')); fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' }, }); fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!); await waitFor(() => expect(mockCreateStore).toHaveBeenCalledWith( 'hh1', expect.objectContaining({ name: 'Walmart' }), ), ); }); it('shows error when create store fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); mockCreateStore.mockRejectedValue(new Error('Store already exists')); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens')); fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' }, }); fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!); await waitFor(() => expect(screen.getByText('Store already exists')).toBeInTheDocument()); }); it('opens edit form for a store', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('CVS')); await userEvent.click(screen.getByTitle('Edit')); expect(screen.getByDisplayValue('CVS')).toBeInTheDocument(); expect(screen.getByText('Edit Store')).toBeInTheDocument(); }); it('saves edited store', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); mockUpdateStore.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('CVS')); await userEvent.click(screen.getByTitle('Edit')); await waitFor(() => screen.getByDisplayValue('CVS')); fireEvent.change(screen.getByDisplayValue('CVS'), { target: { value: 'CVS Pharmacy' } }); fireEvent.submit(screen.getByDisplayValue('CVS Pharmacy').closest('form')!); await waitFor(() => expect(mockUpdateStore).toHaveBeenCalledWith( 'hh1', 'st-1', expect.objectContaining({ name: 'CVS Pharmacy' }), ), ); }); it('dismisses error', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockRejectedValue(new Error('Network error')); render(); await waitFor(() => screen.getByText('Network error')); await userEvent.click(screen.getByText('Dismiss')); expect(screen.queryByText('Network error')).not.toBeInTheDocument(); }); it('shows error when deactivate fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); mockDeactivateStore.mockRejectedValue(new Error('Deactivate failed')); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => screen.getByTitle('Deactivate')); await userEvent.click(screen.getByTitle('Deactivate')); await waitFor(() => expect(screen.getByText('Deactivate failed')).toBeInTheDocument()); }); it('cancels the create store form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens')); await userEvent.click( screen.getAllByRole('button', { name: 'Cancel' })[ screen.getAllByRole('button', { name: 'Cancel' }).length - 1 ]!, ); expect(screen.queryByPlaceholderText('e.g. Walgreens')).not.toBeInTheDocument(); }); it('cancels the edit store form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('CVS')); await userEvent.click(screen.getByTitle('Edit')); await waitFor(() => screen.getByDisplayValue('CVS')); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.queryByDisplayValue('CVS')).not.toBeInTheDocument(); }); it('filters stores by tag', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Walgreens', tags: ['pharmacy'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, { _id: 'st-2', name: 'Costco', tags: ['supermarket'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Walgreens')); expect(screen.getByText('Costco')).toBeInTheDocument(); fireEvent.change(screen.getByDisplayValue('All tags'), { target: { value: 'pharmacy' } }); await waitFor(() => expect(screen.queryByText('Costco')).not.toBeInTheDocument()); expect(screen.getByText('Walgreens')).toBeInTheDocument(); }); it('changes store form fields (notes, address, url)', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens')); fireEvent.change(screen.getByPlaceholderText('123 Main St'), { target: { value: '456 Oak Ave' }, }); fireEvent.change(screen.getByPlaceholderText('https://walgreens.com'), { target: { value: 'https://newurl.com' }, }); fireEvent.change(screen.getByPlaceholderText('Any notes'), { target: { value: 'Good prices' }, }); expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument(); }); it('toggles a preset tag and adds a custom tag', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('Custom tag...')); // Toggle a preset tag (e.g. 'pharmacy') await userEvent.click(screen.getByRole('button', { name: 'pharmacy' })); // Toggle it off again await userEvent.click(screen.getByRole('button', { name: 'pharmacy' })); // Add a custom tag via button fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'mytag' } }); await userEvent.click(screen.getByRole('button', { name: 'Add' })); await waitFor(() => expect(screen.getByText('mytag')).toBeInTheDocument()); // Remove it via the × button await userEvent.click(screen.getByText('×')); expect(screen.queryByText('mytag')).not.toBeInTheDocument(); }); it('adds custom tag via Enter key', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('Custom tag...')); fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'keytag' } }); fireEvent.keyDown(screen.getByPlaceholderText('Custom tag...'), { key: 'Enter' }); await waitFor(() => expect(screen.getByText('keytag')).toBeInTheDocument()); }); it('ignores custom tag via non-Enter keydown', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('Custom tag...')); fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'escapetag' } }); fireEvent.keyDown(screen.getByPlaceholderText('Custom tag...'), { key: 'Escape' }); expect(screen.queryByText('escapetag')).not.toBeInTheDocument(); }); it('renders store card with missing optional parameters correctly', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-addr', name: 'Address Only Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', address: '123 Unique Way', url: undefined, notes: undefined }, { _id: 'st-url', name: 'URL Only Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', address: undefined, url: 'https://onlyurl.com', notes: undefined }, { _id: 'st-notes', name: 'Notes Only Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', address: undefined, url: undefined, notes: 'Special instruction notes' } ], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => expect(screen.getByText('Address Only Store')).toBeInTheDocument()); expect(screen.getByText('123 Unique Way')).toBeInTheDocument(); expect(screen.getByText('https://onlyurl.com')).toBeInTheDocument(); expect(screen.getByText('Special instruction notes')).toBeInTheDocument(); }); it('changes search filter', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, { _id: 'st-2', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Walgreens')); fireEvent.change(screen.getByPlaceholderText('Search stores...'), { target: { value: 'wal' } }); await waitFor(() => expect(screen.queryByText('CVS')).not.toBeInTheDocument()); expect(screen.getByText('Walgreens')).toBeInTheDocument(); }); it('toggles isActive in edit form', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); mockUpdateStore.mockResolvedValue({}); render(); await waitFor(() => screen.getByText('CVS')); await userEvent.click(screen.getByTitle('Edit')); await waitFor(() => screen.getByLabelText('Active')); fireEvent.click(screen.getByLabelText('Active')); expect(screen.getByLabelText('Active')).not.toBeChecked(); }); it('shows inactive stores when toggle checked', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'Active Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, { _id: 'st-2', name: 'Old Store', tags: [], isActive: false, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1', }, ], pagination: { cursor: null, hasMore: false }, }); render(); await waitFor(() => screen.getByText('Active Store')); expect(screen.queryByText('Old Store')).not.toBeInTheDocument(); fireEvent.click(screen.getByLabelText('Show inactive')); expect(screen.getByText('Old Store')).toBeInTheDocument(); }); it('handles create store generic error fallback', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); mockCreateStore.mockRejectedValue('Generic store save error'); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens')); fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } }); fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!); await waitFor(() => expect(screen.getByText('Failed to save store')).toBeInTheDocument()); }); it('handles list stores generic error fallback', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockRejectedValue('Generic list error'); render(); await waitFor(() => expect(screen.getByText('Failed to load stores')).toBeInTheDocument()); }); it('handles deactivate store generic error fallback', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' } ], pagination: { cursor: null, hasMore: false }, }); mockDeactivateStore.mockRejectedValue('Generic deactivate error'); vi.spyOn(window, 'confirm').mockReturnValue(true); render(); await waitFor(() => screen.getByTitle('Deactivate')); await userEvent.click(screen.getByTitle('Deactivate')); await waitFor(() => expect(screen.getByText('Failed to deactivate store')).toBeInTheDocument()); }); it('skips deactivation if confirmation is rejected', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [ { _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' } ], pagination: { cursor: null, hasMore: false }, }); vi.spyOn(window, 'confirm').mockReturnValue(false); render(); await waitFor(() => screen.getByTitle('Deactivate')); await userEvent.click(screen.getByTitle('Deactivate')); expect(mockDeactivateStore).not.toHaveBeenCalled(); }); it('handles empty optional fields creation gracefully', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); mockCreateStore.mockResolvedValue({} as any); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens')); fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } }); fireEvent.change(screen.getByPlaceholderText('123 Main St'), { target: { value: ' ' } }); fireEvent.change(screen.getByPlaceholderText('https://walgreens.com'), { target: { value: ' ' } }); fireEvent.change(screen.getByPlaceholderText('Any notes'), { target: { value: ' ' } }); fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!); await waitFor(() => { expect(mockCreateStore).toHaveBeenCalledWith('hh1', { name: 'Walmart', address: undefined, url: undefined, notes: undefined, tags: [], isActive: true }); }); }); it('renders empty search message when filters are applied with no match', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => expect(screen.getByText(/No stores yet/)).toBeInTheDocument()); fireEvent.change(screen.getByPlaceholderText('Search stores...'), { target: { value: 'MissingName' } }); await waitFor(() => { expect(screen.getByText('No stores match your filters.')).toBeInTheDocument(); }); }); it('prevents adding duplicate custom tags', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false }); mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); render(); await waitFor(() => screen.getByText('Add Store')); await userEvent.click(screen.getByText('Add Store')); await waitFor(() => screen.getByPlaceholderText('Custom tag...')); const input = screen.getByPlaceholderText('Custom tag...'); // Add tag once fireEvent.change(input, { target: { value: 'duplicated' } }); fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => expect(screen.getByText('duplicated')).toBeInTheDocument()); // Attempt to add it again fireEvent.change(input, { target: { value: 'duplicated' } }); fireEvent.keyDown(input, { key: 'Enter' }); // Should only exist once in the document expect(screen.getAllByText('duplicated')).toHaveLength(1); }); });