diff --git a/src/features/flyer/FlyerList.test.tsx b/src/features/flyer/FlyerList.test.tsx
index a1cc765e..f3599e48 100644
--- a/src/features/flyer/FlyerList.test.tsx
+++ b/src/features/flyer/FlyerList.test.tsx
@@ -1,7 +1,7 @@
// src/features/flyer/FlyerList.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import { FlyerList } from './FlyerList';
import { formatShortDate } from './dateUtils';
import type { Flyer, UserProfile } from '../../types';
@@ -257,6 +257,73 @@ describe('FlyerList', () => {
});
});
+ describe('Expiration Status Logic', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should show "Expired" for past dates', () => {
+ // Flyer 1 valid_to is 2023-10-11
+ vi.setSystemTime(new Date('2023-10-12T12:00:00Z'));
+ render(
+ ,
+ );
+ expect(screen.getByText('Expired')).toBeInTheDocument();
+ expect(screen.getByText('Expired')).toHaveClass('text-red-500');
+ });
+
+ it('should show "Expires today" when valid_to is today', () => {
+ vi.setSystemTime(new Date('2023-10-11T12:00:00Z'));
+ render(
+ ,
+ );
+ expect(screen.getByText('Expires today')).toBeInTheDocument();
+ expect(screen.getByText('Expires today')).toHaveClass('text-orange-500');
+ });
+
+ it('should show "Expires in X days" (orange) for <= 3 days', () => {
+ vi.setSystemTime(new Date('2023-10-09T12:00:00Z')); // 2 days left
+ render(
+ ,
+ );
+ expect(screen.getByText('Expires in 2 days')).toBeInTheDocument();
+ expect(screen.getByText('Expires in 2 days')).toHaveClass('text-orange-500');
+ });
+
+ it('should show "Expires in X days" (green) for > 3 days', () => {
+ vi.setSystemTime(new Date('2023-10-05T12:00:00Z')); // 6 days left
+ render(
+ ,
+ );
+ expect(screen.getByText('Expires in 6 days')).toBeInTheDocument();
+ expect(screen.getByText('Expires in 6 days')).toHaveClass('text-green-600');
+ });
+ });
+
describe('Admin Functionality', () => {
const adminProfile: UserProfile = createMockUserProfile({
user: { user_id: 'admin-1', email: 'admin@example.com' },
diff --git a/src/features/flyer/FlyerUploader.test.tsx b/src/features/flyer/FlyerUploader.test.tsx
index 16af09c7..57d82f09 100644
--- a/src/features/flyer/FlyerUploader.test.tsx
+++ b/src/features/flyer/FlyerUploader.test.tsx
@@ -223,14 +223,10 @@ describe('FlyerUploader', () => {
it('should handle a failed job', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' });
- mockedAiApiClient.getJobStatus.mockResolvedValue({
- state: 'failed',
- progress: {
- errorCode: 'UNKNOWN_ERROR',
- message: 'AI model exploded',
- },
- failedReason: 'This is the raw error message.', // The UI should prefer the progress message.
- });
+ // The getJobStatus function throws a specific error when the job fails,
+ // which is then caught by react-query and placed in the `error` state.
+ const jobFailedError = new aiApiClientModule.JobFailedError('AI model exploded', 'UNKNOWN_ERROR');
+ mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError);
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
renderComponent();
@@ -243,7 +239,8 @@ describe('FlyerUploader', () => {
try {
console.log('--- [TEST LOG] ---: 4. AWAITING failure message...');
- expect(await screen.findByText(/Processing failed: AI model exploded/i)).toBeInTheDocument();
+ // The UI should now display the error from the `pollError` state, which includes the "Polling failed" prefix.
+ expect(await screen.findByText(/Polling failed: AI model exploded/i)).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 5. SUCCESS: Failure message found.');
} catch (error) {
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for failure message timed out.');
@@ -262,13 +259,10 @@ describe('FlyerUploader', () => {
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' });
// We need at least one 'active' response to establish a timeout loop so we have something to clear
+ // The second call should be a rejection, as this is how getJobStatus signals a failure.
mockedAiApiClient.getJobStatus
.mockResolvedValueOnce({ state: 'active', progress: { message: 'Working...' } })
- .mockResolvedValueOnce({
- state: 'failed',
- progress: { errorCode: 'UNKNOWN_ERROR', message: 'Fatal Error' },
- failedReason: 'Fatal Error',
- });
+ .mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR'));
renderComponent();
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
@@ -280,7 +274,7 @@ describe('FlyerUploader', () => {
await screen.findByText('Working...');
// Wait for the failure UI
- await waitFor(() => expect(screen.getByText(/Processing failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
+ await waitFor(() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
// Verify clearTimeout was called
expect(clearTimeoutSpy).toHaveBeenCalled();
diff --git a/src/features/shopping/ShoppingList.test.tsx b/src/features/shopping/ShoppingList.test.tsx
index fe9aff00..af60e781 100644
--- a/src/features/shopping/ShoppingList.test.tsx
+++ b/src/features/shopping/ShoppingList.test.tsx
@@ -236,6 +236,24 @@ describe('ShoppingListComponent (in shopping feature)', () => {
alertSpy.mockRestore();
});
+ it('should show a generic alert if reading aloud fails with a non-Error object', async () => {
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
+ vi.spyOn(aiApiClient, 'generateSpeechFromText').mockRejectedValue('A string error');
+
+ render();
+ const readAloudButton = screen.getByTitle(/read list aloud/i);
+
+ fireEvent.click(readAloudButton);
+
+ await waitFor(() => {
+ expect(alertSpy).toHaveBeenCalledWith(
+ 'Could not read list aloud: An unknown error occurred while generating audio.',
+ );
+ });
+
+ alertSpy.mockRestore();
+ });
+
it('should handle interactions with purchased items', () => {
render();
diff --git a/src/features/shopping/ShoppingList.tsx b/src/features/shopping/ShoppingList.tsx
index f1aaad8e..51420618 100644
--- a/src/features/shopping/ShoppingList.tsx
+++ b/src/features/shopping/ShoppingList.tsx
@@ -1,5 +1,5 @@
// src/features/shopping/ShoppingList.tsx
-import React, { useState, useMemo, useCallback, useEffect } from 'react';
+import React, { useState, useMemo, useCallback } from 'react';
import type { ShoppingList, ShoppingListItem, User } from '../../types';
import { UserIcon } from '../../components/icons/UserIcon';
import { ListBulletIcon } from '../../components/icons/ListBulletIcon';
@@ -56,28 +56,6 @@ export const ShoppingListComponent: React.FC = ({
return { neededItems, purchasedItems };
}, [activeList]);
- useEffect(() => {
- if (activeList) {
- console.log('ShoppingList Debug: Active List:', activeList.name);
- console.log(
- 'ShoppingList Debug: Needed Items:',
- neededItems.map((i) => ({
- id: i.shopping_list_item_id,
- name: i.custom_item_name || i.master_item?.name,
- raw: i,
- })),
- );
- console.log(
- 'ShoppingList Debug: Purchased Items:',
- purchasedItems.map((i) => ({
- id: i.shopping_list_item_id,
- name: i.custom_item_name || i.master_item?.name,
- raw: i,
- })),
- );
- }
- }, [activeList, neededItems, purchasedItems]);
-
const handleCreateList = async () => {
const name = prompt('Enter a name for your new shopping list:');
if (name && name.trim()) {
diff --git a/src/features/shopping/WatchedItemsList.test.tsx b/src/features/shopping/WatchedItemsList.test.tsx
index 42c14484..57cfd0f0 100644
--- a/src/features/shopping/WatchedItemsList.test.tsx
+++ b/src/features/shopping/WatchedItemsList.test.tsx
@@ -164,6 +164,15 @@ describe('WatchedItemsList (in shopping feature)', () => {
expect(itemsDesc[1]).toHaveTextContent('Eggs');
expect(itemsDesc[2]).toHaveTextContent('Bread');
expect(itemsDesc[3]).toHaveTextContent('Apples');
+
+ // Click again to sort ascending
+ fireEvent.click(sortButton);
+
+ const itemsAscAgain = screen.getAllByRole('listitem');
+ expect(itemsAscAgain[0]).toHaveTextContent('Apples');
+ expect(itemsAscAgain[1]).toHaveTextContent('Bread');
+ expect(itemsAscAgain[2]).toHaveTextContent('Eggs');
+ expect(itemsAscAgain[3]).toHaveTextContent('Milk');
});
it('should call onAddItemToList when plus icon is clicked', () => {
@@ -222,6 +231,18 @@ describe('WatchedItemsList (in shopping feature)', () => {
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
expect(addButton).toBeDisabled();
});
+
+ it('should not submit if form is submitted with invalid data', () => {
+ render();
+ const nameInput = screen.getByPlaceholderText(/add item/i);
+ const form = nameInput.closest('form')!;
+ const categorySelect = screen.getByDisplayValue('Select a category');
+ fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
+
+ fireEvent.change(nameInput, { target: { value: ' ' } });
+ fireEvent.submit(form);
+ expect(mockOnAddItem).not.toHaveBeenCalled();
+ });
});
describe('Error Handling', () => {
diff --git a/src/hooks/useFlyerUploader.ts b/src/hooks/useFlyerUploader.ts
index ea9723fb..8d02ac60 100644
--- a/src/hooks/useFlyerUploader.ts
+++ b/src/hooks/useFlyerUploader.ts
@@ -44,11 +44,16 @@ export const useFlyerUploader = () => {
enabled: !!jobId,
// Polling logic: react-query handles the interval
refetchInterval: (query) => {
- const data = query.state.data;
+ const data = query.state.data as JobStatus | undefined;
// Stop polling if the job is completed or has failed
if (data?.state === 'completed' || data?.state === 'failed') {
return false;
}
+ // Also stop polling if the query itself has errored (e.g. network error, or JobFailedError thrown from getJobStatus)
+ if (query.state.status === 'error') {
+ logger.warn('[useFlyerUploader] Polling stopped due to query error state.');
+ return false;
+ }
// Otherwise, poll every 3 seconds
return 3000;
},
diff --git a/src/hooks/useShoppingLists.test.tsx b/src/hooks/useShoppingLists.test.tsx
index fb6f7b46..5ab03cc7 100644
--- a/src/hooks/useShoppingLists.test.tsx
+++ b/src/hooks/useShoppingLists.test.tsx
@@ -495,6 +495,22 @@ describe('useShoppingLists Hook', () => {
expect(currentLists[0].items).toHaveLength(1); // Length should remain 1
console.log(' LOG: SUCCESS! Duplicate was not added and API was not called.');
});
+
+ it('should log an error and not call the API if the listId does not exist', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { result } = renderHook(() => useShoppingLists());
+
+ await act(async () => {
+ // Call with a non-existent list ID (mock lists have IDs 1 and 2)
+ await result.current.addItemToList(999, { customItemName: 'Wont be added' });
+ });
+
+ // The API should not have been called because the list was not found.
+ expect(mockAddItemApi).not.toHaveBeenCalled();
+ expect(consoleErrorSpy).toHaveBeenCalledWith('useShoppingLists: List with ID 999 not found.');
+
+ consoleErrorSpy.mockRestore();
+ });
});
describe('updateItemInList', () => {
@@ -689,11 +705,21 @@ describe('useShoppingLists Hook', () => {
setupApiMocks(apiMocksWithError);
apiMock.mockRejectedValue(new Error(errorMessage));
+ // Spy on console.error to ensure the catch block is executed for logging
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
const { result } = renderHook(() => useShoppingLists());
await act(async () => {
await action(result.current);
});
- await waitFor(() => expect(result.current.error).toBe(errorMessage));
+
+ await waitFor(() => {
+ expect(result.current.error).toBe(errorMessage);
+ // Verify that our custom logging within the catch block was called
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+
+ consoleErrorSpy.mockRestore();
},
);
});
diff --git a/src/middleware/multer.middleware.test.ts b/src/middleware/multer.middleware.test.ts
index 6d4a930a..dd8903ae 100644
--- a/src/middleware/multer.middleware.test.ts
+++ b/src/middleware/multer.middleware.test.ts
@@ -1,5 +1,9 @@
// src/middleware/multer.middleware.test.ts
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import multer from 'multer';
+import type { Request, Response, NextFunction } from 'express';
+import { createUploadMiddleware, handleMulterError } from './multer.middleware';
+import { createMockUserProfile } from '../tests/utils/mockFactories';
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
const mocks = vi.hoisted(() => ({
@@ -28,10 +32,12 @@ vi.mock('../services/logger.server', () => ({
// 4. Mock multer to prevent it from doing anything during import.
vi.mock('multer', () => ({
default: vi.fn(() => ({
- single: vi.fn(),
- array: vi.fn(),
+ single: vi.fn().mockImplementation(() => (req: Request, res: Response, next: NextFunction) => next()),
+ array: vi.fn().mockImplementation(() => (req: Request, res: Response, next: NextFunction) => next()),
})),
- diskStorage: vi.fn(),
+ // We mock diskStorage to capture the options passed to it, but let it return a plain object.
+ // The actual storage logic is what we want to test.
+ diskStorage: vi.fn((options) => options),
}));
describe('Multer Middleware Directory Creation', () => {
@@ -71,4 +77,163 @@ describe('Multer Middleware Directory Creation', () => {
'Failed to create multer storage directories on startup.',
);
});
+});
+
+describe('createUploadMiddleware', () => {
+ const mockFile = { originalname: 'test.png' } as Express.Multer.File;
+ const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@user.com' } });
+ let originalNodeEnv: string | undefined;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ originalNodeEnv = process.env.NODE_ENV;
+ });
+
+ afterEach(() => {
+ process.env.NODE_ENV = originalNodeEnv;
+ });
+
+ describe('Avatar Storage', () => {
+ it('should generate a unique filename for an authenticated user', () => {
+ createUploadMiddleware({ storageType: 'avatar' });
+ const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockReq = { user: mockUser } as unknown as Request;
+
+ storageOptions.filename!(mockReq, mockFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(null, expect.stringContaining('user-123-'));
+ expect(cb).toHaveBeenCalledWith(null, expect.stringContaining('.png'));
+ });
+
+ it('should call the callback with an error for an unauthenticated user', () => {
+ // This test covers line 37
+ createUploadMiddleware({ storageType: 'avatar' });
+ const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockReq = {} as Request; // No user on request
+
+ storageOptions.filename!(mockReq, mockFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(
+ new Error('User not authenticated for avatar upload'),
+ expect.any(String),
+ );
+ });
+
+ it('should use a predictable filename in test environment', () => {
+ process.env.NODE_ENV = 'test';
+ createUploadMiddleware({ storageType: 'avatar' });
+ const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockReq = { user: mockUser } as unknown as Request;
+
+ storageOptions.filename!(mockReq, mockFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(null, 'test-avatar.png');
+ });
+ });
+
+ describe('Flyer Storage', () => {
+ it('should generate a unique, sanitized filename in production environment', () => {
+ process.env.NODE_ENV = 'production';
+ const mockFlyerFile = {
+ fieldname: 'flyerFile',
+ originalname: 'My Flyer (Special!).pdf',
+ } as Express.Multer.File;
+ createUploadMiddleware({ storageType: 'flyer' });
+ const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockReq = {} as Request;
+
+ storageOptions.filename!(mockReq, mockFlyerFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(
+ null,
+ expect.stringMatching(/^flyerFile-\d+-\d+-my-flyer-special.pdf$/),
+ );
+ });
+
+ it('should generate a predictable filename in test environment', () => {
+ // This test covers lines 43-46
+ process.env.NODE_ENV = 'test';
+ const mockFlyerFile = {
+ fieldname: 'flyerFile',
+ originalname: 'test-flyer.jpg',
+ } as Express.Multer.File;
+ createUploadMiddleware({ storageType: 'flyer' });
+ const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockReq = {} as Request;
+
+ storageOptions.filename!(mockReq, mockFlyerFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(null, 'flyerFile-test-flyer-image.jpg');
+ });
+ });
+
+ describe('Image File Filter', () => {
+ it('should accept files with an image mimetype', () => {
+ createUploadMiddleware({ storageType: 'flyer', fileFilter: 'image' });
+ const multerOptions = vi.mocked(multer).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockImageFile = { mimetype: 'image/png' } as Express.Multer.File;
+
+ multerOptions!.fileFilter!({} as Request, mockImageFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(null, true);
+ });
+
+ it('should reject files without an image mimetype', () => {
+ createUploadMiddleware({ storageType: 'flyer', fileFilter: 'image' });
+ const multerOptions = vi.mocked(multer).mock.calls[0][0];
+ const cb = vi.fn();
+ const mockTextFile = { mimetype: 'text/plain' } as Express.Multer.File;
+
+ multerOptions!.fileFilter!({} as Request, mockTextFile, cb);
+
+ expect(cb).toHaveBeenCalledWith(new Error('Only image files are allowed!'));
+ });
+ });
+});
+
+describe('handleMulterError Middleware', () => {
+ let mockRequest: Partial;
+ let mockResponse: Partial;
+ let mockNext: NextFunction;
+
+ beforeEach(() => {
+ mockRequest = {};
+ mockResponse = {
+ status: vi.fn().mockReturnThis(),
+ json: vi.fn(),
+ };
+ mockNext = vi.fn();
+ });
+
+ it('should handle a MulterError (e.g., file too large)', () => {
+ const err = new multer.MulterError('LIMIT_FILE_SIZE');
+ handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
+ expect(mockResponse.status).toHaveBeenCalledWith(400);
+ expect(mockResponse.json).toHaveBeenCalledWith({
+ message: 'File upload error: File too large',
+ });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should handle the custom image file filter error', () => {
+ // This test covers lines 59-61
+ const err = new Error('Only image files are allowed!');
+ handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
+ expect(mockResponse.status).toHaveBeenCalledWith(400);
+ expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Only image files are allowed!' });
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should pass on non-multer errors to the next error handler', () => {
+ const err = new Error('A generic error');
+ handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
+ expect(mockNext).toHaveBeenCalledWith(err);
+ expect(mockResponse.status).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
diff --git a/src/pages/admin/FlyerReviewPage.test.tsx b/src/pages/admin/FlyerReviewPage.test.tsx
index 33a9a05b..218206f7 100644
--- a/src/pages/admin/FlyerReviewPage.test.tsx
+++ b/src/pages/admin/FlyerReviewPage.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerReviewPage } from './FlyerReviewPage';
import { MemoryRouter } from 'react-router-dom';
@@ -74,6 +74,13 @@ describe('FlyerReviewPage', () => {
store: { name: 'Store B' },
icon_url: 'icon2.jpg',
},
+ {
+ flyer_id: 3,
+ file_name: 'flyer3.jpg',
+ created_at: '2023-01-03T00:00:00Z',
+ store: null,
+ icon_url: null,
+ },
];
vi.mocked(apiClient.getFlyersForReview).mockResolvedValue({
@@ -95,6 +102,14 @@ describe('FlyerReviewPage', () => {
expect(screen.getByText('flyer1.jpg')).toBeInTheDocument();
expect(screen.getByText('Store B')).toBeInTheDocument();
expect(screen.getByText('flyer2.jpg')).toBeInTheDocument();
+
+ // Test fallback for null store and icon_url
+ expect(screen.getByText('Unknown Store')).toBeInTheDocument();
+ expect(screen.getByText('flyer3.jpg')).toBeInTheDocument();
+ const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
+ const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
+ expect(unknownStoreImage).toHaveAttribute('src', '');
+ expect(unknownStoreImage).not.toHaveAttribute('alt');
});
it('renders error message when API response is not ok', async () => {
@@ -140,4 +155,24 @@ describe('FlyerReviewPage', () => {
'Failed to fetch flyers for review'
);
});
+
+ it('renders a generic error for non-Error rejections', async () => {
+ const nonErrorRejection = { message: 'This is not an Error object' };
+ vi.mocked(apiClient.getFlyersForReview).mockRejectedValue(nonErrorRejection);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('An unknown error occurred while fetching data.')).toBeInTheDocument();
+ });
+
+ expect(logger.error).toHaveBeenCalledWith(
+ { err: nonErrorRejection },
+ 'Failed to fetch flyers for review',
+ );
+ });
});
\ No newline at end of file
diff --git a/src/routes/admin.content.routes.test.ts b/src/routes/admin.content.routes.test.ts
index b697f729..8120876b 100644
--- a/src/routes/admin.content.routes.test.ts
+++ b/src/routes/admin.content.routes.test.ts
@@ -7,6 +7,7 @@ import {
createMockSuggestedCorrection,
createMockBrand,
createMockRecipe,
+ createMockFlyer,
createMockRecipeComment,
createMockUnmatchedFlyerItem,
} from '../tests/utils/mockFactories';
@@ -38,9 +39,11 @@ const { mockedDb } = vi.hoisted(() => {
rejectCorrection: vi.fn(),
updateSuggestedCorrection: vi.fn(),
getUnmatchedFlyerItems: vi.fn(),
+ getFlyersForReview: vi.fn(), // Added for flyer review tests
updateRecipeStatus: vi.fn(),
updateRecipeCommentStatus: vi.fn(),
updateBrandLogo: vi.fn(),
+ getApplicationStats: vi.fn(),
},
flyerRepo: {
getAllBrands: vi.fn(),
@@ -225,6 +228,39 @@ describe('Admin Content Management Routes (/api/admin)', () => {
});
});
+ describe('Flyer Review Routes', () => {
+ it('GET /review/flyers should return flyers for review', async () => {
+ const mockFlyers = [
+ createMockFlyer({ flyer_id: 1, status: 'needs_review' }),
+ createMockFlyer({ flyer_id: 2, status: 'needs_review' }),
+ ];
+ vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockResolvedValue(mockFlyers);
+ const response = await supertest(app).get('/api/admin/review/flyers');
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockFlyers);
+ expect(vi.mocked(mockedDb.adminRepo.getFlyersForReview)).toHaveBeenCalledWith(
+ expect.anything(),
+ );
+ });
+
+ it('GET /review/flyers should return 500 on DB error', async () => {
+ vi.mocked(mockedDb.adminRepo.getFlyersForReview).mockRejectedValue(new Error('DB Error'));
+ const response = await supertest(app).get('/api/admin/review/flyers');
+ expect(response.status).toBe(500);
+ expect(response.body.message).toBe('DB Error');
+ });
+ });
+
+ describe('Stats Routes', () => {
+ // This test covers the error path for GET /stats
+ it('GET /stats should return 500 on DB error', async () => {
+ vi.mocked(mockedDb.adminRepo.getApplicationStats).mockRejectedValue(new Error('DB Error'));
+ const response = await supertest(app).get('/api/admin/stats');
+ expect(response.status).toBe(500);
+ expect(response.body.message).toBe('DB Error');
+ });
+ });
+
describe('Brand Routes', () => {
it('GET /brands should return a list of all brands', async () => {
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Brand A' })];
@@ -282,6 +318,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('logoImage-'));
});
+ it('POST /brands/:id/logo should return 400 if a non-image file is uploaded', async () => {
+ const brandId = 55;
+ const response = await supertest(app)
+ .post(`/api/admin/brands/${brandId}/logo`)
+ .attach('logoImage', Buffer.from('this is not an image'), 'document.txt');
+ expect(response.status).toBe(400);
+ // This message comes from the handleMulterError middleware for the imageFileFilter
+ expect(response.body.message).toBe('File upload error: Only image files are allowed!');
+ });
+
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
const response = await supertest(app)
.post('/api/admin/brands/abc/logo')
diff --git a/src/routes/admin.users.routes.test.ts b/src/routes/admin.users.routes.test.ts
index 3eafeded..f6f6de47 100644
--- a/src/routes/admin.users.routes.test.ts
+++ b/src/routes/admin.users.routes.test.ts
@@ -4,7 +4,7 @@ import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
import type { UserProfile, Profile } from '../types';
-import { NotFoundError } from '../services/db/errors.db';
+import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
vi.mock('../services/db/index.db', () => ({
@@ -22,6 +22,12 @@ vi.mock('../services/db/index.db', () => ({
notificationRepo: {},
}));
+vi.mock('../services/userService', () => ({
+ userService: {
+ deleteUserAsAdmin: vi.fn(),
+ },
+}));
+
// Mock other dependencies that are not directly tested but are part of the adminRouter setup
vi.mock('../services/db/flyer.db');
vi.mock('../services/db/recipe.db');
@@ -53,6 +59,7 @@ import adminRouter from './admin.routes';
// Import the mocked repos to control them in tests
import { adminRepo, userRepo } from '../services/db/index.db';
+import { userService } from '../services/userService';
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
@@ -191,22 +198,28 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
it('should successfully delete a user', async () => {
const targetId = '123e4567-e89b-12d3-a456-426614174999';
vi.mocked(userRepo.deleteUserById).mockResolvedValue(undefined);
+ vi.mocked(userService.deleteUserAsAdmin).mockResolvedValue(undefined);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(204);
expect(userRepo.deleteUserById).toHaveBeenCalledWith(targetId, expect.any(Object));
+ expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, targetId, expect.any(Object));
});
it('should prevent an admin from deleting their own account', async () => {
+ const validationError = new ValidationError([], 'Admins cannot delete their own account.');
+ vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(validationError);
const response = await supertest(app).delete(`/api/admin/users/${adminId}`);
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/Admins cannot delete their own account/);
expect(userRepo.deleteUserById).not.toHaveBeenCalled();
+ expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, adminId, expect.any(Object));
});
it('should return 500 on a generic database error', async () => {
const targetId = '123e4567-e89b-12d3-a456-426614174999';
const dbError = new Error('DB Error');
vi.mocked(userRepo.deleteUserById).mockRejectedValue(dbError);
+ vi.mocked(userService.deleteUserAsAdmin).mockRejectedValue(dbError);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(500);
});
diff --git a/src/services/aiService.server.ts b/src/services/aiService.server.ts
index 5590e217..ae138a20 100644
--- a/src/services/aiService.server.ts
+++ b/src/services/aiService.server.ts
@@ -109,7 +109,10 @@ export class AIService {
private fs: IFileSystem;
private rateLimiter: (fn: () => Promise) => Promise;
private logger: Logger;
- private readonly models = ['gemini-2.5-flash', 'gemini-3-flash', 'gemini-2.5-flash-lite'];
+ // The fallback list is ordered by preference (speed/cost vs. power).
+ // We try the fastest models first, then the more powerful 'pro' model as a high-quality fallback,
+ // and finally the 'lite' model as a last resort.
+ private readonly models = [ 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger;
@@ -230,7 +233,8 @@ export class AIService {
errorMessage.includes('quota') ||
errorMessage.includes('429') || // HTTP 429 Too Many Requests
errorMessage.includes('resource_exhausted') || // Make case-insensitive
- errorMessage.includes('model is overloaded')
+ errorMessage.includes('model is overloaded') ||
+ errorMessage.includes('not found') // Also retry if model is not found (e.g., regional availability or API version issue)
) {
this.logger.warn(
`[AIService Adapter] Model '${modelName}' failed due to quota/rate limit. Trying next model. Error: ${errorMessage}`,