fixing categories 3rd normal form
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m34s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m34s
This commit is contained in:
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => ({
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
195
src/routes/category.routes.ts
Normal file
195
src/routes/category.routes.ts
Normal 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;
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
92
src/services/db/category.db.ts
Normal file
92
src/services/db/category.db.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
174
src/tests/integration/category.routes.test.ts
Normal file
174
src/tests/integration/category.routes.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user