Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9228c9cb | ||
| b392b82c25 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -277,8 +277,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expired')).toHaveClass('text-red-500');
|
||||
expect(screen.getByText('• Expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('• Expired')).toHaveClass('text-red-500');
|
||||
});
|
||||
|
||||
it('should show "Expires today" when valid_to is today', () => {
|
||||
@@ -291,8 +291,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expires today')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expires today')).toHaveClass('text-orange-500');
|
||||
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', () => {
|
||||
@@ -305,8 +305,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expires in 2 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expires in 2 days')).toHaveClass('text-orange-500');
|
||||
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', () => {
|
||||
@@ -319,8 +319,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expires in 6 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expires in 6 days')).toHaveClass('text-green-600');
|
||||
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,12 +9,21 @@ import { useNavigate, MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/aiApiClient');
|
||||
vi.mock('../../services/aiApiClient', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/aiApiClient')>();
|
||||
return {
|
||||
...actual,
|
||||
uploadAndProcessFlyer: vi.fn(),
|
||||
getJobStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../services/logger.client', () => ({
|
||||
// Keep the original logger.info/error but also spy on it for test assertions if needed
|
||||
logger: {
|
||||
info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)),
|
||||
error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)),
|
||||
warn: vi.fn((...args) => console.warn('[LOGGER.WARN]', ...args)),
|
||||
debug: vi.fn((...args) => console.debug('[LOGGER.DEBUG]', ...args)),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/checksum', () => ({
|
||||
|
||||
@@ -672,24 +672,14 @@ describe('useShoppingLists Hook', () => {
|
||||
},
|
||||
{
|
||||
name: 'updateItemInList',
|
||||
action: (hook: any) => {
|
||||
act(() => {
|
||||
hook.setActiveListId(1);
|
||||
});
|
||||
return hook.updateItemInList(101, { is_purchased: true });
|
||||
},
|
||||
action: (hook: any) => hook.updateItemInList(101, { is_purchased: true }),
|
||||
apiMock: mockUpdateItemApi,
|
||||
mockIndex: 3,
|
||||
errorMessage: 'Update failed',
|
||||
},
|
||||
{
|
||||
name: 'removeItemFromList',
|
||||
action: (hook: any) => {
|
||||
act(() => {
|
||||
hook.setActiveListId(1);
|
||||
});
|
||||
return hook.removeItemFromList(101);
|
||||
},
|
||||
action: (hook: any) => hook.removeItemFromList(101),
|
||||
apiMock: mockRemoveItemApi,
|
||||
mockIndex: 4,
|
||||
errorMessage: 'Removal failed',
|
||||
@@ -697,6 +687,17 @@ describe('useShoppingLists Hook', () => {
|
||||
])(
|
||||
'should set an error for $name if the API call fails',
|
||||
async ({ action, apiMock, mockIndex, errorMessage }) => {
|
||||
// Setup a default list so activeListId is set automatically
|
||||
const mockList = createMockShoppingList({ shopping_list_id: 1, name: 'List 1' });
|
||||
mockedUseUserData.mockReturnValue({
|
||||
shoppingLists: [mockList],
|
||||
setShoppingLists: mockSetShoppingLists,
|
||||
watchedItems: [],
|
||||
setWatchedItems: vi.fn(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const apiMocksWithError = [...defaultApiMocks];
|
||||
apiMocksWithError[mockIndex] = {
|
||||
...apiMocksWithError[mockIndex],
|
||||
@@ -709,6 +710,10 @@ describe('useShoppingLists Hook', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useShoppingLists());
|
||||
|
||||
// Wait for the effect to set the active list ID
|
||||
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await action(result.current);
|
||||
});
|
||||
|
||||
@@ -30,15 +30,26 @@ 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().mockImplementation(() => (req: Request, res: Response, next: NextFunction) => next()),
|
||||
array: vi.fn().mockImplementation(() => (req: Request, res: Response, next: NextFunction) => next()),
|
||||
})),
|
||||
// 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),
|
||||
}));
|
||||
vi.mock('multer', () => {
|
||||
const diskStorage = vi.fn((options) => options);
|
||||
class MulterError extends Error {
|
||||
constructor(public code: string) {
|
||||
super(code);
|
||||
this.name = 'MulterError';
|
||||
}
|
||||
}
|
||||
const multer = vi.fn(() => ({
|
||||
single: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
||||
array: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
||||
}));
|
||||
(multer as any).diskStorage = diskStorage;
|
||||
(multer as any).MulterError = MulterError;
|
||||
return {
|
||||
default: multer,
|
||||
diskStorage,
|
||||
MulterError,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Multer Middleware Directory Creation', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/pages/admin/FlyerReviewPage.test.tsx
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FlyerReviewPage } from './FlyerReviewPage';
|
||||
@@ -108,7 +109,7 @@ describe('FlyerReviewPage', () => {
|
||||
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('src');
|
||||
expect(unknownStoreImage).not.toHaveAttribute('alt');
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export const FlyerReviewPage: React.FC = () => {
|
||||
flyers.map((flyer) => (
|
||||
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
||||
<img src={flyer.icon_url || ''} alt={flyer.store?.name} className="w-12 h-12 rounded-md object-cover" />
|
||||
<img src={flyer.icon_url || undefined} alt={flyer.store?.name} className="w-12 h-12 rounded-md object-cover" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
||||
|
||||
@@ -76,10 +76,12 @@ vi.mock('node:fs/promises', () => ({
|
||||
// Named exports
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
// FIX: Add default export to handle `import fs from ...` syntax.
|
||||
default: {
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
vi.mock('../services/backgroundJobService');
|
||||
@@ -325,7 +327,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.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!');
|
||||
expect(response.body.message).toBe('Only image files are allowed!');
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
|
||||
|
||||
@@ -201,7 +201,6 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
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));
|
||||
});
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('AI Service (Server)', () => {
|
||||
await adapter.generateContent(request);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
});
|
||||
@@ -221,14 +221,14 @@ describe('AI Service (Server)', () => {
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check first call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
|
||||
model: 'gemini-2.5-flash',
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
|
||||
// Check second call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
|
||||
model: 'gemini-3-flash',
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
|
||||
model: 'gemini-2.5-flash',
|
||||
...request,
|
||||
});
|
||||
|
||||
@@ -258,8 +258,8 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: nonRetriableError },
|
||||
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
||||
{ error: nonRetriableError }, // The first model in the list is now 'gemini-3-flash-preview'
|
||||
`[AIService Adapter] Model 'gemini-3-flash-preview' failed with a non-retriable error.`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,15 +286,15 @@ describe('AI Service (Server)', () => {
|
||||
);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
|
||||
model: 'gemini-2.5-flash',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
|
||||
model: 'gemini-3-flash',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, {
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, { // The third model in the list is 'gemini-2.5-flash-lite'
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
...request,
|
||||
});
|
||||
|
||||
@@ -92,5 +92,37 @@ describe('Address DB Service', () => {
|
||||
expect(query).toContain('ON CONFLICT (address_id) DO UPDATE');
|
||||
expect(values).toEqual([1, '789 Old Rd', 'Oldtown']);
|
||||
});
|
||||
|
||||
it('should throw UniqueConstraintError on unique constraint violation', async () => {
|
||||
const addressData = { address_line_1: '123 Duplicate St' };
|
||||
const dbError = new Error('duplicate key value violates unique constraint');
|
||||
(dbError as any).code = '23505';
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
|
||||
UniqueConstraintError,
|
||||
);
|
||||
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
|
||||
'An identical address already exists.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: addressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails for other reasons', async () => {
|
||||
const addressData = { address_line_1: '789 Failure Rd' };
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
|
||||
'Failed to upsert address.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: addressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { AdminRepository } from './admin.db';
|
||||
import type { SuggestedCorrection, AdminUserView, Profile } from '../../types';
|
||||
import type { SuggestedCorrection, AdminUserView, Profile, Flyer } from '../../types';
|
||||
import {
|
||||
createMockSuggestedCorrection,
|
||||
createMockAdminUserView,
|
||||
createMockProfile,
|
||||
createMockFlyer,
|
||||
} from '../../tests/utils/mockFactories';
|
||||
// Un-mock the module we are testing
|
||||
vi.unmock('./admin.db');
|
||||
@@ -712,4 +713,28 @@ describe('Admin DB Service', () => {
|
||||
'Database error in updateUserRole',
|
||||
);
|
||||
});
|
||||
|
||||
describe('getFlyersForReview', () => {
|
||||
it('should retrieve flyers with "needs_review" status', async () => {
|
||||
const mockFlyers: Flyer[] = [createMockFlyer({ status: 'needs_review' })];
|
||||
mockDb.query.mockResolvedValue({ rows: mockFlyers });
|
||||
|
||||
const result = await adminRepo.getFlyersForReview(mockLogger);
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("WHERE f.status = 'needs_review'"),
|
||||
);
|
||||
expect(result).toEqual(mockFlyers);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(adminRepo.getFlyersForReview(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve flyers for review.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getFlyersForReview');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('./logger.server', () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -37,10 +38,13 @@ import {
|
||||
sendPasswordResetEmail,
|
||||
sendWelcomeEmail,
|
||||
sendDealNotificationEmail,
|
||||
processEmailJob,
|
||||
} from './emailService.server';
|
||||
import type { WatchedItemDeal } from '../types';
|
||||
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||
import { logger } from './logger.server';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { EmailJobData } from '../types/job-data';
|
||||
|
||||
describe('Email Service (Server)', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -219,4 +223,51 @@ describe('Email Service (Server)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEmailJob', () => {
|
||||
const mockJobData: EmailJobData = {
|
||||
to: 'job@example.com',
|
||||
subject: 'Job Email',
|
||||
html: '<p>Job</p>',
|
||||
text: 'Job',
|
||||
};
|
||||
|
||||
const createMockJob = (data: EmailJobData): Job<EmailJobData> =>
|
||||
({
|
||||
id: 'job-123',
|
||||
name: 'email-job',
|
||||
data,
|
||||
attemptsMade: 1,
|
||||
} as unknown as Job<EmailJobData>);
|
||||
|
||||
it('should call sendMail with job data and log success', async () => {
|
||||
const job = createMockJob(mockJobData);
|
||||
mocks.sendMail.mockResolvedValue({ messageId: 'job-test-id' });
|
||||
|
||||
await processEmailJob(job);
|
||||
|
||||
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
|
||||
const mailOptions = mocks.sendMail.mock.calls[0][0];
|
||||
expect(mailOptions.to).toBe(mockJobData.to);
|
||||
expect(mailOptions.subject).toBe(mockJobData.subject);
|
||||
expect(logger.info).toHaveBeenCalledWith('Picked up email job.');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ to: 'job@example.com', subject: 'Job Email', messageId: 'job-test-id' },
|
||||
'Email sent successfully.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log an error and re-throw if sendMail fails', async () => {
|
||||
const job = createMockJob(mockJobData);
|
||||
const emailError = new Error('SMTP Connection Failed');
|
||||
mocks.sendMail.mockRejectedValue(emailError);
|
||||
|
||||
await expect(processEmailJob(job)).rejects.toThrow(emailError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: emailError, jobData: mockJobData, attemptsMade: 1 },
|
||||
'Email job failed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user