diff --git a/src/components/AchievementsList.test.tsx b/src/components/AchievementsList.test.tsx new file mode 100644 index 00000000..6dbaa3e1 --- /dev/null +++ b/src/components/AchievementsList.test.tsx @@ -0,0 +1,64 @@ +// src/components/AchievementsList.test.tsx +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { AchievementsList } from './AchievementsList'; +import { Achievement, UserAchievement } from '../types'; + +const mockAchievements: (UserAchievement & Achievement)[] = [ + { + achievement_id: 1, + user_id: 'user-123', + achieved_at: '2024-01-01T00:00:00Z', + name: 'Recipe Creator', + description: 'Create your first recipe.', + icon: 'chef-hat', + points_value: 25, + }, + { + achievement_id: 2, + user_id: 'user-123', + achieved_at: '2024-02-15T00:00:00Z', + name: 'List Maker', + description: 'Create your first shopping list.', + icon: 'list', + points_value: 15, + }, + { + achievement_id: 3, + user_id: 'user-123', + achieved_at: '2024-03-10T00:00:00Z', + name: 'Unknown Achievement', + description: 'An achievement with an unmapped icon.', + icon: 'star', // This icon is not in the component's map + points_value: 5, + }, +]; + +describe('AchievementsList', () => { + it('should render the list of achievements with correct details', () => { + render(); + + expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument(); + + // Check first achievement + expect(screen.getByText('Recipe Creator')).toBeInTheDocument(); + expect(screen.getByText('Create your first recipe.')).toBeInTheDocument(); + expect(screen.getByText('+25 Points')).toBeInTheDocument(); + expect(screen.getByText('🧑‍🍳')).toBeInTheDocument(); // Icon for 'chef-hat' + + // Check second achievement + expect(screen.getByText('List Maker')).toBeInTheDocument(); + expect(screen.getByText('+15 Points')).toBeInTheDocument(); + expect(screen.getByText('📋')).toBeInTheDocument(); // Icon for 'list' + + // Check achievement with default icon + expect(screen.getByText('Unknown Achievement')).toBeInTheDocument(); + expect(screen.getByText('🏆')).toBeInTheDocument(); // Default icon + }); + + it('should render a message when there are no achievements', () => { + render(); + expect(screen.getByText('No achievements earned yet. Keep exploring to unlock them!')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/AdminRoute.test.tsx b/src/components/AdminRoute.test.tsx index d8d21cf5..6760f519 100644 --- a/src/components/AdminRoute.test.tsx +++ b/src/components/AdminRoute.test.tsx @@ -24,7 +24,7 @@ const renderWithRouter = (profile: Profile | null, initialPath: string) => { describe('AdminRoute', () => { it('should render the admin content when user has admin role', () => { - const adminProfile: Profile = { user_id: '1', role: 'admin' }; + const adminProfile: Profile = { user_id: '1', role: 'admin', points: 0 }; renderWithRouter(adminProfile, '/admin'); expect(screen.getByText('Admin Page Content')).toBeInTheDocument(); @@ -32,7 +32,7 @@ describe('AdminRoute', () => { }); it('should redirect to home page when user does not have admin role', () => { - const userProfile: Profile = { user_id: '2', role: 'user' }; + const userProfile: Profile = { user_id: '2', role: 'user', points: 0 }; renderWithRouter(userProfile, '/admin'); // The user is redirected, so we should see the home page content diff --git a/src/components/FlyerCorrectionTool.test.tsx b/src/components/FlyerCorrectionTool.test.tsx new file mode 100644 index 00000000..60bbadcd --- /dev/null +++ b/src/components/FlyerCorrectionTool.test.tsx @@ -0,0 +1,147 @@ +// src/components/FlyerCorrectionTool.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 { FlyerCorrectionTool } from './FlyerCorrectionTool'; +import * as aiApiClient from '../services/aiApiClient'; +import { notifyError, notifySuccess } from '../services/notificationService'; + +// Mock dependencies +vi.mock('../services/aiApiClient'); +vi.mock('../services/notificationService'); +vi.mock('../services/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +const mockedAiApiClient = aiApiClient as Mocked; +const mockedNotifyError = notifyError as Mocked; +const mockedNotifySuccess = notifySuccess as Mocked; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + imageUrl: 'http://example.com/flyer.jpg', + onDataExtracted: vi.fn(), +}; + +describe('FlyerCorrectionTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock global fetch for fetching the image blob + global.fetch = vi.fn(() => + Promise.resolve(new Response(new Blob(['dummy-image-content'], { type: 'image/jpeg' }))) + ) as Mocked; + + // Mock canvas methods for jsdom environment + window.HTMLCanvasElement.prototype.getContext = () => ({ + clearRect: vi.fn(), + strokeRect: vi.fn(), + setLineDash: vi.fn(), + } as any); + }); + + it('should not render when isOpen is false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render correctly when isOpen is true', () => { + render(); + expect(screen.getByRole('heading', { name: /flyer correction tool/i })).toBeInTheDocument(); + expect(screen.getByAltText('Flyer for correction')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /extract store name/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeInTheDocument(); + }); + + it('should call onClose when the close button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it('should have disabled extraction buttons initially', () => { + render(); + expect(screen.getByRole('button', { name: /extract store name/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeDisabled(); + }); + + it('should enable extraction buttons after a selection is made', () => { + render(); + const canvas = screen.getByRole('dialog').querySelector('canvas')!; + + // Simulate drawing a rectangle + fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); + fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 }); + fireEvent.mouseUp(canvas); + + expect(screen.getByRole('button', { name: /extract store name/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /extract sale dates/i })).toBeEnabled(); + }); + + it('should call rescanImageArea with correct parameters and show success', async () => { + mockedAiApiClient.rescanImageArea.mockResolvedValue(new Response(JSON.stringify({ text: 'Super Store' }))); + render(); + const canvas = screen.getByRole('dialog').querySelector('canvas')!; + const image = screen.getByAltText('Flyer for correction'); + + // Mock image dimensions for coordinate scaling + Object.defineProperty(image, 'naturalWidth', { value: 1000 }); + Object.defineProperty(image, 'naturalHeight', { value: 800 }); + Object.defineProperty(image, 'clientWidth', { value: 500 }); + Object.defineProperty(image, 'clientHeight', { value: 400 }); + + // Simulate drawing a rectangle + fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); + fireEvent.mouseMove(canvas, { clientX: 60, clientY: 30 }); + fireEvent.mouseUp(canvas); + + // Click the extract button + fireEvent.click(screen.getByRole('button', { name: /extract store name/i })); + + // Check for loading state + expect(await screen.findByText('Processing...')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledTimes(1); + // Check that coordinates were scaled correctly (e.g., 500 -> 1000 is a 2x scale) + expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledWith( + expect.any(File), + { x: 20, y: 20, width: 100, height: 40 }, // 10*2, 10*2, (60-10)*2, (30-10)*2 + 'store_name' + ); + }); + + await waitFor(() => { + expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store'); + expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store'); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + }); + + it('should show an error notification if rescan fails', async () => { + mockedAiApiClient.rescanImageArea.mockRejectedValue(new Error('AI failed')); + render(); + const canvas = screen.getByRole('dialog').querySelector('canvas')!; + + // Draw a selection to enable the button + fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 }); + fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 }); + fireEvent.mouseUp(canvas); + + fireEvent.click(screen.getByRole('button', { name: /extract sale dates/i })); + + await waitFor(() => { + expect(mockedNotifyError).toHaveBeenCalledWith('AI failed'); + }); + }); + + it('should show an error if trying to extract without a selection', () => { + render(); + // Buttons are disabled, but we can simulate a click for robustness + fireEvent.click(screen.getByRole('button', { name: /extract store name/i })); + expect(mockedNotifyError).toHaveBeenCalledWith('Please select an area on the image first.'); + }); +}); diff --git a/src/pages/MyDealsPage.test.tsx b/src/pages/MyDealsPage.test.tsx new file mode 100644 index 00000000..066e0986 --- /dev/null +++ b/src/pages/MyDealsPage.test.tsx @@ -0,0 +1,94 @@ +// src/components/MyDealsPage.test.tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import MyDealsPage from './MyDealsPage'; +import * as apiClient from '../services/apiClient'; +import { WatchedItemDeal } from '../types'; + +// Mock the apiClient. The component uses a local `fetchBestSalePrices` which calls `apiFetch`. +vi.mock('../services/apiClient'); +const mockedApiClient = apiClient as Mocked; + +// Mock the logger +vi.mock('../services/logger', () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock lucide-react icons to prevent rendering errors in the test environment +vi.mock('lucide-react', () => ({ + AlertCircle: () =>
, + Tag: () =>
, + Store: () =>
, + Calendar: () =>
, +})); + +describe('MyDealsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should display a loading message initially', () => { + // Mock a pending promise + mockedApiClient.apiFetch.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading your deals...')).toBeInTheDocument(); + }); + + it('should display an error message if the API call fails', async () => { + mockedApiClient.apiFetch.mockResolvedValue(new Response(null, { status: 500, statusText: 'Server Error' })); + render(); + + await waitFor(() => { + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Failed to fetch deals. Please try again later.')).toBeInTheDocument(); + }); + }); + + it('should display a message when no deals are found', async () => { + mockedApiClient.apiFetch.mockResolvedValue(new Response(JSON.stringify([]), { + headers: { 'Content-Type': 'application/json' }, + })); + render(); + + await waitFor(() => { + expect(screen.getByText('No deals found for your watched items right now.')).toBeInTheDocument(); + }); + }); + + it('should render the list of deals on successful fetch', async () => { + const mockDeals: WatchedItemDeal[] = [ + { + master_item_id: 1, + item_name: 'Organic Bananas', + best_price_in_cents: 99, + store_name: 'Green Grocer', + flyer_id: 101, + valid_to: '2024-10-20', + }, + { + master_item_id: 2, + item_name: 'Almond Milk', + best_price_in_cents: 349, + store_name: 'SuperMart', + flyer_id: 102, + valid_to: '2024-10-22', + }, + ]; + mockedApiClient.apiFetch.mockResolvedValue(new Response(JSON.stringify(mockDeals), { + headers: { 'Content-Type': 'application/json' }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText('Organic Bananas')).toBeInTheDocument(); + expect(screen.getByText('$0.99')).toBeInTheDocument(); + expect(screen.getByText('Almond Milk')).toBeInTheDocument(); + expect(screen.getByText('$3.49')).toBeInTheDocument(); + expect(screen.getByText('Green Grocer')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/MyDealsPage.tsx b/src/pages/MyDealsPage.tsx similarity index 100% rename from src/components/MyDealsPage.tsx rename to src/pages/MyDealsPage.tsx diff --git a/src/pages/UserProfilePage.test.tsx b/src/pages/UserProfilePage.test.tsx new file mode 100644 index 00000000..3a5b214e --- /dev/null +++ b/src/pages/UserProfilePage.test.tsx @@ -0,0 +1,163 @@ +// src/pages/UserProfilePage.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 UserProfilePage from './UserProfilePage'; +import * as apiClient from '../services/apiClient'; +import { UserProfile, Achievement, UserAchievement } from '../types'; + +// Mock dependencies +vi.mock('../services/apiClient'); +vi.mock('../services/logger'); +vi.mock('../services/notificationService'); +vi.mock('../components/AchievementsList', () => ({ + AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => ( +
+ Achievements Count: {achievements.length} +
+ ), +})); + +const mockedApiClient = apiClient as Mocked; + +// --- Mock Data --- +const mockProfile: UserProfile = { + user_id: 'user-123', + user: { user_id: 'user-123', email: 'test@example.com' }, + full_name: 'Test User', + avatar_url: 'http://example.com/avatar.jpg', + points: 150, + role: 'user', +}; + +const mockAchievements: (UserAchievement & Achievement)[] = [ + { + achievement_id: 1, + user_id: 'user-123', + achieved_at: '2024-01-01T00:00:00Z', + name: 'First Steps', + description: 'Uploaded first flyer.', + icon: 'upload', + points_value: 10, + }, +]; + +describe('UserProfilePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should display a loading message initially', () => { + mockedApiClient.getAuthenticatedUserProfile.mockReturnValue(new Promise(() => {})); + mockedApiClient.getUserAchievements.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Loading profile...')).toBeInTheDocument(); + }); + + it('should display an error message if fetching profile fails', async () => { + mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Network Error')); + mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements))); + render(); + + await waitFor(() => { + expect(screen.getByText('Error: Network Error')).toBeInTheDocument(); + }); + }); + + it('should display an error message if fetching achievements fails', async () => { + mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile))); + mockedApiClient.getUserAchievements.mockRejectedValue(new Error('Achievements service down')); + render(); + + await waitFor(() => { + expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument(); + }); + }); + + it('should render the profile and achievements on successful fetch', async () => { + mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile))); + mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements))); + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument(); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + expect(screen.getByText('150 Points')).toBeInTheDocument(); + expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', mockProfile.avatar_url); + expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent('Achievements Count: 1'); + }); + }); + + describe('Name Editing', () => { + beforeEach(() => { + mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile))); + mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements))); + }); + + it('should allow editing and saving the user name', async () => { + const updatedProfile = { ...mockProfile, full_name: 'Updated Name' }; + mockedApiClient.updateUserProfile.mockResolvedValue(new Response(JSON.stringify(updatedProfile))); + render(); + + // Wait for initial render + await screen.findByText('Test User'); + + // Click edit button + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + + // Change name and save + const nameInput = screen.getByRole('textbox'); + fireEvent.change(nameInput, { target: { value: 'Updated Name' } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({ full_name: 'Updated Name' }); + expect(screen.getByRole('heading', { name: 'Updated Name' })).toBeInTheDocument(); + }); + }); + + it('should allow canceling the name edit', async () => { + render(); + await screen.findByText('Test User'); + + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument(); + }); + }); + + describe('Avatar Upload', () => { + beforeEach(() => { + mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile))); + mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements))); + }); + + it('should upload a new avatar and update the image source', async () => { + const updatedProfile = { ...mockProfile, avatar_url: 'http://example.com/new-avatar.png' }; + mockedApiClient.uploadAvatar.mockResolvedValue(new Response(JSON.stringify(updatedProfile))); + render(); + + await screen.findByAltText('User Avatar'); + + // Mock the hidden file input + const fileInput = screen.getByTestId('avatar-file-input'); + const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' }); + + // Simulate file selection + Object.defineProperty(fileInput, 'files', { + value: [file], + }); + fireEvent.change(fileInput); + + // Check for loading state + expect(await screen.findByTestId('avatar-upload-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(file); + expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', updatedProfile.avatar_url); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/components/UserProfilePage.tsx b/src/pages/UserProfilePage.tsx similarity index 94% rename from src/components/UserProfilePage.tsx rename to src/pages/UserProfilePage.tsx index ee2334c9..3441f7ba 100644 --- a/src/components/UserProfilePage.tsx +++ b/src/pages/UserProfilePage.tsx @@ -3,7 +3,7 @@ import * as apiClient from '../services/apiClient'; import { UserProfile, Achievement, UserAchievement } from '../types'; import { logger } from '../services/logger'; import { notifySuccess, notifyError } from '../services/notificationService'; -import { AchievementsList } from './AchievementsList'; +import { AchievementsList } from '../components/AchievementsList'; const UserProfilePage: React.FC = () => { const [profile, setProfile] = useState(null); @@ -121,13 +121,14 @@ const UserProfilePage: React.FC = () => {
Change
- {isUploading &&
} + {isUploading &&
}
diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index a520b265..c748508d 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -230,7 +230,7 @@ describe('Admin Routes (/api/admin)', () => { it('should approve a correction and return a success message', async () => { // Arrange const correctionId = 123; - mockedDb.approveCorrection.mockResolvedValue(); // Mock the DB call to succeed + mockedDb.approveCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed // Act const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`); @@ -269,7 +269,7 @@ describe('Admin Routes (/api/admin)', () => { it('should reject a correction and return a success message', async () => { // Arrange const correctionId = 789; - mockedDb.rejectCorrection.mockResolvedValue(); // Mock the DB call to succeed + mockedDb.rejectCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed // Act const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`); @@ -352,7 +352,7 @@ describe('Admin Routes (/api/admin)', () => { it('should upload a logo and update the brand', async () => { // Arrange const brandId = 55; - mockedDb.updateBrandLogo.mockResolvedValue(); // Mock the DB call + mockedDb.updateBrandLogo.mockResolvedValue(undefined); // Mock the DB call // Create a dummy file for supertest to attach. // supertest needs a real file path to stream from. diff --git a/src/routes/auth.test.ts b/src/routes/auth.test.ts index 39cd9b09..55e57e3a 100644 --- a/src/routes/auth.test.ts +++ b/src/routes/auth.test.ts @@ -214,7 +214,7 @@ describe('Auth Routes (/api/auth)', () => { it('should send a reset link if the user exists', async () => { // Arrange const mockUser = { user_id: 'user-123', email: 'test@test.com' }; - mockedDb.findUserByEmail.mockResolvedValue(mockUser as any); + mockedDb.findUserByEmail.mockResolvedValue(mockUser as Awaited>); mockedDb.createPasswordResetToken.mockResolvedValue(); // Act @@ -251,8 +251,8 @@ describe('Auth Routes (/api/auth)', () => { it('should reset the password with a valid token and strong password', async () => { // Arrange const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token' }; - mockedDb.getValidResetTokens.mockResolvedValue([tokenRecord] as any); - (bcrypt.compare as Mocked).mockResolvedValue(true); // Mock that the token matches the hash + mockedDb.getValidResetTokens.mockResolvedValue([tokenRecord] as Awaited>); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Mock that the token matches the hash mockedDb.updateUserPassword.mockResolvedValue(); mockedDb.deleteResetToken.mockResolvedValue(); @@ -270,8 +270,8 @@ describe('Auth Routes (/api/auth)', () => { it('should reject with an invalid or expired token', async () => { // Arrange: Mock that the token does not match any valid hashes. - mockedDb.getValidResetTokens.mockResolvedValue([{ user_id: 'user-123', token_hash: 'hashed-token' }] as any); - (bcrypt.compare as Mocked).mockResolvedValue(false); + mockedDb.getValidResetTokens.mockResolvedValue([{ user_id: 'user-123', token_hash: 'hashed-token' } as Awaited>[0]]); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); // Act const response = await supertest(app) @@ -288,7 +288,7 @@ describe('Auth Routes (/api/auth)', () => { it('should issue a new access token with a valid refresh token cookie', async () => { // Arrange const mockUser = { user_id: 'user-123', email: 'test@test.com' }; - mockedDb.findUserByRefreshToken.mockResolvedValue(mockUser as any); + mockedDb.findUserByRefreshToken.mockResolvedValue(mockUser); // Act const response = await supertest(app) diff --git a/src/routes/public.routes.test.ts b/src/routes/public.routes.test.ts index 3a8f8037..e4c1e4c0 100644 --- a/src/routes/public.routes.test.ts +++ b/src/routes/public.routes.test.ts @@ -64,14 +64,14 @@ describe('Public Routes (/api)', () => { describe('GET /health/storage', () => { it('should return 200 OK if storage is writable', async () => { - (mockedFs.access as Mock).mockResolvedValue(undefined); + vi.mocked(mockedFs.access).mockResolvedValue(undefined); const response = await supertest(app).get('/api/health/storage'); expect(response.status).toBe(200); expect(response.body.success).toBe(true); }); it('should return 500 if storage is not accessible', async () => { - (mockedFs.access as Mock).mockRejectedValue(new Error('Permission denied')); + vi.mocked(mockedFs.access).mockRejectedValue(new Error('Permission denied')); const response = await supertest(app).get('/api/health/storage'); expect(response.status).toBe(500); expect(response.body.success).toBe(false); diff --git a/src/routes/system.test.ts b/src/routes/system.test.ts index 4cb83587..2a2daf6d 100644 --- a/src/routes/system.test.ts +++ b/src/routes/system.test.ts @@ -42,7 +42,7 @@ describe('System Routes (/api/system)', () => { └───────────┴───────────┘ `; // The `exec` callback receives (error, stdout, stderr). For success, error is null. - (mockedExec as Mock).mockImplementation(( + vi.mocked(mockedExec).mockImplementation(( command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void ) => { @@ -63,7 +63,7 @@ describe('System Routes (/api/system)', () => { const pm2StoppedOutput = ` │ status │ stopped │ `; - (mockedExec as Mock).mockImplementation(( + vi.mocked(mockedExec).mockImplementation(( command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void ) => { @@ -82,7 +82,7 @@ describe('System Routes (/api/system)', () => { it('should return success: false when pm2 process does not exist', async () => { // Arrange: Simulate the error and stdout when a process is not found. const processNotFoundOutput = "[PM2][ERROR] Process or Namespace flyer-crawler-api doesn't exist"; - (mockedExec as Mock).mockImplementation(( + vi.mocked(mockedExec).mockImplementation(( command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void ) => { @@ -100,7 +100,7 @@ describe('System Routes (/api/system)', () => { it('should return 500 on a generic exec error', async () => { // Arrange: Simulate a generic failure of the `exec` command. - (mockedExec as Mock).mockImplementation(( + vi.mocked(mockedExec).mockImplementation(( command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void ) => { @@ -120,7 +120,7 @@ describe('System Routes (/api/system)', () => { // Arrange: Simulate a scenario where the command writes to stderr but doesn't // produce a formal error object for the callback's first argument. const stderrMessage = 'A non-fatal warning or configuration issue.'; - (mockedExec as Mock).mockImplementation(( + vi.mocked(mockedExec).mockImplementation(( command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void ) => { diff --git a/src/routes/user.test.ts b/src/routes/user.test.ts index 8103cf5c..a95f8150 100644 --- a/src/routes/user.test.ts +++ b/src/routes/user.test.ts @@ -417,7 +417,7 @@ describe('User Routes (/api/users)', () => { }); it('PUT should successfully set the appliances', async () => { - (mockedDb.setUserAppliances as Mocked).mockResolvedValue([]); + vi.mocked(mockedDb.setUserAppliances).mockResolvedValue([]); const applianceIds = [2, 4, 6]; const response = await supertest(app).put('/api/users/me/appliances').send({ applianceIds }); expect(response.status).toBe(204); diff --git a/src/services/aiService.server.test.ts b/src/services/aiService.server.test.ts index 5f860253..b9fec21a 100644 --- a/src/services/aiService.server.test.ts +++ b/src/services/aiService.server.test.ts @@ -1,14 +1,15 @@ // src/services/aiService.server.test.ts -import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MasterGroceryItem } from '../types'; +import type { readFile as ReadFileFn } from 'fs/promises'; // Mock fs/promises const mockReadFile = vi.fn(); vi.mock('fs/promises', () => ({ default: { - readFile: (...args: any[]) => mockReadFile(...args), + readFile: (...args: Parameters) => mockReadFile(...args), }, - readFile: (...args: any[]) => mockReadFile(...args), + readFile: (...args: Parameters) => mockReadFile(...args), })); // Mock the Google GenAI library diff --git a/src/services/db/recipe.test.ts b/src/services/db/recipe.test.ts index 1e0b846d..73fb1b02 100644 --- a/src/services/db/recipe.test.ts +++ b/src/services/db/recipe.test.ts @@ -83,7 +83,7 @@ describe('Recipe DB Service', () => { describe('removeFavoriteRecipe', () => { it('should execute a DELETE query', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockQuery.mockResolvedValue({ rowCount: 1 }); await removeFavoriteRecipe('user-123', 1); expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]); }); @@ -111,7 +111,7 @@ describe('Recipe DB Service', () => { describe('forkRecipe', () => { it('should call the fork_recipe database function', async () => { - const mockRecipe: Recipe = { recipe_id: 2, name: 'Forked Recipe', avg_rating: 0, rating_count: 0, status: 'private', created_at: new Date().toISOString() }; + const mockRecipe: Recipe = { recipe_id: 2, name: 'Forked Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'private', created_at: new Date().toISOString() }; mockQuery.mockResolvedValue({ rows: [mockRecipe] }); const result = await forkRecipe('user-123', 1); diff --git a/src/services/db/shopping.test.ts b/src/services/db/shopping.test.ts index 0d5cd88c..f89225a8 100644 --- a/src/services/db/shopping.test.ts +++ b/src/services/db/shopping.test.ts @@ -69,7 +69,7 @@ describe('Shopping DB Service', () => { describe('deleteShoppingList', () => { it('should execute a DELETE query with user ownership check', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockQuery.mockResolvedValue({ rowCount: 1 }); await deleteShoppingList(1, 'user-123'); expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [1, 'user-123']); }); @@ -89,7 +89,7 @@ describe('Shopping DB Service', () => { describe('removeShoppingListItem', () => { it('should execute a DELETE query', async () => { - mockQuery.mockResolvedValue({ rows: [] }); + mockQuery.mockResolvedValue({ rowCount: 1 }); await removeShoppingListItem(101); expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [101]); }); diff --git a/src/services/db/user.test.ts b/src/services/db/user.test.ts index de059729..a237590b 100644 --- a/src/services/db/user.test.ts +++ b/src/services/db/user.test.ts @@ -23,7 +23,7 @@ import { logSearchQuery, } from './user'; import { mockQuery, mockConnect } from '../../tests/setup/mock-db'; -import type { Profile, User } from '../../types'; +import type { Profile } from '../../types'; // Mock other db services that are used by functions in user.ts vi.mock('./shopping', () => ({ @@ -92,7 +92,7 @@ describe('User DB Service', () => { describe('updateUserProfile', () => { it('should execute an UPDATE query for the user profile', async () => { - const mockProfile: Profile = { user_id: '123', full_name: 'Updated Name', role: 'user' }; + const mockProfile: Profile = { user_id: '123', full_name: 'Updated Name', role: 'user', points: 0 }; mockQuery.mockResolvedValue({ rows: [mockProfile] }); await updateUserProfile('123', { full_name: 'Updated Name' }); diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 9a6997e3..ffd0caef 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -2,10 +2,8 @@ import toast, { ToastOptions } from 'react-hot-toast'; console.log('[NotificationService] Module loaded.'); -// eslint-disable-next-line @typescript-eslint/no-explicit-any console.log('[NotificationService] Imported toast object type:', typeof toast); console.log('[NotificationService] Imported toast keys:', Object.keys(toast || {})); -// eslint-disable-next-line @typescript-eslint/no-explicit-any if (toast && (toast as any).default) { console.log('[NotificationService] Has .default property:', Object.keys((toast as any).default)); } @@ -38,10 +36,8 @@ export const notifySuccess = (message: string) => { if (typeof toast.success !== 'function') { console.error('[NotificationService] CRITICAL: toast.success is not a function. It is:', typeof toast.success); // Fallback check for default property (common in CJS/ESM interop issues) - // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((toast as any).default && typeof (toast as any).default.success === 'function') { console.warn('[NotificationService] Found success on .default, using that instead.'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any (toast as any).default.success(message, { ...commonToastOptions, iconTheme: { primary: '#10B981', secondary: '#fff' } }); return; }