more TS fixes + tests

This commit is contained in:
2025-11-29 16:20:18 -08:00
parent 399243c6a0
commit aae0875e2b
9 changed files with 844 additions and 5 deletions

View 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
View 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.');
});
});
});
});

View 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');
});
});
});

View 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'
);
});
});
});

View File

@@ -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');
});
});
});

View 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.');
});
});
});

View 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' });
});
});

View 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' });
});
});

View 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',
},
})
);
});
});
});