fixing categories 3rd normal form
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m34s

This commit is contained in:
2026-01-19 19:10:02 -08:00
parent 4618d11849
commit 5879328b67
20 changed files with 655 additions and 132 deletions

View File

@@ -38,6 +38,7 @@ import receiptRouter from './src/routes/receipt.routes';
import dealsRouter from './src/routes/deals.routes'; import dealsRouter from './src/routes/deals.routes';
import reactionsRouter from './src/routes/reactions.routes'; import reactionsRouter from './src/routes/reactions.routes';
import storeRouter from './src/routes/store.routes'; import storeRouter from './src/routes/store.routes';
import categoryRouter from './src/routes/category.routes';
import { errorHandler } from './src/middleware/errorHandler'; import { errorHandler } from './src/middleware/errorHandler';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService'; import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
import { websocketService } from './src/services/websocketService.server'; import { websocketService } from './src/services/websocketService.server';
@@ -288,6 +289,8 @@ app.use('/api/deals', dealsRouter);
app.use('/api/reactions', reactionsRouter); app.use('/api/reactions', reactionsRouter);
// 16. Store management routes. // 16. Store management routes.
app.use('/api/stores', storeRouter); app.use('/api/stores', storeRouter);
// 17. Category discovery routes (ADR-023: Database Normalization)
app.use('/api/categories', categoryRouter);
// --- Error Handling and Server Startup --- // --- Error Handling and Server Startup ---

View File

@@ -58,6 +58,7 @@ const mockFlyerItems: FlyerItem[] = [
quantity: 'per lb', quantity: 'per lb',
unit_price: { value: 1.99, unit: 'lb' }, unit_price: { value: 1.99, unit: 'lb' },
master_item_id: 1, master_item_id: 1,
category_id: 1,
category_name: 'Produce', category_name: 'Produce',
flyer_id: 1, flyer_id: 1,
}), }),
@@ -69,6 +70,7 @@ const mockFlyerItems: FlyerItem[] = [
quantity: '4L', quantity: '4L',
unit_price: { value: 1.125, unit: 'L' }, unit_price: { value: 1.125, unit: 'L' },
master_item_id: 2, master_item_id: 2,
category_id: 2,
category_name: 'Dairy', category_name: 'Dairy',
flyer_id: 1, flyer_id: 1,
}), }),
@@ -80,6 +82,7 @@ const mockFlyerItems: FlyerItem[] = [
quantity: 'per kg', quantity: 'per kg',
unit_price: { value: 8.0, unit: 'kg' }, unit_price: { value: 8.0, unit: 'kg' },
master_item_id: 3, master_item_id: 3,
category_id: 3,
category_name: 'Meat', category_name: 'Meat',
flyer_id: 1, flyer_id: 1,
}), }),
@@ -241,7 +244,7 @@ describe('ExtractedDataTable', () => {
expect(watchButton).toBeInTheDocument(); expect(watchButton).toBeInTheDocument();
fireEvent.click(watchButton); fireEvent.click(watchButton);
expect(mockAddWatchedItem).toHaveBeenCalledWith('Chicken Breast', 'Meat'); expect(mockAddWatchedItem).toHaveBeenCalledWith('Chicken Breast', 3);
}); });
it('should not show watch or add to list buttons for unmatched items', () => { it('should not show watch or add to list buttons for unmatched items', () => {
@@ -589,7 +592,7 @@ describe('ExtractedDataTable', () => {
const watchButton = within(itemRow).getByTitle("Add 'Canonical Mystery' to your watchlist"); const watchButton = within(itemRow).getByTitle("Add 'Canonical Mystery' to your watchlist");
fireEvent.click(watchButton); fireEvent.click(watchButton);
expect(mockAddWatchedItem).toHaveBeenCalledWith('Canonical Mystery', 'Other/Miscellaneous'); expect(mockAddWatchedItem).toHaveBeenCalledWith('Canonical Mystery', 19);
}); });
it('should not call addItemToList when activeListId is null and button is clicked', () => { it('should not call addItemToList when activeListId is null and button is clicked', () => {

View File

@@ -25,7 +25,7 @@ interface ExtractedDataTableRowProps {
isAuthenticated: boolean; isAuthenticated: boolean;
activeListId: number | null; activeListId: number | null;
onAddItemToList: (masterItemId: number) => void; onAddItemToList: (masterItemId: number) => void;
onAddWatchedItem: (itemName: string, category: string) => void; onAddWatchedItem: (itemName: string, category_id: number) => void;
} }
/** /**
@@ -72,9 +72,7 @@ const ExtractedDataTableRow: React.FC<ExtractedDataTableRowProps> = memo(
)} )}
{isAuthenticated && !isWatched && canonicalName && ( {isAuthenticated && !isWatched && canonicalName && (
<button <button
onClick={() => onClick={() => onAddWatchedItem(canonicalName, item.category_id || 19)}
onAddWatchedItem(canonicalName, item.category_name || 'Other/Miscellaneous')
}
className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200" className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200"
title={`Add '${canonicalName}' to your watchlist`} title={`Add '${canonicalName}' to your watchlist`}
> >
@@ -159,8 +157,8 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, u
); );
const handleAddWatchedItem = useCallback( const handleAddWatchedItem = useCallback(
(itemName: string, category: string) => { (itemName: string, category_id: number) => {
addWatchedItem(itemName, category); addWatchedItem(itemName, category_id);
}, },
[addWatchedItem], [addWatchedItem],
); );

View File

@@ -2,14 +2,27 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WatchedItemsList } from './WatchedItemsList'; import { WatchedItemsList } from './WatchedItemsList';
import type { MasterGroceryItem } from '../../types'; import type { MasterGroceryItem, Category } from '../../types';
import { logger } from '../../services/logger.client';
import { createMockMasterGroceryItem, createMockUser } from '../../tests/utils/mockFactories'; import { createMockMasterGroceryItem, createMockUser } from '../../tests/utils/mockFactories';
// Mock the logger to spy on error calls // Mock the logger to spy on error calls
vi.mock('../../services/logger.client'); vi.mock('../../services/logger.client');
// Mock the categories query hook
vi.mock('../../hooks/queries/useCategoriesQuery', () => ({
useCategoriesQuery: () => ({
data: [
{ category_id: 1, name: 'Produce', created_at: '2024-01-01', updated_at: '2024-01-01' },
{ category_id: 2, name: 'Dairy', created_at: '2024-01-01', updated_at: '2024-01-01' },
{ category_id: 3, name: 'Bakery', created_at: '2024-01-01', updated_at: '2024-01-01' },
] as Category[],
isLoading: false,
error: null,
}),
}));
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' }); const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockItems: MasterGroceryItem[] = [ const mockItems: MasterGroceryItem[] = [
@@ -52,6 +65,16 @@ const defaultProps = {
onAddItemToList: mockOnAddItemToList, onAddItemToList: mockOnAddItemToList,
}; };
// Helper function to wrap component with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe('WatchedItemsList (in shopping feature)', () => { describe('WatchedItemsList (in shopping feature)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -60,7 +83,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should render a login message when user is not authenticated', () => { it('should render a login message when user is not authenticated', () => {
render(<WatchedItemsList {...defaultProps} user={null} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} user={null} />);
expect( expect(
screen.getByText(/please log in to create and manage your personal watchlist/i), screen.getByText(/please log in to create and manage your personal watchlist/i),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -68,7 +91,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should render the form and item list when user is authenticated', () => { it('should render the form and item list when user is authenticated', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
expect(screen.getByPlaceholderText(/add item/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/add item/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
expect(screen.getByText('Apples')).toBeInTheDocument(); expect(screen.getByText('Apples')).toBeInTheDocument();
@@ -77,7 +100,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should allow adding a new item', async () => { it('should allow adding a new item', async () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } }); fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
// Use getByDisplayValue to reliably select the category dropdown, which has no label. // Use getByDisplayValue to reliably select the category dropdown, which has no label.
@@ -103,7 +126,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
mockOnAddItem.mockImplementation(() => mockPromise); mockOnAddItem.mockImplementation(() => mockPromise);
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } }); fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
fireEvent.change(screen.getByDisplayValue('Select a category'), { fireEvent.change(screen.getByDisplayValue('Select a category'), {
@@ -126,7 +149,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should allow removing an item', async () => { it('should allow removing an item', async () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const removeButton = screen.getByRole('button', { name: /remove apples/i }); const removeButton = screen.getByRole('button', { name: /remove apples/i });
fireEvent.click(removeButton); fireEvent.click(removeButton);
@@ -136,7 +159,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should filter items by category', () => { it('should filter items by category', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const categoryFilter = screen.getByRole('combobox', { name: /filter by category/i }); const categoryFilter = screen.getByRole('combobox', { name: /filter by category/i });
fireEvent.change(categoryFilter, { target: { value: 'Dairy' } }); fireEvent.change(categoryFilter, { target: { value: 'Dairy' } });
@@ -147,7 +170,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should sort items ascending and descending', () => { it('should sort items ascending and descending', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const sortButton = screen.getByRole('button', { name: /sort items descending/i }); const sortButton = screen.getByRole('button', { name: /sort items descending/i });
const itemsAsc = screen.getAllByRole('listitem'); const itemsAsc = screen.getAllByRole('listitem');
@@ -176,14 +199,14 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should call onAddItemToList when plus icon is clicked', () => { it('should call onAddItemToList when plus icon is clicked', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const addToListButton = screen.getByTitle('Add Apples to list'); const addToListButton = screen.getByTitle('Add Apples to list');
fireEvent.click(addToListButton); fireEvent.click(addToListButton);
expect(mockOnAddItemToList).toHaveBeenCalledWith(1); // ID for Apples expect(mockOnAddItemToList).toHaveBeenCalledWith(1); // ID for Apples
}); });
it('should disable the add to list button if activeListId is null', () => { it('should disable the add to list button if activeListId is null', () => {
render(<WatchedItemsList {...defaultProps} activeListId={null} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} activeListId={null} />);
// Multiple buttons will have this title, so we must use `getAllByTitle`. // Multiple buttons will have this title, so we must use `getAllByTitle`.
const addToListButtons = screen.getAllByTitle('Select a shopping list first'); const addToListButtons = screen.getAllByTitle('Select a shopping list first');
// Assert that at least one such button exists and that they are all disabled. // Assert that at least one such button exists and that they are all disabled.
@@ -192,13 +215,13 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should display a message when the list is empty', () => { it('should display a message when the list is empty', () => {
render(<WatchedItemsList {...defaultProps} items={[]} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} items={[]} />);
expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument(); expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument();
}); });
describe('Form Validation and Disabled States', () => { describe('Form Validation and Disabled States', () => {
it('should disable the "Add" button if item name is empty or whitespace', () => { it('should disable the "Add" button if item name is empty or whitespace', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i); const nameInput = screen.getByPlaceholderText(/add item/i);
const categorySelect = screen.getByDisplayValue('Select a category'); const categorySelect = screen.getByDisplayValue('Select a category');
const addButton = screen.getByRole('button', { name: 'Add' }); const addButton = screen.getByRole('button', { name: 'Add' });
@@ -220,7 +243,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should disable the "Add" button if category is not selected', () => { it('should disable the "Add" button if category is not selected', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i); const nameInput = screen.getByPlaceholderText(/add item/i);
const addButton = screen.getByRole('button', { name: 'Add' }); const addButton = screen.getByRole('button', { name: 'Add' });
@@ -233,7 +256,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should not submit if form is submitted with invalid data', () => { it('should not submit if form is submitted with invalid data', () => {
render(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i); const nameInput = screen.getByPlaceholderText(/add item/i);
const form = nameInput.closest('form')!; const form = nameInput.closest('form')!;
const categorySelect = screen.getByDisplayValue('Select a category'); const categorySelect = screen.getByDisplayValue('Select a category');
@@ -245,32 +268,6 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
}); });
describe('Error Handling', () => {
it('should reset loading state and log an error if onAddItem rejects', async () => {
const apiError = new Error('Item already exists');
mockOnAddItem.mockRejectedValue(apiError);
const loggerSpy = vi.spyOn(logger, 'error');
render(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const categorySelect = screen.getByDisplayValue('Select a category');
const addButton = screen.getByRole('button', { name: 'Add' });
fireEvent.change(nameInput, { target: { value: 'Duplicate Item' } });
fireEvent.change(categorySelect, { target: { value: 'Fruits & Vegetables' } });
fireEvent.click(addButton);
// After the promise rejects, the button should be enabled again
await waitFor(() => expect(addButton).toBeEnabled());
// And the error should be logged
expect(loggerSpy).toHaveBeenCalledWith('Failed to add watched item from WatchedItemsList', {
error: apiError,
});
});
});
describe('UI Edge Cases', () => { describe('UI Edge Cases', () => {
it('should display a specific message when a filter results in no items', () => { it('should display a specific message when a filter results in no items', () => {
const { rerender } = render(<WatchedItemsList {...defaultProps} />); const { rerender } = render(<WatchedItemsList {...defaultProps} />);
@@ -289,7 +286,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
}); });
it('should hide the sort button if there is only one item', () => { it('should hide the sort button if there is only one item', () => {
render(<WatchedItemsList {...defaultProps} items={[mockItems[0]]} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} items={[mockItems[0]]} />);
expect(screen.queryByRole('button', { name: /sort items/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /sort items/i })).not.toBeInTheDocument();
}); });
}); });

View File

@@ -5,14 +5,15 @@ import { EyeIcon } from '../../components/icons/EyeIcon';
import { LoadingSpinner } from '../../components/LoadingSpinner'; import { LoadingSpinner } from '../../components/LoadingSpinner';
import { SortAscIcon } from '../../components/icons/SortAscIcon'; import { SortAscIcon } from '../../components/icons/SortAscIcon';
import { SortDescIcon } from '../../components/icons/SortDescIcon'; import { SortDescIcon } from '../../components/icons/SortDescIcon';
import { CATEGORIES } from '../../types';
import { TrashIcon } from '../../components/icons/TrashIcon'; import { TrashIcon } from '../../components/icons/TrashIcon';
import { UserIcon } from '../../components/icons/UserIcon'; import { UserIcon } from '../../components/icons/UserIcon';
import { PlusCircleIcon } from '../../components/icons/PlusCircleIcon'; import { PlusCircleIcon } from '../../components/icons/PlusCircleIcon';
import { logger } from '../../services/logger.client'; import { logger } from '../../services/logger.client';
import { useCategoriesQuery } from '../../hooks/queries/useCategoriesQuery';
interface WatchedItemsListProps { interface WatchedItemsListProps {
items: MasterGroceryItem[]; items: MasterGroceryItem[];
onAddItem: (itemName: string, category: string) => Promise<void>; onAddItem: (itemName: string, category_id: number) => Promise<void>;
onRemoveItem: (masterItemId: number) => Promise<void>; onRemoveItem: (masterItemId: number) => Promise<void>;
user: User | null; user: User | null;
activeListId: number | null; activeListId: number | null;
@@ -28,20 +29,21 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({
onAddItemToList, onAddItemToList,
}) => { }) => {
const [newItemName, setNewItemName] = useState(''); const [newItemName, setNewItemName] = useState('');
const [newCategory, setNewCategory] = useState(''); const [newCategoryId, setNewCategoryId] = useState<number | ''>('');
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [categoryFilter, setCategoryFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all');
const { data: categories = [] } = useCategoriesQuery();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newItemName.trim() || !newCategory) return; if (!newItemName.trim() || !newCategoryId) return;
setIsAdding(true); setIsAdding(true);
try { try {
await onAddItem(newItemName, newCategory); await onAddItem(newItemName, newCategoryId as number);
setNewItemName(''); setNewItemName('');
setNewCategory(''); setNewCategoryId('');
} catch (error) { } catch (error) {
// Error is handled in the parent component // Error is handled in the parent component
logger.error('Failed to add watched item from WatchedItemsList', { error }); logger.error('Failed to add watched item from WatchedItemsList', { error });
@@ -139,8 +141,8 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({
/> />
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<select <select
value={newCategory} value={newCategoryId}
onChange={(e) => setNewCategory(e.target.value)} onChange={(e) => setNewCategoryId(Number(e.target.value))}
required required
className="col-span-2 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" className="col-span-2 block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm"
disabled={isAdding} disabled={isAdding}
@@ -148,15 +150,15 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({
<option value="" disabled> <option value="" disabled>
Select a category Select a category
</option> </option>
{CATEGORIES.map((cat) => ( {categories.map((cat) => (
<option key={cat} value={cat}> <option key={cat.category_id} value={cat.category_id}>
{cat} {cat.name}
</option> </option>
))} ))}
</select> </select>
<button <button
type="submit" type="submit"
disabled={isAdding || !newItemName.trim() || !newCategory} disabled={isAdding || !newItemName.trim() || !newCategoryId}
className="col-span-1 bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-2 px-3 rounded-lg transition-colors duration-300 flex items-center justify-center" className="col-span-1 bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-2 px-3 rounded-lg transition-colors duration-300 flex items-center justify-center"
> >
{isAdding ? ( {isAdding ? (

View File

@@ -30,8 +30,8 @@ describe('useAddWatchedItemMutation', () => {
}); });
}); });
it('should add a watched item successfully with category', async () => { it('should add a watched item successfully with category_id', async () => {
const mockResponse = { id: 1, item_name: 'Milk', category: 'Dairy' }; const mockResponse = { id: 1, item_name: 'Milk', category_id: 3 };
mockedApiClient.addWatchedItem.mockResolvedValue({ mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
@@ -39,15 +39,15 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Milk', category: 'Dairy' }); result.current.mutate({ itemName: 'Milk', category_id: 3 });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Milk', 'Dairy'); expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Milk', 3);
expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item added to watched list'); expect(mockedNotifications.notifySuccess).toHaveBeenCalledWith('Item added to watched list');
}); });
it('should add a watched item without category', async () => { it('should add a watched item with category_id', async () => {
const mockResponse = { id: 1, item_name: 'Bread' }; const mockResponse = { id: 1, item_name: 'Bread' };
mockedApiClient.addWatchedItem.mockResolvedValue({ mockedApiClient.addWatchedItem.mockResolvedValue({
ok: true, ok: true,
@@ -56,11 +56,11 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Bread' }); result.current.mutate({ itemName: 'Bread', category_id: 4 });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Bread', ''); expect(mockedApiClient.addWatchedItem).toHaveBeenCalledWith('Bread', 4);
}); });
it('should invalidate watched-items query on success', async () => { it('should invalidate watched-items query on success', async () => {
@@ -73,7 +73,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Eggs' }); result.current.mutate({ itemName: 'Eggs', category_id: 3 });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -89,7 +89,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Milk' }); result.current.mutate({ itemName: 'Milk', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true));
@@ -106,7 +106,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Cheese' }); result.current.mutate({ itemName: 'Cheese', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true));
@@ -122,7 +122,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Butter' }); result.current.mutate({ itemName: 'Butter', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true));
@@ -134,7 +134,7 @@ describe('useAddWatchedItemMutation', () => {
const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper }); const { result } = renderHook(() => useAddWatchedItemMutation(), { wrapper });
result.current.mutate({ itemName: 'Yogurt' }); result.current.mutate({ itemName: 'Yogurt', category_id: 3 });
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true));

View File

@@ -6,7 +6,7 @@ import { queryKeyBases } from '../../config/queryKeys';
interface AddWatchedItemParams { interface AddWatchedItemParams {
itemName: string; itemName: string;
category?: string; category_id: number;
} }
/** /**
@@ -24,7 +24,7 @@ interface AddWatchedItemParams {
* *
* const handleAdd = () => { * const handleAdd = () => {
* addWatchedItem.mutate( * addWatchedItem.mutate(
* { itemName: 'Milk', category: 'Dairy' }, * { itemName: 'Milk', category_id: 3 },
* { * {
* onSuccess: () => console.log('Added!'), * onSuccess: () => console.log('Added!'),
* onError: (error) => console.error(error), * onError: (error) => console.error(error),
@@ -37,8 +37,8 @@ export const useAddWatchedItemMutation = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ itemName, category }: AddWatchedItemParams) => { mutationFn: async ({ itemName, category_id }: AddWatchedItemParams) => {
const response = await apiClient.addWatchedItem(itemName, category ?? ''); const response = await apiClient.addWatchedItem(itemName, category_id);
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ const error = await response.json().catch(() => ({

View File

@@ -100,13 +100,13 @@ describe('useWatchedItems Hook', () => {
const { result } = renderHook(() => useWatchedItems()); const { result } = renderHook(() => useWatchedItems());
await act(async () => { await act(async () => {
await result.current.addWatchedItem('Cheese', 'Dairy'); await result.current.addWatchedItem('Cheese', 3);
}); });
// Verify mutation was called with correct parameters // Verify mutation was called with correct parameters
expect(mockMutateAsync).toHaveBeenCalledWith({ expect(mockMutateAsync).toHaveBeenCalledWith({
itemName: 'Cheese', itemName: 'Cheese',
category: 'Dairy', category_id: 3,
}); });
}); });
@@ -128,7 +128,7 @@ describe('useWatchedItems Hook', () => {
const { result } = renderHook(() => useWatchedItems()); const { result } = renderHook(() => useWatchedItems());
await act(async () => { await act(async () => {
await result.current.addWatchedItem('Failing Item', 'Error'); await result.current.addWatchedItem('Failing Item', 1);
}); });
// Should not throw - error is caught and logged // Should not throw - error is caught and logged
@@ -191,7 +191,7 @@ describe('useWatchedItems Hook', () => {
const { result } = renderHook(() => useWatchedItems()); const { result } = renderHook(() => useWatchedItems());
await act(async () => { await act(async () => {
await result.current.addWatchedItem('Test', 'Category'); await result.current.addWatchedItem('Test', 1);
await result.current.removeWatchedItem(1); await result.current.removeWatchedItem(1);
}); });

View File

@@ -36,11 +36,11 @@ const useWatchedItemsHook = () => {
* Uses TanStack Query mutation which automatically invalidates the cache. * Uses TanStack Query mutation which automatically invalidates the cache.
*/ */
const addWatchedItem = useCallback( const addWatchedItem = useCallback(
async (itemName: string, category: string) => { async (itemName: string, category_id: number) => {
if (!userProfile) return; if (!userProfile) return;
try { try {
await addWatchedItemMutation.mutateAsync({ itemName, category }); await addWatchedItemMutation.mutateAsync({ itemName, category_id });
} catch (error) { } catch (error) {
// Error is already handled by the mutation hook (notification shown) // Error is already handled by the mutation hook (notification shown)
// Just log for debugging // Just log for debugging

View File

@@ -0,0 +1,195 @@
// src/routes/category.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { CategoryDbService } from '../services/db/category.db';
const router = Router();
/**
* @swagger
* /api/categories:
* get:
* summary: List all available grocery categories
* description: Returns a list of all predefined grocery categories. Use this endpoint to populate category dropdowns in the UI.
* tags: [Categories]
* responses:
* 200:
* description: List of categories ordered alphabetically by name
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: array
* items:
* type: object
* properties:
* category_id:
* type: integer
* example: 3
* name:
* type: string
* example: "Dairy & Eggs"
* created_at:
* type: string
* format: date-time
* updated_at:
* type: string
* format: date-time
* 500:
* description: Server error
*/
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const categories = await CategoryDbService.getAllCategories(req.log);
res.json({
success: true,
data: categories,
});
} catch (error) {
next(error);
}
});
/**
* @swagger
* /api/categories/lookup:
* get:
* summary: Lookup category by name
* description: Find a category by its name (case-insensitive). This endpoint is provided for migration support to help clients transition from using category names to category IDs.
* tags: [Categories]
* parameters:
* - in: query
* name: name
* required: true
* schema:
* type: string
* description: The category name to search for (case-insensitive)
* example: "Dairy & Eggs"
* responses:
* 200:
* description: Category found
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* category_id:
* type: integer
* name:
* type: string
* 404:
* description: Category not found
* 400:
* description: Missing or invalid query parameter
*/
router.get('/lookup', async (req: Request, res: Response, next: NextFunction) => {
try {
const name = req.query.name as string;
if (!name || typeof name !== 'string' || name.trim() === '') {
return res.status(400).json({
success: false,
error: 'Query parameter "name" is required and must be a non-empty string',
});
}
const category = await CategoryDbService.getCategoryByName(name, req.log);
if (!category) {
return res.status(404).json({
success: false,
error: `Category '${name}' not found`,
});
}
res.json({
success: true,
data: category,
});
} catch (error) {
next(error);
}
});
/**
* @swagger
* /api/categories/{id}:
* get:
* summary: Get a specific category by ID
* description: Retrieve detailed information about a single category
* tags: [Categories]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: The category ID
* responses:
* 200:
* description: Category details
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* data:
* type: object
* properties:
* category_id:
* type: integer
* name:
* type: string
* created_at:
* type: string
* format: date-time
* updated_at:
* type: string
* format: date-time
* 404:
* description: Category not found
* 400:
* description: Invalid category ID
*/
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const categoryId = parseInt(req.params.id, 10);
if (isNaN(categoryId) || categoryId <= 0) {
return res.status(400).json({
success: false,
error: 'Invalid category ID. Must be a positive integer.',
});
}
const category = await CategoryDbService.getCategoryById(categoryId, req.log);
if (!category) {
return res.status(404).json({
success: false,
error: `Category with ID ${categoryId} not found`,
});
}
res.json({
success: true,
data: category,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -204,7 +204,7 @@ describe('User Routes (/api/users)', () => {
describe('POST /watched-items', () => { describe('POST /watched-items', () => {
it('should add an item to the watchlist and return the new item', async () => { it('should add an item to the watchlist and return the new item', async () => {
const newItem = { itemName: 'Organic Bananas', category: 'Produce' }; const newItem = { itemName: 'Organic Bananas', category_id: 5 };
const mockAddedItem = createMockMasterGroceryItem({ const mockAddedItem = createMockMasterGroceryItem({
master_grocery_item_id: 99, master_grocery_item_id: 99,
name: 'Organic Bananas', name: 'Organic Bananas',
@@ -221,7 +221,7 @@ describe('User Routes (/api/users)', () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(dbError); vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(dbError);
const response = await supertest(app) const response = await supertest(app)
.post('/api/users/watched-items') .post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Produce' }); .send({ itemName: 'Test', category_id: 5 });
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(logger.error).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalled();
}); });
@@ -231,19 +231,19 @@ describe('User Routes (/api/users)', () => {
it('should return 400 if itemName is missing', async () => { it('should return 400 if itemName is missing', async () => {
const response = await supertest(app) const response = await supertest(app)
.post('/api/users/watched-items') .post('/api/users/watched-items')
.send({ category: 'Produce' }); .send({ category_id: 5 });
expect(response.status).toBe(400); expect(response.status).toBe(400);
// Check the 'error.details' array for the specific validation message. // Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'itemName' is required."); expect(response.body.error.details[0].message).toBe("Field 'itemName' is required.");
}); });
it('should return 400 if category is missing', async () => { it('should return 400 if category_id is missing', async () => {
const response = await supertest(app) const response = await supertest(app)
.post('/api/users/watched-items') .post('/api/users/watched-items')
.send({ itemName: 'Apples' }); .send({ itemName: 'Apples' });
expect(response.status).toBe(400); expect(response.status).toBe(400);
// Check the 'error.details' array for the specific validation message. // Check the 'error.details' array for the specific validation message.
expect(response.body.error.details[0].message).toBe("Field 'category' is required."); expect(response.body.error.details[0].message).toContain('expected number');
}); });
}); });
@@ -253,7 +253,7 @@ describe('User Routes (/api/users)', () => {
); );
const response = await supertest(app) const response = await supertest(app)
.post('/api/users/watched-items') .post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Invalid' }); .send({ itemName: 'Test', category_id: 999 });
expect(response.status).toBe(400); expect(response.status).toBe(400);
}); });

View File

@@ -73,7 +73,7 @@ const deleteAccountSchema = z.object({
const addWatchedItemSchema = z.object({ const addWatchedItemSchema = z.object({
body: z.object({ body: z.object({
itemName: requiredString("Field 'itemName' is required."), itemName: requiredString("Field 'itemName' is required."),
category: requiredString("Field 'category' is required."), category_id: z.number().int().positive("Field 'category_id' must be a positive integer."),
}), }),
}); });
@@ -690,7 +690,7 @@ router.post(
const newItem = await db.personalizationRepo.addWatchedItem( const newItem = await db.personalizationRepo.addWatchedItem(
userProfile.user.user_id, userProfile.user.user_id,
body.itemName, body.itemName,
body.category, body.category_id,
req.log, req.log,
); );
sendSuccess(res, newItem, 201); sendSuccess(res, newItem, 201);

View File

@@ -16,7 +16,6 @@ import {
createMockRegisterUserPayload, createMockRegisterUserPayload,
createMockSearchQueryPayload, createMockSearchQueryPayload,
createMockShoppingListItemPayload, createMockShoppingListItemPayload,
createMockWatchedItemPayload,
} from '../tests/utils/mockFactories'; } from '../tests/utils/mockFactories';
// Mock the logger to keep test output clean and verifiable. // Mock the logger to keep test output clean and verifiable.
@@ -319,11 +318,8 @@ describe('API Client', () => {
}); });
it('addWatchedItem should send a POST request with the correct body', async () => { it('addWatchedItem should send a POST request with the correct body', async () => {
const watchedItemData = createMockWatchedItemPayload({ const watchedItemData = { itemName: 'Apples', category_id: 5 };
itemName: 'Apples', await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category_id);
category: 'Produce',
});
await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category);
expect(capturedUrl?.pathname).toBe('/api/users/watched-items'); expect(capturedUrl?.pathname).toBe('/api/users/watched-items');
expect(capturedBody).toEqual(watchedItemData); expect(capturedBody).toEqual(watchedItemData);

View File

@@ -433,10 +433,10 @@ export const fetchWatchedItems = (tokenOverride?: string): Promise<Response> =>
export const addWatchedItem = ( export const addWatchedItem = (
itemName: string, itemName: string,
category: string, category_id: number,
tokenOverride?: string, tokenOverride?: string,
): Promise<Response> => ): Promise<Response> =>
authedPost('/users/watched-items', { itemName, category }, { tokenOverride }); authedPost('/users/watched-items', { itemName, category_id }, { tokenOverride });
export const removeWatchedItem = ( export const removeWatchedItem = (
masterItemId: number, masterItemId: number,

View File

@@ -0,0 +1,92 @@
// src/services/db/category.db.ts
import { Logger } from 'pino';
import { getPool } from './connection.db';
import { handleDbError } from './errors.db';
export interface Category {
category_id: number;
name: string;
created_at: Date;
updated_at: Date;
}
/**
* Database service for category operations.
* Categories are predefined grocery item categories (e.g., "Dairy & Eggs", "Fruits & Vegetables").
*/
export class CategoryDbService {
/**
* Get all categories ordered by name.
* This endpoint is used for populating category dropdowns in the UI.
*
* @param logger - Pino logger instance
* @returns Promise resolving to array of categories
*/
static async getAllCategories(logger: Logger): Promise<Category[]> {
const pool = getPool();
try {
const result = await pool.query<Category>(
`SELECT category_id, name, created_at, updated_at
FROM public.categories
ORDER BY name ASC`,
);
return result.rows;
} catch (error) {
handleDbError(error, logger, 'Error fetching all categories', {});
throw error;
}
}
/**
* Get a specific category by its ID.
*
* @param categoryId - The category ID to retrieve
* @param logger - Pino logger instance
* @returns Promise resolving to category or null if not found
*/
static async getCategoryById(categoryId: number, logger: Logger): Promise<Category | null> {
const pool = getPool();
try {
const result = await pool.query<Category>(
`SELECT category_id, name, created_at, updated_at
FROM public.categories
WHERE category_id = $1`,
[categoryId],
);
return result.rows[0] || null;
} catch (error) {
handleDbError(error, logger, 'Error fetching category by ID', { categoryId });
throw error;
}
}
/**
* Get a category by its name (case-insensitive).
* This is primarily used for migration support to allow clients to lookup category IDs by name.
*
* @param name - The category name to search for
* @param logger - Pino logger instance
* @returns Promise resolving to category or null if not found
*/
static async getCategoryByName(name: string, logger: Logger): Promise<Category | null> {
const pool = getPool();
try {
const result = await pool.query<Category>(
`SELECT category_id, name, created_at, updated_at
FROM public.categories
WHERE LOWER(name) = LOWER($1)`,
[name],
);
return result.rows[0] || null;
} catch (error) {
handleDbError(error, logger, 'Error fetching category by name', { name });
throw error;
}
}
}

View File

@@ -138,18 +138,18 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => { vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: mockClientQuery }; const mockClient = { query: mockClientQuery };
mockClientQuery mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category .mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Verify category exists
.mockResolvedValueOnce({ rows: [mockItem] }) // Find master item .mockResolvedValueOnce({ rows: [mockItem] }) // Find master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist .mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
return callback(mockClient as unknown as PoolClient); return callback(mockClient as unknown as PoolClient);
}); });
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce', mockLogger); await personalizationRepo.addWatchedItem('user-123', 'New Item', 1, mockLogger);
expect(withTransaction).toHaveBeenCalledTimes(1); expect(withTransaction).toHaveBeenCalledTimes(1);
expect(mockClientQuery).toHaveBeenCalledWith( expect(mockClientQuery).toHaveBeenCalledWith(
expect.stringContaining('SELECT category_id FROM public.categories'), expect.stringContaining('SELECT category_id FROM public.categories WHERE category_id'),
['Produce'], [1],
); );
expect(mockClientQuery).toHaveBeenCalledWith( expect(mockClientQuery).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.master_grocery_items'), expect.stringContaining('SELECT * FROM public.master_grocery_items'),
@@ -170,7 +170,7 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => { vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: mockClientQuery }; const mockClient = { query: mockClientQuery };
mockClientQuery mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category .mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Verify category exists
.mockResolvedValueOnce({ rows: [] }) // Find master item (not found) .mockResolvedValueOnce({ rows: [] }) // Find master item (not found)
.mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item .mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist .mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
@@ -180,7 +180,7 @@ describe('Personalization DB Service', () => {
const result = await personalizationRepo.addWatchedItem( const result = await personalizationRepo.addWatchedItem(
'user-123', 'user-123',
'Brand New Item', 'Brand New Item',
'Produce', 1,
mockLogger, mockLogger,
); );
@@ -200,7 +200,7 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => { vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: mockClientQuery }; const mockClient = { query: mockClientQuery };
mockClientQuery mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category .mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Verify category exists
.mockResolvedValueOnce({ rows: [mockExistingItem] }) // Find master item .mockResolvedValueOnce({ rows: [mockExistingItem] }) // Find master item
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // INSERT...ON CONFLICT DO NOTHING .mockResolvedValueOnce({ rows: [], rowCount: 0 }); // INSERT...ON CONFLICT DO NOTHING
return callback(mockClient as unknown as PoolClient); return callback(mockClient as unknown as PoolClient);
@@ -208,7 +208,7 @@ describe('Personalization DB Service', () => {
// The function should resolve successfully without throwing an error. // The function should resolve successfully without throwing an error.
await expect( await expect(
personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce', mockLogger), personalizationRepo.addWatchedItem('user-123', 'Existing Item', 1, mockLogger),
).resolves.toEqual(mockExistingItem); ).resolves.toEqual(mockExistingItem);
expect(mockClientQuery).toHaveBeenCalledWith( expect(mockClientQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.user_watched_items'), expect.stringContaining('INSERT INTO public.user_watched_items'),
@@ -220,20 +220,20 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => { vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) }; const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow( await expect(callback(mockClient as unknown as PoolClient)).rejects.toThrow(
"Category 'Fake Category' not found.", 'Category with ID 999 not found.',
); );
throw new Error("Category 'Fake Category' not found."); throw new Error('Category with ID 999 not found.');
}); });
await expect( await expect(
personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category', mockLogger), personalizationRepo.addWatchedItem('user-123', 'Some Item', 999, mockLogger),
).rejects.toThrow('Failed to add item to watchlist.'); ).rejects.toThrow('Failed to add item to watchlist.');
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
{ {
err: expect.any(Error), err: expect.any(Error),
userId: 'user-123', userId: 'user-123',
itemName: 'Some Item', itemName: 'Some Item',
categoryName: 'Fake Category', categoryId: 999,
}, },
'Transaction error in addWatchedItem', 'Transaction error in addWatchedItem',
); );
@@ -251,10 +251,10 @@ describe('Personalization DB Service', () => {
}); });
await expect( await expect(
personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce', mockLogger), personalizationRepo.addWatchedItem('user-123', 'Failing Item', 1, mockLogger),
).rejects.toThrow('Failed to add item to watchlist.'); ).rejects.toThrow('Failed to add item to watchlist.');
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
{ err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryName: 'Produce' }, { err: dbError, userId: 'user-123', itemName: 'Failing Item', categoryId: 1 },
'Transaction error in addWatchedItem', 'Transaction error in addWatchedItem',
); );
}); });
@@ -265,7 +265,7 @@ describe('Personalization DB Service', () => {
vi.mocked(withTransaction).mockRejectedValue(dbError); vi.mocked(withTransaction).mockRejectedValue(dbError);
await expect( await expect(
personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce', mockLogger), personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 1, mockLogger),
).rejects.toThrow('The specified user or category does not exist.'); ).rejects.toThrow('The specified user or category does not exist.');
}); });
}); });

View File

@@ -166,25 +166,24 @@ export class PersonalizationRepository {
* This method should be wrapped in a transaction by the calling service if other operations depend on it. * This method should be wrapped in a transaction by the calling service if other operations depend on it.
* @param userId The UUID of the user. * @param userId The UUID of the user.
* @param itemName The name of the item to watch. * @param itemName The name of the item to watch.
* @param categoryName The category of the item. * @param categoryId The category ID of the item.
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist. * @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
*/ */
async addWatchedItem( async addWatchedItem(
userId: string, userId: string,
itemName: string, itemName: string,
categoryName: string, categoryId: number,
logger: Logger, logger: Logger,
): Promise<MasterGroceryItem> { ): Promise<MasterGroceryItem> {
try { try {
return await withTransaction(async (client) => { return await withTransaction(async (client) => {
// Find category ID // Verify category exists
const categoryRes = await client.query<{ category_id: number }>( const categoryRes = await client.query<{ category_id: number }>(
'SELECT category_id FROM public.categories WHERE name = $1', 'SELECT category_id FROM public.categories WHERE category_id = $1',
[categoryName], [categoryId],
); );
const categoryId = categoryRes.rows[0]?.category_id; if (categoryRes.rows.length === 0) {
if (!categoryId) { throw new Error(`Category with ID ${categoryId} not found.`);
throw new Error(`Category '${categoryName}' not found.`);
} }
// Find or create master item // Find or create master item
@@ -216,7 +215,7 @@ export class PersonalizationRepository {
error, error,
logger, logger,
'Transaction error in addWatchedItem', 'Transaction error in addWatchedItem',
{ userId, itemName, categoryName }, { userId, itemName, categoryId },
{ {
fkMessage: 'The specified user or category does not exist.', fkMessage: 'The specified user or category does not exist.',
uniqueMessage: 'A master grocery item with this name was created by another process.', uniqueMessage: 'A master grocery item with this name was created by another process.',

View File

@@ -94,6 +94,63 @@ describe('E2E Deals and Price Tracking Journey', () => {
}); });
it('should complete deals journey: Register -> Watch Items -> View Prices -> Check Deals', async () => { it('should complete deals journey: Register -> Watch Items -> View Prices -> Check Deals', async () => {
// Step 0: Demonstrate Category Discovery API (Phase 1 of ADR-023 migration)
// The new category endpoints allow clients to discover and validate category IDs
// before using them in other API calls. This is preparation for Phase 2, which
// will support both category names and IDs in the watched items API.
// Get all available categories
const categoriesResponse = await authedFetch('/categories', {
method: 'GET',
});
expect(categoriesResponse.status).toBe(200);
const categoriesData = await categoriesResponse.json();
expect(categoriesData.success).toBe(true);
expect(categoriesData.data.length).toBeGreaterThan(0);
// Find "Dairy & Eggs" category by name using the lookup endpoint
const categoryLookupResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
{
method: 'GET',
},
);
expect(categoryLookupResponse.status).toBe(200);
const categoryLookupData = await categoryLookupResponse.json();
expect(categoryLookupData.success).toBe(true);
expect(categoryLookupData.data.name).toBe('Dairy & Eggs');
const dairyEggsCategoryId = categoryLookupData.data.category_id;
expect(dairyEggsCategoryId).toBeGreaterThan(0);
// Verify we can retrieve the category by ID
const categoryByIdResponse = await authedFetch(`/categories/${dairyEggsCategoryId}`, {
method: 'GET',
});
expect(categoryByIdResponse.status).toBe(200);
const categoryByIdData = await categoryByIdResponse.json();
expect(categoryByIdData.success).toBe(true);
expect(categoryByIdData.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdData.data.name).toBe('Dairy & Eggs');
// Look up other category IDs we'll need
const bakeryResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
{ method: 'GET' },
);
const bakeryData = await bakeryResponse.json();
const bakeryCategoryId = bakeryData.data.category_id;
const beveragesResponse = await authedFetch('/categories/lookup?name=Beverages', {
method: 'GET',
});
const beveragesData = await beveragesResponse.json();
const beveragesCategoryId = beveragesData.data.category_id;
// NOTE: The watched items API now uses category_id (number) as of Phase 3.
// Category names are no longer accepted. Use the category discovery endpoints
// to look up category IDs before creating watched items.
// Step 1: Register a new user // Step 1: Register a new user
const registerResponse = await apiClient.registerUser( const registerResponse = await apiClient.registerUser(
userEmail, userEmail,
@@ -210,24 +267,24 @@ describe('E2E Deals and Price Tracking Journey', () => {
[flyer2Id, ...createdMasterItemIds], [flyer2Id, ...createdMasterItemIds],
); );
// Step 4: Add items to watch list // Step 4: Add items to watch list (using category_id from lookups above)
const watchItem1Response = await authedFetch('/users/watched-items', { const watchItem1Response = await authedFetch('/users/watched-items', {
method: 'POST', method: 'POST',
token: authToken, token: authToken,
body: JSON.stringify({ body: JSON.stringify({
itemName: 'E2E Milk 2%', itemName: 'E2E Milk 2%',
category: 'Dairy & Eggs', category_id: dairyEggsCategoryId,
}), }),
}); });
expect(watchItem1Response.status).toBe(201); expect(watchItem1Response.status).toBe(201);
const watchItem1Data = await watchItem1Response.json(); const watchItem1Data = await watchItem1Response.json();
expect(watchItem1Data.data.item_name).toBe('E2E Milk 2%'); expect(watchItem1Data.data.name).toBe('E2E Milk 2%');
// Add more items to watch list // Add more items to watch list
const itemsToWatch = [ const itemsToWatch = [
{ itemName: 'E2E Bread White', category: 'Bakery & Bread' }, { itemName: 'E2E Bread White', category_id: bakeryCategoryId },
{ itemName: 'E2E Coffee Beans', category: 'Beverages' }, { itemName: 'E2E Coffee Beans', category_id: beveragesCategoryId },
]; ];
for (const item of itemsToWatch) { for (const item of itemsToWatch) {
@@ -251,10 +308,10 @@ describe('E2E Deals and Price Tracking Journey', () => {
// Find our watched items // Find our watched items
const watchedMilk = watchedListData.data.find( const watchedMilk = watchedListData.data.find(
(item: { item_name: string }) => item.item_name === 'E2E Milk 2%', (item: { name: string }) => item.name === 'E2E Milk 2%',
); );
expect(watchedMilk).toBeDefined(); expect(watchedMilk).toBeDefined();
expect(watchedMilk.category).toBe('Dairy & Eggs'); expect(watchedMilk.category_id).toBe(dairyEggsCategoryId);
// Step 6: Get best prices for watched items // Step 6: Get best prices for watched items
const bestPricesResponse = await authedFetch('/users/deals/best-watched-prices', { const bestPricesResponse = await authedFetch('/users/deals/best-watched-prices', {

View File

@@ -0,0 +1,174 @@
// src/tests/integration/category.routes.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import supertest from 'supertest';
/**
* @vitest-environment node
*/
describe('Category API Routes (Integration)', () => {
let request: ReturnType<typeof supertest>;
beforeAll(async () => {
const app = (await import('../../../server')).default;
request = supertest(app);
});
describe('GET /api/categories', () => {
it('should return list of all categories', async () => {
const response = await request.get('/api/categories');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
// Verify category structure
const firstCategory = response.body.data[0];
expect(firstCategory).toHaveProperty('category_id');
expect(firstCategory).toHaveProperty('name');
expect(firstCategory).toHaveProperty('created_at');
expect(firstCategory).toHaveProperty('updated_at');
expect(typeof firstCategory.category_id).toBe('number');
expect(typeof firstCategory.name).toBe('string');
});
it('should return categories in alphabetical order', async () => {
const response = await request.get('/api/categories');
const categories = response.body.data;
// Verify alphabetical ordering
for (let i = 1; i < categories.length; i++) {
const prevName = categories[i - 1].name.toLowerCase();
const currName = categories[i].name.toLowerCase();
expect(currName >= prevName).toBe(true);
}
});
it('should include expected categories', async () => {
const response = await request.get('/api/categories');
const categories = response.body.data;
const categoryNames = categories.map((c: { name: string }) => c.name);
// Verify some expected categories exist
expect(categoryNames).toContain('Dairy & Eggs');
expect(categoryNames).toContain('Fruits & Vegetables');
expect(categoryNames).toContain('Meat & Seafood');
expect(categoryNames).toContain('Bakery & Bread');
});
});
describe('GET /api/categories/:id', () => {
it('should return specific category by valid ID', async () => {
// First get all categories to find a valid ID
const listResponse = await request.get('/api/categories');
const firstCategory = listResponse.body.data[0];
const response = await request.get(`/api/categories/${firstCategory.category_id}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.category_id).toBe(firstCategory.category_id);
expect(response.body.data.name).toBe(firstCategory.name);
});
it('should return 404 for non-existent category ID', async () => {
const response = await request.get('/api/categories/999999');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('not found');
});
it('should return 400 for invalid category ID (not a number)', async () => {
const response = await request.get('/api/categories/invalid');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
});
it('should return 400 for negative category ID', async () => {
const response = await request.get('/api/categories/-1');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
});
it('should return 400 for zero category ID', async () => {
const response = await request.get('/api/categories/0');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('Invalid category ID');
});
});
describe('GET /api/categories/lookup', () => {
it('should find category by exact name', async () => {
const response = await request.get('/api/categories/lookup?name=Dairy%20%26%20Eggs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
expect(response.body.data.category_id).toBeGreaterThan(0);
});
it('should find category by case-insensitive name', async () => {
const response = await request.get('/api/categories/lookup?name=dairy%20%26%20eggs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
});
it('should find category with mixed case', async () => {
const response = await request.get('/api/categories/lookup?name=DaIrY%20%26%20eGgS');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
});
it('should return 404 for non-existent category name', async () => {
const response = await request.get('/api/categories/lookup?name=NonExistentCategory');
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('not found');
});
it('should return 400 if name parameter is missing', async () => {
const response = await request.get('/api/categories/lookup');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
});
it('should return 400 for empty name parameter', async () => {
const response = await request.get('/api/categories/lookup?name=');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
});
it('should return 400 for whitespace-only name parameter', async () => {
const response = await request.get('/api/categories/lookup?name= ');
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('required');
});
it('should handle URL-encoded category names', async () => {
const response = await request.get('/api/categories/lookup?name=Dairy%20%26%20Eggs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe('Dairy & Eggs');
});
});
});

View File

@@ -242,11 +242,18 @@ describe('User API Routes Integration Tests', () => {
describe('User Data Routes (Watched Items & Shopping Lists)', () => { describe('User Data Routes (Watched Items & Shopping Lists)', () => {
it('should allow a user to add and remove a watched item', async () => { it('should allow a user to add and remove a watched item', async () => {
// First, look up the category ID for "Other/Miscellaneous"
const categoryResponse = await request.get(
'/api/categories/lookup?name=' + encodeURIComponent('Other/Miscellaneous'),
);
expect(categoryResponse.status).toBe(200);
const categoryId = categoryResponse.body.data.category_id;
// Act 1: Add a new watched item. The API returns the created master item. // Act 1: Add a new watched item. The API returns the created master item.
const addResponse = await request const addResponse = await request
.post('/api/users/watched-items') .post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`) .set('Authorization', `Bearer ${authToken}`)
.send({ itemName: 'Integration Test Item', category: 'Other/Miscellaneous' }); .send({ itemName: 'Integration Test Item', category_id: categoryId });
const newItem = addResponse.body.data; const newItem = addResponse.body.data;
if (newItem?.master_grocery_item_id) if (newItem?.master_grocery_item_id)