more TS fixes + tests
This commit is contained in:
98
src/features/charts/PriceHistoryChart.test.tsx
Normal file
98
src/features/charts/PriceHistoryChart.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/features/charts/PriceHistoryChart.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 { PriceHistoryChart } from './PriceHistoryChart';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { MasterGroceryItem, ItemPriceHistory } from '../../types';
|
||||
|
||||
// Mock the apiClient
|
||||
vi.mock('../../services/apiClient');
|
||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../../services/logger', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the recharts library to prevent rendering complex SVGs in jsdom
|
||||
vi.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="responsive-container">{children}</div>,
|
||||
LineChart: ({ children }: { children: React.ReactNode }) => <div data-testid="line-chart">{children}</div>,
|
||||
CartesianGrid: () => <div data-testid="cartesian-grid" />,
|
||||
XAxis: () => <div data-testid="x-axis" />,
|
||||
YAxis: () => <div data-testid="y-axis" />,
|
||||
Tooltip: () => <div data-testid="tooltip" />,
|
||||
Legend: () => <div data-testid="legend" />,
|
||||
Line: ({ name }: { name: string }) => <div data-testid={`line-${name}`} />,
|
||||
}));
|
||||
|
||||
const mockWatchedItems: MasterGroceryItem[] = [
|
||||
{ master_grocery_item_id: 1, name: 'Organic Bananas', created_at: '2024-01-01' },
|
||||
{ master_grocery_item_id: 2, name: 'Almond Milk', created_at: '2024-01-01' },
|
||||
];
|
||||
|
||||
const mockPriceHistory: ItemPriceHistory[] = [
|
||||
{ item_price_history_id: 1, master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110, data_points_count: 1 },
|
||||
{ item_price_history_id: 2, master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99, data_points_count: 1 },
|
||||
{ item_price_history_id: 3, master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350, data_points_count: 1 },
|
||||
{ item_price_history_id: 4, master_item_id: 2, summary_date: '2024-10-08', avg_price_in_cents: 349, data_points_count: 1 },
|
||||
];
|
||||
|
||||
describe('PriceHistoryChart', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render a placeholder when there are no watched items', () => {
|
||||
render(<PriceHistoryChart watchedItems={[]} />);
|
||||
expect(screen.getByText('Price history will appear here once you add items to your watchlist.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a loading state while fetching data', () => {
|
||||
mockedApiClient.fetchHistoricalPriceData.mockReturnValue(new Promise(() => {}));
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
expect(screen.getByText('Loading price history...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display an error message if the API call fails', async () => {
|
||||
mockedApiClient.fetchHistoricalPriceData.mockRejectedValue(new Error('API is down'));
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Could not load price history.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a message if no historical data is returned', async () => {
|
||||
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue(new Response(JSON.stringify([])));
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No historical price data available for your watched items yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the chart with data on successful fetch', async () => {
|
||||
mockedApiClient.fetchHistoricalPriceData.mockResolvedValue(new Response(JSON.stringify(mockPriceHistory)));
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the API was called with the correct item IDs
|
||||
expect(mockedApiClient.fetchHistoricalPriceData).toHaveBeenCalledWith([1, 2]);
|
||||
|
||||
// Check that the chart components are rendered
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('x-axis')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('y-axis')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend')).toBeInTheDocument();
|
||||
|
||||
// Check that a line is rendered for each watched item
|
||||
expect(screen.getByTestId('line-Organic Bananas')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('line-Almond Milk')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/routes/budget.test.ts
Normal file
171
src/routes/budget.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// src/routes/budget.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import budgetRouter from './budget';
|
||||
import * as db from '../services/db';
|
||||
import { UserProfile, Budget, SpendingByCategory } from '../types';
|
||||
|
||||
// Mock the entire db service
|
||||
vi.mock('../services/db');
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the passport authentication middleware
|
||||
vi.mock('./passport', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import passport from './passport';
|
||||
const mockedAuthenticate = vi.mocked(passport.authenticate);
|
||||
|
||||
// Create a minimal Express app to host our router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/budgets', budgetRouter);
|
||||
|
||||
describe('Budget Routes (/api/budgets)', () => {
|
||||
const mockUserProfile: UserProfile = {
|
||||
user_id: 'user-123',
|
||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||
role: 'user',
|
||||
points: 100,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for unauthenticated user
|
||||
mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is not authenticated', () => {
|
||||
it('GET / should return 401 Unauthorized', async () => {
|
||||
const response = await supertest(app).get('/api/budgets');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST / should return 401 Unauthorized', async () => {
|
||||
const response = await supertest(app).post('/api/budgets').send({});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PUT /:id should return 401 Unauthorized', async () => {
|
||||
const response = await supertest(app).put('/api/budgets/1').send({});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('DELETE /:id should return 401 Unauthorized', async () => {
|
||||
const response = await supertest(app).delete('/api/budgets/1');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /spending-analysis should return 401 Unauthorized', async () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is authenticated', () => {
|
||||
beforeEach(() => {
|
||||
// Mock for authenticated user
|
||||
mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
req.user = mockUserProfile;
|
||||
next();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should return a list of budgets for the user', async () => {
|
||||
const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01' }];
|
||||
mockedDb.getBudgetsForUser.mockResolvedValue(mockBudgets);
|
||||
|
||||
const response = await supertest(app).get('/api/budgets');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockBudgets);
|
||||
expect(mockedDb.getBudgetsForUser).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('should create a new budget and return it', async () => {
|
||||
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' };
|
||||
const mockCreatedBudget: Budget = { budget_id: 2, user_id: 'user-123', ...newBudgetData };
|
||||
mockedDb.createBudget.mockResolvedValue(mockCreatedBudget);
|
||||
|
||||
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toEqual(mockCreatedBudget);
|
||||
expect(mockedDb.createBudget).toHaveBeenCalledWith('user-123', newBudgetData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('should update an existing budget', async () => {
|
||||
const budgetUpdates = { amount_cents: 60000 };
|
||||
const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 60000, period: 'monthly', start_date: '2024-01-01' };
|
||||
mockedDb.updateBudget.mockResolvedValue(mockUpdatedBudget);
|
||||
|
||||
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedBudget);
|
||||
expect(mockedDb.updateBudget).toHaveBeenCalledWith(1, 'user-123', budgetUpdates);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('should delete a budget', async () => {
|
||||
mockedDb.deleteBudget.mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(app).delete('/api/budgets/1');
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockedDb.deleteBudget).toHaveBeenCalledWith(1, 'user-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /spending-analysis', () => {
|
||||
it('should return spending analysis data for a valid date range', async () => {
|
||||
const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }];
|
||||
mockedDb.getSpendingByCategory.mockResolvedValue(mockSpendingData);
|
||||
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockSpendingData);
|
||||
expect(mockedDb.getSpendingByCategory).toHaveBeenCalledWith('user-123', '2024-01-01', '2024-01-31');
|
||||
});
|
||||
|
||||
it('should return 400 if startDate is missing', async () => {
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis?endDate=2024-01-31');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Both startDate and endDate query parameters are required.');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
mockedDb.getSpendingByCategory.mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Failed to fetch spending analysis.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
93
src/routes/gamification.test.ts
Normal file
93
src/routes/gamification.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/routes/gamification.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import gamificationRouter from './gamification';
|
||||
import * as db from '../services/db';
|
||||
import { UserProfile, Achievement, UserAchievement } from '../types';
|
||||
|
||||
// Mock the entire db service
|
||||
vi.mock('../services/db');
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the passport authentication middleware
|
||||
vi.mock('./passport', () => ({
|
||||
default: {
|
||||
authenticate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import passport from './passport';
|
||||
const mockedAuthenticate = vi.mocked(passport.authenticate);
|
||||
|
||||
// Create a minimal Express app to host our router
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/achievements', gamificationRouter);
|
||||
|
||||
describe('Gamification Routes (/api/achievements)', () => {
|
||||
const mockUserProfile: UserProfile = {
|
||||
user_id: 'user-123',
|
||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||
role: 'user',
|
||||
points: 100,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for unauthenticated user for protected routes
|
||||
mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('should return a list of all achievements (public endpoint)', async () => {
|
||||
const mockAchievements: Achievement[] = [
|
||||
{ achievement_id: 1, name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 },
|
||||
{ achievement_id: 2, name: 'Budget Master', description: '...', icon: 'piggy-bank', points_value: 50 },
|
||||
];
|
||||
mockedDb.getAllAchievements.mockResolvedValue(mockAchievements);
|
||||
|
||||
const response = await supertest(app).get('/api/achievements');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAchievements);
|
||||
expect(mockedDb.getAllAchievements).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /me', () => {
|
||||
it('should return 401 Unauthorized when user is not authenticated', async () => {
|
||||
const response = await supertest(app).get('/api/achievements/me');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return achievements for the authenticated user', async () => {
|
||||
// Mock for authenticated user
|
||||
mockedAuthenticate.mockImplementation(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
req.user = mockUserProfile;
|
||||
next();
|
||||
});
|
||||
|
||||
const mockUserAchievements: (UserAchievement & Achievement)[] = [{ achievement_id: 1, user_id: 'user-123', achieved_at: '2024-01-01', name: 'First Steps', description: '...', icon: 'footprints', points_value: 10 }];
|
||||
mockedDb.getUserAchievements.mockResolvedValue(mockUserAchievements);
|
||||
|
||||
const response = await supertest(app).get('/api/achievements/me');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUserAchievements);
|
||||
expect(mockedDb.getUserAchievements).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
106
src/services/aiApiClient.test.ts
Normal file
106
src/services/aiApiClient.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/services/aiApiClient.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import * as apiClient from './apiClient';
|
||||
import * as aiApiClient from './aiApiClient';
|
||||
import { MasterGroceryItem } from '../types';
|
||||
|
||||
// Mock the underlying apiClient to isolate our tests to the aiApiClient logic.
|
||||
vi.mock('./apiClient');
|
||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
||||
|
||||
describe('AI API Client', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mock history before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('extractCoreDataFromImage', () => {
|
||||
it('should construct FormData and call apiFetchWithAuth correctly', async () => {
|
||||
const files = [new File([''], 'flyer1.jpg', { type: 'image/jpeg' })];
|
||||
const masterItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Milk', created_at: '' }];
|
||||
|
||||
await aiApiClient.extractCoreDataFromImage(files, masterItems);
|
||||
|
||||
expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = mockedApiClient.apiFetchWithAuth.mock.calls[0];
|
||||
|
||||
expect(url).toBe('/ai/process-flyer');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBeInstanceOf(FormData);
|
||||
|
||||
// Verify FormData content
|
||||
const formData = options.body as FormData;
|
||||
expect(formData.get('flyerImages')).toBeInstanceOf(File);
|
||||
expect(formData.get('masterItems')).toBe(JSON.stringify(masterItems));
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTripWithMaps', () => {
|
||||
it('should call apiFetchWithAuth with the correct JSON body', async () => {
|
||||
const mockLocation: GeolocationCoordinates = {
|
||||
latitude: 48.4284,
|
||||
longitude: -123.3656,
|
||||
accuracy: 0,
|
||||
altitude: null,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
toJSON: () => ({}), // Add the missing toJSON method
|
||||
};
|
||||
|
||||
await aiApiClient.planTripWithMaps([], undefined, mockLocation, 'test-token');
|
||||
|
||||
expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith(
|
||||
'/ai/plan-trip',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: [], store: undefined, userLocation: mockLocation }),
|
||||
},
|
||||
'test-token'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescanImageArea', () => {
|
||||
it('should construct FormData and call apiFetchWithAuth for rescanning', async () => {
|
||||
const file = new File([''], 'flyer.jpg', { type: 'image/jpeg' });
|
||||
const cropArea = { x: 10, y: 10, width: 100, height: 50 };
|
||||
const extractionType = 'store_name';
|
||||
|
||||
await aiApiClient.rescanImageArea(file, cropArea, extractionType, 'test-token');
|
||||
|
||||
expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
|
||||
const [url, options, token] = mockedApiClient.apiFetchWithAuth.mock.calls[0];
|
||||
|
||||
expect(url).toBe('/ai/rescan-area');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(token).toBe('test-token');
|
||||
expect(options.body).toBeInstanceOf(FormData);
|
||||
|
||||
// Verify FormData content
|
||||
const formData = options.body as FormData;
|
||||
expect(formData.get('image')).toBeInstanceOf(File);
|
||||
expect(formData.get('cropArea')).toBe(JSON.stringify(cropArea));
|
||||
expect(formData.get('extractionType')).toBe(extractionType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQuickInsights', () => {
|
||||
it('should call apiFetchWithAuth with the items in the body', async () => {
|
||||
await aiApiClient.getQuickInsights([], 'test-token');
|
||||
|
||||
expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApiClient.apiFetchWithAuth).toHaveBeenCalledWith(
|
||||
'/ai/quick-insights',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: [] }),
|
||||
},
|
||||
'test-token'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,19 +14,28 @@ vi.mock('fs/promises', () => ({
|
||||
|
||||
// Mock the Google GenAI library
|
||||
const mockGenerateContent = vi.fn();
|
||||
const mockGetGenerativeModel = vi.fn(() => ({
|
||||
generateContent: mockGenerateContent,
|
||||
}));
|
||||
vi.mock('@google/genai', () => {
|
||||
// Mock GoogleGenAI as a class constructor
|
||||
return {
|
||||
GoogleGenAI: class {
|
||||
get models() {
|
||||
return {
|
||||
generateContent: mockGenerateContent,
|
||||
};
|
||||
getGenerativeModel() {
|
||||
return mockGetGenerativeModel();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the sharp library
|
||||
const mockToBuffer = vi.fn();
|
||||
const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
|
||||
const mockSharp = vi.fn(() => ({ extract: mockExtract }));
|
||||
vi.mock('sharp', () => ({
|
||||
__esModule: true,
|
||||
default: mockSharp,
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('./logger.server', () => ({
|
||||
logger: {
|
||||
@@ -146,4 +155,40 @@ describe('AI Service (Server)', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTextFromImageArea', () => {
|
||||
it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
|
||||
const { extractTextFromImageArea } = await import('./aiService.server');
|
||||
const imagePath = 'path/to/image.jpg';
|
||||
const cropArea = { x: 10, y: 20, width: 100, height: 50 };
|
||||
const extractionType = 'store_name';
|
||||
|
||||
// Mock sharp's output
|
||||
const mockCroppedBuffer = Buffer.from('cropped-image-data');
|
||||
mockToBuffer.mockResolvedValue(mockCroppedBuffer);
|
||||
|
||||
// Mock AI response
|
||||
mockGenerateContent.mockResolvedValue({ response: { text: () => 'Super Store' } });
|
||||
|
||||
const result = await extractTextFromImageArea(imagePath, 'image/jpeg', cropArea, extractionType);
|
||||
|
||||
// 1. Verify sharp was called correctly
|
||||
expect(mockSharp).toHaveBeenCalledWith(imagePath);
|
||||
expect(mockExtract).toHaveBeenCalledWith({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 100,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
// 2. Verify the AI was called with the cropped image data and correct prompt
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
const aiCallArgs = mockGenerateContent.mock.calls[0][0];
|
||||
expect(aiCallArgs.contents[0].parts[0].text).toContain('What is the store name in this image?');
|
||||
expect(aiCallArgs.contents[0].parts[1].inlineData.data).toBe(mockCroppedBuffer.toString('base64'));
|
||||
|
||||
// 3. Verify the result
|
||||
expect(result.text).toBe('Super Store');
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/services/backgroundJobService.test.ts
Normal file
133
src/services/backgroundJobService.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// src/services/backgroundJobService.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import cron from 'node-cron';
|
||||
import * as db from './db';
|
||||
import { logger } from './logger.server';
|
||||
import { sendDealNotificationEmail } from './emailService.server';
|
||||
import { runDailyDealCheck, startBackgroundJobs } from './backgroundJobService';
|
||||
import { WatchedItemDeal } from '../types';
|
||||
import { AdminUserView } from './db/admin';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node-cron');
|
||||
vi.mock('./db');
|
||||
vi.mock('./logger.server');
|
||||
vi.mock('./emailService.server');
|
||||
|
||||
const mockedDb = db as Mocked<typeof db>;
|
||||
const mockedCron = cron as Mocked<typeof cron>;
|
||||
const mockedLogger = logger as Mocked<typeof logger>;
|
||||
const mockedEmailService = { sendDealNotificationEmail } as Mocked<{ sendDealNotificationEmail: typeof sendDealNotificationEmail }>;
|
||||
|
||||
describe('Background Job Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('runDailyDealCheck', () => {
|
||||
const mockUsers: AdminUserView[] = [
|
||||
{ user_id: 'user-1', email: 'user1@test.com', full_name: 'User One', role: 'user', created_at: '', avatar_url: null },
|
||||
{ user_id: 'user-2', email: 'user2@test.com', full_name: 'User Two', role: 'user', created_at: '', avatar_url: null },
|
||||
];
|
||||
|
||||
const mockDeals: WatchedItemDeal[] = [
|
||||
{ master_item_id: 1, item_name: 'Apples', best_price_in_cents: 199, store_name: 'Green Grocer', flyer_id: 101, valid_to: '2024-10-20' },
|
||||
];
|
||||
|
||||
it('should do nothing if no users are found', async () => {
|
||||
mockedDb.getAllUsers.mockResolvedValue([]);
|
||||
|
||||
await runDailyDealCheck();
|
||||
|
||||
expect(mockedLogger.info).toHaveBeenCalledWith('[BackgroundJob] Starting daily deal check for all users...');
|
||||
expect(mockedLogger.info).toHaveBeenCalledWith('[BackgroundJob] No users found. Skipping deal check.');
|
||||
expect(mockedDb.getBestSalePricesForUser).not.toHaveBeenCalled();
|
||||
expect(mockedEmailService.sendDealNotificationEmail).not.toHaveBeenCalled();
|
||||
expect(mockedDb.createBulkNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process users but not send notifications if no deals are found', async () => {
|
||||
mockedDb.getAllUsers.mockResolvedValue([mockUsers[0]]);
|
||||
mockedDb.getBestSalePricesForUser.mockResolvedValue([]);
|
||||
|
||||
await runDailyDealCheck();
|
||||
|
||||
expect(mockedDb.getBestSalePricesForUser).toHaveBeenCalledWith('user-1');
|
||||
expect(mockedEmailService.sendDealNotificationEmail).not.toHaveBeenCalled();
|
||||
expect(mockedDb.createBulkNotifications).not.toHaveBeenCalled();
|
||||
expect(mockedLogger.info).toHaveBeenCalledWith('[BackgroundJob] Daily deal check completed successfully.');
|
||||
});
|
||||
|
||||
it('should create notifications and send emails when deals are found', async () => {
|
||||
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
|
||||
mockedDb.getBestSalePricesForUser.mockResolvedValue(mockDeals);
|
||||
|
||||
await runDailyDealCheck();
|
||||
|
||||
// Check that it processed both users
|
||||
expect(mockedDb.getBestSalePricesForUser).toHaveBeenCalledTimes(2);
|
||||
expect(mockedDb.getBestSalePricesForUser).toHaveBeenCalledWith('user-1');
|
||||
expect(mockedDb.getBestSalePricesForUser).toHaveBeenCalledWith('user-2');
|
||||
|
||||
// Check that emails were sent for both users
|
||||
expect(mockedEmailService.sendDealNotificationEmail).toHaveBeenCalledTimes(2);
|
||||
expect(mockedEmailService.sendDealNotificationEmail).toHaveBeenCalledWith('user1@test.com', 'User One', mockDeals);
|
||||
|
||||
// Check that in-app notifications were created for both users
|
||||
expect(mockedDb.createBulkNotifications).toHaveBeenCalledTimes(1);
|
||||
const notificationPayload = mockedDb.createBulkNotifications.mock.calls[0][0];
|
||||
expect(notificationPayload).toHaveLength(2);
|
||||
expect(notificationPayload[0]).toEqual({
|
||||
user_id: 'user-1',
|
||||
content: 'You have 1 new deal(s) on your watched items!',
|
||||
link_url: '/dashboard/deals',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle and log errors for individual users without stopping the process', async () => {
|
||||
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
|
||||
// First user fails, second succeeds
|
||||
mockedDb.getBestSalePricesForUser
|
||||
.mockRejectedValueOnce(new Error('User 1 DB Error'))
|
||||
.mockResolvedValueOnce(mockDeals);
|
||||
|
||||
await runDailyDealCheck();
|
||||
|
||||
// Check that it logged the error for user 1
|
||||
expect(mockedLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to process deals for user user-1'),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Check that it still processed user 2 successfully
|
||||
expect(mockedEmailService.sendDealNotificationEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mockedEmailService.sendDealNotificationEmail).toHaveBeenCalledWith('user2@test.com', 'User Two', mockDeals);
|
||||
expect(mockedDb.createBulkNotifications).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.createBulkNotifications.mock.calls[0][0]).toHaveLength(1); // Only one notification created
|
||||
});
|
||||
|
||||
it('should log a critical error if getAllUsers fails', async () => {
|
||||
mockedDb.getAllUsers.mockRejectedValue(new Error('Critical DB Failure'));
|
||||
|
||||
await runDailyDealCheck();
|
||||
|
||||
expect(mockedLogger.error).toHaveBeenCalledWith(
|
||||
'[BackgroundJob] A critical error occurred during the daily deal check:',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startBackgroundJobs', () => {
|
||||
it('should schedule the cron job with the correct schedule and function', () => {
|
||||
startBackgroundJobs();
|
||||
|
||||
expect(mockedCron.schedule).toHaveBeenCalledTimes(1);
|
||||
// Check the schedule string
|
||||
expect(mockedCron.schedule).toHaveBeenCalledWith('0 2 * * *', expect.any(Function));
|
||||
// Check that the function passed is indeed runDailyDealCheck
|
||||
expect(mockedCron.schedule.mock.calls[0][1]).toBe(runDailyDealCheck);
|
||||
expect(mockedLogger.info).toHaveBeenCalledWith('[BackgroundJob] Cron job for daily deal checks has been scheduled.');
|
||||
});
|
||||
});
|
||||
});
|
||||
66
src/services/logger.server.test.ts
Normal file
66
src/services/logger.server.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// src/services/logger.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { logger } from './logger.server';
|
||||
|
||||
describe('Server Logger', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let spies: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear any previous calls before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create fresh spies for each test on the global console object
|
||||
spies = {
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore all spies
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('logger.info should format the message correctly with timestamp, PID, and [INFO] prefix', () => {
|
||||
const message = 'This is a server info message';
|
||||
const data = { id: 1, name: 'test' };
|
||||
logger.info(message, data);
|
||||
|
||||
expect(spies.log).toHaveBeenCalledTimes(1);
|
||||
// Use stringMatching for the dynamic parts (timestamp, PID)
|
||||
expect(spies.log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[.+\] \[PID:\d+\] \[INFO\] This is a server info message/),
|
||||
data
|
||||
);
|
||||
});
|
||||
|
||||
it('logger.warn should format the message correctly with timestamp, PID, and [WARN] prefix', () => {
|
||||
const message = 'This is a server warning';
|
||||
logger.warn(message);
|
||||
|
||||
expect(spies.warn).toHaveBeenCalledTimes(1);
|
||||
expect(spies.warn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[.+\] \[PID:\d+\] \[WARN\] This is a server warning/)
|
||||
);
|
||||
});
|
||||
|
||||
it('logger.error should format the message correctly with timestamp, PID, and [ERROR] prefix', () => {
|
||||
const message = 'A server error occurred';
|
||||
const error = new Error('Test Server Error');
|
||||
logger.error(message, error);
|
||||
|
||||
expect(spies.error).toHaveBeenCalledTimes(1);
|
||||
expect(spies.error).toHaveBeenCalledWith(expect.stringMatching(/\[.+\] \[PID:\d+\] \[ERROR\] A server error occurred/), error);
|
||||
});
|
||||
|
||||
it('logger.debug should format the message correctly with timestamp, PID, and [DEBUG] prefix', () => {
|
||||
const message = 'Debugging server data';
|
||||
logger.debug(message, { key: 'value' });
|
||||
|
||||
expect(spies.debug).toHaveBeenCalledTimes(1);
|
||||
expect(spies.debug).toHaveBeenCalledWith(expect.stringMatching(/\[.+\] \[PID:\d+\] \[DEBUG\] Debugging server data/), { key: 'value' });
|
||||
});
|
||||
});
|
||||
66
src/services/logger.test.ts
Normal file
66
src/services/logger.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// src/services/logger.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { logger } from './logger';
|
||||
|
||||
describe('Isomorphic Logger', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let spies: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear any previous calls before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create fresh spies for each test on the global console object
|
||||
spies = {
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore all spies
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('logger.info should format the message correctly with timestamp, PID, and [INFO] prefix', () => {
|
||||
const message = 'This is an isomorphic info message';
|
||||
const data = { id: 1, name: 'test' };
|
||||
logger.info(message, data);
|
||||
|
||||
expect(spies.log).toHaveBeenCalledTimes(1);
|
||||
// Use stringMatching for the dynamic parts (timestamp, PID)
|
||||
expect(spies.log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[.+\] \[PID:\d+\] \[INFO\] This is an isomorphic info message/),
|
||||
data
|
||||
);
|
||||
});
|
||||
|
||||
it('logger.warn should format the message correctly with timestamp, PID, and [WARN] prefix', () => {
|
||||
const message = 'This is an isomorphic warning';
|
||||
logger.warn(message);
|
||||
|
||||
expect(spies.warn).toHaveBeenCalledTimes(1);
|
||||
expect(spies.warn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[.+\] \[PID:\d+\] \[WARN\] This is an isomorphic warning/)
|
||||
);
|
||||
});
|
||||
|
||||
it('logger.error should format the message correctly with timestamp, PID, and [ERROR] prefix', () => {
|
||||
const message = 'An isomorphic error occurred';
|
||||
const error = new Error('Test Isomorphic Error');
|
||||
logger.error(message, error);
|
||||
|
||||
expect(spies.error).toHaveBeenCalledTimes(1);
|
||||
expect(spies.error).toHaveBeenCalledWith(expect.stringMatching(/\[.+\] \[PID:\d+\] \[ERROR\] An isomorphic error occurred/), error);
|
||||
});
|
||||
|
||||
it('logger.debug should format the message correctly with timestamp, PID, and [DEBUG] prefix', () => {
|
||||
const message = 'Debugging isomorphic data';
|
||||
logger.debug(message, { key: 'value' });
|
||||
|
||||
expect(spies.debug).toHaveBeenCalledTimes(1);
|
||||
expect(spies.debug).toHaveBeenCalledWith(expect.stringMatching(/\[.+\] \[PID:\d+\] \[DEBUG\] Debugging isomorphic data/), { key: 'value' });
|
||||
});
|
||||
});
|
||||
61
src/services/notificationService.test.ts
Normal file
61
src/services/notificationService.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// src/services/notificationService.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { notifySuccess, notifyError } from './notificationService';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Mock the react-hot-toast library. We mock the default export which is the toast object.
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Get a typed reference to the mocked functions for type-safe assertions.
|
||||
const mockedToast = toast as Mocked<typeof toast>;
|
||||
|
||||
describe('Notification Service', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mock history before each test to ensure isolation.
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('notifySuccess', () => {
|
||||
it('should call toast.success with the correct message and options', () => {
|
||||
const message = 'Operation was successful!';
|
||||
notifySuccess(message);
|
||||
|
||||
expect(mockedToast.success).toHaveBeenCalledTimes(1);
|
||||
// Check that the message is correct and that the options object is passed.
|
||||
expect(mockedToast.success).toHaveBeenCalledWith(
|
||||
message,
|
||||
expect.objectContaining({
|
||||
style: expect.any(Object), // Check that common styles are included
|
||||
iconTheme: {
|
||||
primary: '#10B981', // Check for the specific success icon color
|
||||
secondary: '#fff',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyError', () => {
|
||||
it('should call toast.error with the correct message and options', () => {
|
||||
const message = 'Something went wrong!';
|
||||
notifyError(message);
|
||||
|
||||
expect(mockedToast.error).toHaveBeenCalledTimes(1);
|
||||
expect(mockedToast.error).toHaveBeenCalledWith(
|
||||
message,
|
||||
expect.objectContaining({
|
||||
style: expect.any(Object),
|
||||
iconTheme: {
|
||||
primary: '#EF4444', // Check for the specific error icon color
|
||||
secondary: '#fff',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user