import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { MemoryRouter } from 'react-router-dom'; import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { ManageProductsScreen } from './ManageProductsScreen'; let mockScannedBarcode = '123456'; const mockUseProducts = vi.fn(); const mockUseCategories = vi.fn(); const productDeleteMock = vi.fn(); const pickItemCountMock = vi.fn(); const pickItemsStore: any[] = []; const mockDb = { products: { add: vi.fn(), update: vi.fn(), delete: productDeleteMock, where: vi.fn(), }, pickItems: { where: vi.fn(), add: vi.fn(), }, pickLists: { toArray: vi.fn(), }, transaction: vi.fn(), }; vi.mock('../hooks/dataHooks', () => ({ useProducts: () => mockUseProducts(), useCategories: () => mockUseCategories(), })); vi.mock('../context/DBProvider', () => ({ useDatabase: () => mockDb, })); vi.mock('uuid', () => ({ v4: () => 'new-product-id', })); vi.mock('../components/BarcodeScannerView', () => ({ BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => ( ), })); const server = setupServer(); beforeEach(() => { mockUseProducts.mockReset(); mockUseCategories.mockReset(); mockScannedBarcode = '123456'; pickItemsStore.length = 0; Object.values(mockDb.products).forEach((fn) => fn.mockReset()); mockDb.products.where.mockImplementation(() => ({ equals: (value: string) => ({ first: () => Promise.resolve(mockUseProducts().find((product: any) => product.barcode === value)), }), })); pickItemCountMock.mockReset(); pickItemCountMock.mockImplementation((value?: string, field?: string) => Promise.resolve(pickItemsStore.filter((item) => item[field ?? 'product_id'] === value).length), ); mockDb.pickItems.add.mockReset(); mockDb.pickItems.add.mockImplementation(async (item) => { pickItemsStore.push(item); return item.id; }); mockDb.pickItems.where.mockReset(); mockDb.pickItems.where.mockImplementation((field: string) => ({ equals: (value: string) => ({ count: () => pickItemCountMock(value, field), filter: (predicate: (item: any) => boolean) => ({ first: () => Promise.resolve( pickItemsStore.find((item) => item[field] === value && predicate(item)) ?? undefined, ), }), first: () => Promise.resolve(pickItemsStore.find((item) => item[field] === value) ?? undefined), }), })); mockDb.pickLists.toArray.mockReset(); mockDb.pickLists.toArray.mockResolvedValue([]); mockDb.transaction.mockReset(); mockDb.transaction.mockImplementation(async (_mode: string, ...args: unknown[]) => { const callback = args[args.length - 1] as () => Promise; return callback(); }); }); describe('ManageProductsScreen barcode lookup', () => { beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); beforeEach(() => { mockUseProducts.mockReturnValue([]); mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]); }); it('prefills the product name after scanning a barcode', async () => { server.use( http.get('https://world.openfoodfacts.org/api/v2/product/123456.json', () => HttpResponse.json({ status: 1, product: { product_name: 'OFF Test Product', }, }), ), ); const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: /scan barcode/i })); await user.click(screen.getByRole('button', { name: /mock scan/i })); await waitFor(() => { expect(screen.getByLabelText(/name/i)).toHaveValue('OFF Test Product'); }); }); it('prevents adding a product with a duplicate barcode', async () => { mockUseProducts.mockReturnValue([ { id: 'prod-1', name: 'Existing Product', category: 'Snacks', barcode: '123456', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, ]); const user = userEvent.setup(); render( , ); await user.type(screen.getByLabelText(/name/i), 'New Product'); await user.click(screen.getByRole('button', { name: /scan barcode/i })); await user.click(screen.getByRole('button', { name: /mock scan/i })); await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: /scan barcode/i })); await user.click(screen.getByRole('button', { name: /save product/i })); expect(await screen.findByText(/barcode is already assigned/i)).toBeVisible(); expect(mockDb.products.add).not.toHaveBeenCalled(); }); it('prevents adding a product with a duplicate name (case-insensitive)', async () => { mockUseProducts.mockReturnValue([ { id: 'prod-1', name: 'Existing Product', category: 'Snacks', barcode: undefined, unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, ]); const user = userEvent.setup(); render( , ); await user.type(screen.getByLabelText(/name/i), 'existing product'); await user.click(screen.getByRole('button', { name: /save product/i })); expect(await screen.findByText(/product with this name already exists/i)).toBeVisible(); expect(mockDb.products.add).not.toHaveBeenCalled(); }); it('informs the user when barcode lookup happens offline', async () => { const originalNavigator = navigator; Object.defineProperty(globalThis, 'navigator', { value: { onLine: false }, configurable: true, }); try { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: /scan barcode/i })); await user.click(screen.getByRole('button', { name: /mock scan/i })); expect(await screen.findByText(/you are offline\. enter details manually\./i)).toBeVisible(); } finally { Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, configurable: true, }); } }); it('prevents updating a product to use an existing barcode', async () => { mockUseProducts.mockReturnValue([ { id: 'prod-1', name: 'Existing Product', category: 'Snacks', barcode: '123456', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, { id: 'prod-2', name: 'Another Product', category: 'Snacks', barcode: '654321', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, ]); const user = userEvent.setup(); render( , ); await user.click(screen.getByLabelText(/edit existing product/i)); const barcodeField = screen.getByLabelText(/barcode/i); fireEvent.change(barcodeField, { target: { value: '654321' } }); expect(barcodeField).toHaveValue('654321'); await user.click(screen.getByLabelText(/save product/i)); await waitFor(() => { expect(barcodeField).toHaveAccessibleDescription('This barcode is already assigned to another product.'); expect(barcodeField).toHaveAttribute('aria-invalid', 'true'); }); expect(mockDb.products.update).not.toHaveBeenCalled(); }); it('prevents updating a product to use an existing name', async () => { mockUseProducts.mockReturnValue([ { id: 'prod-1', name: 'Existing Product', category: 'Snacks', barcode: '123456', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, { id: 'prod-2', name: 'Another Product', category: 'Snacks', barcode: '654321', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, ]); const user = userEvent.setup(); render( , ); await user.click(screen.getByLabelText(/edit another product/i)); const nameField = screen .getAllByLabelText(/name/i) .find((input) => (input as HTMLInputElement).value === 'Another Product'); expect(nameField).toBeDefined(); fireEvent.change(nameField as Element, { target: { value: 'Existing Product' } }); expect(nameField).toHaveValue('Existing Product'); await user.click(screen.getByLabelText(/save product/i)); await waitFor(() => { expect(nameField).toHaveAccessibleDescription('A product with this name already exists.'); expect(nameField).toHaveAttribute('aria-invalid', 'true'); }); expect(mockDb.products.update).not.toHaveBeenCalled(); }); }); describe('ManageProductsScreen auto-adding products to pick lists', () => { it('adds a new product to matching pick lists when auto-add is enabled', async () => { mockUseProducts.mockReturnValue([]); mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]); mockDb.pickLists.toArray.mockResolvedValue([ { id: 'list-1', area_id: 'area-1', created_at: 0, categories: ['Snacks'], auto_add_new_products: true, }, ]); const user = userEvent.setup(); render( , ); await user.type(screen.getByLabelText(/name/i), 'Granola Bar'); await user.click(screen.getByRole('button', { name: /save product/i })); await waitFor(() => { expect(mockDb.pickItems.add).toHaveBeenCalledTimes(1); }); const addedItem = mockDb.pickItems.add.mock.calls[0][0]; expect(addedItem.pick_list_id).toBe('list-1'); expect(addedItem.product_id).toBe('new-product-id'); expect(addedItem.status).toBe('pending'); }); }); describe('ManageProductsScreen deletion safeguards', () => { it('blocks deletion when pick items reference the product', async () => { mockUseProducts.mockReturnValue([ { id: 'prod-chips', name: 'Chips', category: 'Snacks', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, ]); mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]); pickItemsStore.push( { id: 'item-1', product_id: 'prod-chips', pick_list_id: 'list-1' }, { id: 'item-2', product_id: 'prod-chips', pick_list_id: 'list-2' }, ); const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: /delete chips/i })); expect(productDeleteMock).not.toHaveBeenCalled(); expect(await screen.findByText(/cannot delete this product while 2 pick item\(s\) reference it/i)).toBeVisible(); }); }); describe('ManageProductsScreen filtering feedback', () => { it('informs the user when no products match the search and category filter', async () => { mockUseProducts.mockReturnValue([ { id: 'prod-1', name: 'Chips', category: 'Snacks', unit_type: 'unit', bulk_name: 'pack', archived: false, created_at: 0, updated_at: 0, }, ]); mockUseCategories.mockReturnValue([ { id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }, { id: 'cat-2', name: 'Drinks', created_at: 0, updated_at: 0 }, ]); const user = userEvent.setup(); render( , ); await user.type(screen.getByPlaceholderText(/search/i), 'Soda'); const [filterSelect] = screen.getAllByLabelText(/category/i); await user.click(filterSelect); await user.click(screen.getByRole('option', { name: /drinks/i })); expect( await screen.findByText(/no products match your search and category filter\./i), ).toBeVisible(); }); });