Merge pull request #19 from beatz174-bit/codex/fix-duplicate-barcode-addition-in-product-table
Enforce unique barcodes in product management
This commit is contained in:
@@ -50,28 +50,41 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product));
|
||||
const [isScannerOpen, setIsScannerOpen] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setFormState(getInitialFormState(product));
|
||||
setSaveError('');
|
||||
}, [product]);
|
||||
|
||||
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formState.name) return;
|
||||
await onSave(product.id, {
|
||||
name: formState.name,
|
||||
category: formState.category,
|
||||
barcode: formState.barcode || undefined,
|
||||
});
|
||||
setIsEditing(false);
|
||||
try {
|
||||
await onSave(product.id, {
|
||||
name: formState.name,
|
||||
category: formState.category,
|
||||
barcode: formState.barcode || undefined,
|
||||
});
|
||||
setIsEditing(false);
|
||||
setSaveError('');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
||||
setSaveError('This barcode is already assigned to another product.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setFormState(getInitialFormState(product));
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -99,9 +112,17 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
||||
value={formState.barcode}
|
||||
onChange={handleChange('barcode')}
|
||||
size="small"
|
||||
error={Boolean(saveError)}
|
||||
helperText={saveError || undefined}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<Button size="small" onClick={() => setFormState((prev) => ({ ...prev, barcode: '' }))}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setFormState((prev) => ({ ...prev, barcode: '' }));
|
||||
setSaveError('');
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
),
|
||||
|
||||
@@ -51,6 +51,45 @@ export class StockFillDB extends Dexie {
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
this.version(3)
|
||||
.stores({
|
||||
products:
|
||||
'id, name, category, barcode, archived, created_at, updated_at',
|
||||
areas: 'id, name, created_at, updated_at',
|
||||
pickLists: 'id, area_id, created_at, completed_at',
|
||||
pickItems: 'id, pick_list_id, product_id, status, created_at, updated_at',
|
||||
categories: 'id, name, created_at, updated_at',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
const products = await tx.table('products').toArray();
|
||||
const seenBarcodes = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
await Promise.all(
|
||||
products.map((product) => {
|
||||
if (!product.barcode) return undefined;
|
||||
|
||||
if (seenBarcodes.has(product.barcode)) {
|
||||
return tx.table('products').update(product.id, {
|
||||
barcode: undefined,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
seenBarcodes.add(product.barcode);
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
this.version(4).stores({
|
||||
products: 'id, name, category, &barcode, archived, created_at, updated_at',
|
||||
areas: 'id, name, created_at, updated_at',
|
||||
pickLists: 'id, area_id, created_at, completed_at',
|
||||
pickItems: 'id, pick_list_id, product_id, status, created_at, updated_at',
|
||||
categories: 'id, name, created_at, updated_at',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,39 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen, waitFor } 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, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ManageProductsScreen } from './ManageProductsScreen';
|
||||
|
||||
const productsMock = [{ id: 'prod-1', name: 'Chips', category: 'Snacks', archived: false, created_at: 0, updated_at: 0 }];
|
||||
const categoriesMock = [{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }];
|
||||
let mockScannedBarcode = '123456';
|
||||
const mockUseProducts = vi.fn();
|
||||
const mockUseCategories = vi.fn();
|
||||
|
||||
const mockDb = {
|
||||
products: {
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
where: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('../hooks/dataHooks', () => ({
|
||||
useProducts: () => productsMock,
|
||||
useCategories: () => categoriesMock,
|
||||
useProducts: () => mockUseProducts(),
|
||||
useCategories: () => mockUseCategories(),
|
||||
}));
|
||||
|
||||
const productDeleteMock = vi.fn();
|
||||
const pickItemCountMock = vi.fn();
|
||||
|
||||
vi.mock('../context/DBProvider', () => ({
|
||||
useDatabase: () => ({
|
||||
products: {
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: productDeleteMock,
|
||||
},
|
||||
pickItems: {
|
||||
where: () => ({
|
||||
equals: () => ({
|
||||
count: pickItemCountMock,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
useDatabase: () => mockDb,
|
||||
}));
|
||||
|
||||
vi.mock('../components/BarcodeScannerView', () => ({
|
||||
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||
<button type="button" onClick={() => onDetected?.('123456')}>
|
||||
<button type="button" onClick={() => onDetected?.(mockScannedBarcode)}>
|
||||
Mock Scan
|
||||
</button>
|
||||
),
|
||||
@@ -49,6 +46,18 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseProducts.mockReturnValue([]);
|
||||
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||
mockScannedBarcode = '123456';
|
||||
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)),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('prefills the product name after scanning a barcode', async () => {
|
||||
server.use(
|
||||
http.get('https://world.openfoodfacts.org/api/v2/product/123456.json', () =>
|
||||
@@ -75,6 +84,84 @@ describe('ManageProductsScreen barcode lookup', () => {
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<ManageProductsScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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 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(
|
||||
<MemoryRouter>
|
||||
<ManageProductsScreen />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageProductsScreen deletion safeguards', () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export const ManageProductsScreen = () => {
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [barcode, setBarcode] = useState('');
|
||||
const [barcodeError, setBarcodeError] = useState('');
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
||||
'idle',
|
||||
@@ -69,6 +70,30 @@ export const ManageProductsScreen = () => {
|
||||
return Array.from(new Set([...categoryNames, ...productCategories]));
|
||||
}, [categories, products]);
|
||||
|
||||
const findBarcodeConflict = useCallback(
|
||||
(value?: string, productId?: string) =>
|
||||
value
|
||||
? products.find((product) => product.barcode === value && product.id !== productId)
|
||||
: undefined,
|
||||
[products],
|
||||
);
|
||||
|
||||
const assertUniqueBarcode = useCallback(
|
||||
async (value?: string, productId?: string) => {
|
||||
if (!value) return;
|
||||
|
||||
const conflict =
|
||||
findBarcodeConflict(value, productId) ?? (await db.products.where('barcode').equals(value).first());
|
||||
|
||||
if (conflict && conflict.id !== productId) {
|
||||
const error = new Error('This barcode is already assigned to another product.');
|
||||
error.name = 'DuplicateBarcodeError';
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[db.products, findBarcodeConflict],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryOptions.length === 0) return;
|
||||
if (!categoryOptions.includes(category)) {
|
||||
@@ -97,10 +122,22 @@ export const ManageProductsScreen = () => {
|
||||
setLookupStatus('idle');
|
||||
setExternalProduct(null);
|
||||
}
|
||||
setBarcodeError('');
|
||||
}, [barcode]);
|
||||
|
||||
const addProduct = async () => {
|
||||
if (!name || !category) return;
|
||||
|
||||
try {
|
||||
await assertUniqueBarcode(barcode || undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
||||
setBarcodeError(error.message);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db.products.add({
|
||||
id: uuidv4(),
|
||||
name,
|
||||
@@ -125,6 +162,8 @@ export const ManageProductsScreen = () => {
|
||||
barcode?: string;
|
||||
},
|
||||
) => {
|
||||
await assertUniqueBarcode(updates.barcode, productId);
|
||||
|
||||
await db.products.update(productId, {
|
||||
...updates,
|
||||
unit_type: DEFAULT_UNIT_TYPE,
|
||||
@@ -200,6 +239,8 @@ export const ManageProductsScreen = () => {
|
||||
label="Barcode"
|
||||
value={barcode}
|
||||
onChange={(event) => setBarcode(event.target.value)}
|
||||
error={Boolean(barcodeError)}
|
||||
helperText={barcodeError || undefined}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<Button onClick={() => setBarcode('')} size="small">
|
||||
|
||||
Reference in New Issue
Block a user