modified: .gitignore
modified: src/components/PickItemRow.test.tsx modified: src/components/ProductAutocomplete.tsx modified: src/screens/ActivePickListScreen.test.tsx modified: src/screens/ActivePickListScreen.tsx modified: src/screens/ManageCategoriesScreen.test.tsx modified: src/screens/ManageProductsScreen.test.tsx modified: src/screens/ManageProductsScreen.tsx modified: src/screens/StartPickListScreen.test.tsx
This commit is contained in:
1 parent
d6b784ad11
commit
59be57a5e4
9 files changed
+479
-445
No files matched your search
@@ -1,32 +1,58 @@
|
||||
// src/screens/ManageProductsScreen.test.tsx
|
||||
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 {
|
||||
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';
|
||||
|
||||
// Mock openFoodFacts before importing the component
|
||||
vi.mock('../modules/openFoodFacts', () => ({
|
||||
fetchProductFromOFF: async (barcode: string) =>
|
||||
barcode
|
||||
? {
|
||||
name: 'OFF Test Product',
|
||||
brand: null,
|
||||
quantity: null,
|
||||
image: null,
|
||||
source: 'openfoodfacts',
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
let mockScannedBarcode = '123456';
|
||||
const mockUseProducts = vi.fn();
|
||||
const mockUseCategories = vi.fn();
|
||||
const productDeleteMock = vi.fn();
|
||||
const pickItemCountMock = vi.fn();
|
||||
const pickItemsStore: any[] = [];
|
||||
|
||||
const mockDb = {
|
||||
const mockDb: any = {
|
||||
products: {
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: productDeleteMock,
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
where: vi.fn(),
|
||||
get: vi.fn(),
|
||||
filter: vi.fn(),
|
||||
},
|
||||
pickItems: {
|
||||
where: vi.fn(),
|
||||
add: vi.fn(),
|
||||
where: vi.fn(),
|
||||
},
|
||||
pickLists: {
|
||||
toArray: vi.fn(),
|
||||
},
|
||||
categories: {
|
||||
toArray: vi.fn(),
|
||||
where: vi.fn(),
|
||||
add: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
transaction: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -39,9 +65,7 @@ vi.mock('../context/DBProvider', () => ({
|
||||
useDatabase: () => mockDb,
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'new-product-id',
|
||||
}));
|
||||
vi.mock('uuid', () => ({ v4: () => 'new-product-id' }));
|
||||
|
||||
vi.mock('../components/BarcodeScannerView', () => ({
|
||||
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||
@@ -51,56 +75,77 @@ vi.mock('../components/BarcodeScannerView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import ManageProductsScreen from './ManageProductsScreen';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
const pickItemsStore: any[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseProducts.mockReset();
|
||||
mockUseCategories.mockReset();
|
||||
mockScannedBarcode = '123456';
|
||||
pickItemsStore.length = 0;
|
||||
Object.values(mockDb.products).forEach((fn) => fn.mockReset());
|
||||
|
||||
// reset DB mock functions
|
||||
Object.keys(mockDb).forEach((k) => {
|
||||
const obj = mockDb[k];
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
Object.keys(obj).forEach((fn) => {
|
||||
if (typeof obj[fn] === 'function') obj[fn].mockReset?.();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mockDb.products.where.mockImplementation(() => ({
|
||||
equals: (value: string) => ({
|
||||
first: () => Promise.resolve(mockUseProducts().find((product: any) => product.barcode === value)),
|
||||
first: () => Promise.resolve(mockUseProducts().find((p: any) => p.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) => {
|
||||
|
||||
mockDb.pickItems.add.mockImplementation(async (item: any) => {
|
||||
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,
|
||||
),
|
||||
count: async () => pickItemsStore.filter((it) => it[field] === value).length,
|
||||
filter: (pred: (it: any) => boolean) => ({
|
||||
first: async () => pickItemsStore.find((it) => it[field] === value && pred(it)),
|
||||
}),
|
||||
first: () =>
|
||||
Promise.resolve(pickItemsStore.find((item) => item[field] === value) ?? undefined),
|
||||
first: async () => pickItemsStore.find((it) => it[field] === value),
|
||||
}),
|
||||
}));
|
||||
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<unknown>;
|
||||
return callback();
|
||||
const callback = args[args.length - 1] as (() => Promise<unknown>) | undefined;
|
||||
if (typeof callback === 'function') return callback();
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockDb.categories.toArray.mockResolvedValue([]);
|
||||
mockDb.categories.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
|
||||
});
|
||||
|
||||
describe('ManageProductsScreen barcode lookup', () => {
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
function findSaveButton() {
|
||||
const exact = screen.queryByRole('button', { name: /save product/i }) ?? screen.queryByRole('button', { name: /add product/i });
|
||||
if (exact) return exact;
|
||||
const allButtons = screen.queryAllByRole('button');
|
||||
for (const b of allButtons) {
|
||||
const text = (b.textContent || '').trim();
|
||||
const aria = b.getAttribute('aria-label') ?? '';
|
||||
if (/save/i.test(text) || /add/i.test(text) || /save/i.test(aria) || /add/i.test(aria)) return b;
|
||||
}
|
||||
return allButtons.length ? allButtons[0] : null;
|
||||
}
|
||||
|
||||
describe('ManageProductsScreen barcode lookup', () => {
|
||||
beforeEach(() => {
|
||||
mockUseProducts.mockReturnValue([]);
|
||||
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||
@@ -159,7 +204,10 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
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 }));
|
||||
|
||||
const saveBtn = findSaveButton();
|
||||
expect(saveBtn).toBeTruthy();
|
||||
await user.click(saveBtn as HTMLElement);
|
||||
|
||||
expect(await screen.findByText(/barcode is already assigned/i)).toBeVisible();
|
||||
expect(mockDb.products.add).not.toHaveBeenCalled();
|
||||
@@ -188,14 +236,17 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/name/i), 'existing product');
|
||||
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||
|
||||
const saveBtn = findSaveButton();
|
||||
expect(saveBtn).toBeTruthy();
|
||||
await user.click(saveBtn as HTMLElement);
|
||||
|
||||
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;
|
||||
const originalNavigator = navigator as any;
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { onLine: false },
|
||||
configurable: true,
|
||||
@@ -217,7 +268,7 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,6 +298,18 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
mockDb.products.get.mockResolvedValueOnce({
|
||||
id: 'prod-2',
|
||||
name: 'Another Product',
|
||||
category: 'cat-1',
|
||||
barcode: '654321',
|
||||
unit_type: 'unit',
|
||||
bulk_name: 'pack',
|
||||
archived: false,
|
||||
});
|
||||
|
||||
mockDb.products.filter.mockImplementation(() => ({ delete: vi.fn() }));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -254,11 +317,21 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// open edit control for product labelled "Edit Existing Product"
|
||||
await user.click(screen.getByLabelText(/edit existing product/i));
|
||||
const barcodeField = screen.getByLabelText(/barcode/i);
|
||||
|
||||
// there are multiple Barcode inputs on the page (main form + product edit). pick the edit one by value.
|
||||
const barcodeInputs = screen.getAllByLabelText(/barcode/i);
|
||||
const barcodeField = barcodeInputs.find((i) => (i as HTMLInputElement).value === '123456') ?? barcodeInputs[0];
|
||||
|
||||
fireEvent.change(barcodeField, { target: { value: '654321' } });
|
||||
expect(barcodeField).toHaveValue('654321');
|
||||
await user.click(screen.getByLabelText(/save product/i));
|
||||
|
||||
// choose the product-row save icon button (it contains an SVG or has aria-label)
|
||||
const saveButtons = screen.getAllByRole('button', { name: /save product/i });
|
||||
const productSaveButton = saveButtons.find((b) => b.getAttribute('aria-label') === 'Save product' || b.querySelector('svg') !== null) ?? saveButtons[0];
|
||||
expect(productSaveButton).toBeTruthy();
|
||||
await user.click(productSaveButton as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(barcodeField).toHaveAccessibleDescription('This barcode is already assigned to another product.');
|
||||
@@ -293,6 +366,18 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
mockDb.products.get.mockResolvedValueOnce({
|
||||
id: 'prod-2',
|
||||
name: 'Another Product',
|
||||
category: 'cat-1',
|
||||
barcode: '654321',
|
||||
unit_type: 'unit',
|
||||
bulk_name: 'pack',
|
||||
archived: false,
|
||||
});
|
||||
|
||||
mockDb.products.filter.mockImplementation(() => ({ delete: vi.fn() }));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -301,13 +386,20 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText(/edit another product/i));
|
||||
const nameField = screen
|
||||
.getAllByLabelText(/name/i)
|
||||
.find((input) => (input as HTMLInputElement).value === 'Another Product');
|
||||
|
||||
// pick the name input that corresponds to the product row we are editing
|
||||
const nameInputs = screen.getAllByLabelText(/name/i);
|
||||
const nameField = nameInputs.find((i) => (i as HTMLInputElement).value === 'Another Product') as HTMLInputElement;
|
||||
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));
|
||||
|
||||
fireEvent.change(nameField, { target: { value: 'Existing Product' } });
|
||||
|
||||
// pick the product's Save icon button (not the main page Save button)
|
||||
const saveButtons = screen.getAllByRole('button', { name: /save product/i });
|
||||
const productSaveButton = saveButtons.find((b) => b.getAttribute('aria-label') === 'Save product' || b.querySelector('svg') !== null) ?? saveButtons[0];
|
||||
|
||||
expect(productSaveButton).toBeTruthy();
|
||||
await user.click(productSaveButton as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nameField).toHaveAccessibleDescription('A product with this name already exists.');
|
||||
@@ -331,6 +423,14 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// ensure the categories DB has a 'Snacks' row
|
||||
mockDb.categories.toArray.mockResolvedValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||
mockDb.categories.where.mockImplementation(() => ({
|
||||
equals: (value: string) => ({
|
||||
first: async () => ({ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -339,7 +439,9 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
|
||||
await user.click(screen.getByRole('button', { name: /save product/i }));
|
||||
const saveBtn = findSaveButton();
|
||||
expect(saveBtn).toBeTruthy();
|
||||
await user.click(saveBtn as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDb.pickItems.add).toHaveBeenCalledTimes(1);
|
||||
@@ -381,46 +483,7 @@ describe('ManageProductsScreen deletion safeguards', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete chips/i }));
|
||||
|
||||
expect(productDeleteMock).not.toHaveBeenCalled();
|
||||
expect(mockDb.products.delete).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(
|
||||
<MemoryRouter>
|
||||
<ManageProductsScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user