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}`,