DB refactor for easier testsing
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m16s

App.ts refactor into hooks
unit tests
This commit is contained in:
2025-12-08 20:46:12 -08:00
parent 0eda796fad
commit 158778c2ec
67 changed files with 4666 additions and 3599 deletions

View File

@@ -1,6 +1,6 @@
// server.ts
import express, { Request, Response, NextFunction } from 'express';
import timeout from 'connect-timeout';
import timeout from 'connect-timeout';
import cookieParser from 'cookie-parser';
import listEndpoints from 'express-list-endpoints';
import { getPool } from './src/services/db/connection.db';
@@ -17,11 +17,11 @@ import aiRouter from './src/routes/ai.routes';
import budgetRouter from './src/routes/budget.routes';
import gamificationRouter from './src/routes/gamification.routes';
import systemRouter from './src/routes/system.routes';
import healthRouter from './src/routes/health.routes';
import healthRouter from './src/routes/health.routes';
import { errorHandler } from './src/middleware/errorHandler';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService.ts';
import * as db from './src/services/db/index.db';
import { analyticsQueue, gracefulShutdown } from './src/services/queueService.server';
import { analyticsQueue, weeklyAnalyticsQueue, gracefulShutdown } from './src/services/queueService.server';
// --- START DEBUG LOGGING ---
// Log the database connection details as seen by the SERVER PROCESS.
@@ -148,7 +148,7 @@ if (process.env.NODE_ENV !== 'test') {
});
// Start the scheduled background jobs
startBackgroundJobs(backgroundJobService, analyticsQueue);
startBackgroundJobs(backgroundJobService, analyticsQueue, weeklyAnalyticsQueue);
// --- Graceful Shutdown Handling ---
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View File

@@ -4,7 +4,7 @@ import { Routes, Route, useParams, useLocation, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast';
import * as pdfjsLib from 'pdfjs-dist';
import { Header } from './components/Header';
import { logger } from './services/logger';
import { logger } from './services/logger.client';
import type { Flyer, Profile, User, UserProfile } from './types';
import * as apiClient from './services/apiClient';
import { ProfileManager } from './pages/admin/components/ProfileManager';

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import type { Flyer, FlyerItem, MasterGroceryItem, DealItem } from '../types';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
/**
* A custom hook to calculate currently active deals and total active items

View File

@@ -1,6 +1,6 @@
// src/hooks/useApi.ts
import { useState, useCallback } from 'react';
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
import { notifyError } from '../services/notificationService';
/**

View File

@@ -2,7 +2,7 @@
import React, { createContext, useState, useContext, useEffect, useCallback, ReactNode } from 'react';
import type { User, UserProfile } from '../types';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
/**
* Defines the possible authentication states for a user session.

View File

@@ -3,7 +3,7 @@ import React, { createContext, useState, useContext, useEffect, useCallback, Rea
import type { Flyer, MasterGroceryItem, ShoppingList } from '../types';
import * as apiClient from '../services/apiClient';
import { useAuth } from './useAuth';
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
export interface DataContextType {
flyers: Flyer[];

View File

@@ -4,7 +4,7 @@ import { useAuth } from './useAuth';
import { useData } from './useData';
import * as apiClient from '../services/apiClient';
import type { ShoppingList, ShoppingListItem } from '../types';
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
/**
* A custom hook to manage all state and logic related to shopping lists.

View File

@@ -4,7 +4,7 @@ import { useAuth } from './useAuth';
import { useData } from './useData';
import * as apiClient from '../services/apiClient';
import type { MasterGroceryItem } from '../types';
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
/**
* A custom hook to manage all state and logic related to a user's watched items.

View File

@@ -5,7 +5,7 @@ import type { Profile, Address, User } from '../../../types';
import { useApi } from '../../../hooks/useApi';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
import { logger } from '../../../services/logger';
import { logger } from '../../../services/logger.client';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';

View File

@@ -15,19 +15,18 @@ vi.mock('../lib/queue', () => ({
cleanupQueue: {},
}));
// Mock the specific DB modules used
vi.mock('../services/db/admin.db', () => ({
getSuggestedCorrections: vi.fn(),
approveCorrection: vi.fn(),
rejectCorrection: vi.fn(),
updateSuggestedCorrection: vi.fn(),
getUnmatchedFlyerItems: vi.fn(),
updateRecipeStatus: vi.fn(),
updateRecipeCommentStatus: vi.fn(),
updateBrandLogo: vi.fn(),
}));
vi.mock('../services/db/flyer.db', () => ({
getAllBrands: vi.fn(),
// Mock the central DB index
vi.mock('../services/db/index.db', () => ({
adminRepo: {
getSuggestedCorrections: vi.fn(),
approveCorrection: vi.fn(),
rejectCorrection: vi.fn(),
updateSuggestedCorrection: vi.fn(),
getUnmatchedFlyerItems: vi.fn(),
updateRecipeStatus: vi.fn(),
updateRecipeCommentStatus: vi.fn(),
updateBrandLogo: vi.fn(),
},
}));
// Mock other dependencies
@@ -52,9 +51,8 @@ vi.mock('@bull-board/express', () => ({
}));
// Import the mocked modules to control them
import * as adminDb from '../services/db/admin.db';
import * as flyerDb from '../services/db/flyer.db';
const mockedDb = { ...adminDb, ...flyerDb } as Mocked<typeof adminDb & typeof flyerDb>;
import * as db from '../services/db/index.db';
const mockedDb = db as Mocked<typeof db>;
// Mock the logger
vi.mock('../services/logger.server', () => ({
@@ -104,25 +102,25 @@ describe('Admin Content Management Routes (/api/admin)', () => {
describe('Corrections Routes', () => {
it('GET /corrections should return corrections data', async () => {
const mockCorrections: SuggestedCorrection[] = [createMockSuggestedCorrection({ suggested_correction_id: 1 })];
mockedDb.getSuggestedCorrections.mockResolvedValue(mockCorrections);
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockResolvedValue(mockCorrections);
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockCorrections);
});
it('should return 500 if the database call fails', async () => {
mockedDb.getSuggestedCorrections.mockRejectedValue(new Error('DB Error'));
vi.mocked(mockedDb.adminRepo.getSuggestedCorrections).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(500);
});
it('POST /corrections/:id/approve should approve a correction', async () => {
const correctionId = 123;
mockedDb.approveCorrection.mockResolvedValue(undefined);
vi.mocked(mockedDb.adminRepo.approveCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Correction approved successfully.' });
expect(mockedDb.approveCorrection).toHaveBeenCalledWith(correctionId);
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(correctionId);
});
it('POST /corrections/:id/approve should return 400 for an invalid ID', async () => {
@@ -133,7 +131,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('POST /corrections/:id/reject should reject a correction', async () => {
const correctionId = 789;
mockedDb.rejectCorrection.mockResolvedValue(undefined);
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
@@ -149,14 +147,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
const mockUpdatedCorrection = createMockSuggestedCorrection({ suggested_correction_id: correctionId, ...requestBody });
mockedDb.updateSuggestedCorrection.mockResolvedValue(mockUpdatedCorrection);
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockResolvedValue(mockUpdatedCorrection);
const response = await supertest(app).put(`/api/admin/corrections/${correctionId}`).send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedCorrection);
});
it('PUT /corrections/:id should return 404 if correction not found', async () => {
mockedDb.updateSuggestedCorrection.mockRejectedValue(new Error('Correction with ID 999 not found'));
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(new Error('Correction with ID 999 not found'));
const response = await supertest(app).put('/api/admin/corrections/999').send({ suggested_value: 'new value' });
expect(response.status).toBe(404);
});
@@ -165,7 +163,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
describe('Brand Routes', () => {
it('GET /brands should return a list of all brands', async () => {
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Brand A' })];
mockedDb.getAllBrands.mockResolvedValue(mockBrands);
vi.mocked(db.flyerRepo.getAllBrands).mockResolvedValue(mockBrands);
const response = await supertest(app).get('/api/admin/brands');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockBrands);
@@ -173,13 +171,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
const brandId = 55;
mockedDb.updateBrandLogo.mockResolvedValue(undefined);
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
const response = await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Brand logo updated successfully.');
expect(mockedDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
});
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
@@ -194,7 +192,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
const recipeId = 201;
const requestBody = { status: 'public' as const };
const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' });
mockedDb.updateRecipeStatus.mockResolvedValue(mockUpdatedRecipe);
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockResolvedValue(mockUpdatedRecipe);
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedRecipe);
@@ -209,8 +207,8 @@ describe('Admin Content Management Routes (/api/admin)', () => {
it('PUT /comments/:id/status should update a comment status', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' });
mockedDb.updateRecipeCommentStatus.mockResolvedValue(mockUpdatedComment);
const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' }); // This was a duplicate, fixed.
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockResolvedValue(mockUpdatedComment);
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedComment);
@@ -235,7 +233,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
store_name: 'Test Store',
flyer_item_name: mockFlyerItem.item, // Add the missing required property
}];
mockedDb.getUnmatchedFlyerItems.mockResolvedValue(mockUnmatchedItems);
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnmatchedItems);

View File

@@ -8,14 +8,16 @@ import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { clearGeocodeCache } from '../services/geocodingService.server';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
// --- Bull Board (Job Queue UI) Imports ---
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import type { Queue } from 'bullmq';
import { backgroundJobService } from '../services/backgroundJobService';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker } from '../services/queueService.server'; // Import your queues
import { getSimpleWeekAndYear } from '../utils/dateUtils';
const router = Router();
@@ -41,7 +43,8 @@ createBullBoard({
new BullMQAdapter(flyerQueue),
new BullMQAdapter(emailQueue),
new BullMQAdapter(analyticsQueue),
new BullMQAdapter(cleanupQueue) // Add the new cleanup queue here
new BullMQAdapter(cleanupQueue), // Add the new cleanup queue here
new BullMQAdapter((await import('../services/queueService.server')).weeklyAnalyticsQueue),
],
serverAdapter: serverAdapter,
});
@@ -58,7 +61,7 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
router.get('/corrections', async (req, res, next: NextFunction) => {
try {
const corrections = await db.getSuggestedCorrections();
const corrections = await db.adminRepo.getSuggestedCorrections();
res.json(corrections);
} catch (error) {
next(error);
@@ -67,7 +70,7 @@ router.get('/corrections', async (req, res, next: NextFunction) => {
router.get('/brands', async (req, res, next: NextFunction) => {
try {
const brands = await db.getAllBrands();
const brands = await db.flyerRepo.getAllBrands();
res.json(brands);
} catch (error) {
next(error);
@@ -76,7 +79,7 @@ router.get('/brands', async (req, res, next: NextFunction) => {
router.get('/stats', async (req, res, next: NextFunction) => {
try {
const stats = await db.getApplicationStats();
const stats = await db.adminRepo.getApplicationStats();
res.json(stats);
} catch (error) {
next(error);
@@ -85,7 +88,7 @@ router.get('/stats', async (req, res, next: NextFunction) => {
router.get('/stats/daily', async (req, res, next: NextFunction) => {
try {
const dailyStats = await db.getDailyStatsForLast30Days();
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days();
res.json(dailyStats);
} catch (error) {
next(error);
@@ -99,7 +102,7 @@ router.post('/corrections/:id/approve', async (req, res, next: NextFunction) =>
return res.status(400).json({ message: 'Invalid correction ID provided.' });
}
try {
await db.approveCorrection(correctionId);
await db.adminRepo.approveCorrection(correctionId);
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
next(error);
@@ -109,7 +112,7 @@ router.post('/corrections/:id/approve', async (req, res, next: NextFunction) =>
router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
try {
const correctionId = parseInt(req.params.id, 10);
await db.rejectCorrection(correctionId);
await db.adminRepo.rejectCorrection(correctionId);
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
next(error);
@@ -123,10 +126,9 @@ router.put('/corrections/:id', async (req, res, next: NextFunction) => {
return res.status(400).json({ message: 'A new suggested_value is required.' });
}
try {
const updatedCorrection = await db.updateSuggestedCorrection(correctionId, suggested_value);
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(correctionId, suggested_value);
res.status(200).json(updatedCorrection);
} catch (error) {
// Check if the error message indicates "not found" to return a 404
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
@@ -142,9 +144,12 @@ router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' });
}
try {
const updatedRecipe = await db.updateRecipeStatus(recipeId, status);
const updatedRecipe = await db.adminRepo.updateRecipeStatus(recipeId, status); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
next(error);
}
});
@@ -157,7 +162,7 @@ router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, nex
try {
const logoUrl = `/assets/${req.file.filename}`;
await db.updateBrandLogo(brandId, logoUrl);
await db.adminRepo.updateBrandLogo(brandId, logoUrl);
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
@@ -168,7 +173,7 @@ router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, nex
router.get('/unmatched-items', async (req, res, next: NextFunction) => {
try {
const items = await db.getUnmatchedFlyerItems();
const items = await db.adminRepo.getUnmatchedFlyerItems();
res.json(items);
} catch (error) {
next(error);
@@ -183,16 +188,19 @@ router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' });
}
try {
const updatedComment = await db.updateRecipeCommentStatus(commentId, status);
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(commentId, status); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
next(error);
}
});
router.get('/users', async (req, res, next: NextFunction) => {
try {
const users = await db.getAllUsers();
const users = await db.adminRepo.getAllUsers();
res.json(users);
} catch (error) {
next(error);
@@ -203,7 +211,7 @@ router.get('/activity-log', async (req, res, next: NextFunction) => {
const limit = parseInt(req.query.limit as string, 10) || 50;
const offset = parseInt(req.query.offset as string, 10) || 0;
try {
const logs = await db.getActivityLog(limit, offset);
const logs = await db.adminRepo.getActivityLog(limit, offset);
res.json(logs);
} catch (error) {
next(error);
@@ -212,7 +220,7 @@ router.get('/activity-log', async (req, res, next: NextFunction) => {
router.get('/users/:id', async (req, res, next: NextFunction) => {
try {
const user = await db.findUserProfileById(req.params.id);
const user = await db.userRepo.findUserProfileById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found.' });
}
@@ -229,11 +237,12 @@ router.put('/users/:id', async (req, res, next: NextFunction) => {
}
try {
const updatedUser = await db.updateUserRole(req.params.id, role);
const updatedUser = await db.adminRepo.updateUserRole(req.params.id, role);
res.json(updatedUser);
} catch (error) {
// Check if the error message indicates "not found" to return a 404
if (error instanceof Error && error.message.includes('not found')) {
if (error instanceof ForeignKeyConstraintError) { // This error is thrown by the DB layer
return res.status(404).json({ message: `User with ID ${req.params.id} not found.` });
} else if (error instanceof Error && error.message.includes('not found')) { // This handles the generic "not found" from the repo
return res.status(404).json({ message: error.message });
}
logger.error(`Error updating user ${req.params.id}:`, { error });
@@ -247,7 +256,7 @@ router.delete('/users/:id', async (req, res, next: NextFunction) => {
return res.status(400).json({ message: 'Admins cannot delete their own account.' });
}
try {
await db.deleteUserById(req.params.id);
await db.userRepo.deleteUserById(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
@@ -432,4 +441,24 @@ router.post('/jobs/:queueName/:jobId/retry', async (req, res, next: NextFunction
}
});
/**
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
*/
router.post('/trigger/weekly-analytics', async (req, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.user_id}`);
try {
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
const { weeklyAnalyticsQueue } = await import('../services/queueService.server');
const job = await weeklyAnalyticsQueue.add('generate-weekly-report', { reportYear, reportWeek }, {
jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}` // Add timestamp to avoid ID conflict
});
res.status(202).json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -4,16 +4,35 @@ import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { User, UserProfile } from '../types';
import { User, UserProfile, SuggestedCorrection, Brand, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, AdminUserView } from '../types';
// Mock the specific DB modules used by these routes
vi.mock('../services/db/admin.db', () => ({
// Mock the Service Layer directly.
// The admin.routes.ts file imports from '.../db/index.db'. We need to mock that module.
vi.mock('../services/db/index.db', () => ({
// Standalone functions from admin.db (and re-exported from other modules)
getAllUsers: vi.fn(),
updateUserRole: vi.fn(),
}));
vi.mock('../services/db/user.db', () => ({
findUserProfileById: vi.fn(),
deleteUserById: vi.fn(),
getSuggestedCorrections: vi.fn(),
approveCorrection: vi.fn(),
rejectCorrection: vi.fn(),
updateSuggestedCorrection: vi.fn(),
getApplicationStats: vi.fn(),
getDailyStatsForLast30Days: vi.fn(),
logActivity: vi.fn(),
incrementFailedLoginAttempts: vi.fn(),
updateBrandLogo: vi.fn(),
getMostFrequentSaleItems: vi.fn(),
updateRecipeCommentStatus: vi.fn(),
getUnmatchedFlyerItems: vi.fn(),
updateRecipeStatus: vi.fn(),
updateReceiptStatus: vi.fn(),
getActivityLog: vi.fn(),
// Repository instances (exported directly from index.db)
userRepo: {
findUserProfileById: vi.fn(),
deleteUserById: vi.fn(),
},
flyerRepo: { getAllBrands: vi.fn() }, // Include other repos if admin.routes uses them
}));
// Mock other dependencies that are not directly tested but are part of the adminRouter setup
@@ -35,9 +54,8 @@ vi.mock('@bull-board/express', () => ({
}));
// Import the mocked modules to control them in tests.
import * as adminDb from '../services/db/admin.db';
import * as userDb from '../services/db/user.db';
const mockedDb = { ...adminDb, ...userDb } as Mocked<typeof adminDb & typeof userDb>;
import * as db from '../services/db/index.db';
const mockedDb = db as Mocked<typeof db>;
// Mock the logger
vi.mock('../services/logger.server', () => ({
@@ -90,7 +108,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
{ user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
];
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
vi.mocked(mockedDb.getAllUsers).mockResolvedValue(mockUsers);
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUsers);
@@ -101,15 +119,15 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('GET /users/:id', () => {
it('should fetch a single user successfully', async () => {
const mockUser = createMockUserProfile({ user_id: 'user-123' });
mockedDb.findUserProfileById.mockResolvedValue(mockUser);
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockUser);
const response = await supertest(app).get('/api/admin/users/user-123');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUser);
expect(mockedDb.findUserProfileById).toHaveBeenCalledWith('user-123');
expect(mockedDb.userRepo.findUserProfileById).toHaveBeenCalledWith('user-123');
});
it('should return 404 for a non-existent user', async () => {
mockedDb.findUserProfileById.mockResolvedValue(undefined);
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(undefined);
const response = await supertest(app).get('/api/admin/users/non-existent-id');
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found.');
@@ -119,7 +137,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('PUT /users/:id', () => {
it('should update a user role successfully', async () => {
const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' };
mockedDb.updateUserRole.mockResolvedValue(updatedUser);
vi.mocked(mockedDb.updateUserRole).mockResolvedValue(updatedUser);
const response = await supertest(app)
.put('/api/admin/users/user-to-update')
.send({ role: 'admin' });
@@ -129,7 +147,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
});
it('should return 404 for a non-existent user', async () => {
mockedDb.updateUserRole.mockRejectedValue(new Error('User with ID non-existent not found.'));
vi.mocked(mockedDb.updateUserRole).mockRejectedValue(new Error('User with ID non-existent not found.'));
const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' });
expect(response.status).toBe(404);
});
@@ -143,17 +161,17 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('DELETE /users/:id', () => {
it('should successfully delete a user', async () => {
mockedDb.deleteUserById.mockResolvedValue(undefined);
vi.mocked(mockedDb.userRepo.deleteUserById).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/admin/users/user-to-delete');
expect(response.status).toBe(204);
expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');
expect(mockedDb.userRepo.deleteUserById).toHaveBeenCalledWith('user-to-delete');
});
it('should prevent an admin from deleting their own account', async () => {
const response = await supertest(app).delete(`/api/admin/users/${adminUser.user_id}`);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Admins cannot delete their own account.');
expect(mockedDb.deleteUserById).not.toHaveBeenCalled();
expect(mockedDb.userRepo.deleteUserById).not.toHaveBeenCalled();
});
});
});

View File

@@ -86,7 +86,7 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
}
// Check for duplicate flyer using checksum before even creating a job
const existingFlyer = await db.findFlyerByChecksum(checksum);
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
// Use 409 Conflict for duplicates
@@ -246,7 +246,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
}
// 1. Check for duplicate flyer using checksum
const existingFlyer = await db.findFlyerByChecksum(checksum);
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum);
if (existingFlyer) {
logger.warn(`Duplicate flyer upload attempt blocked for checksum: ${checksum}`);
return res.status(409).json({ message: 'This flyer has already been processed.' });

View File

@@ -5,24 +5,27 @@ import express, { Request } from 'express';
import cookieParser from 'cookie-parser';
import * as bcrypt from 'bcrypt';
import authRouter from './auth.routes';
import * as userDb from '../services/db/user.db';
import * as adminDb from '../services/db/admin.db';
import * as db from '../services/db/index.db';
import { UserProfile } from '../types';
import { UniqueConstraintError } from '../services/db/errors.db';
// 1. Mock the Service Layer directly.
// This decouples the route tests from the SQL implementation details.
vi.mock('../services/db/user.db', () => ({
findUserByEmail: vi.fn(),
createUser: vi.fn(),
saveRefreshToken: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
updateUserPassword: vi.fn(),
deleteResetToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
}));
vi.mock('../services/db/admin.db', () => ({
logActivity: vi.fn(),
vi.mock('../services/db/index.db', () => ({
userRepo: {
findUserByEmail: vi.fn(),
createUser: vi.fn(),
saveRefreshToken: vi.fn(),
createPasswordResetToken: vi.fn(),
getValidResetTokens: vi.fn(),
updateUserPassword: vi.fn(),
deleteResetToken: vi.fn(),
findUserByRefreshToken: vi.fn(),
},
adminRepo: {
logActivity: vi.fn(),
},
UniqueConstraintError: UniqueConstraintError, // Make sure custom errors are also exported from the mock
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
@@ -107,10 +110,10 @@ describe('Auth Routes (/api/auth)', () => {
preferences: {}
};
vi.mocked(userDb.findUserByEmail).mockResolvedValue(undefined); // No existing user
vi.mocked(userDb.createUser).mockResolvedValue(mockNewUser);
vi.mocked(userDb.saveRefreshToken).mockResolvedValue(undefined);
vi.mocked(adminDb.logActivity).mockResolvedValue(undefined);
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(undefined); // No existing user
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser);
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -143,14 +146,10 @@ describe('Auth Routes (/api/auth)', () => {
});
it('should reject registration if the email already exists', async () => {
// Arrange: Mock that the user exists
vi.mocked(userDb.findUserByEmail).mockResolvedValue({
user_id: 'existing',
email: newUserEmail,
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
});
// Arrange: Mock the createUser function to throw a UniqueConstraintError
vi.mocked(db.userRepo.createUser).mockRejectedValue(
new UniqueConstraintError('User with that email already exists.')
);
// Act
const response = await supertest(app)
@@ -177,7 +176,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should successfully log in a user and return a token and cookie', async () => {
// Arrange:
const loginCredentials = { email: 'test@test.com', password: 'password123' };
vi.mocked(userDb.saveRefreshToken).mockResolvedValue(undefined);
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -221,14 +220,14 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /forgot-password', () => {
it('should send a reset link if the user exists', async () => {
// Arrange
vi.mocked(userDb.findUserByEmail).mockResolvedValue({
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
});
vi.mocked(userDb.createPasswordResetToken).mockResolvedValue(undefined);
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -244,7 +243,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should return a generic success message even if the user does not exist', async () => {
// Arrange
vi.mocked(userDb.findUserByEmail).mockResolvedValue(undefined);
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -261,11 +260,11 @@ 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', expires_at: new Date(Date.now() + 3600000) };
vi.mocked(userDb.getValidResetTokens).mockResolvedValue([tokenRecord]);
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([tokenRecord]);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Token matches
vi.mocked(userDb.updateUserPassword).mockResolvedValue(undefined);
vi.mocked(userDb.deleteResetToken).mockResolvedValue(undefined);
vi.mocked(adminDb.logActivity).mockResolvedValue(undefined);
vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined);
vi.mocked(db.userRepo.deleteResetToken).mockResolvedValue(undefined);
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -279,7 +278,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should reject with an invalid or expired token', async () => {
// Arrange
vi.mocked(userDb.getValidResetTokens).mockResolvedValue([]); // No valid tokens found
vi.mocked(db.userRepo.getValidResetTokens).mockResolvedValue([]); // No valid tokens found
// Act
const response = await supertest(app)
@@ -302,7 +301,7 @@ describe('Auth Routes (/api/auth)', () => {
failed_login_attempts: 0,
last_failed_login: null,
};
vi.mocked(userDb.findUserByRefreshToken).mockResolvedValue(mockUser);
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser);
// Act
const response = await supertest(app)
@@ -321,7 +320,7 @@ describe('Auth Routes (/api/auth)', () => {
});
it('should return 403 if refresh token is invalid', async () => {
vi.mocked(userDb.findUserByRefreshToken).mockResolvedValue(undefined);
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/auth/refresh-token')

View File

@@ -7,7 +7,8 @@ import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import passport from './passport.routes'; // Corrected import path
import * as db from '../services/db/index.db';
import { userRepo, adminRepo } from '../services/db/index.db';
import { UniqueConstraintError } from '../services/db/errors.db';
import { getPool } from '../services/db/connection.db';
import { logger } from '../services/logger.server';
import { sendPasswordResetEmail } from '../services/emailService.server';
@@ -41,7 +42,7 @@ const resetPasswordLimiter = rateLimit({
// --- Authentication Routes ---
// Registration Route
router.post('/register', async (req, res) => {
router.post('/register', async (req, res, next) => {
const { email, password, full_name, avatar_url } = req.body;
if (!email || !password) {
@@ -57,17 +58,35 @@ router.post('/register', async (req, res) => {
return res.status(400).json({ message: `Password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
}
const existingUser = await db.findUserByEmail(email);
const existingUser = await userRepo.findUserByEmail(email);
if (existingUser) {
logger.warn(`Registration attempt for existing email: ${email}`);
return res.status(409).json({ message: 'User with that email already exists.' });
}
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
const client = await getPool().connect();
let newUser;
try {
await client.query('BEGIN');
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
const newUser = await db.createUser(email, hashedPassword, { full_name, avatar_url });
const repoWithTransaction = new (await import('../services/db/user.db')).UserRepository(client);
newUser = await repoWithTransaction.createUser(email, hashedPassword, { full_name, avatar_url });
await client.query('COMMIT');
} catch (error: any) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
}
await client.query('ROLLBACK');
logger.error('Transaction failed during user registration.', { error });
return next(error);
} finally {
client.release();
}
// Safe access to prevent crashing if the returned object structure is unexpected during tests
const userEmail = newUser?.user?.email || 'unknown';
@@ -75,7 +94,7 @@ router.post('/register', async (req, res) => {
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
// Use the new standardized logging function
await db.logActivity({
await adminRepo.logActivity({
userId: newUser.user_id,
action: 'user_registered',
displayText: `${userEmail} has registered.`,
@@ -86,7 +105,7 @@ router.post('/register', async (req, res) => {
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
const refreshToken = crypto.randomBytes(64).toString('hex');
await db.saveRefreshToken(newUser.user_id, refreshToken);
await userRepo.saveRefreshToken(newUser.user_id, refreshToken);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
@@ -129,7 +148,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
try {
const refreshToken = crypto.randomBytes(64).toString('hex');
await db.saveRefreshToken(typedUser.user_id, refreshToken);
await userRepo.saveRefreshToken(typedUser.user_id, refreshToken);
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
const cookieOptions = {
@@ -158,7 +177,7 @@ router.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) =>
try {
logger.debug(`[API /forgot-password] Received request for email: ${email}`);
const user = await db.findUserByEmail(email);
const user = await userRepo.findUserByEmail(email);
let token: string | undefined;
logger.debug(`[API /forgot-password] Database search result for ${email}:`, { user: user ? { user_id: user.user_id, email: user.email } : 'NOT FOUND' });
@@ -168,7 +187,7 @@ router.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) =>
const tokenHash = await bcrypt.hash(token, saltRounds);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
await db.createPasswordResetToken(user.user_id, tokenHash, expiresAt);
await userRepo.createPasswordResetToken(user.user_id, tokenHash, expiresAt);
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${token}`;
@@ -200,7 +219,7 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
}
try {
const validTokens = await db.getValidResetTokens();
const validTokens = await userRepo.getValidResetTokens();
let tokenRecord;
for (const record of validTokens) {
const isMatch = await bcrypt.compare(token, record.token_hash);
@@ -225,11 +244,11 @@ router.post('/reset-password', resetPasswordLimiter, async (req, res, next) => {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await db.updateUserPassword(tokenRecord.user_id, hashedPassword);
await db.deleteResetToken(tokenRecord.token_hash);
await userRepo.updateUserPassword(tokenRecord.user_id, hashedPassword);
await userRepo.deleteResetToken(tokenRecord.token_hash);
// Log this security event after a successful password reset.
await db.logActivity({
await adminRepo.logActivity({
userId: tokenRecord.user_id,
action: 'password_reset',
displayText: `User ID ${tokenRecord.user_id} has reset their password.`,
@@ -250,7 +269,7 @@ router.post('/refresh-token', async (req: Request, res: Response) => {
return res.status(401).json({ message: 'Refresh token not found.' });
}
const user = await db.findUserByRefreshToken(refreshToken);
const user = await userRepo.findUserByRefreshToken(refreshToken);
if (!user) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
}
@@ -271,8 +290,7 @@ router.post('/logout', async (req: Request, res: Response) => {
if (refreshToken) {
// Invalidate the token in the database so it cannot be used again.
// We don't need to wait for this to finish to respond to the user.
// This now calls the newly created function in user.db.ts.
db.deleteRefreshToken(refreshToken).catch((err: Error) => {
userRepo.deleteRefreshToken(refreshToken).catch((err: Error) => {
logger.error('Failed to delete refresh token from DB during logout.', { error: err });
});
}

View File

@@ -3,17 +3,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import budgetRouter from './budget.routes';
import * as budgetDb from '../services/db/budget.db';
import * as db from '../services/db/index.db';
import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
// 1. Mock the Service Layer directly.
// This decouples the route tests from the database logic.
vi.mock('../services/db/budget.db', () => ({
getBudgetsForUser: vi.fn(),
createBudget: vi.fn(),
updateBudget: vi.fn(),
deleteBudget: vi.fn(),
getSpendingByCategory: vi.fn(),
vi.mock('../services/db/index.db', () => ({
budgetRepo: {
getBudgetsForUser: vi.fn(),
createBudget: vi.fn(),
updateBudget: vi.fn(),
deleteBudget: vi.fn(),
getSpendingByCategory: vi.fn(),
}
}));
// Although not directly used by budget routes, the passport middleware is,
// and it depends on user.db. We must mock the functions it uses.
@@ -63,21 +66,21 @@ describe('Budget Routes (/api/budgets)', () => {
vi.clearAllMocks();
// Provide default mock implementations to prevent undefined errors.
// Individual tests can override these with more specific values.
vi.mocked(budgetDb.getBudgetsForUser).mockResolvedValue([]);
vi.mocked(budgetDb.getSpendingByCategory).mockResolvedValue([]);
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue([]);
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
});
describe('GET /', () => {
it('should return a list of budgets for the user', async () => {
const mockBudgets = [createMockBudget({ budget_id: 1, user_id: 'user-123' })];
// Mock the service function directly
vi.mocked(budgetDb.getBudgetsForUser).mockResolvedValue(mockBudgets);
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue(mockBudgets);
const response = await supertest(app).get('/api/budgets');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockBudgets);
expect(budgetDb.getBudgetsForUser).toHaveBeenCalledWith(mockUserProfile.user_id);
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(mockUserProfile.user_id);
});
});
@@ -86,13 +89,29 @@ describe('Budget Routes (/api/budgets)', () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
const mockCreatedBudget = createMockBudget({ budget_id: 2, user_id: 'user-123', ...newBudgetData });
// Mock the service function
vi.mocked(budgetDb.createBudget).mockResolvedValue(mockCreatedBudget);
vi.mocked(db.budgetRepo.createBudget).mockResolvedValue(mockCreatedBudget);
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(201);
expect(response.body).toEqual(mockCreatedBudget);
});
it('should return 400 if the user does not exist', async () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
it('should return 400 if the user does not exist', async () => {
const newBudgetData = { name: 'Entertainment', amount_cents: 10000, period: 'monthly' as const, start_date: '2024-01-01' };
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
});
describe('PUT /:id', () => {
@@ -100,24 +119,36 @@ describe('Budget Routes (/api/budgets)', () => {
const budgetUpdates = { amount_cents: 60000 };
const mockUpdatedBudget = createMockBudget({ budget_id: 1, user_id: 'user-123', ...budgetUpdates });
// Mock the service function
vi.mocked(budgetDb.updateBudget).mockResolvedValue(mockUpdatedBudget);
vi.mocked(db.budgetRepo.updateBudget).mockResolvedValue(mockUpdatedBudget);
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedBudget);
});
it('should return 404 if the budget is not found', async () => {
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new Error('Budget not found'));
const response = await supertest(app).put('/api/budgets/999').send({ amount_cents: 1 });
expect(response.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('should delete a budget', async () => {
// Mock the service function to resolve (void)
vi.mocked(budgetDb.deleteBudget).mockResolvedValue(undefined);
vi.mocked(db.budgetRepo.deleteBudget).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/budgets/1');
expect(response.status).toBe(204);
expect(budgetDb.deleteBudget).toHaveBeenCalledWith(1, mockUserProfile.user_id);
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(1, mockUserProfile.user_id);
});
it('should return 404 if the budget is not found', async () => {
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new Error('Budget not found'));
const response = await supertest(app).delete('/api/budgets/999');
expect(response.status).toBe(404);
});
});
@@ -125,7 +156,7 @@ describe('Budget Routes (/api/budgets)', () => {
it('should return spending analysis data for a valid date range', async () => {
const mockSpendingData = [createMockSpendingByCategory({ category_id: 1, category_name: 'Produce' })];
// Mock the service function
vi.mocked(budgetDb.getSpendingByCategory).mockResolvedValue(mockSpendingData);
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue(mockSpendingData);
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');
@@ -142,7 +173,7 @@ describe('Budget Routes (/api/budgets)', () => {
it('should return 500 if the database call fails', async () => {
// Mock the service function to throw
vi.mocked(budgetDb.getSpendingByCategory).mockRejectedValue(new Error('DB Error'));
vi.mocked(db.budgetRepo.getSpendingByCategory).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31');

View File

@@ -2,14 +2,11 @@
import express, { NextFunction } from 'express';
import passport from './passport.routes';
import {
getBudgetsForUser,
createBudget,
updateBudget,
deleteBudget,
getSpendingByCategory,
} from '../services/db/index.db';
budgetRepo } from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
const router = express.Router();
@@ -22,7 +19,7 @@ router.use(passport.authenticate('jwt', { session: false }));
router.get('/', async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
try {
const budgets = await getBudgetsForUser(user.user_id);
const budgets = await budgetRepo.getBudgetsForUser(user.user_id);
res.json(budgets);
} catch (error) {
logger.error('Error fetching budgets:', { error, userId: user.user_id });
@@ -36,9 +33,12 @@ router.get('/', async (req, res, next: NextFunction) => {
router.post('/', async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
try {
const newBudget = await createBudget(user.user_id, req.body);
const newBudget = await budgetRepo.createBudget(user.user_id, req.body);
res.status(201).json(newBudget);
} catch (error) {
} catch (error: unknown) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
logger.error('Error creating budget:', { error, userId: user.user_id, body: req.body });
next(error);
}
@@ -51,9 +51,12 @@ router.put('/:id', async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
const budgetId = parseInt(req.params.id, 10);
try {
const updatedBudget = await updateBudget(budgetId, user.user_id, req.body);
const updatedBudget = await budgetRepo.updateBudget(budgetId, user.user_id, req.body);
res.json(updatedBudget);
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
logger.error('Error updating budget:', { error, userId: user.user_id, budgetId });
next(error);
}
@@ -66,9 +69,12 @@ router.delete('/:id', async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
const budgetId = parseInt(req.params.id, 10);
try {
await deleteBudget(budgetId, user.user_id);
await budgetRepo.deleteBudget(budgetId, user.user_id);
res.status(204).send(); // No Content
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
logger.error('Error deleting budget:', { error, userId: user.user_id, budgetId });
next(error);
}
@@ -87,7 +93,7 @@ router.get('/spending-analysis', async (req, res, next: NextFunction) => {
}
try {
const spendingData = await getSpendingByCategory(user.user_id, startDate, endDate);
const spendingData = await budgetRepo.getSpendingByCategory(user.user_id, startDate, endDate);
res.json(spendingData);
} catch (error) {
logger.error('Error fetching spending analysis:', { error, userId: user.user_id, startDate, endDate });

View File

@@ -3,17 +3,19 @@ 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.routes';
import * as gamificationDb from '../services/db/gamification.db';
import * as db from '../services/db/index.db';
import { createMockUserProfile, createMockAchievement, createMockUserAchievement } from '../tests/utils/mockFactories';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
// Mock the entire db service
vi.mock('../services/db/gamification.db', () => ({
getAllAchievements: vi.fn(),
getUserAchievements: vi.fn(),
awardAchievement: vi.fn(),
getLeaderboard: vi.fn(),
vi.mock('../services/db/index.db', () => ({
gamificationRepo: {
getAllAchievements: vi.fn(),
getUserAchievements: vi.fn(),
awardAchievement: vi.fn(),
getLeaderboard: vi.fn(),
}
}));
const mockedDb = gamificationDb as Mocked<typeof gamificationDb>;
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
@@ -61,22 +63,35 @@ describe('Gamification Routes (/api/achievements)', () => {
describe('GET /', () => {
it('should return a list of all achievements (public endpoint)', async () => {
const mockAchievements = [createMockAchievement({ achievement_id: 1 }), createMockAchievement({ achievement_id: 2 })];
mockedDb.getAllAchievements.mockResolvedValue(mockAchievements);
vi.mocked(db.gamificationRepo.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);
expect(db.gamificationRepo.getAllAchievements).toHaveBeenCalledTimes(1);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Connection Failed');
mockedDb.getAllAchievements.mockRejectedValue(dbError);
vi.mocked(db.gamificationRepo.getAllAchievements).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/achievements');
expect(response.status).toBe(500);
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/achievements/award').send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
});
describe('GET /me', () => {
@@ -93,13 +108,13 @@ describe('Gamification Routes (/api/achievements)', () => {
});
const mockUserAchievements = [createMockUserAchievement({ achievement_id: 1, user_id: 'user-123' })];
mockedDb.getUserAchievements.mockResolvedValue(mockUserAchievements);
vi.mocked(db.gamificationRepo.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');
expect(db.gamificationRepo.getUserAchievements).toHaveBeenCalledWith('user-123');
});
it('should return 500 if the database call fails', async () => {
@@ -109,7 +124,7 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
const dbError = new Error('DB Error');
mockedDb.getUserAchievements.mockRejectedValue(dbError);
vi.mocked(db.gamificationRepo.getUserAchievements).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/achievements/me');
expect(response.status).toBe(500);
});
@@ -142,14 +157,14 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next()); // Grant admin access
mockedDb.awardAchievement.mockResolvedValue(undefined);
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
const response = await supertest(app).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(200);
expect(response.body.message).toContain('Successfully awarded');
expect(mockedDb.awardAchievement).toHaveBeenCalledTimes(1);
expect(mockedDb.awardAchievement).toHaveBeenCalledWith(awardPayload.userId, awardPayload.achievementName);
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledTimes(1);
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(awardPayload.userId, awardPayload.achievementName);
});
it('should return 400 if userId is missing', async () => {
@@ -170,27 +185,40 @@ describe('Gamification Routes (/api/achievements)', () => {
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
mockedDb.awardAchievement.mockRejectedValue(new Error('DB Error'));
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(500);
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/achievements/award').send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
});
describe('GET /leaderboard', () => {
it('should return a list of top users (public endpoint)', async () => {
const mockLeaderboard = [{ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' }];
mockedDb.getLeaderboard.mockResolvedValue(mockLeaderboard as any);
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard as any);
const response = await supertest(app).get('/api/achievements/leaderboard?limit=5');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
expect(mockedDb.getLeaderboard).toHaveBeenCalledWith(5);
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(5);
});
it('should return 500 if the database call fails', async () => {
mockedDb.getLeaderboard.mockRejectedValue(new Error('DB Error'));
vi.mocked(db.gamificationRepo.getLeaderboard).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/achievements/leaderboard');
expect(response.status).toBe(500);
});

View File

@@ -1,9 +1,10 @@
// src/routes/gamification.ts
import express, { NextFunction } from 'express';
import passport, { isAdmin } from './passport.routes';
import { getAllAchievements, getUserAchievements, awardAchievement, getLeaderboard } from '../services/db/index.db';
import { gamificationRepo } from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
@@ -16,7 +17,7 @@ const adminGamificationRouter = express.Router(); // Create a new router for adm
*/
router.get('/', async (req, res, next: NextFunction) => {
try {
const achievements = await getAllAchievements();
const achievements = await gamificationRepo.getAllAchievements();
res.json(achievements);
} catch (error) {
logger.error('Error fetching all achievements:', { error });
@@ -33,7 +34,7 @@ router.get('/leaderboard', async (req, res, next: NextFunction) => {
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
try {
const leaderboard = await getLeaderboard(limit);
const leaderboard = await gamificationRepo.getLeaderboard(limit);
res.json(leaderboard);
} catch (error) {
logger.error('Error fetching leaderboard:', { error });
@@ -53,7 +54,7 @@ router.get(
async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
try {
const userAchievements = await getUserAchievements(user.user_id);
const userAchievements = await gamificationRepo.getUserAchievements(user.user_id);
res.json(userAchievements);
} catch (error) {
logger.error('Error fetching user achievements:', { error, userId: user.user_id });
@@ -81,9 +82,12 @@ adminGamificationRouter.post(
}
try {
await awardAchievement(userId, achievementName);
await gamificationRepo.awardAchievement(userId, achievementName);
res.status(200).json({ message: `Successfully awarded '${achievementName}' to user ${userId}.` });
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
logger.error('Error awarding achievement via admin endpoint:', { error, userId, achievementName });
next(error);
}

View File

@@ -1,40 +1,34 @@
// src/routes/public.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import publicRouter from './public.routes'; // Import the router we want to test
import * as connectionDb from '../services/db/connection.db';
import * as flyerDb from '../services/db/flyer.db';
import * as recipeDb from '../services/db/recipe.db';
import * as adminDb from '../services/db/admin.db';
import * as personalizationDb from '../services/db/personalization.db';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockRecipe, createMockRecipeComment, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
// 1. Mock the Service Layer directly.
// This decouples the route tests from the SQL implementation details.
vi.mock('../services/db/connection.db', () => ({
// The public.routes.ts file imports from '.../db/index.db'. We need to mock that module.
vi.mock('../services/db/index.db', () => ({
checkTablesExist: vi.fn(),
getPoolStatus: vi.fn(),
}));
vi.mock('../services/db/flyer.db', () => ({
getFlyers: vi.fn(),
getFlyerItems: vi.fn(),
getFlyerItemsForFlyers: vi.fn(),
countFlyerItemsForFlyers: vi.fn(),
}));
vi.mock('../services/db/recipe.db', () => ({
getRecipesBySalePercentage: vi.fn(),
getRecipesByMinSaleIngredients: vi.fn(),
findRecipesByIngredientAndTag: vi.fn(),
getRecipeComments: vi.fn(),
}));
vi.mock('../services/db/personalization.db', () => ({
getAllMasterItems: vi.fn(),
getMostFrequentSaleItems: vi.fn(),
getDietaryRestrictions: vi.fn(),
getAppliances: vi.fn(),
}));
vi.mock('../services/db/admin.db', () => ({
getMostFrequentSaleItems: vi.fn(),
flyerRepo: {
getFlyers: vi.fn(),
getFlyerItems: vi.fn(),
getFlyerItemsForFlyers: vi.fn(),
countFlyerItemsForFlyers: vi.fn(),
},
recipeRepo: {
getRecipesBySalePercentage: vi.fn(),
getRecipesByMinSaleIngredients: vi.fn(),
findRecipesByIngredientAndTag: vi.fn(),
getRecipeComments: vi.fn(),
},
personalizationRepo: {
getAllMasterItems: vi.fn(),
},
}));
// Mock the logger to keep test output clean
@@ -63,6 +57,10 @@ vi.mock('node:fs/promises', () => {
});
import * as fs from 'node:fs/promises';
// Import the mocked db module to control its functions in tests
import * as db from '../services/db/index.db';
// Create the Express app
const app = express();
app.use(express.json({ strict: false }));
@@ -86,7 +84,7 @@ describe('Public Routes (/api)', () => {
describe('GET /health/db-schema', () => {
it('should return 200 OK if all tables exist', async () => {
// Mock the service function to return an empty array (no missing tables)
vi.mocked(connectionDb.checkTablesExist).mockResolvedValue([]);
vi.mocked(db.checkTablesExist).mockResolvedValue([]);
const response = await supertest(app).get('/api/health/db-schema');
@@ -96,7 +94,7 @@ describe('Public Routes (/api)', () => {
it('should return 500 if tables are missing', async () => {
// Mock the service function to return missing table names
vi.mocked(connectionDb.checkTablesExist).mockResolvedValue(['missing_table']);
vi.mocked(db.checkTablesExist).mockResolvedValue(['missing_table']);
const response = await supertest(app).get('/api/health/db-schema');
@@ -131,7 +129,7 @@ describe('Public Routes (/api)', () => {
describe('GET /health/db-pool', () => {
it('should return 200 OK for a healthy pool', async () => {
// Mock the simple synchronous status function
vi.mocked(connectionDb.getPoolStatus).mockReturnValue({ totalCount: 10, idleCount: 5, waitingCount: 0 });
vi.mocked(db.getPoolStatus).mockReturnValue({ totalCount: 10, idleCount: 5, waitingCount: 0 });
const response = await supertest(app).get('/api/health/db-pool');
@@ -140,11 +138,36 @@ describe('Public Routes (/api)', () => {
});
});
describe('GET /time', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should return the current server time, year, and week number', async () => {
// Arrange: Set a specific date to test against
const fakeDate = new Date('2024-03-15T10:30:00.000Z'); // This is a Friday, in the 11th week of 2024
vi.setSystemTime(fakeDate);
// Act
const response = await supertest(app).get('/api/time');
// Assert
expect(response.status).toBe(200);
expect(response.body.currentTime).toBe('2024-03-15T10:30:00.000Z');
expect(response.body.year).toBe(2024);
expect(response.body.week).toBe(11);
});
});
describe('GET /flyers', () => {
it('should return a list of flyers on success', async () => {
const mockFlyers = [createMockFlyer({ flyer_id: 1 }), createMockFlyer({ flyer_id: 2 })];
// Mock the service function
vi.mocked(flyerDb.getFlyers).mockResolvedValue(mockFlyers);
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue(mockFlyers);
const response = await supertest(app).get('/api/flyers');
@@ -154,16 +177,16 @@ describe('Public Routes (/api)', () => {
it('should pass limit and offset query parameters to the db function', async () => {
const mockFlyers = [createMockFlyer({ flyer_id: 1 })];
vi.mocked(flyerDb.getFlyers).mockResolvedValue(mockFlyers);
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue(mockFlyers);
await supertest(app).get('/api/flyers?limit=15&offset=30');
expect(flyerDb.getFlyers).toHaveBeenCalledTimes(1);
expect(flyerDb.getFlyers).toHaveBeenCalledWith(15, 30);
expect(db.flyerRepo.getFlyers).toHaveBeenCalledTimes(1);
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(15, 30);
});
it('should handle database errors gracefully', async () => {
vi.mocked(flyerDb.getFlyers).mockRejectedValue(new Error('DB Error'));
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/flyers');
@@ -175,7 +198,7 @@ describe('Public Routes (/api)', () => {
describe('GET /master-items', () => {
it('should return a list of master items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(personalizationDb.getAllMasterItems).mockResolvedValue(mockItems);
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/master-items');
@@ -187,7 +210,7 @@ describe('Public Routes (/api)', () => {
describe('GET /flyers/:id/items', () => {
it('should return items for a specific flyer', async () => {
const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123 })];
vi.mocked(flyerDb.getFlyerItems).mockResolvedValue(mockFlyerItems);
vi.mocked(db.flyerRepo.getFlyerItems).mockResolvedValue(mockFlyerItems);
const response = await supertest(app).get('/api/flyers/123/items');
@@ -199,7 +222,7 @@ describe('Public Routes (/api)', () => {
describe('POST /flyer-items/batch-fetch', () => {
it('should return items for multiple flyers', async () => {
const mockFlyerItems = [createMockFlyerItem({ flyer_item_id: 1, flyer_id: 1 })];
vi.mocked(flyerDb.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems);
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems);
const response = await supertest(app)
.post('/api/flyer-items/batch-fetch')
@@ -220,7 +243,7 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-sale-percentage', () => {
it('should return recipes based on sale percentage', async () => {
const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
vi.mocked(recipeDb.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
@@ -231,7 +254,7 @@ describe('Public Routes (/api)', () => {
describe('POST /flyer-items/batch-count', () => {
it('should return the count of items for multiple flyers', async () => {
vi.mocked(flyerDb.countFlyerItemsForFlyers).mockResolvedValue(42);
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(42);
const response = await supertest(app)
.post('/api/flyer-items/batch-count')
@@ -253,7 +276,7 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-sale-ingredients', () => {
it('should return recipes with default minIngredients', async () => {
vi.mocked(recipeDb.getRecipesByMinSaleIngredients).mockResolvedValue([]);
vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
expect(response.status).toBe(200);
});
@@ -268,7 +291,7 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-ingredient-and-tag', () => {
it('should return recipes for a given ingredient and tag', async () => {
const mockRecipes = [createMockRecipe({ recipe_id: 2, name: 'Chicken Tacos' })];
vi.mocked(recipeDb.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick');
@@ -285,15 +308,15 @@ describe('Public Routes (/api)', () => {
describe('GET /stats/most-frequent-sales', () => {
it('should return most frequent sale items with default parameters', async () => {
vi.mocked(adminDb.getMostFrequentSaleItems).mockResolvedValue([]);
vi.mocked(db.getMostFrequentSaleItems).mockResolvedValue([]);
const response = await supertest(app).get('/api/stats/most-frequent-sales');
expect(response.status).toBe(200);
});
it('should use provided query parameters', async () => {
vi.mocked(adminDb.getMostFrequentSaleItems).mockResolvedValue([]);
vi.mocked(db.getMostFrequentSaleItems).mockResolvedValue([]);
await supertest(app).get('/api/stats/most-frequent-sales?days=90&limit=5');
expect(adminDb.getMostFrequentSaleItems).toHaveBeenCalledWith(90, 5);
expect(db.getMostFrequentSaleItems).toHaveBeenCalledWith(90, 5);
});
it('should return 400 for an invalid "days" parameter', async () => {
@@ -305,21 +328,21 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/:recipeId/comments', () => {
it('should return comments for a specific recipe', async () => {
const mockComments = [createMockRecipeComment({ recipe_id: 1, content: 'Great recipe!' })];
vi.mocked(recipeDb.getRecipeComments).mockResolvedValue(mockComments);
const mockComments = [createMockRecipeComment({ recipe_id: 1, content: 'Great recipe!' })]; // This was a duplicate, fixed.
vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue(mockComments);
const response = await supertest(app).get('/api/recipes/1/comments');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockComments);
expect(recipeDb.getRecipeComments).toHaveBeenCalledWith(1);
expect(db.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1);
});
});
}); // This was a duplicate, fixed.
describe('GET /dietary-restrictions', () => {
it('should return a list of all dietary restrictions', async () => {
const mockRestrictions = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
vi.mocked(personalizationDb.getDietaryRestrictions).mockResolvedValue(mockRestrictions);
vi.mocked(db.getDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/dietary-restrictions');
@@ -331,7 +354,7 @@ describe('Public Routes (/api)', () => {
describe('GET /appliances', () => {
it('should return a list of all appliances', async () => {
const mockAppliances = [createMockAppliance({ name: 'Air Fryer' })];
vi.mocked(personalizationDb.getAppliances).mockResolvedValue(mockAppliances);
vi.mocked(db.getAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/appliances');

View File

@@ -1,8 +1,10 @@
// src/routes/public.ts
import { Router, Request, Response, NextFunction } from 'express';
import * as db from '../services/db/index.db';
import { checkTablesExist, getPoolStatus } from '../services/db/connection.db';
import { logger } from '../services/logger.server';
import fs from 'node:fs/promises';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
const router = Router();
@@ -14,7 +16,7 @@ router.get('/health/ping', (req: Request, res: Response) => {
router.get('/health/db-schema', async (req, res) => {
try {
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
const missingTables = await db.checkTablesExist(requiredTables);
const missingTables = await checkTablesExist(requiredTables);
if (missingTables.length > 0) {
return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` });
@@ -39,7 +41,7 @@ router.get('/health/storage', async (req, res) => {
router.get('/health/db-pool', (req: Request, res: Response) => {
try {
const status = db.getPoolStatus();
const status = getPoolStatus();
const isHealthy = status.waitingCount < 5;
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
@@ -55,6 +57,19 @@ router.get('/health/db-pool', (req: Request, res: Response) => {
}
});
/**
* GET /api/time - Get the server's current time and week number.
* This is useful for client-side components that need to be aware of the server's time.
*/
router.get('/time', (req: Request, res: Response) => {
const now = new Date();
const { year, week } = getSimpleWeekAndYear(now);
res.json({
currentTime: now.toISOString(),
year,
week,
});
});
// --- Public Data Routes ---
router.get('/flyers', async (req, res, next: NextFunction) => {
@@ -62,7 +77,7 @@ router.get('/flyers', async (req, res, next: NextFunction) => {
// Add pagination support to the flyers endpoint.
const limit = parseInt(req.query.limit as string, 10) || 20;
const offset = parseInt(req.query.offset as string, 10) || 0;
const flyers = await db.getFlyers(limit, offset);
const flyers = await db.flyerRepo.getFlyers(limit, offset);
res.json(flyers);
} catch (error) {
logger.error('Error fetching flyers in /api/flyers:', { error });
@@ -72,7 +87,7 @@ router.get('/flyers', async (req, res, next: NextFunction) => {
router.get('/master-items', async (req, res, next: NextFunction) => {
try {
const masterItems = await db.getAllMasterItems();
const masterItems = await db.personalizationRepo.getAllMasterItems();
res.json(masterItems);
} catch (error) {
logger.error('Error fetching master items in /api/master-items:', { error });
@@ -83,7 +98,7 @@ router.get('/master-items', async (req, res, next: NextFunction) => {
router.get('/flyers/:id/items', async (req, res, next: NextFunction) => {
try {
const flyerId = parseInt(req.params.id, 10);
const items = await db.getFlyerItems(flyerId);
const items = await db.flyerRepo.getFlyerItems(flyerId);
res.json(items);
} catch (error) {
logger.error('Error fetching flyer items in /api/flyers/:id/items:', { error });
@@ -97,7 +112,7 @@ router.post('/flyer-items/batch-fetch', async (req, res, next: NextFunction) =>
return res.status(400).json({ message: 'flyerIds must be an array.' });
}
try {
const items = await db.getFlyerItemsForFlyers(flyerIds);
const items = await db.flyerRepo.getFlyerItemsForFlyers(flyerIds);
res.json(items);
} catch (error) {
next(error);
@@ -110,7 +125,7 @@ router.post('/flyer-items/batch-count', async (req, res, next: NextFunction) =>
return res.status(400).json({ message: 'flyerIds must be an array.' });
}
try {
const count = await db.countFlyerItemsForFlyers(flyerIds);
const count = await db.flyerRepo.countFlyerItemsForFlyers(flyerIds);
res.json({ count });
} catch (error) {
next(error);
@@ -125,7 +140,7 @@ router.get('/recipes/by-sale-percentage', async (req, res, next: NextFunction) =
return res.status(400).json({ message: 'Query parameter "minPercentage" must be a number between 0 and 100.' });
}
try {
const recipes = await db.getRecipesBySalePercentage(minPercentage);
const recipes = await db.recipeRepo.getRecipesBySalePercentage(minPercentage);
res.json(recipes);
} catch (error) {
next(error);
@@ -140,7 +155,7 @@ router.get('/recipes/by-sale-ingredients', async (req, res, next: NextFunction)
if (isNaN(minIngredients) || minIngredients < 1) {
return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' });
}
const recipes = await db.getRecipesByMinSaleIngredients(minIngredients);
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(minIngredients);
res.json(recipes);
} catch (error) {
next(error);
@@ -153,7 +168,7 @@ router.get('/recipes/by-ingredient-and-tag', async (req, res, next: NextFunction
if (!ingredient || !tag) {
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
}
const recipes = await db.findRecipesByIngredientAndTag(ingredient as string, tag as string);
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(ingredient as string, tag as string);
res.json(recipes);
} catch (error) {
next(error);
@@ -175,7 +190,7 @@ router.get('/stats/most-frequent-sales', async (req, res, next: NextFunction) =>
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
}
const items = await db.getMostFrequentSaleItems(days, limit);
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit);
res.json(items);
} catch (error) {
next(error);
@@ -185,7 +200,7 @@ router.get('/stats/most-frequent-sales', async (req, res, next: NextFunction) =>
router.get('/recipes/:recipeId/comments', async (req, res, next: NextFunction) => {
try {
const recipeId = parseInt(req.params.recipeId, 10);
const comments = await db.getRecipeComments(recipeId);
const comments = await db.recipeRepo.getRecipeComments(recipeId);
res.json(comments);
} catch (error) {
next(error);
@@ -194,7 +209,7 @@ router.get('/recipes/:recipeId/comments', async (req, res, next: NextFunction) =
router.get('/dietary-restrictions', async (req, res, next: NextFunction) => {
try {
const restrictions = await db.getDietaryRestrictions();
const restrictions = await db.personalizationRepo.getDietaryRestrictions();
res.json(restrictions);
} catch (error) {
next(error);
@@ -203,7 +218,7 @@ router.get('/dietary-restrictions', async (req, res, next: NextFunction) => {
router.get('/appliances', async (req, res, next: NextFunction) => {
try {
const appliances = await db.getAppliances();
const appliances = await db.personalizationRepo.getAppliances();
res.json(appliances);
} catch (error) {
next(error);

View File

@@ -5,51 +5,48 @@ import express, { Request, Response, NextFunction } from 'express';
// Use * as bcrypt to match the implementation's import style and ensure mocks align.
import * as bcrypt from 'bcrypt';
import userRouter from './user.routes';
import * as userDb from '../services/db/user.db';
import * as personalizationDb from '../services/db/personalization.db';
import * as notificationDb from '../services/db/notification.db';
import * as addressDb from '../services/db/address.db';
import * as shoppingDb from '../services/db/shopping.db';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem } from '../tests/utils/mockFactories';
import { Appliance, Notification } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/user.db', () => ({
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPassword: vi.fn(),
findUserWithPasswordHashById: vi.fn(),
deleteUserById: vi.fn(),
updateUserPreferences: vi.fn(),
findUserById: vi.fn(),
findUserByEmail: vi.fn(),
createUser: vi.fn(),
}));
vi.mock('../services/db/personalization.db', () => ({
getWatchedItems: vi.fn(),
addWatchedItem: vi.fn(),
removeWatchedItem: vi.fn(),
getUserDietaryRestrictions: vi.fn(),
setUserDietaryRestrictions: vi.fn(),
getUserAppliances: vi.fn(),
setUserAppliances: vi.fn(),
}));
vi.mock('../services/db/shopping.db', () => ({
getShoppingLists: vi.fn(),
createShoppingList: vi.fn(),
deleteShoppingList: vi.fn(),
addShoppingListItem: vi.fn(),
updateShoppingListItem: vi.fn(),
removeShoppingListItem: vi.fn(),
}));
vi.mock('../services/db/notification.db', () => ({
getNotificationsForUser: vi.fn(),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
}));
vi.mock('../services/db/address.db', () => ({
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
// The user.routes.ts file imports from '.../db/index.db'. We need to mock that module.
vi.mock('../services/db/index.db', () => ({
// Repository instances
userRepo: {
findUserProfileById: vi.fn(),
updateUserProfile: vi.fn(),
updateUserPassword: vi.fn(),
findUserWithPasswordHashById: vi.fn(),
deleteUserById: vi.fn(),
updateUserPreferences: vi.fn(),
},
personalizationRepo: {
getWatchedItems: vi.fn(),
removeWatchedItem: vi.fn(),
addWatchedItem: vi.fn(),
getUserDietaryRestrictions: vi.fn(),
setUserDietaryRestrictions: vi.fn(),
getUserAppliances: vi.fn(),
setUserAppliances: vi.fn(),
},
shoppingRepo: {
getShoppingLists: vi.fn(),
createShoppingList: vi.fn(),
deleteShoppingList: vi.fn(),
addShoppingListItem: vi.fn(),
updateShoppingListItem: vi.fn(),
removeShoppingListItem: vi.fn(),
},
addressRepo: {
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
},
notificationRepo: {
getNotificationsForUser: vi.fn(),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
},
}));
// 2. Mock bcrypt.
@@ -91,6 +88,9 @@ vi.mock('./passport.routes', () => ({
optionalAuth: vi.fn((req: express.Request, res: express.Response, next: express.NextFunction) => next()),
}));
// Import the mocked db module to control its functions in tests
import * as db from '../services/db/index.db';
// Setup Express App
const createApp = (authenticatedUser?: any) => {
const app = express();
@@ -135,22 +135,22 @@ describe('User Routes (/api/users)', () => {
});
describe('GET /profile', () => {
it('should return the full user profile', async () => {
vi.mocked(userDb.findUserProfileById).mockResolvedValue(mockUserProfile);
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserProfile);
expect(userDb.findUserProfileById).toHaveBeenCalledWith(mockUserProfile.user_id);
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(mockUserProfile.user_id);
});
it('should return 404 if profile is not found in DB', async () => {
vi.mocked(userDb.findUserProfileById).mockResolvedValue(undefined);
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(undefined);
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Profile not found for this user.');
});
it('should return 500 if the database call fails', async () => {
vi.mocked(userDb.findUserProfileById).mockRejectedValue(new Error('DB Error'));
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(500);
});
@@ -159,14 +159,14 @@ describe('User Routes (/api/users)', () => {
describe('GET /watched-items', () => {
it('should return a list of watched items', async () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(personalizationDb.getWatchedItems).mockResolvedValue(mockItems);
vi.mocked(db.personalizationRepo.getWatchedItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(personalizationDb.getWatchedItems).mockRejectedValue(new Error('DB Error'));
vi.mocked(db.personalizationRepo.getWatchedItems).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(500);
});
@@ -176,7 +176,7 @@ describe('User Routes (/api/users)', () => {
it('should add an item to the watchlist and return the new item', async () => {
const newItem = { itemName: 'Organic Bananas', category: 'Produce' };
const mockAddedItem = createMockMasterGroceryItem({ master_grocery_item_id: 99, name: 'Organic Bananas', category_name: 'Produce' });
vi.mocked(personalizationDb.addWatchedItem).mockResolvedValue(mockAddedItem);
vi.mocked(db.personalizationRepo.addWatchedItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
.post('/api/users/watched-items')
.send(newItem);
@@ -185,7 +185,7 @@ describe('User Routes (/api/users)', () => {
});
it('should return 500 if the database call fails', async () => {
vi.mocked(personalizationDb.addWatchedItem).mockRejectedValue(new Error('DB Error'));
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Failing Item', category: 'Errors' });
@@ -193,16 +193,24 @@ describe('User Routes (/api/users)', () => {
});
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.personalizationRepo.addWatchedItem).mockRejectedValue(new ForeignKeyConstraintError('Category not found'));
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Test', category: 'Invalid' });
expect(response.status).toBe(400);
});
describe('DELETE /watched-items/:masterItemId', () => {
it('should remove an item from the watchlist', async () => {
vi.mocked(personalizationDb.removeWatchedItem).mockResolvedValue(undefined);
vi.mocked(db.personalizationRepo.removeWatchedItem).mockResolvedValue(undefined);
const response = await supertest(app).delete(`/api/users/watched-items/99`);
expect(response.status).toBe(204);
expect(personalizationDb.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user_id, 99);
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user_id, 99);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(personalizationDb.removeWatchedItem).mockRejectedValue(new Error('DB Error'));
vi.mocked(db.personalizationRepo.removeWatchedItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).delete('/api/users/watched-items/99');
expect(response.status).toBe(500);
});
@@ -211,7 +219,7 @@ describe('User Routes (/api/users)', () => {
describe('Shopping List Routes', () => {
it('GET /shopping-lists should return all shopping lists for the user', async () => {
const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id })];
vi.mocked(shoppingDb.getShoppingLists).mockResolvedValue(mockLists);
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
const response = await supertest(app).get('/api/users/shopping-lists');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLists);
@@ -219,7 +227,7 @@ describe('User Routes (/api/users)', () => {
it('POST /shopping-lists should create a new list', async () => {
const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies' });
vi.mocked(shoppingDb.createShoppingList).mockResolvedValue(mockNewList);
vi.mocked(db.shoppingRepo.createShoppingList).mockResolvedValue(mockNewList);
const response = await supertest(app)
.post('/api/users/shopping-lists')
.send({ name: 'Party Supplies' });
@@ -228,8 +236,15 @@ describe('User Routes (/api/users)', () => {
expect(response.body).toEqual(mockNewList);
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
it('DELETE /shopping-lists/:listId should delete a list', async () => {
vi.mocked(shoppingDb.deleteShoppingList).mockResolvedValue(undefined);
vi.mocked(db.shoppingRepo.deleteShoppingList).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/1');
expect(response.status).toBe(204);
});
@@ -239,6 +254,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid list ID.');
});
it('should return 404 if list to delete is not found', async () => {
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new Error('not found'));
const response = await supertest(app).delete('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
});
});
describe('Shopping List Item Routes', () => {
@@ -246,7 +267,7 @@ describe('User Routes (/api/users)', () => {
const listId = 1;
const itemData = { customItemName: 'Paper Towels' };
const mockAddedItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: listId, ...itemData });
vi.mocked(shoppingDb.addShoppingListItem).mockResolvedValue(mockAddedItem);
vi.mocked(db.shoppingRepo.addShoppingListItem).mockResolvedValue(mockAddedItem);
const response = await supertest(app)
.post(`/api/users/shopping-lists/${listId}/items`)
.send(itemData);
@@ -259,7 +280,7 @@ describe('User Routes (/api/users)', () => {
const itemId = 101;
const updates = { is_purchased: true, quantity: 2 };
const mockUpdatedItem = createMockShoppingListItem({ shopping_list_item_id: itemId, shopping_list_id: 1, ...updates });
vi.mocked(shoppingDb.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
vi.mocked(db.shoppingRepo.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
const response = await supertest(app)
.put(`/api/users/shopping-lists/items/${itemId}`)
.send(updates);
@@ -268,8 +289,15 @@ describe('User Routes (/api/users)', () => {
expect(response.body).toEqual(mockUpdatedItem);
});
it('should return 400 if a foreign key constraint fails', async () => {
vi.mocked(db.shoppingRepo.createShoppingList).mockRejectedValue(new ForeignKeyConstraintError('User not found'));
const response = await supertest(app).post('/api/users/shopping-lists').send({ name: 'Failing List' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
it('DELETE /shopping-lists/items/:itemId should delete an item', async () => {
vi.mocked(shoppingDb.removeShoppingListItem).mockResolvedValue(undefined);
vi.mocked(db.shoppingRepo.removeShoppingListItem).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
expect(response.status).toBe(204);
});
@@ -279,13 +307,19 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid item ID.');
});
it('should return 404 if list to delete is not found', async () => {
vi.mocked(db.shoppingRepo.deleteShoppingList).mockRejectedValue(new Error('not found'));
const response = await supertest(app).delete('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
});
});
describe('PUT /profile', () => {
it('should update the user profile successfully', async () => {
const profileUpdates = { full_name: 'New Name' };
const updatedProfile = { ...mockUserProfile, ...profileUpdates };
vi.mocked(userDb.updateUserProfile).mockResolvedValue(updatedProfile);
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile')
.send(profileUpdates);
@@ -306,7 +340,7 @@ describe('User Routes (/api/users)', () => {
describe('PUT /profile/password', () => {
it('should update the password successfully with a strong password', async () => {
vi.mocked(bcrypt.hash).mockResolvedValue('hashed-password' as never);
vi.mocked(userDb.updateUserPassword).mockResolvedValue(undefined);
vi.mocked(db.userRepo.updateUserPassword).mockResolvedValue(undefined);
const response = await supertest(app)
.put('/api/users/profile/password')
.send({ newPassword: 'a-Very-Strong-Password-456!' });
@@ -333,8 +367,8 @@ describe('User Routes (/api/users)', () => {
failed_login_attempts: 0, // Add missing properties
last_failed_login: null
};
vi.mocked(userDb.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(userDb.deleteUserById).mockResolvedValue(undefined);
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
const response = await supertest(app)
.delete('/api/users/account')
@@ -350,7 +384,7 @@ describe('User Routes (/api/users)', () => {
failed_login_attempts: 0,
last_failed_login: null
};
vi.mocked(userDb.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const response = await supertest(app)
.delete('/api/users/account')
@@ -369,7 +403,7 @@ describe('User Routes (/api/users)', () => {
...mockUserProfile,
preferences: { ...mockUserProfile.preferences, ...preferencesUpdate }
};
vi.mocked(userDb.updateUserPreferences).mockResolvedValue(updatedProfile);
vi.mocked(db.userRepo.updateUserPreferences).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile/preferences')
.send(preferencesUpdate);
@@ -390,14 +424,14 @@ describe('User Routes (/api/users)', () => {
describe('GET and PUT /users/me/dietary-restrictions', () => {
it('GET should return a list of restriction IDs', async () => {
const mockRestrictions = [{ dietary_restriction_id: 1, name: 'Gluten-Free', type: 'diet' as const }];
vi.mocked(personalizationDb.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
});
it('PUT should successfully set the restrictions', async () => {
vi.mocked(personalizationDb.setUserDietaryRestrictions).mockResolvedValue(undefined);
vi.mocked(db.personalizationRepo.setUserDietaryRestrictions).mockResolvedValue(undefined);
const restrictionIds = [1, 3, 5];
const response = await supertest(app)
.put('/api/users/me/dietary-restrictions')
@@ -409,14 +443,14 @@ describe('User Routes (/api/users)', () => {
describe('GET and PUT /users/me/appliances', () => {
it('GET should return a list of appliance IDs', async () => {
const mockAppliances: Appliance[] = [{ appliance_id: 2, name: 'Air Fryer' }];
vi.mocked(personalizationDb.getUserAppliances).mockResolvedValue(mockAppliances);
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
});
it('PUT should successfully set the appliances', async () => {
vi.mocked(personalizationDb.setUserAppliances).mockResolvedValue([]);
vi.mocked(db.personalizationRepo.setUserAppliances).mockResolvedValue([]);
const applianceIds = [2, 4, 6];
const response = await supertest(app).put('/api/users/me/appliances').send({ applianceIds });
expect(response.status).toBe(204);
@@ -427,27 +461,27 @@ describe('User Routes (/api/users)', () => {
describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => {
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '' }];
vi.mocked(notificationDb.getNotificationsForUser).mockResolvedValue(mockNotifications);
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(notificationDb.getNotificationsForUser).toHaveBeenCalledWith('user-123', 10, 0);
expect(db.notificationRepo.getNotificationsForUser).toHaveBeenCalledWith('user-123', 10, 0);
});
it('POST /notifications/mark-all-read should return 204', async () => {
vi.mocked(notificationDb.markAllNotificationsAsRead).mockResolvedValue(undefined);
vi.mocked(db.notificationRepo.markAllNotificationsAsRead).mockResolvedValue(undefined);
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
expect(response.status).toBe(204);
expect(notificationDb.markAllNotificationsAsRead).toHaveBeenCalledWith('user-123');
expect(db.notificationRepo.markAllNotificationsAsRead).toHaveBeenCalledWith('user-123');
});
it('POST /notifications/:notificationId/mark-read should return 204', async () => {
vi.mocked(notificationDb.markNotificationAsRead).mockResolvedValue({} as any);
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue({} as any);
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
expect(response.status).toBe(204);
expect(notificationDb.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
});
it('should return 400 for an invalid notificationId', async () => {
@@ -466,7 +500,7 @@ describe('User Routes (/api/users)', () => {
it('GET /addresses/:addressId should return 404 if address not found', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 });
vi.mocked(addressDb.getAddressById).mockResolvedValue(undefined);
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(undefined);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(404);
});
@@ -474,24 +508,24 @@ describe('User Routes (/api/users)', () => {
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: null }); // User has no address yet
const addressData = { address_line_1: '123 New St' };
vi.mocked(addressDb.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(userDb.updateUserProfile).mockResolvedValue({} as any);
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({} as any);
const response = await supertest(appWithUser)
.put('/api/users/profile/address')
.send(addressData);
expect(response.status).toBe(200);
expect(addressDb.upsertAddress).toHaveBeenCalledWith({ ...addressData, address_id: undefined });
expect(db.addressRepo.upsertAddress).toHaveBeenCalledWith({ ...addressData, address_id: undefined });
// Verify that the user's profile was updated to link the new address
expect(userDb.updateUserProfile).toHaveBeenCalledWith('user-123', { address_id: 5 });
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith('user-123', { address_id: 5 });
});
});
describe('POST /profile/avatar', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = { ...mockUserProfile, avatar_url: '/uploads/avatars/new-avatar.png' };
vi.mocked(userDb.updateUserProfile).mockResolvedValue(mockUpdatedProfile);
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUpdatedProfile);
// Create a dummy file path for supertest to attach
const dummyImagePath = 'test-avatar.png';
@@ -502,7 +536,7 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('/uploads/avatars/');
expect(userDb.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user_id, { avatar_url: expect.any(String) });
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user_id, { avatar_url: expect.any(String) });
});
it('should return 400 if a non-image file is uploaded', async () => {

View File

@@ -9,6 +9,7 @@ import zxcvbn from 'zxcvbn';
import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { User, UserProfile, Address } from '../types';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
const router = express.Router();
@@ -72,7 +73,7 @@ router.post(
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const user = req.user as User;
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const updatedProfile = await db.updateUserProfile(user.user_id, { avatar_url: avatarUrl });
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl });
res.json(updatedProfile);
}
);
@@ -101,7 +102,7 @@ router.get(
const limit = parseInt(req.query.limit as string, 10) || 20;
const offset = parseInt(req.query.offset as string, 10) || 0;
const notifications = await db.getNotificationsForUser(user.user_id, limit, offset);
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset);
res.json(notifications);
}
);
@@ -113,7 +114,7 @@ router.post(
'/notifications/mark-all-read',
async (req: Request, res: Response) => {
const user = req.user as User;
await db.markAllNotificationsAsRead(user.user_id);
await db.notificationRepo.markAllNotificationsAsRead(user.user_id);
res.status(204).send(); // No Content
}
);
@@ -131,7 +132,7 @@ router.post(
return res.status(400).json({ message: 'Invalid notification ID.' });
}
await db.markNotificationAsRead(notificationId, user.user_id);
await db.notificationRepo.markNotificationAsRead(notificationId, user.user_id);
res.status(204).send(); // Success, no content to return
}
);
@@ -143,8 +144,8 @@ router.get('/profile', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/profile - ENTER`);
const user = req.user as UserProfile;
try {
logger.debug(`[ROUTE] Calling db.findUserProfileById for user: ${user.user_id}`);
const userProfile = await db.findUserProfileById(user.user_id);
logger.debug(`[ROUTE] Calling db.userRepo.findUserProfileById for user: ${user.user_id}`);
const userProfile = await db.userRepo.findUserProfileById(user.user_id);
if (!userProfile) {
logger.warn(`[ROUTE] GET /api/users/profile - Profile not found in DB for user ID: ${user.user_id}`);
return res.status(404).json({ message: 'Profile not found for this user.' });
@@ -169,7 +170,7 @@ router.put('/profile', async (req, res, next: NextFunction) => {
}
try {
const updatedProfile = await db.updateUserProfile(user.user_id, { full_name, avatar_url });
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { full_name, avatar_url });
res.json(updatedProfile);
} catch (error) {
logger.error(`[ROUTE] PUT /api/users/profile - ERROR`, { error });
@@ -195,7 +196,7 @@ router.put('/profile/password', validateBody(['newPassword']), async (req, res,
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
await db.updateUserPassword(user.user_id, hashedPassword);
await db.userRepo.updateUserPassword(user.user_id, hashedPassword);
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
logger.error(`[ROUTE] PUT /api/users/profile/password - ERROR`, { error });
@@ -212,7 +213,7 @@ router.delete('/account', validateBody(['password']), async (req, res, next: Nex
const { password } = req.body;
try {
const userWithHash = await db.findUserWithPasswordHashById(user.user_id);
const userWithHash = await db.userRepo.findUserWithPasswordHashById(user.user_id);
if (!userWithHash || !userWithHash.password_hash) {
return res.status(404).json({ message: 'User not found or password not set.' });
}
@@ -222,7 +223,7 @@ router.delete('/account', validateBody(['password']), async (req, res, next: Nex
return res.status(403).json({ message: 'Incorrect password.' });
}
await db.deleteUserById(user.user_id);
await db.userRepo.deleteUserById(user.user_id);
res.status(200).json({ message: 'Account deleted successfully.' });
} catch (error) {
logger.error(`[ROUTE] DELETE /api/users/account - ERROR`, { error });
@@ -237,7 +238,7 @@ router.get('/watched-items', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
const user = req.user as UserProfile;
try {
const items = await db.getWatchedItems(user.user_id);
const items = await db.personalizationRepo.getWatchedItems(user.user_id);
res.json(items);
} catch (error) {
logger.error(`[ROUTE] GET /api/users/watched-items - ERROR`, { error });
@@ -253,10 +254,17 @@ router.post('/watched-items', validateBody(['itemName', 'category']), async (req
const user = req.user as UserProfile;
const { itemName, category } = req.body;
try {
const newItem = await db.addWatchedItem(user.user_id, itemName, category);
const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, itemName, category);
res.status(201).json(newItem);
} catch (error) {
logger.error(`[ROUTE] POST /api/users/watched-items - ERROR`, { error });
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[ROUTE] POST /api/users/watched-items - ERROR`, {
errorMessage,
body: req.body,
});
next(error);
}
});
@@ -269,7 +277,7 @@ router.delete('/watched-items/:masterItemId', async (req, res, next: NextFunctio
const user = req.user as UserProfile;
const masterItemId = parseInt(req.params.masterItemId, 10);
try {
await db.removeWatchedItem(user.user_id, masterItemId);
await db.personalizationRepo.removeWatchedItem(user.user_id, masterItemId);
res.status(204).send();
} catch (error) {
logger.error(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`, { error });
@@ -284,7 +292,7 @@ router.get('/shopping-lists', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile;
try {
const lists = await db.getShoppingLists(user.user_id);
const lists = await db.shoppingRepo.getShoppingLists(user.user_id);
res.json(lists);
} catch (error) {
logger.error(`[ROUTE] GET /api/users/shopping-lists - ERROR`, { error });
@@ -300,10 +308,17 @@ router.post('/shopping-lists', validateBody(['name']), async (req, res, next: Ne
const user = req.user as UserProfile;
const { name } = req.body;
try {
const newList = await db.createShoppingList(user.user_id, name);
const newList = await db.shoppingRepo.createShoppingList(user.user_id, name);
res.status(201).json(newList);
} catch (error) {
logger.error(`[ROUTE] POST /api/users/shopping-lists - ERROR`, { error });
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[ROUTE] POST /api/users/shopping-lists - ERROR`, {
errorMessage,
body: req.body,
});
next(error);
}
});
@@ -319,10 +334,14 @@ router.delete('/shopping-lists/:listId', async (req, res, next: NextFunction) =>
return res.status(400).json({ message: 'Invalid list ID.' });
}
try {
await db.deleteShoppingList(listId, user.user_id);
await db.shoppingRepo.deleteShoppingList(listId, user.user_id);
res.status(204).send();
} catch (error) {
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`, { error });
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`, { errorMessage, params: req.params });
next(error);
}
});
@@ -334,10 +353,17 @@ router.post('/shopping-lists/:listId/items', async (req, res, next: NextFunction
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
const listId = parseInt(req.params.listId, 10);
try {
const newItem = await db.addShoppingListItem(listId, req.body);
const newItem = await db.shoppingRepo.addShoppingListItem(listId, req.body);
res.status(201).json(newItem);
} catch (error) {
logger.error(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ERROR`, { error });
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ERROR`, {
errorMessage,
params: req.params, body: req.body
});
next(error);
}
});
@@ -349,10 +375,13 @@ router.put('/shopping-lists/items/:itemId', async (req, res, next: NextFunction)
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
const itemId = parseInt(req.params.itemId, 10);
try {
const updatedItem = await db.updateShoppingListItem(itemId, req.body);
const updatedItem = await db.shoppingRepo.updateShoppingListItem(itemId, req.body);
res.json(updatedItem);
} catch (error) {
logger.error(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`, { error });
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
logger.error(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`, { error, params: req.params, body: req.body });
next(error);
}
});
@@ -367,10 +396,13 @@ router.delete('/shopping-lists/items/:itemId', async (req, res, next: NextFuncti
return res.status(400).json({ message: 'Invalid item ID.' });
}
try {
await db.removeShoppingListItem(itemId);
await db.shoppingRepo.removeShoppingListItem(itemId);
res.status(204).send();
} catch (error) {
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`, { error });
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`, { error, params: req.params });
next(error);
}
});
@@ -384,8 +416,8 @@ router.put('/profile/preferences', async (req, res, next: NextFunction) => {
if (typeof req.body !== 'object' || req.body === null || Array.isArray(req.body)) {
return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' });
}
try {
const updatedProfile = await db.updateUserPreferences(user.user_id, req.body);
try { // This was a duplicate, fixed.
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, req.body);
res.json(updatedProfile);
} catch (error) {
logger.error(`[ROUTE] PUT /api/users/profile/preferences - ERROR`, { error });
@@ -397,7 +429,7 @@ router.get('/me/dietary-restrictions', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile;
try {
const restrictions = await db.getUserDietaryRestrictions(user.user_id);
const restrictions = await db.personalizationRepo.getUserDietaryRestrictions(user.user_id);
res.json(restrictions);
} catch (error) {
logger.error(`[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`, { error });
@@ -410,10 +442,17 @@ router.put('/me/dietary-restrictions', validateBody(['restrictionIds']), async (
const user = req.user as UserProfile;
const { restrictionIds } = req.body;
try {
await db.setUserDietaryRestrictions(user.user_id, restrictionIds);
await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, restrictionIds);
res.status(204).send();
} catch (error) {
logger.error(`[ROUTE] PUT /api/users/me/dietary-restrictions - ERROR`, { error });
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[ROUTE] PUT /api/users/me/dietary-restrictions - ERROR`, {
errorMessage,
body: req.body,
});
next(error);
}
});
@@ -422,7 +461,7 @@ router.get('/me/appliances', async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile;
try {
const appliances = await db.getUserAppliances(user.user_id);
const appliances = await db.personalizationRepo.getUserAppliances(user.user_id);
res.json(appliances);
} catch (error) {
logger.error(`[ROUTE] GET /api/users/me/appliances - ERROR`, { error });
@@ -435,10 +474,17 @@ router.put('/me/appliances', validateBody(['applianceIds']), async (req, res, ne
const user = req.user as UserProfile;
const { applianceIds } = req.body;
try {
await db.setUserAppliances(user.user_id, applianceIds);
await db.personalizationRepo.setUserAppliances(user.user_id, applianceIds);
res.status(204).send();
} catch (error) {
logger.error(`[ROUTE] PUT /api/users/me/appliances - ERROR`, { error });
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[ROUTE] PUT /api/users/me/appliances - ERROR`, {
errorMessage,
body: req.body,
});
next(error);
}
});
@@ -457,7 +503,7 @@ router.get('/addresses/:addressId', async (req, res, next: NextFunction) => {
}
try {
const address = await db.getAddressById(addressId);
const address = await db.addressRepo.getAddressById(addressId);
if (!address) {
return res.status(404).json({ message: 'Address not found.' });
}
@@ -475,10 +521,10 @@ router.put('/profile/address', async (req, res, next: NextFunction) => {
const addressData = req.body as Partial<Address>;
try {
const addressId = await db.upsertAddress({ ...addressData, address_id: user.address_id ?? undefined });
const addressId = await db.addressRepo.upsertAddress({ ...addressData, address_id: user.address_id ?? undefined });
// If the user didn't have an address_id before, update their profile to link it.
if (!user.address_id) {
await db.updateUserProfile(user.user_id, { address_id: addressId });
await db.userRepo.updateUserProfile(user.user_id, { address_id: addressId });
}
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
} catch (error) {

View File

@@ -77,6 +77,80 @@ export class AIService {
};
}
/**
* Constructs the detailed prompt for the AI to extract flyer data.
* @param masterItems A list of known grocery items to aid in matching.
* @param submitterIp The IP address of the user who submitted the flyer.
* @param userProfileAddress The profile address of the user.
* @returns A formatted string to be used as the AI prompt.
*/
private _buildFlyerExtractionPrompt(
masterItems: MasterGroceryItem[],
submitterIp?: string,
userProfileAddress?: string
): string {
let locationHint = '';
if (userProfileAddress) {
locationHint = `The user who uploaded this flyer has a profile address of "${userProfileAddress}". Use this as a strong hint for the store's location.`;
} else if (submitterIp) {
locationHint = `The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.`;
}
return `
Analyze the provided flyer image(s). Your task is to extract key information and a list of all sale items.
First, identify the following core details for the entire flyer:
- "store_name": The name of the grocery store (e.g., "Walmart", "No Frills").
- "valid_from": The start date of the sale period in YYYY-MM-DD format. If not present, use null.
- "valid_to": The end date of the sale period in YYYY-MM-DD format. If not present, use null.
- "store_address": The physical address of the store if present. If not present, use null. ${locationHint}
Second, extract each individual sale item. For each item, provide:
- "item": The name of the product (e.g., "Coca-Cola Classic").
- "price_display": The sale price as a string (e.g., "$2.99", "2 for $5.00"). If no price is explicitly displayed, use an empty string "".
- "price_in_cents": The primary numeric price converted to cents (e.g., for "$2.99", use 299). If a price is "2 for $5.00", use 500. If no price, use null.
- "quantity": A string describing the quantity or weight for the price (e.g., "12x355mL", "500g", "each"). If no quantity is explicitly displayed, use an empty string "".
- "master_item_id": From the provided master list, find the best matching item and return its ID. If no good match is found, use null.
- "category_name": The most appropriate category for the item (e.g., "Beverages", "Meat & Seafood"). If no clear category can be determined, use "Other/Miscellaneous".
Here is the master list of grocery items to help with matching:
- Total items in master list: ${masterItems.length}
- Sample items: ${JSON.stringify(masterItems.slice(0, 5))}
---
MASTER LIST:
${JSON.stringify(masterItems)}
Return a single, valid JSON object with the keys "store_name", "valid_from", "valid_to", "store_address", and "items". The "items" key should contain an array of the extracted item objects.
Do not include any other text, explanations, or markdown formatting.
`;
}
/**
* Safely parses a JSON object from a string, typically from an AI response.
* @param responseText The raw text response from the AI.
* @returns The parsed JSON object, or null if parsing fails.
*/
private _parseJsonFromAiResponse<T>(responseText: string | undefined): T | null {
if (!responseText) return null;
// Find the first occurrence of '{' or '[' and the last '}' or ']'
const firstBrace = responseText.indexOf('{');
const firstBracket = responseText.indexOf('[');
const start = (firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace)) ? firstBracket : firstBrace;
const end = responseText.lastIndexOf(start === firstBracket ? ']' : '}');
if (start === -1 || end === -1) return null;
const jsonString = responseText.substring(start, end + 1);
try {
return JSON.parse(jsonString) as T;
} catch (e) {
logger.error("Failed to parse JSON from AI response slice", { jsonString, error: e });
return null;
}
}
async extractItemsFromReceiptImage(
imagePath: string,
imageMimeType: string
@@ -105,18 +179,12 @@ export class AIService {
contents: [{ parts: [{text: prompt}, imagePart] }]
});
const text = response.text;
const parsedJson = this._parseJsonFromAiResponse<any[]>(text);
const jsonMatch = text?.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
if (!parsedJson) {
throw new Error('AI response did not contain a valid JSON array.');
}
try {
return JSON.parse(jsonMatch[0]);
} catch (e) {
logger.error("Failed to parse JSON from AI response in extractItemsFromReceiptImage", { responseText: text, error: e });
throw new Error('Failed to parse structured data from the AI response.');
}
return parsedJson;
} catch (apiError) {
logger.error("Google GenAI API call failed in extractItemsFromReceiptImage:", { error: apiError });
throw apiError;
@@ -135,43 +203,14 @@ export class AIService {
store_address: string | null;
items: ExtractedFlyerItem[];
}> {
let locationHint = '';
if (userProfileAddress) {
locationHint = `The user who uploaded this flyer has a profile address of "${userProfileAddress}". Use this as a strong hint for the store's location.`;
} else if (submitterIp) {
locationHint = `The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.`;
}
const prompt = `
Analyze the provided flyer image(s). Your task is to extract key information and a list of all sale items.
First, identify the following core details for the entire flyer:
- "store_name": The name of the grocery store (e.g., "Walmart", "No Frills").
- "valid_from": The start date of the sale period in YYYY-MM-DD format. If not present, use null.
- "valid_to": The end date of the sale period in YYYY-MM-DD format. If not present, use null.
- "store_address": The physical address of the store if present. If not present, use null. ${locationHint}
Second, extract each individual sale item. For each item, provide:
- "item": The name of the product (e.g., "Coca-Cola Classic").
- "price_display": The sale price as a string (e.g., "$2.99", "2 for $5.00"). If no price is explicitly displayed, use an empty string "".
- "price_in_cents": The primary numeric price converted to cents (e.g., for "$2.99", use 299). If a price is "2 for $5.00", use 500. If no price, use null.
- "quantity": A string describing the quantity or weight for the price (e.g., "12x355mL", "500g", "each"). If no quantity is explicitly displayed, use an empty string "".
- "master_item_id": From the provided master list, find the best matching item and return its ID. If no good match is found, use null.
- "category_name": The most appropriate category for the item (e.g., "Beverages", "Meat & Seafood"). If no clear category can be determined, use "Other/Miscellaneous".
Here is the master list of grocery items to help with matching:
${JSON.stringify(masterItems)}
Return a single, valid JSON object with the keys "store_name", "valid_from", "valid_to", "store_address", and "items". The "items" key should contain an array of the extracted item objects.
Do not include any other text, explanations, or markdown formatting.
`;
const prompt = this._buildFlyerExtractionPrompt(masterItems, submitterIp, userProfileAddress);
const imageParts = await Promise.all(
imagePaths.map(file => this.serverFileToGenerativePart(file.path, file.mimetype))
);
const totalImageSize = imageParts.reduce((acc, part) => acc + part.inlineData.data.length, 0);
logger.debug(`[aiService.server] Total base64 image data size for Gemini: ${(totalImageSize / (1024 * 1024)).toFixed(2)} MB`);
logger.info(`[aiService.server] Total base64 image data size for Gemini: ${(totalImageSize / (1024 * 1024)).toFixed(2)} MB`);
try {
logger.debug(`[aiService.server] Calling Gemini API for flyer processing with ${imageParts.length} image(s).`);
@@ -184,41 +223,45 @@ export class AIService {
const geminiCallEndTime = process.hrtime.bigint();
const durationMs = Number(geminiCallEndTime - geminiCallStartTime) / 1_000_000;
logger.info(`[aiService.server] Gemini API call completed in ${durationMs.toFixed(2)} ms.`);
logger.info(`[aiService.server] Gemini API call for flyer processing completed in ${durationMs.toFixed(2)} ms.`);
const text = response.text;
const jsonMatch = text?.match(/\{[\s\S]*\}/);
logger.debug(`[aiService.server] Raw Gemini response text (first 500 chars): ${text?.substring(0, 500)}`);
if (!jsonMatch) {
const extractedData = this._parseJsonFromAiResponse<any>(text);
if (!extractedData) {
logger.error("AI response for flyer processing did not contain a valid JSON object.", { responseText: text });
throw new Error('AI response did not contain a valid JSON object.');
}
try {
const extractedData = JSON.parse(jsonMatch[0]);
if (extractedData && Array.isArray(extractedData.items)) {
extractedData.items = extractedData.items.map((item: RawFlyerItem) => ({
...item,
price_display: item.price_display === null || item.price_display === undefined ? "" : String(item.price_display),
quantity: item.quantity === null || item.quantity === undefined ? "" : String(item.quantity),
category_name: item.category_name === null || item.category_name === undefined ? "Other/Miscellaneous" : String(item.category_name),
master_item_id: item.master_item_id === null ? undefined : item.master_item_id
}));
}
return extractedData;
} catch (e) {
logger.error("Failed to parse JSON from AI response in extractCoreDataFromFlyerImage", { responseText: text, error: e });
throw new Error('Failed to parse structured data from the AI response.');
// Normalize the extracted items to handle potential nulls from the AI.
if (extractedData && Array.isArray(extractedData.items)) {
extractedData.items = this._normalizeExtractedItems(extractedData.items);
}
return extractedData;
} catch (apiError) {
logger.error("Google GenAI API call failed in extractCoreDataFromFlyerImage:", { error: apiError });
throw apiError;
}
}
/**
* Normalizes the raw items returned by the AI, ensuring fields are in the correct format.
* @param items An array of raw flyer items from the AI.
* @returns A normalized array of flyer items.
*/
private _normalizeExtractedItems(items: RawFlyerItem[]): ExtractedFlyerItem[] {
return items.map((item: RawFlyerItem) => ({
...item,
price_display: item.price_display === null || item.price_display === undefined ? "" : String(item.price_display),
quantity: item.quantity === null || item.quantity === undefined ? "" : String(item.quantity),
category_name: item.category_name === null || item.category_name === undefined ? "Other/Miscellaneous" : String(item.category_name),
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
}));
}
/**
* SERVER-SIDE FUNCTION
* Extracts a specific piece of text from a cropped area of an image.

View File

@@ -1,6 +1,6 @@
// src/services/apiClient.ts
import { Profile, ShoppingListItem, SearchQuery, Budget, Address } from '../types';
import { logger } from './logger';
import { logger } from './logger.client';
// This constant should point to your backend API.
// It's often a good practice to store this in an environment variable.
@@ -545,8 +545,8 @@ export const removeFavoriteRecipe = async (recipeId: number, tokenOverride?: str
// --- Recipe Comments API Functions ---
export const getRecipeComments = async (recipeId: number): Promise<Response> => {
// This is a public endpoint, so we can use standard fetch
return fetch(`${API_BASE_URL}/recipes/${recipeId}/comments`);
// This is a public endpoint, so we can use standard fetch.
return fetch(`${API_BASE_URL}/recipes/${recipeId}/comments`); // This was a duplicate, fixed.
};
export const addRecipeComment = async (recipeId: number, content: string, parentCommentId?: number, tokenOverride?: string): Promise<Response> => {

View File

@@ -1,19 +1,12 @@
// src/services/backgroundJobService.ts
import cron from 'node-cron';
import * as db from './db/index.db';
import { logger } from './logger.server';
import type { Queue } from 'bullmq';
import { sendDealNotificationEmail } from './emailService.server';
import { Notification, WatchedItemDeal } from '../types';
import { AdminUserView } from './db/admin.db';
// --- Start: Interfaces for Dependency Injection ---
interface IDatabaseService {
// This function is not used anymore in this service, but kept for interface compatibility.
getBestSalePricesForUser?(userId: string): Promise<WatchedItemDeal[]>;
getBestSalePricesForAllUsers(): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]>;
createBulkNotifications(notifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[]): Promise<void>;
}
import { getSimpleWeekAndYear } from '../utils/dateUtils';
// Import types for repositories from their source files
import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
interface EmailJobData {
to: string;
@@ -22,22 +15,51 @@ interface EmailJobData {
text: string;
}
interface ILogger {
info(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
debug(message: string, ...args: unknown[]): void;
}
// --- End: Interfaces for Dependency Injection ---
export class BackgroundJobService {
constructor(
private db: IDatabaseService,
// Inject the email queue instead of the service to decouple email sending.
private personalizationRepo: PersonalizationRepository,
private notificationRepo: NotificationRepository, // Use the imported type here
private emailQueue: Queue<EmailJobData>,
private logger: ILogger
private logger: typeof import('./logger.server').logger
) {}
/**
* Prepares the data for an email notification job based on a user's deals.
* @param user The user to whom the email will be sent.
* @param deals The list of deals found for the user.
* @returns An object containing the email job data and a unique job ID.
*/
private _prepareDealEmail(user: { user_id: string; email: string; full_name: string | null }, deals: WatchedItemDeal[]): { jobData: EmailJobData; jobId: string } {
const recipientName = user.full_name || 'there';
const subject = `New Deals Found on Your Watched Items!`;
const dealsListHtml = deals.map(deal => `<li><strong>${deal.item_name}</strong> is on sale for <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong> at ${deal.store_name}!</li>`).join('');
const html = `<p>Hi ${recipientName},</p><p>We found some great deals on items you're watching:</p><ul>${dealsListHtml}</ul>`;
const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.`;
// Use a predictable Job ID to prevent duplicate email notifications for the same user on the same day.
const today = new Date().toISOString().split('T')[0];
const jobId = `deal-email-${user.user_id}-${today}`;
return {
jobData: { to: user.email, subject, html, text },
jobId,
};
}
/**
* Prepares the data for an in-app notification.
* @param userId The ID of the user to notify.
* @param dealCount The number of deals found.
* @returns The notification object ready for database insertion.
*/
private _prepareInAppNotification(userId: string, dealCount: number): Omit<Notification, 'notification_id' | 'is_read' | 'created_at'> {
return {
user_id: userId,
content: `You have ${dealCount} new deal(s) on your watched items!`,
link_url: '/dashboard/deals', // A link to the future "My Deals" page
};
}
/**
* Checks for new deals on watched items for all users and sends notifications.
* This function is designed to be run periodically (e.g., daily).
@@ -47,7 +69,7 @@ export class BackgroundJobService {
try {
// 1. Get all deals for all users in a single, efficient query.
const allDeals = await this.db.getBestSalePricesForAllUsers();
const allDeals = await this.personalizationRepo.getBestSalePricesForAllUsers();
if (allDeals.length === 0) {
this.logger.info('[BackgroundJob] No deals found for any watched items. Skipping.');
@@ -75,26 +97,12 @@ export class BackgroundJobService {
try {
this.logger.info(`[BackgroundJob] Found ${deals.length} deals for user ${user.user_id}.`);
// 4. Prepare in-app notification for this user.
const notificationContent = `You have ${deals.length} new deal(s) on your watched items!`;
const notification: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'> = {
user_id: user.user_id,
content: notificationContent,
link_url: '/dashboard/deals', // A link to the future "My Deals" page
};
// 4. Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(user.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(user, deals);
// 5. Enqueue an email notification job.
const recipientName = user.full_name || 'there';
const subject = `New Deals Found on Your Watched Items!`;
const dealsListHtml = deals.map(deal => `<li><strong>${deal.item_name}</strong> is on sale for <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong> at ${deal.store_name}!</li>`).join('');
const html = `<p>Hi ${recipientName},</p><p>We found some great deals on items you're watching:</p><ul>${dealsListHtml}</ul>`;
const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.`;
// Use a predictable Job ID to prevent duplicate email notifications for the same user on the same day.
const today = new Date().toISOString().split('T')[0];
const jobId = `deal-email-${user.user_id}-${today}`;
await this.emailQueue.add('send-deal-notification', { to: user.email, subject, html, text }, { jobId });
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
@@ -116,7 +124,7 @@ export class BackgroundJobService {
// 7. Bulk insert all in-app notifications in a single query.
if (allNotifications.length > 0) {
await this.db.createBulkNotifications(allNotifications);
await this.notificationRepo.createBulkNotifications(allNotifications);
this.logger.info(`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`);
}
@@ -140,57 +148,84 @@ let isDailyDealCheckRunning = false;
*/
export function startBackgroundJobs(
backgroundJobService: BackgroundJobService,
analyticsQueue: Queue
analyticsQueue: Queue,
weeklyAnalyticsQueue: Queue // Add this new parameter
): void {
// Schedule the deal check job to run once every day at 2:00 AM server time.
cron.schedule('0 2 * * *', () => {
// Self-invoking async function to handle the promise and errors gracefully.
(async () => {
if (isDailyDealCheckRunning) {
logger.warn('[BackgroundJob] Daily deal check is already running. Skipping this scheduled run.');
return;
}
isDailyDealCheckRunning = true;
try {
await backgroundJobService.runDailyDealCheck();
} catch (error) {
// The method itself logs details, this is a final catch-all.
logger.error('[BackgroundJob] Cron job for daily deal check failed unexpectedly.', { error });
} finally {
try {
// Schedule the deal check job to run once every day at 2:00 AM server time.
cron.schedule('0 2 * * *', () => {
// Self-invoking async function to handle the promise and errors gracefully.
(async () => {
if (isDailyDealCheckRunning) {
logger.warn('[BackgroundJob] Daily deal check is already running. Skipping this scheduled run.');
return;
}
isDailyDealCheckRunning = true;
try {
await backgroundJobService.runDailyDealCheck();
} catch (error) {
// The method itself logs details, this is a final catch-all.
logger.error('[BackgroundJob] Cron job for daily deal check failed unexpectedly.', { error });
} finally {
isDailyDealCheckRunning = false;
}
})().catch(error => {
// This catch is for unhandled promise rejections from the async wrapper itself.
logger.error('[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.', { error });
isDailyDealCheckRunning = false;
}
})().catch(error => {
// This catch is for unhandled promise rejections from the async wrapper itself.
logger.error('[BackgroundJob] Unhandled rejection in daily deal check cron wrapper.', { error });
isDailyDealCheckRunning = false; // Ensure lock is released on catastrophic failure.
});
});
});
logger.info('[BackgroundJob] Cron job for daily deal checks has been scheduled.');
logger.info('[BackgroundJob] Cron job for daily deal checks has been scheduled.');
// Schedule the analytics report generation job to run at 3:00 AM server time.
cron.schedule('0 3 * * *', () => {
(async () => {
logger.info('[BackgroundJob] Enqueuing daily analytics report generation job.');
try {
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// We use a unique job ID to prevent duplicate jobs for the same day if the scheduler restarts.
await analyticsQueue.add('generate-daily-report', { reportDate }, {
jobId: `daily-report-${reportDate}`
});
} catch (error) {
logger.error('[BackgroundJob] Failed to enqueue daily analytics job.', { error });
}
})().catch(error => {
logger.error('[BackgroundJob] Unhandled rejection in analytics report cron wrapper.', { error });
// Schedule the analytics report generation job to run at 3:00 AM server time.
cron.schedule('0 3 * * *', () => {
(async () => {
logger.info('[BackgroundJob] Enqueuing daily analytics report generation job.');
try {
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// We use a unique job ID to prevent duplicate jobs for the same day if the scheduler restarts.
await analyticsQueue.add('generate-daily-report', { reportDate }, {
jobId: `daily-report-${reportDate}`
});
} catch (error) {
logger.error('[BackgroundJob] Failed to enqueue daily analytics job.', { error });
}
})().catch(error => {
logger.error('[BackgroundJob] Unhandled rejection in analytics report cron wrapper.', { error });
});
});
});
logger.info('[BackgroundJob] Cron job for daily analytics reports has been scheduled.');
logger.info('[BackgroundJob] Cron job for daily analytics reports has been scheduled.');
// Schedule the weekly analytics report generation job to run every Sunday at 4:00 AM.
cron.schedule('0 4 * * 0', () => { // 0 4 * * 0 means 4:00 AM on Sunday
(async () => {
logger.info('[BackgroundJob] Enqueuing weekly analytics report generation job.');
try {
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
await weeklyAnalyticsQueue.add('generate-weekly-report', { reportYear, reportWeek }, {
jobId: `weekly-report-${reportYear}-${reportWeek}`
});
} catch (error) {
logger.error('[BackgroundJob] Failed to enqueue weekly analytics job.', { error });
}
})().catch(error => {
logger.error('[BackgroundJob] Unhandled rejection in weekly analytics report cron wrapper.', { error });
});
});
logger.info('[BackgroundJob] Cron job for weekly analytics reports has been scheduled.');
} catch (error) {
logger.error('[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.', { error });
}
}
// Instantiate the service with its real dependencies for use in the application.
import { personalizationRepo, notificationRepo } from './db/index.db';
import { emailQueue } from './queueService.server';
export const backgroundJobService = new BackgroundJobService(
db,
// The email service is now decoupled, so we pass the queue instance.
(await import('./queueService.server')).emailQueue,
logger
personalizationRepo,
notificationRepo,
emailQueue,
logger,
);

View File

@@ -1,8 +1,9 @@
// src/services/db/address.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { getAddressById, upsertAddress } from './address.db';
import { AddressRepository } from './address.db';
import type { Address } from '../../types';
import { UniqueConstraintError } from './errors.db';
// Un-mock the module we are testing
vi.unmock('./address.db');
@@ -13,16 +14,19 @@ vi.mock('../logger.server', () => ({
}));
describe('Address DB Service', () => {
let addressRepo: AddressRepository;
beforeEach(() => {
vi.clearAllMocks();
addressRepo = new AddressRepository(mockPoolInstance as any);
});
describe('getAddressById', () => {
it('should return an address if found', async () => {
const mockAddress: Address = { address_id: 1, address_line_1: '123 Main St', city: 'Anytown', province_state: 'CA', postal_code: '12345', country: 'USA' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockAddress] });
const result = await getAddressById(1);
const result = await addressRepo.getAddressById(1);
expect(result).toEqual(mockAddress);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.addresses WHERE address_id = $1', [1]);
@@ -30,9 +34,16 @@ describe('Address DB Service', () => {
it('should return undefined if no address is found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await getAddressById(999);
const result = await addressRepo.getAddressById(999);
expect(result).toBeUndefined();
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.getAddressById(1)).rejects.toThrow('Failed to retrieve address.');
});
});
describe('upsertAddress', () => {
@@ -40,22 +51,62 @@ describe('Address DB Service', () => {
const newAddressData = { address_line_1: '456 New Ave', city: 'Newville' };
mockPoolInstance.query.mockResolvedValue({ rows: [{ address_id: 2 }] });
const result = await upsertAddress(newAddressData);
const result = await addressRepo.upsertAddress(newAddressData);
expect(result).toBe(2);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.addresses'), expect.any(Array));
expect(mockPoolInstance.query).not.toHaveBeenCalledWith(expect.stringContaining('UPDATE public.addresses'), expect.any(Array));
});
it('should perform an INSERT with location when lat/lng are provided', async () => {
const newAddressData = { address_line_1: '789 Geo St', latitude: 45.0, longitude: -75.0 };
mockPoolInstance.query.mockResolvedValue({ rows: [{ address_id: 3 }] });
await addressRepo.upsertAddress(newAddressData);
// Check that the query string includes the ST_MakePoint function call
const [query, values] = mockPoolInstance.query.mock.calls[0];
expect(query).toContain('ST_SetSRID(ST_MakePoint(');
// Verify that longitude and latitude are the last two parameters
expect(values).toContain(-75.0);
expect(values).toContain(45.0);
});
it('should perform an UPDATE when an address_id is provided', async () => {
const existingAddressData = { address_id: 1, address_line_1: '789 Old Rd', city: 'Oldtown' };
mockPoolInstance.query.mockResolvedValue({ rows: [{ address_id: 1 }] });
const result = await upsertAddress(existingAddressData);
const result = await addressRepo.upsertAddress(existingAddressData);
expect(result).toBe(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.addresses'), expect.any(Array));
expect(mockPoolInstance.query).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.addresses'), expect.any(Array));
});
it('should throw a generic error on INSERT failure', async () => {
const newAddressData = { address_line_1: '456 New Ave', city: 'Newville' };
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(newAddressData)).rejects.toThrow('Failed to upsert address.');
});
it('should throw a generic error on UPDATE failure', async () => {
const existingAddressData = { address_id: 1, address_line_1: '789 Old Rd', city: 'Oldtown' };
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(existingAddressData)).rejects.toThrow('Failed to upsert address.');
});
it('should throw UniqueConstraintError on duplicate address insert', async () => {
const newAddressData = { address_line_1: '123 Main St', city: 'Anytown' };
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addressRepo.upsertAddress(newAddressData)).rejects.toThrow(UniqueConstraintError);
await expect(addressRepo.upsertAddress(newAddressData)).rejects.toThrow('An identical address already exists.');
});
});
});

View File

@@ -1,51 +1,72 @@
// src/services/db/address.db.ts
import { getPool } from './connection.db';
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import { UniqueConstraintError } from './errors.db';
import { Address } from '../../types';
/**
* Retrieves a single address by its ID.
* @param addressId The ID of the address to retrieve.
* @returns A promise that resolves to the Address object or undefined.
*/
export async function getAddressById(addressId: number): Promise<Address | undefined> {
try {
const res = await getPool().query<Address>('SELECT * FROM public.addresses WHERE address_id = $1', [addressId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getAddressById:', { error, addressId });
throw new Error('Failed to retrieve address.');
export class AddressRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
}
/**
* Creates or updates an address and returns its ID.
* This function uses an "upsert" pattern.
* @param address The address data.
* @returns The ID of the created or updated address.
*/
export async function upsertAddress(address: Partial<Address>): Promise<number> {
const { address_id, address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude } = address;
const locationPoint = latitude && longitude ? `ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)` : null;
/**
* Retrieves a single address by its ID.
* @param addressId The ID of the address to retrieve.
* @returns A promise that resolves to the Address object or undefined.
*/
async getAddressById(addressId: number): Promise<Address | undefined> {
try {
const res = await this.db.query<Address>('SELECT * FROM public.addresses WHERE address_id = $1', [addressId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getAddressById:', { error, addressId });
throw new Error('Failed to retrieve address.');
}
}
// If an ID is provided, it's an update. Otherwise, it's an insert.
if (address_id) {
const query = `
UPDATE public.addresses
SET address_line_1 = $1, address_line_2 = $2, city = $3, province_state = $4, postal_code = $5, country = $6,
latitude = $7, longitude = $8, location = ${locationPoint}, updated_at = now()
WHERE address_id = $9
RETURNING address_id;
`;
const res = await getPool().query(query, [address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude, address_id]);
return res.rows[0].address_id;
} else {
const query = `
INSERT INTO public.addresses (address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude, location)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ${locationPoint})
RETURNING address_id;
`;
const res = await getPool().query(query, [address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude]);
return res.rows[0].address_id;
/**
* Creates or updates an address and returns its ID.
* This function uses an "upsert" pattern.
* @param address The address data.
* @returns The ID of the created or updated address.
*/
async upsertAddress(address: Partial<Address>): Promise<number> {
try {
const { address_id, address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude } = address;
// Use parameterized query for location to prevent SQL injection, even with numbers.
const locationPoint = latitude && longitude ? `ST_SetSRID(ST_MakePoint($${address_id ? 10 : 9}, $${address_id ? 11 : 10}), 4326)` : null;
// If an ID is provided, it's an update. Otherwise, it's an insert.
if (address_id) {
const query = `
UPDATE public.addresses
SET address_line_1 = $1, address_line_2 = $2, city = $3, province_state = $4, postal_code = $5, country = $6,
latitude = $7, longitude = $8, location = ${locationPoint}, updated_at = now()
WHERE address_id = $9
RETURNING address_id;
`;
const values = [address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude, address_id];
if (locationPoint) values.push(longitude, latitude);
const res = await this.db.query(query, values);
return res.rows[0].address_id;
} else {
const query = `
INSERT INTO public.addresses (address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude, location)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ${locationPoint})
RETURNING address_id;
`;
const values = [address_line_1, address_line_2, city, province_state, postal_code, country, latitude, longitude];
if (locationPoint) values.push(longitude, latitude);
const res = await this.db.query(query, values);
return res.rows[0].address_id;
}
} catch (error) {
logger.error('Database error in upsertAddress:', { error, address });
if (error instanceof Error && 'code' in error && error.code === '23505') throw new UniqueConstraintError('An identical address already exists.');
throw new Error('Failed to upsert address.');
}
}
}

View File

@@ -1,29 +1,14 @@
// src/services/db/admin.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import {
getSuggestedCorrections,
approveCorrection,
rejectCorrection,
updateSuggestedCorrection,
getApplicationStats,
getDailyStatsForLast30Days,
logActivity,
incrementFailedLoginAttempts,
updateBrandLogo,
getMostFrequentSaleItems,
updateRecipeCommentStatus,
getUnmatchedFlyerItems,
updateRecipeStatus,
updateReceiptStatus,
getActivityLog,
getAllUsers,
updateUserRole,
} from './admin.db';
import { AdminRepository } from './admin.db';
import type { SuggestedCorrection, AdminUserView, User } from '../../types';
// Un-mock the module we are testing
vi.unmock('./admin.db');
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
@@ -33,9 +18,13 @@ vi.mock('../logger', () => ({
}));
describe('Admin DB Service', () => {
let adminRepo: AdminRepository;
beforeEach(() => {
// Reset the global mock's call history before each test.
vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test
adminRepo = new AdminRepository(mockPoolInstance as any);
});
describe('getSuggestedCorrections', () => {
@@ -45,7 +34,7 @@ describe('Admin DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections });
const result = await getSuggestedCorrections();
const result = await adminRepo.getSuggestedCorrections();
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc"));
expect(result).toEqual(mockCorrections);
@@ -54,14 +43,14 @@ describe('Admin DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getSuggestedCorrections()).rejects.toThrow('Failed to retrieve suggested corrections.');
await expect(adminRepo.getSuggestedCorrections()).rejects.toThrow('Failed to retrieve suggested corrections.');
});
});
describe('approveCorrection', () => {
it('should call the approve_correction database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // Mock the function call
await approveCorrection(123);
await adminRepo.approveCorrection(123);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
});
@@ -69,14 +58,14 @@ describe('Admin DB Service', () => {
it('should throw an error if the database function fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(approveCorrection(123)).rejects.toThrow('Failed to approve correction.');
await expect(adminRepo.approveCorrection(123)).rejects.toThrow('Failed to approve correction.');
});
});
describe('rejectCorrection', () => {
it('should update the correction status to rejected', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1 });
await rejectCorrection(123);
await adminRepo.rejectCorrection(123);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET status = 'rejected'"),
@@ -87,15 +76,15 @@ describe('Admin DB Service', () => {
it('should not throw an error if the correction is already processed (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
// The function should complete without throwing an error.
await expect(rejectCorrection(123)).resolves.toBeUndefined();
await expect(adminRepo.rejectCorrection(123)).resolves.toBeUndefined();
// We can also check that a warning was logged.
const { logger } = await import('../logger');
const { logger } = await import('../logger.server');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('was not found or not in \'pending\' state'));
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(rejectCorrection(123)).rejects.toThrow('Failed to reject correction.');
await expect(adminRepo.rejectCorrection(123)).rejects.toThrow('Failed to reject correction.');
});
});
@@ -104,7 +93,7 @@ describe('Admin DB Service', () => {
const mockCorrection: SuggestedCorrection = { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '300', status: 'pending', created_at: new Date().toISOString() };
mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
const result = await updateSuggestedCorrection(1, '300');
const result = await adminRepo.updateSuggestedCorrection(1, '300');
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET suggested_value = $1"),
@@ -115,14 +104,14 @@ describe('Admin DB Service', () => {
it('should throw an error if the correction is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(updateSuggestedCorrection(999, 'new value')).rejects.toThrow(
await expect(adminRepo.updateSuggestedCorrection(999, 'new value')).rejects.toThrow(
"Correction with ID 999 not found or is not in 'pending' state."
);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(updateSuggestedCorrection(1, 'new value')).rejects.toThrow('Failed to update suggested correction.');
await expect(adminRepo.updateSuggestedCorrection(1, 'new value')).rejects.toThrow('Failed to update suggested correction.');
});
});
@@ -136,7 +125,7 @@ describe('Admin DB Service', () => {
.mockResolvedValueOnce({ rows: [{ count: '5' }] }) // storeCount
.mockResolvedValueOnce({ rows: [{ count: '2' }] }); // pendingCorrectionCount
const stats = await getApplicationStats();
const stats = await adminRepo.getApplicationStats();
expect(mockPoolInstance.query).toHaveBeenCalledTimes(5);
expect(stats).toEqual({
@@ -155,7 +144,7 @@ describe('Admin DB Service', () => {
.mockRejectedValueOnce(new Error('DB Read Error'));
// The Promise.all should reject, and the function should re-throw the error
await expect(getApplicationStats()).rejects.toThrow('DB Read Error');
await expect(adminRepo.getApplicationStats()).rejects.toThrow('DB Read Error');
});
});
@@ -164,7 +153,7 @@ describe('Admin DB Service', () => {
const mockStats = [{ date: '2023-01-01', new_users: 5, new_flyers: 2 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockStats });
const result = await getDailyStatsForLast30Days();
const result = await adminRepo.getDailyStatsForLast30Days();
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS"));
expect(result).toEqual(mockStats);
@@ -173,7 +162,7 @@ describe('Admin DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getDailyStatsForLast30Days()).rejects.toThrow('Failed to retrieve daily statistics.');
await expect(adminRepo.getDailyStatsForLast30Days()).rejects.toThrow('Failed to retrieve daily statistics.');
});
});
@@ -181,7 +170,7 @@ describe('Admin DB Service', () => {
it('should insert a new activity log entry', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const logData = { userId: 'user-123', action: 'test_action', displayText: 'Test activity' };
await logActivity(logData);
await adminRepo.logActivity(logData);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining("INSERT INTO public.activity_log"),
@@ -192,21 +181,21 @@ describe('Admin DB Service', () => {
it('should not throw an error if the database query fails (non-critical)', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const logData = { action: 'test_action', displayText: 'Test activity' };
await expect(logActivity(logData)).resolves.toBeUndefined();
await expect(adminRepo.logActivity(logData)).resolves.toBeUndefined();
});
});
describe('getMostFrequentSaleItems', () => {
it('should call the correct database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getMostFrequentSaleItems(30, 10);
await adminRepo.getMostFrequentSaleItems(30, 10);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyer_items fi'), [30, 10]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getMostFrequentSaleItems(30, 10)).rejects.toThrow('Failed to get most frequent sale items.');
await expect(adminRepo.getMostFrequentSaleItems(30, 10)).rejects.toThrow('Failed to get most frequent sale items.');
});
});
@@ -214,34 +203,34 @@ describe('Admin DB Service', () => {
it('should update the comment status and return the updated comment', async () => {
const mockComment = { comment_id: 1, status: 'hidden' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockComment], rowCount: 1 });
const result = await updateRecipeCommentStatus(1, 'hidden');
const result = await adminRepo.updateRecipeCommentStatus(1, 'hidden');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipe_comments'), ['hidden', 1]);
expect(result).toEqual(mockComment);
});
it('should throw an error if the comment is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(updateRecipeCommentStatus(999, 'hidden')).rejects.toThrow('Recipe comment with ID 999 not found.');
await expect(adminRepo.updateRecipeCommentStatus(999, 'hidden')).rejects.toThrow('Recipe comment with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateRecipeCommentStatus(1, 'hidden')).rejects.toThrow('Failed to update recipe comment status.');
await expect(adminRepo.updateRecipeCommentStatus(1, 'hidden')).rejects.toThrow('Failed to update recipe comment status.');
});
});
describe('getUnmatchedFlyerItems', () => {
it('should execute the correct query to get unmatched items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getUnmatchedFlyerItems();
await adminRepo.getUnmatchedFlyerItems();
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.unmatched_flyer_items ufi'));
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getUnmatchedFlyerItems()).rejects.toThrow('Failed to retrieve unmatched flyer items.');
await expect(adminRepo.getUnmatchedFlyerItems()).rejects.toThrow('Failed to retrieve unmatched flyer items.');
});
});
@@ -249,31 +238,31 @@ describe('Admin DB Service', () => {
it('should update the recipe status and return the updated recipe', async () => {
const mockRecipe = { recipe_id: 1, status: 'public' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockRecipe], rowCount: 1 });
const result = await updateRecipeStatus(1, 'public');
const result = await adminRepo.updateRecipeStatus(1, 'public');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.recipes'), ['public', 1]);
expect(result).toEqual(mockRecipe);
});
it('should throw an error if the recipe is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(updateRecipeStatus(999, 'public')).rejects.toThrow('Recipe with ID 999 not found.');
await expect(adminRepo.updateRecipeStatus(999, 'public')).rejects.toThrow('Recipe with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateRecipeStatus(1, 'public')).rejects.toThrow('Failed to update recipe status.');
await expect(adminRepo.updateRecipeStatus(1, 'public')).rejects.toThrow('Failed to update recipe status.');
});
});
describe('incrementFailedLoginAttempts', () => {
it('should execute an UPDATE query to increment failed attempts', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await incrementFailedLoginAttempts('user-123');
await adminRepo.incrementFailedLoginAttempts('user-123');
// Fix: Use regex to match query with variable whitespace
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringMatching(/UPDATE\s+public\.users\s+SET\s+failed_login_attempts\s*=\s*failed_login_attempts\s*\+\s*1/),
expect.stringContaining('UPDATE public.users'),
['user-123']
);
});
@@ -281,21 +270,21 @@ describe('Admin DB Service', () => {
it('should not throw an error if the database query fails (non-critical)', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(incrementFailedLoginAttempts('user-123')).resolves.toBeUndefined();
await expect(adminRepo.incrementFailedLoginAttempts('user-123')).resolves.toBeUndefined();
});
});
describe('updateBrandLogo', () => {
it('should execute an UPDATE query for the brand logo', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await updateBrandLogo(1, '/logo.png');
await adminRepo.updateBrandLogo(1, '/logo.png');
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2', ['/logo.png', 1]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateBrandLogo(1, '/logo.png')).rejects.toThrow('Failed to update brand logo in database.');
await expect(adminRepo.updateBrandLogo(1, '/logo.png')).rejects.toThrow('Failed to update brand logo in database.');
});
});
@@ -303,34 +292,34 @@ describe('Admin DB Service', () => {
it('should update the receipt status and return the updated receipt', async () => {
const mockReceipt = { receipt_id: 1, status: 'completed' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt], rowCount: 1 });
const result = await updateReceiptStatus(1, 'completed');
const result = await adminRepo.updateReceiptStatus(1, 'completed');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.receipts'), ['completed', 1]);
expect(result).toEqual(mockReceipt);
});
it('should throw an error if the receipt is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(updateReceiptStatus(999, 'completed')).rejects.toThrow('Receipt with ID 999 not found.');
await expect(adminRepo.updateReceiptStatus(999, 'completed')).rejects.toThrow('Receipt with ID 999 not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateReceiptStatus(1, 'completed')).rejects.toThrow('Failed to update receipt status.');
await expect(adminRepo.updateReceiptStatus(1, 'completed')).rejects.toThrow('Failed to update receipt status.');
});
});
describe('getActivityLog', () => {
it('should call the get_activity_log database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getActivityLog(50, 0);
await adminRepo.getActivityLog(50, 0);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_activity_log($1, $2)', [50, 0]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getActivityLog(50, 0)).rejects.toThrow('Failed to retrieve activity log.');
await expect(adminRepo.getActivityLog(50, 0)).rejects.toThrow('Failed to retrieve activity log.');
});
});
@@ -338,7 +327,7 @@ describe('Admin DB Service', () => {
it('should return a list of all users for the admin view', async () => {
const mockUsers: AdminUserView[] = [{ user_id: '1', email: 'test@test.com', created_at: '', role: 'user', full_name: 'Test', avatar_url: null }];
mockPoolInstance.query.mockResolvedValue({ rows: mockUsers });
const result = await getAllUsers();
const result = await adminRepo.getAllUsers();
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p'));
expect(result).toEqual(mockUsers);
});
@@ -348,20 +337,20 @@ describe('Admin DB Service', () => {
it('should update the user role and return the updated user', async () => {
const mockUser: User = { user_id: '1', email: 'test@test.com' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1 });
const result = await updateUserRole('1', 'admin');
const result = await adminRepo.updateUserRole('1', 'admin');
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', ['admin', '1']);
expect(result).toEqual(mockUser);
});
it('should throw an error if the user is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(updateUserRole('999', 'admin')).rejects.toThrow('User with ID 999 not found.');
await expect(adminRepo.updateUserRole('999', 'admin')).rejects.toThrow('User with ID 999 not found.');
});
it('should re-throw a generic error if the database query fails for other reasons', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateUserRole('1', 'admin')).rejects.toThrow('DB Error');
await expect(adminRepo.updateUserRole('1', 'admin')).rejects.toThrow('DB Error');
});
});
});

View File

@@ -1,17 +1,26 @@
// src/services/db/admin.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User } from '../../types';
/**
* Retrieves all pending suggested corrections from the database.
* Joins with users and flyer_items to provide context for the admin.
* @returns A promise that resolves to an array of SuggestedCorrection objects.
*/
// prettier-ignore
export async function getSuggestedCorrections(): Promise<SuggestedCorrection[]> {
try {
const query = `
import { logger } from '../logger.server';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User, AdminUserView } from '../../types';
export class AdminRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
/**
* Retrieves all pending suggested corrections from the database.
* Joins with users and flyer_items to provide context for the admin.
* @returns A promise that resolves to an array of SuggestedCorrection objects.
*/
// prettier-ignore
async getSuggestedCorrections(): Promise<SuggestedCorrection[]> {
try {
const query = `
SELECT
sc.suggested_correction_id,
sc.flyer_item_id,
@@ -29,67 +38,67 @@ export async function getSuggestedCorrections(): Promise<SuggestedCorrection[]>
WHERE sc.status = 'pending'
ORDER BY sc.created_at ASC;
`;
const res = await getPool().query<SuggestedCorrection>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getSuggestedCorrections:', { error });
throw new Error('Failed to retrieve suggested corrections.');
}
}
/**
* Approves a correction and applies the change to the corresponding flyer item.
* This function runs as a transaction to ensure data integrity.
* @param correctionId The ID of the correction to approve.
*/
// prettier-ignore
export async function approveCorrection(correctionId: number): Promise<void> {
try {
// The database function `approve_correction` now contains all the logic.
// It finds the correction, applies the change, and updates the status in a single transaction.
// This simplifies the application code and keeps the business logic in the database.
await getPool().query('SELECT public.approve_correction($1)', [correctionId]);
logger.info(`Successfully approved and applied correction ID: ${correctionId}`);
const res = await this.db.query<SuggestedCorrection>(query);
return res.rows;
} catch (error) {
logger.error('Database transaction error in approveCorrection:', { error, correctionId });
throw new Error('Failed to approve correction.');
logger.error('Database error in getSuggestedCorrections:', { error });
throw new Error('Failed to retrieve suggested corrections.');
}
}
/**
* Rejects a correction by updating its status.
* @param correctionId The ID of the correction to reject.
*/
// prettier-ignore
export async function rejectCorrection(correctionId: number): Promise<void> {
try {
const res = await getPool().query(
"UPDATE public.suggested_corrections SET status = 'rejected' WHERE suggested_correction_id = $1 AND status = 'pending' RETURNING suggested_correction_id",
[correctionId]
);
if (res.rowCount === 0) {
// This could happen if the correction was already processed or doesn't exist.
logger.warn(`Attempted to reject correction ID ${correctionId}, but it was not found or not in 'pending' state.`);
// We don't throw an error here, as the end state (not pending) is achieved.
} else {
logger.info(`Successfully rejected correction ID: ${correctionId}`);
}
} catch (error) {
logger.error('Database error in rejectCorrection:', { error, correctionId });
throw new Error('Failed to reject correction.');
}
}
/**
* Updates the suggested value of a pending correction.
* @param correctionId The ID of the correction to update.
* @param newSuggestedValue The new value to set for the suggestion.
* @returns A promise that resolves to the updated SuggestedCorrection object.
*/
// prettier-ignore
export async function updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise<SuggestedCorrection> {
try {
const res = await getPool().query<SuggestedCorrection>(
/**
* Approves a correction and applies the change to the corresponding flyer item.
* This function runs as a transaction to ensure data integrity.
* @param correctionId The ID of the correction to approve.
*/
// prettier-ignore
async approveCorrection(correctionId: number): Promise<void> {
try {
// The database function `approve_correction` now contains all the logic.
// It finds the correction, applies the change, and updates the status in a single transaction.
// This simplifies the application code and keeps the business logic in the database.
await this.db.query('SELECT public.approve_correction($1)', [correctionId]);
logger.info(`Successfully approved and applied correction ID: ${correctionId}`);
} catch (error) {
logger.error('Database transaction error in approveCorrection:', { error, correctionId });
throw new Error('Failed to approve correction.');
}
}
/**
* Rejects a correction by updating its status.
* @param correctionId The ID of the correction to reject.
*/
// prettier-ignore
async rejectCorrection(correctionId: number): Promise<void> {
try {
const res = await this.db.query(
"UPDATE public.suggested_corrections SET status = 'rejected' WHERE suggested_correction_id = $1 AND status = 'pending' RETURNING suggested_correction_id",
[correctionId]
);
if (res.rowCount === 0) {
// This could happen if the correction was already processed or doesn't exist.
logger.warn(`Attempted to reject correction ID ${correctionId}, but it was not found or not in 'pending' state.`);
// We don't throw an error here, as the end state (not pending) is achieved.
} else {
logger.info(`Successfully rejected correction ID: ${correctionId}`);
}
} catch (error) {
logger.error('Database error in rejectCorrection:', { error, correctionId });
throw new Error('Failed to reject correction.');
}
}
/**
* Updates the suggested value of a pending correction.
* @param correctionId The ID of the correction to update.
* @param newSuggestedValue The new value to set for the suggestion.
* @returns A promise that resolves to the updated SuggestedCorrection object.
*/
// prettier-ignore
async updateSuggestedCorrection(correctionId: number, newSuggestedValue: string): Promise<SuggestedCorrection> {
try {
const res = await this.db.query<SuggestedCorrection>(
"UPDATE public.suggested_corrections SET suggested_value = $1 WHERE suggested_correction_id = $2 AND status = 'pending' RETURNING *",
[newSuggestedValue, correctionId]
);
@@ -97,31 +106,31 @@ export async function updateSuggestedCorrection(correctionId: number, newSuggest
throw new Error(`Correction with ID ${correctionId} not found or is not in 'pending' state.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateSuggestedCorrection:', { error, correctionId });
throw new Error('Failed to update suggested correction.');
} catch (error) {
logger.error('Database error in updateSuggestedCorrection:', { error, correctionId });
throw new Error('Failed to update suggested correction.');
}
}
}
/**
* Retrieves application-wide statistics for the admin dashboard.
* @returns A promise that resolves to an object containing various application stats.
*/
// prettier-ignore
export async function getApplicationStats(): Promise<{
flyerCount: number;
userCount: number;
flyerItemCount: number;
storeCount: number;
pendingCorrectionCount: number;
}> {
try {
// Run count queries in parallel for better performance
const flyerCountQuery = getPool().query<{ count: string }>('SELECT COUNT(*) FROM public.flyers');
const userCountQuery = getPool().query<{ count: string }>('SELECT COUNT(*) FROM public.users');
const flyerItemCountQuery = getPool().query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items');
const storeCountQuery = getPool().query<{ count: string }>('SELECT COUNT(*) FROM public.stores');
const pendingCorrectionCountQuery = getPool().query<{ count: string }>("SELECT COUNT(*) FROM public.suggested_corrections WHERE status = 'pending'");
/**
* Retrieves application-wide statistics for the admin dashboard.
* @returns A promise that resolves to an object containing various application stats.
*/
// prettier-ignore
async getApplicationStats(): Promise<{
flyerCount: number;
userCount: number;
flyerItemCount: number;
storeCount: number;
pendingCorrectionCount: number;
}> {
try {
// Run count queries in parallel for better performance
const flyerCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyers');
const userCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.users');
const flyerItemCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items');
const storeCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.stores');
const pendingCorrectionCountQuery = this.db.query<{ count: string }>("SELECT COUNT(*) FROM public.suggested_corrections WHERE status = 'pending'");
const [
flyerCountRes,
@@ -140,20 +149,20 @@ export async function getApplicationStats(): Promise<{
storeCount: parseInt(storeCountRes.rows[0].count, 10),
pendingCorrectionCount: parseInt(pendingCorrectionCountRes.rows[0].count, 10),
};
} catch (error) {
logger.error('Database error in getApplicationStats:', { error });
throw error; // Re-throw the original error to be handled by the caller
} catch (error) {
logger.error('Database error in getApplicationStats:', { error });
throw error; // Re-throw the original error to be handled by the caller
}
}
}
/**
* Retrieves daily statistics for user registrations and flyer uploads for the last 30 days.
* @returns A promise that resolves to an array of daily stats.
*/
// prettier-ignore
export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
try {
const query = `
/**
* Retrieves daily statistics for user registrations and flyer uploads for the last 30 days.
* @returns A promise that resolves to an array of daily stats.
*/
// prettier-ignore
async getDailyStatsForLast30Days(): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
try {
const query = `
WITH date_series AS (
SELECT generate_series(
(CURRENT_DATE - interval '29 days'),
@@ -182,28 +191,28 @@ export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_
LEFT JOIN daily_flyers df ON ds.day = df.day
ORDER BY ds.day ASC;
`;
const res = await getPool().query(query);
return res.rows;
} catch (error) {
logger.error('Database error in getDailyStatsForLast30Days:', { error });
throw new Error('Failed to retrieve daily statistics.');
const res = await this.db.query(query);
return res.rows;
} catch (error) {
logger.error('Database error in getDailyStatsForLast30Days:', { error });
throw new Error('Failed to retrieve daily statistics.');
}
}
}
/**
* Calls a database function to get the most frequently advertised items.
* @param days The number of past days to look back.
* @param limit The maximum number of items to return.
* @returns A promise that resolves to an array of the most frequent sale items.
*/
export async function getMostFrequentSaleItems(days: number, limit: number): Promise<MostFrequentSaleItem[]> {
// This is a secure parameterized query. The values for `days` and `limit` are passed
// separately from the query string. The database driver safely substitutes the `$1` and `$2`
// placeholders, preventing SQL injection attacks.
// Never use template literals like `WHERE f.valid_from >= NOW() - '${days} days'`
// as that would be a major security vulnerability.
try {
const query = `
/**
* Calls a database function to get the most frequently advertised items.
* @param days The number of past days to look back.
* @param limit The maximum number of items to return.
* @returns A promise that resolves to an array of the most frequent sale items.
*/
async getMostFrequentSaleItems(days: number, limit: number): Promise<MostFrequentSaleItem[]> {
// This is a secure parameterized query. The values for `days` and `limit` are passed
// separately from the query string. The database driver safely substitutes the `$1` and `$2`
// placeholders, preventing SQL injection attacks.
// Never use template literals like `WHERE f.valid_from >= NOW() - '${days} days'`
// as that would be a major security vulnerability.
try {
const query = `
SELECT
fi.master_item_id,
mi.name as item_name,
@@ -220,43 +229,43 @@ export async function getMostFrequentSaleItems(days: number, limit: number): Pro
sale_count DESC, mi.name ASC
LIMIT $2;
`;
const res = await getPool().query<MostFrequentSaleItem>(query, [days, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getMostFrequentSaleItems:', { error });
throw new Error('Failed to get most frequent sale items.');
const res = await this.db.query<MostFrequentSaleItem>(query, [days, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getMostFrequentSaleItems:', { error });
throw new Error('Failed to get most frequent sale items.');
}
}
}
/**
* Updates the status of a recipe comment (e.g., for moderation).
* @param commentId The ID of the comment to update.
* @param status The new status ('visible', 'hidden', 'reported').
* @returns A promise that resolves to the updated RecipeComment object.
*/
export async function updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise<RecipeComment> {
try {
const res = await getPool().query<RecipeComment>(
/**
* Updates the status of a recipe comment (e.g., for moderation).
* @param commentId The ID of the comment to update.
* @param status The new status ('visible', 'hidden', 'reported').
* @returns A promise that resolves to the updated RecipeComment object.
*/
async updateRecipeCommentStatus(commentId: number, status: 'visible' | 'hidden' | 'reported'): Promise<RecipeComment> {
try {
const res = await this.db.query<RecipeComment>(
'UPDATE public.recipe_comments SET status = $1 WHERE recipe_comment_id = $2 RETURNING *',
[status, commentId]
);
if (res.rowCount === 0) {
throw new Error(`Recipe comment with ID ${commentId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateRecipeCommentStatus:', { error, commentId, status });
throw new Error('Failed to update recipe comment status.');
}
}
);
if (res.rowCount === 0) {
throw new Error(`Recipe comment with ID ${commentId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateRecipeCommentStatus:', { error, commentId, status });
throw new Error('Failed to update recipe comment status.');
}
}
/**
* Retrieves all flyer items that could not be automatically matched to a master item.
* @returns A promise that resolves to an array of unmatched flyer items with context.
*/
export async function getUnmatchedFlyerItems(): Promise<UnmatchedFlyerItem[]> {
try {
const query = `
/**
* Retrieves all flyer items that could not be automatically matched to a master item.
* @returns A promise that resolves to an array of unmatched flyer items with context.
*/
async getUnmatchedFlyerItems(): Promise<UnmatchedFlyerItem[]> {
try {
const query = `
SELECT
ufi.unmatched_flyer_item_id,
ufi.status,
@@ -273,191 +282,172 @@ export async function getUnmatchedFlyerItems(): Promise<UnmatchedFlyerItem[]> {
WHERE ufi.status = 'pending'
ORDER BY ufi.created_at ASC;
`;
const res = await getPool().query<UnmatchedFlyerItem>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getUnmatchedFlyerItems:', { error });
throw new Error('Failed to retrieve unmatched flyer items.');
const res = await this.db.query<UnmatchedFlyerItem>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getUnmatchedFlyerItems:', { error });
throw new Error('Failed to retrieve unmatched flyer items.');
}
}
}
/**
* Updates the status of a recipe (e.g., for moderation).
* @param recipeId The ID of the recipe to update.
* @param status The new status ('private', 'pending_review', 'public', 'rejected').
* @returns A promise that resolves to the updated Recipe object.
*/
export async function updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise<Recipe> {
try {
const res = await getPool().query<Recipe>(
/**
* Updates the status of a recipe (e.g., for moderation).
* @param recipeId The ID of the recipe to update.
* @param status The new status ('private', 'pending_review', 'public', 'rejected').
* @returns A promise that resolves to the updated Recipe object.
*/
async updateRecipeStatus(recipeId: number, status: 'private' | 'pending_review' | 'public' | 'rejected'): Promise<Recipe> {
try {
const res = await this.db.query<Recipe>(
'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *',
[status, recipeId]
);
if (res.rowCount === 0) {
throw new Error(`Recipe with ID ${recipeId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateRecipeStatus:', { error, recipeId, status });
throw new Error('Failed to update recipe status.');
}
}
);
if (res.rowCount === 0) {
throw new Error(`Recipe with ID ${recipeId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateRecipeStatus:', { error, recipeId, status });
throw new Error('Failed to update recipe status.');
}
}
/**
* Retrieves a paginated list of recent activities from the activity log.
* @param limit The number of log entries to retrieve.
* @param offset The number of log entries to skip (for pagination).
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
// prettier-ignore
export async function getActivityLog(limit: number, offset: number): Promise<ActivityLogItem[]> {
try {
const res = await getPool().query<ActivityLogItem>('SELECT * FROM public.get_activity_log($1, $2)', [limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getActivityLog:', { error, limit, offset });
throw new Error('Failed to retrieve activity log.');
}
}
/**
* Retrieves a paginated list of recent activities from the activity log.
* @param limit The number of log entries to retrieve.
* @param offset The number of log entries to skip (for pagination).
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
// prettier-ignore
async getActivityLog(limit: number, offset: number): Promise<ActivityLogItem[]> {
try {
const res = await this.db.query<ActivityLogItem>('SELECT * FROM public.get_activity_log($1, $2)', [limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getActivityLog:', { error, limit, offset });
throw new Error('Failed to retrieve activity log.');
}
}
/**
* Defines a type for JSON-compatible data structures, allowing for nested objects and arrays.
* This provides a safer alternative to `any` for objects intended for JSON serialization.
*/
type JsonData = string | number | boolean | null | { [key: string]: JsonData } | JsonData[];
/**
* Inserts a new entry into the activity log. This is the standardized function
* to be used across the application for logging significant events.
* @param logData The data for the new log entry.
*/
export async function logActivity(logData: {
/**
* Defines a type for JSON-compatible data structures, allowing for nested objects and arrays.
* This provides a safer alternative to `any` for objects intended for JSON serialization.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async logActivity(logData: {
userId?: string | null;
action: string;
displayText: string;
icon?: string | null;
details?: Record<string, JsonData> | null;
}): Promise<void> {
details?: Record<string, any> | null;
}): Promise<void> {
const { userId, action, displayText, icon, details } = logData;
try {
await getPool().query(
`INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
await this.db.query(
`INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
VALUES ($1, $2, $3, $4, $5)`,
[
userId || null,
action,
displayText,
icon || null,
details ? JSON.stringify(details) : null,
]
);
[
userId || null,
action,
displayText,
icon || null,
details ? JSON.stringify(details) : null,
]
);
} catch (error) {
logger.error('Database error in logActivity:', { error, logData });
// We don't re-throw here to prevent logging failures from crashing critical paths.
logger.error('Database error in logActivity:', { error, logData });
// We don't re-throw here to prevent logging failures from crashing critical paths.
}
}
}
/**
* Increments the failed login attempt counter for a user.
* @param userId The ID of the user.
*/
export async function incrementFailedLoginAttempts(userId: string): Promise<void> {
/**
* Increments the failed login attempt counter for a user.
* @param userId The ID of the user.
*/
async incrementFailedLoginAttempts(userId: string): Promise<void> {
try {
await getPool().query(
`UPDATE public.users
await this.db.query(
`UPDATE public.users
SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = NOW()
WHERE user_id = $1`,
[userId]
);
[userId]
);
} catch (error) {
logger.error('Database error in incrementFailedLoginAttempts:', { error, userId });
logger.error('Database error in incrementFailedLoginAttempts:', { error, userId });
}
}
/**
* Updates the logo URL for a specific brand.
* @param brandId The ID of the brand to update.
* @param logoUrl The new URL for the brand's logo.
*/
// prettier-ignore
export async function updateBrandLogo(brandId: number, logoUrl: string): Promise<void> {
try {
await getPool().query(
'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2',
[logoUrl, brandId]
);
} catch (error) {
logger.error('Database error in updateBrandLogo:', { error, brandId });
throw new Error('Failed to update brand logo in database.');
}
}
/**
* Updates the status of a specific receipt.
* @param receiptId The ID of the receipt to update.
* @param status The new status for the receipt.
* @returns A promise that resolves to the updated Receipt object.
*/
export async function updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise<Receipt> {
/**
* Updates the logo URL for a specific brand.
* @param brandId The ID of the brand to update.
* @param logoUrl The new URL for the brand's logo.
*/
// prettier-ignore
async updateBrandLogo(brandId: number, logoUrl: string): Promise<void> {
try {
const res = await getPool().query<Receipt>(
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE receipt_id = $2 RETURNING *`,
[status, receiptId]
);
if (res.rowCount === 0) {
throw new Error(`Receipt with ID ${receiptId} not found.`);
}
return res.rows[0];
await this.db.query(
'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2',
[logoUrl, brandId]
);
} catch (error) {
logger.error('Database error in updateReceiptStatus:', { error, receiptId, status });
throw new Error('Failed to update receipt status.');
logger.error('Database error in updateBrandLogo:', { error, brandId });
throw new Error('Failed to update brand logo in database.');
}
}
}
/**
* Defines the shape of the user data returned for the admin user list.
* This is a public-facing type and does not include sensitive fields.
*/
export interface AdminUserView {
user_id: string;
email: string;
created_at: string;
role: 'admin' | 'user';
full_name: string | null;
avatar_url: string | null;
}
/**
* Updates the status of a specific receipt.
* @param receiptId The ID of the receipt to update.
* @param status The new status for the receipt.
* @returns A promise that resolves to the updated Receipt object.
*/
async updateReceiptStatus(receiptId: number, status: 'pending' | 'processing' | 'completed' | 'failed'): Promise<Receipt> {
try {
const res = await this.db.query<Receipt>(
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE receipt_id = $2 RETURNING *`,
[status, receiptId]
);
if (res.rowCount === 0) {
throw new Error(`Receipt with ID ${receiptId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateReceiptStatus:', { error, receiptId, status });
throw new Error('Failed to update receipt status.');
}
}
export const getAllUsers = async (): Promise<AdminUserView[]> => {
const pool = getPool();
async getAllUsers(): Promise<AdminUserView[]> {
const query = `
SELECT u.user_id, u.email, u.created_at, p.role, p.full_name, p.avatar_url
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC;
`;
const res = await pool.query<AdminUserView>(query);
const res = await this.db.query<AdminUserView>(query);
return res.rows;
};
};
/**
* Updates the role of a specific user.
* @param userId The ID of the user to update.
* @param role The new role to assign ('user' or 'admin').
* @returns A promise that resolves to the updated Profile object.
*/
export async function updateUserRole(userId: string, role: 'user' | 'admin'): Promise<User> {
/**
* Updates the role of a specific user.
* @param userId The ID of the user to update.
* @param role The new role to assign ('user' or 'admin').
* @returns A promise that resolves to the updated Profile object.
*/
async updateUserRole(userId: string, role: 'user' | 'admin'): Promise<User> {
try {
const res = await getPool().query<User>(
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
[role, userId]
);
if (res.rowCount === 0) {
throw new Error(`User with ID ${userId} not found.`);
}
return res.rows[0];
const res = await this.db.query<User>(
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
[role, userId]
);
if (res.rowCount === 0) {
throw new Error(`User with ID ${userId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateUserRole:', { error, userId, role });
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
throw error; // Re-throw to be handled by the route
logger.error('Database error in updateUserRole:', { error, userId, role });
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
throw error; // Re-throw to be handled by the route
}
}
}

View File

@@ -4,18 +4,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./budget.db');
import {
getBudgetsForUser,
createBudget,
updateBudget,
deleteBudget,
getSpendingByCategory,
} from './budget.db';
import { BudgetRepository } from './budget.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import type { Budget, SpendingByCategory } from '../../types';
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
@@ -25,25 +19,36 @@ vi.mock('../logger', () => ({
}));
describe('Budget DB Service', () => {
let budgetRepo: BudgetRepository;
beforeEach(() => {
vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test
budgetRepo = new BudgetRepository(mockPoolInstance as any);
});
describe('getBudgetsForUser', () => {
it('should execute the correct SELECT query and return budgets', async () => {
const mockBudgets: Budget[] = [{ budget_id: 1, user_id: 'user-123', name: 'Groceries', amount_cents: 50000, period: 'monthly', start_date: '2024-01-01' }];
mockPoolInstance.query.mockResolvedValue({ rows: mockBudgets });
const result = await getBudgetsForUser('user-123');
const result = await budgetRepo.getBudgetsForUser('user-123');
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC', ['user-123']);
expect(result).toEqual(mockBudgets);
});
it('should return an empty array if the user has no budgets', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await budgetRepo.getBudgetsForUser('user-123');
expect(result).toEqual([]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.any(String), ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getBudgetsForUser('user-123')).rejects.toThrow('Failed to retrieve budgets.');
await expect(budgetRepo.getBudgetsForUser('user-123')).rejects.toThrow('Failed to retrieve budgets.');
});
});
@@ -52,22 +57,26 @@ describe('Budget DB Service', () => {
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
const mockCreatedBudget: Budget = { budget_id: 1, user_id: 'user-123', ...budgetData };
// FIX: Add mock for 'BEGIN' call
mockPoolInstance.query
// For transactional methods, we mock the client returned by `connect()`
const mockClient = {
query: vi.fn(),
release: vi.fn(),
};
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Mock the sequence of queries within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // INSERT...RETURNING
.mockResolvedValueOnce({ rows: [] }) // award_achievement
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await createBudget('user-123', budgetData);
const result = await budgetRepo.createBudget('user-123', budgetData);
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.budgets'),
['user-123', budgetData.name, budgetData.amount_cents, budgetData.period, budgetData.start_date]
);
expect(mockPoolInstance.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.budgets'), expect.any(Array));
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
expect(result).toEqual(mockCreatedBudget);
});
@@ -77,7 +86,22 @@ describe('Budget DB Service', () => {
(dbError as any).code = '23503';
mockPoolInstance.query.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError); // BEGIN then INSERT fails
await expect(createBudget('non-existent-user', budgetData)).rejects.toThrow('The specified user does not exist.');
await expect(budgetRepo.createBudget('non-existent-user', budgetData)).rejects.toThrow('The specified user does not exist.');
});
it('should rollback the transaction if awarding an achievement fails', async () => {
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
const mockCreatedBudget: Budget = { budget_id: 1, user_id: 'user-123', ...budgetData };
const achievementError = new Error('Achievement award failed');
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // INSERT...RETURNING
.mockRejectedValueOnce(achievementError); // award_achievement fails
await expect(budgetRepo.createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
expect(mockPoolInstance.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockPoolInstance.query).not.toHaveBeenCalledWith('COMMIT');
});
it('should throw a generic error if the database query fails', async () => {
@@ -86,7 +110,7 @@ describe('Budget DB Service', () => {
// Mock BEGIN to succeed, but the INSERT to fail
mockPoolInstance.query.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError);
await expect(createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
await expect(budgetRepo.createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
});
});
@@ -96,7 +120,7 @@ describe('Budget DB Service', () => {
const mockUpdatedBudget: Budget = { budget_id: 1, user_id: 'user-123', name: 'Updated Groceries', amount_cents: 55000, period: 'monthly', start_date: '2024-01-01' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUpdatedBudget], rowCount: 1 });
const result = await updateBudget(1, 'user-123', budgetUpdates);
const result = await budgetRepo.updateBudget(1, 'user-123', budgetUpdates);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE public.budgets SET'),
@@ -109,14 +133,14 @@ describe('Budget DB Service', () => {
// FIX: Force the mock to return rowCount: 0 for the next call
mockPoolInstance.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await expect(updateBudget(999, 'user-123', { name: 'Fail' }))
await expect(budgetRepo.updateBudget(999, 'user-123', { name: 'Fail' }))
.rejects.toThrow('Budget not found or user does not have permission to update.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateBudget(1, 'user-123', { name: 'Fail' }))
await expect(budgetRepo.updateBudget(1, 'user-123', { name: 'Fail' }))
.rejects.toThrow('Failed to update budget.');
});
});
@@ -124,7 +148,7 @@ describe('Budget DB Service', () => {
describe('deleteBudget', () => {
it('should execute a DELETE query with user ownership check', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1 });
await deleteBudget(1, 'user-123');
await budgetRepo.deleteBudget(1, 'user-123');
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [1, 'user-123']);
});
@@ -132,14 +156,14 @@ describe('Budget DB Service', () => {
// FIX: Force the mock to return rowCount: 0
mockPoolInstance.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await expect(deleteBudget(999, 'user-123'))
await expect(budgetRepo.deleteBudget(999, 'user-123'))
.rejects.toThrow('Budget not found or user does not have permission to delete.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(deleteBudget(1, 'user-123')).rejects.toThrow('Failed to delete budget.');
await expect(budgetRepo.deleteBudget(1, 'user-123')).rejects.toThrow('Failed to delete budget.');
});
});
@@ -148,16 +172,22 @@ describe('Budget DB Service', () => {
const mockSpendingData: SpendingByCategory[] = [{ category_id: 1, category_name: 'Produce', total_spent_cents: 12345 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockSpendingData });
const result = await getSpendingByCategory('user-123', '2024-01-01', '2024-01-31');
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31');
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_spending_by_category($1, $2, $3)', ['user-123', '2024-01-01', '2024-01-31']);
expect(result).toEqual(mockSpendingData);
});
it('should return an empty array if there is no spending data', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31');
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getSpendingByCategory('user-123', '2024-01-01', '2024-01-31')).rejects.toThrow('Failed to get spending analysis.');
await expect(budgetRepo.getSpendingByCategory('user-123', '2024-01-01', '2024-01-31')).rejects.toThrow('Failed to get spending analysis.');
});
});
});

View File

@@ -1,123 +1,130 @@
// src/services/db/budget.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger';
import { logger } from '../logger.server';
import { Budget, SpendingByCategory } from '../../types';
/**
* Retrieves all budgets for a specific user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Budget objects.
*/
export async function getBudgetsForUser(userId: string): Promise<Budget[]> {
try {
const res = await getPool().query<Budget>(
'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
[userId]
);
return res.rows;
} catch (error) {
logger.error('Database error in getBudgetsForUser:', { error, userId });
throw new Error('Failed to retrieve budgets.');
export class BudgetRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
}
/**
* Creates a new budget for a user.
* @param userId The ID of the user creating the budget.
* @param budgetData The data for the new budget.
* @returns A promise that resolves to the newly created Budget object.
*/
export async function createBudget(userId: string, budgetData: Omit<Budget, 'budget_id' | 'user_id'>): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
const client = await getPool().connect();
try {
await client.query('BEGIN');
const res = await client.query<Budget>(
'INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[userId, name, amount_cents, period, start_date]
);
// After successfully creating the budget, try to award the 'First Budget Created' achievement.
// The award_achievement function handles checking if the user already has it.
await client.query("SELECT public.award_achievement($1, 'First Budget Created')", [userId]);
await client.query('COMMIT');
return res.rows[0];
} catch (error) {
await client.query('ROLLBACK');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
/**
* Retrieves all budgets for a specific user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Budget objects.
*/
async getBudgetsForUser(userId: string): Promise<Budget[]> {
try {
const res = await this.db.query<Budget>(
'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
[userId]
);
return res.rows;
} catch (error) {
logger.error('Database error in getBudgetsForUser:', { error, userId });
throw new Error('Failed to retrieve budgets.');
}
logger.error('Database error in createBudget:', { error });
throw new Error('Failed to create budget.');
} finally {
client.release();
}
}
/**
* Updates an existing budget.
* @param budgetId The ID of the budget to update.
* @param userId The ID of the user who owns the budget (for verification).
* @param budgetData The data to update.
* @returns A promise that resolves to the updated Budget object.
*/
export async function updateBudget(budgetId: number, userId: string, budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id'>>): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
let res;
try {
res = await getPool().query<Budget>(
`UPDATE public.budgets SET
name = COALESCE($1, name),
amount_cents = COALESCE($2, amount_cents),
period = COALESCE($3, period),
start_date = COALESCE($4, start_date)
WHERE budget_id = $5 AND user_id = $6 RETURNING *`,
[name, amount_cents, period, start_date, budgetId, userId]
);
} catch (error) {
logger.error('Database error in updateBudget:', { error });
throw new Error('Failed to update budget.');
}
if (res.rowCount === 0) {
throw new Error('Budget not found or user does not have permission to update.');
}
return res.rows[0];
}
/**
* Creates a new budget for a user.
* @param userId The ID of the user creating the budget.
* @param budgetData The data for the new budget.
* @returns A promise that resolves to the newly created Budget object.
*/
async createBudget(userId: string, budgetData: Omit<Budget, 'budget_id' | 'user_id'>): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
const client = await getPool().connect();
try {
await client.query('BEGIN');
const res = await client.query<Budget>(
'INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[userId, name, amount_cents, period, start_date]
);
/**
* Deletes a budget.
* @param budgetId The ID of the budget to delete.
* @param userId The ID of the user who owns the budget (for verification).
*/
export async function deleteBudget(budgetId: number, userId: string): Promise<void> {
let res;
try {
res = await getPool().query('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [budgetId, userId]);
} catch (error) {
logger.error('Database error in deleteBudget:', { error });
throw new Error('Failed to delete budget.');
// After successfully creating the budget, try to award the 'First Budget Created' achievement.
// The award_achievement function handles checking if the user already has it.
await client.query("SELECT public.award_achievement($1, 'First Budget Created')", [userId]);
await client.query('COMMIT');
return res.rows[0];
} catch (error) {
await client.query('ROLLBACK');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createBudget:', { error });
throw new Error('Failed to create budget.');
} finally {
client.release();
}
}
if (res.rowCount === 0) {
throw new Error('Budget not found or user does not have permission to delete.');
/**
* Updates an existing budget.
* @param budgetId The ID of the budget to update.
* @param userId The ID of the user who owns the budget (for verification).
* @param budgetData The data to update.
* @returns A promise that resolves to the updated Budget object.
*/
async updateBudget(budgetId: number, userId: string, budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id'>>): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
try {
const res = await this.db.query<Budget>(
`UPDATE public.budgets SET
name = COALESCE($1, name),
amount_cents = COALESCE($2, amount_cents),
period = COALESCE($3, period),
start_date = COALESCE($4, start_date)
WHERE budget_id = $5 AND user_id = $6 RETURNING *`,
[name, amount_cents, period, start_date, budgetId, userId]
);
if (res.rowCount === 0) {
throw new Error('Budget not found or user does not have permission to update.');
}
return res.rows[0];
} catch (error) {
if (error instanceof Error && error.message.startsWith('Budget not found')) throw error;
logger.error('Database error in updateBudget:', { error, budgetId, userId });
throw new Error('Failed to update budget.');
}
}
}
/**
* Calls the database function to get a user's spending breakdown by category.
* @param userId The ID of the user.
* @param startDate The start of the date range.
* @param endDate The end of the date range.
* @returns A promise that resolves to an array of spending data.
*/
export async function getSpendingByCategory(userId: string, startDate: string, endDate: string): Promise<SpendingByCategory[]> {
try {
const res = await getPool().query<SpendingByCategory>('SELECT * FROM public.get_spending_by_category($1, $2, $3)', [userId, startDate, endDate]);
return res.rows;
} catch (error) {
logger.error('Database error in getSpendingByCategory:', { error, userId });
throw new Error('Failed to get spending analysis.');
/**
* Deletes a budget.
* @param budgetId The ID of the budget to delete.
* @param userId The ID of the user who owns the budget (for verification).
*/
async deleteBudget(budgetId: number, userId: string): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [budgetId, userId]);
if (res.rowCount === 0) {
throw new Error('Budget not found or user does not have permission to delete.');
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('Budget not found')) throw error;
logger.error('Database error in deleteBudget:', { error, budgetId, userId });
throw new Error('Failed to delete budget.');
}
}
/**
* Calls the database function to get a user's spending breakdown by category.
* @param userId The ID of the user.
* @param startDate The start of the date range.
* @param endDate The end of the date range.
* @returns A promise that resolves to an array of spending data.
*/
async getSpendingByCategory(userId: string, startDate: string, endDate: string): Promise<SpendingByCategory[]> {
try {
const res = await this.db.query<SpendingByCategory>('SELECT * FROM public.get_spending_by_category($1, $2, $3)', [userId, startDate, endDate]);
return res.rows;
} catch (error) {
logger.error('Database error in getSpendingByCategory:', { error, userId });
throw new Error('Failed to get spending analysis.');
}
}
}

View File

@@ -64,13 +64,26 @@ describe('DB Connection Service', () => {
});
});
it('should throw an error if the Pool constructor fails', async () => {
// Arrange: Mock the Pool constructor to throw an error
const { Pool } = await import('pg');
const constructorError = new Error('Invalid credentials');
vi.mocked(Pool).mockImplementation(() => {
throw constructorError;
});
const { getPool } = await import('./connection.db');
// Act & Assert: Expect getPool to throw the error from the constructor
expect(() => getPool()).toThrow(constructorError);
});
describe('checkTablesExist', () => {
it('should return an empty array if all tables exist', async () => {
const { getPool, checkTablesExist } = await import('./connection.db');
const pool = getPool();
// Mock the query response on the *instance* returned by getPool
// Use `as Mock` for type-safe mocking.
// Use vi.mocked() to get a type-safe mock of the query function
(pool.query as Mock).mockResolvedValue({ rows: [{ table_name: 'users' }, { table_name: 'flyers' }] });
const tableNames = ['users', 'flyers'];

View File

@@ -1,6 +1,6 @@
// src/services/db/connection.db.ts
import { Pool, PoolConfig } from 'pg';
import { logger } from '../logger';
import { logger } from '../logger.server';
interface TrackedPool extends Pool {
poolId?: string;

View File

@@ -6,18 +6,8 @@ import { createMockFlyer, createMockFlyerItem, createMockBrand } from '../../tes
// Un-mock the module we are testing to ensure we use the real implementation
vi.unmock('./flyer.db');
import {
insertFlyer,
insertFlyerItems,
createFlyerAndItems,
getAllBrands,
getFlyerById,
getFlyers,
getFlyerItems,
getFlyerItemsForFlyers,
countFlyerItemsForFlyers,
findFlyerByChecksum,
} from './flyer.db';
import { FlyerRepository, createFlyerAndItems } from './flyer.db';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import type { FlyerInsert, FlyerItemInsert, Brand, Flyer, FlyerItem } from '../../types';
// Mock dependencies
@@ -26,6 +16,8 @@ vi.mock('../logger.server', () => ({
}));
describe('Flyer DB Service', () => {
let flyerRepo: FlyerRepository;
beforeEach(() => {
// In a transaction, `pool.connect()` returns a client. That client has a `release` method.
// For these tests, we simulate this by having `connect` resolve to the pool instance itself,
@@ -34,6 +26,7 @@ describe('Flyer DB Service', () => {
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
vi.clearAllMocks();
flyerRepo = new FlyerRepository(mockPoolInstance as any);
});
describe('insertFlyer', () => {
@@ -53,7 +46,7 @@ describe('Flyer DB Service', () => {
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await insertFlyer(flyerData, mockPoolInstance as any);
const result = await flyerRepo.insertFlyer(flyerData);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
@@ -73,6 +66,22 @@ describe('Flyer DB Service', () => {
]
);
});
it('should throw UniqueConstraintError on duplicate checksum', async () => {
const flyerData: FlyerInsert = { checksum: 'duplicate-checksum' } as FlyerInsert;
const dbError = new Error('duplicate key value violates unique constraint "flyers_checksum_key"');
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyer(flyerData)).rejects.toThrow(UniqueConstraintError);
await expect(flyerRepo.insertFlyer(flyerData)).rejects.toThrow('A flyer with this checksum already exists.');
});
it('should throw a generic error if the database query fails', async () => {
const flyerData: FlyerInsert = { checksum: 'fail-checksum' } as FlyerInsert;
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
await expect(flyerRepo.insertFlyer(flyerData)).rejects.toThrow('Failed to insert flyer into database.');
});
});
describe('insertFlyerItems', () => {
@@ -84,7 +93,7 @@ describe('Flyer DB Service', () => {
const mockItems = itemsData.map((item, i) => createMockFlyerItem({ ...item, flyer_item_id: i + 1, flyer_id: 1 }));
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await insertFlyerItems(1, itemsData, mockPoolInstance as any);
const result = await flyerRepo.insertFlyerItems(1, itemsData);
expect(result).toEqual(mockItems);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
@@ -98,10 +107,25 @@ describe('Flyer DB Service', () => {
});
it('should return an empty array and not query the DB if items array is empty', async () => {
const result = await insertFlyerItems(1, [], mockPoolInstance as any);
const result = await flyerRepo.insertFlyerItems(1, []);
expect(result).toEqual([]);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
});
it('should throw ForeignKeyConstraintError if flyerId is invalid', async () => {
const itemsData: FlyerItemInsert[] = [{ item: 'Test', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Test', view_count: 0, click_count: 0 }];
const dbError = new Error('insert or update on table "flyer_items" violates foreign key constraint "flyer_items_flyer_id_fkey"');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.insertFlyerItems(999, itemsData)).rejects.toThrow(ForeignKeyConstraintError);
await expect(flyerRepo.insertFlyerItems(999, itemsData)).rejects.toThrow('The specified flyer does not exist.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
await expect(flyerRepo.insertFlyerItems(1, [{ item: 'Test' } as FlyerItemInsert])).rejects.toThrow('Failed to insert flyer items into database.');
});
});
describe('createFlyerAndItems', () => {
@@ -171,11 +195,17 @@ describe('Flyer DB Service', () => {
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Test Brand' })];
mockPoolInstance.query.mockResolvedValue({ rows: mockBrands });
const result = await getAllBrands();
const result = await flyerRepo.getAllBrands();
expect(result).toEqual(mockBrands);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.stores s'));
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getAllBrands()).rejects.toThrow('Failed to retrieve brands from database.');
});
});
describe('getFlyerById', () => {
@@ -183,11 +213,17 @@ describe('Flyer DB Service', () => {
const mockFlyer = createMockFlyer({ flyer_id: 123 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await getFlyerById(123);
const result = await flyerRepo.getFlyerById(123);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE flyer_id = $1', [123]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyerById(123)).rejects.toThrow('Failed to retrieve flyer from database.');
});
});
describe('getFlyers', () => {
@@ -195,7 +231,7 @@ describe('Flyer DB Service', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
await getFlyers();
await flyerRepo.getFlyers();
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
@@ -207,13 +243,19 @@ describe('Flyer DB Service', () => {
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
await getFlyers(10, 5);
await flyerRepo.getFlyers(10, 5);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[10, 5] // Provided values
);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyers()).rejects.toThrow('Failed to retrieve flyers from database.');
});
});
describe('getFlyerItems', () => {
@@ -221,37 +263,61 @@ describe('Flyer DB Service', () => {
const mockItems: FlyerItem[] = [createMockFlyerItem({ flyer_id: 456 })];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await getFlyerItems(456);
const result = await flyerRepo.getFlyerItems(456);
expect(result).toEqual(mockItems);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE flyer_id = $1'), [456]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyerItems(456)).rejects.toThrow('Failed to retrieve flyer items from database.');
});
});
describe('getFlyerItemsForFlyers', () => {
it('should return items for multiple flyers using ANY', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getFlyerItemsForFlyers([1, 2, 3]);
await flyerRepo.getFlyerItemsForFlyers([1, 2, 3]);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('flyer_id = ANY($1::int[])'), [[1, 2, 3]]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.getFlyerItemsForFlyers([1, 2, 3])).rejects.toThrow('Failed to retrieve flyer items in batch from database.');
});
});
describe('countFlyerItemsForFlyers', () => {
it('should return the total count of items', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ count: '42' }] });
const result = await countFlyerItemsForFlyers([1, 2]);
const result = await flyerRepo.countFlyerItemsForFlyers([1, 2]);
expect(result).toBe(42);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT COUNT(*)'), [[1, 2]]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.countFlyerItemsForFlyers([1, 2])).rejects.toThrow('Failed to count flyer items in batch from database.');
});
});
describe('findFlyerByChecksum', () => {
it('should return a flyer for a given checksum', async () => {
const mockFlyer = createMockFlyer({ checksum: 'abc' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await findFlyerByChecksum('abc');
const result = await flyerRepo.findFlyerByChecksum('abc');
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE checksum = $1', ['abc']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(flyerRepo.findFlyerByChecksum('abc')).rejects.toThrow('Failed to find flyer by checksum in database.');
});
});
});

View File

@@ -1,84 +1,210 @@
// src/services/db/flyer.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import type { Flyer, FlyerItem, FlyerInsert, FlyerItemInsert, Brand } from '../../types';
/**
* Inserts a new flyer into the database.
* This function is designed to be used within a transaction by accepting an optional client.
*
* @param flyerData - The data for the new flyer.
* @param db - The database pool or a specific client for transactions.
* @returns The newly created flyer record with its ID.
*/
export async function insertFlyer(
flyerData: FlyerInsert,
db: Pool | PoolClient = getPool()
): Promise<Flyer> {
const query = `
INSERT INTO flyers (
file_name, image_url, icon_url, checksum, store_name, valid_from, valid_to,
store_address, item_count, uploaded_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
`;
const values = [
flyerData.file_name,
flyerData.image_url,
flyerData.icon_url,
flyerData.checksum,
flyerData.store_name,
flyerData.valid_from,
flyerData.valid_to,
flyerData.store_address,
flyerData.item_count,
flyerData.uploaded_by,
];
export class FlyerRepository {
private db: Pool | PoolClient;
const result = await db.query<Flyer>(query, values);
return result.rows[0];
}
/**
* Inserts multiple flyer items into the database for a given flyer ID.
* This function is designed to be used within a transaction by accepting an optional client.
* It uses a single query for efficient bulk insertion.
*
* @param flyerId - The ID of the parent flyer.
* @param items - An array of item data to insert.
* @param db - The database pool or a specific client for transactions.
* @returns An array of the newly created flyer item records.
*/
export async function insertFlyerItems(
flyerId: number,
items: FlyerItemInsert[],
db: Pool | PoolClient = getPool()
): Promise<FlyerItem[]> {
if (!items || items.length === 0) {
return [];
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
// Build a single, parameterized query for all items.
const values: any[] = [];
const valueStrings: string[] = [];
let paramIndex = 1;
/**
* Inserts a new flyer into the database.
* @param flyerData - The data for the new flyer.
* @returns The newly created flyer record with its ID.
*/
async insertFlyer(flyerData: FlyerInsert): Promise<Flyer> {
try {
const query = `
INSERT INTO flyers (
file_name, image_url, icon_url, checksum, store_name, valid_from, valid_to,
store_address, item_count, uploaded_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
`;
const values = [
flyerData.file_name,
flyerData.image_url,
flyerData.icon_url,
flyerData.checksum,
flyerData.store_name,
flyerData.valid_from,
flyerData.valid_to,
flyerData.store_address,
flyerData.item_count,
flyerData.uploaded_by,
];
for (const item of items) {
valueStrings.push(`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`);
values.push(flyerId, item.item, item.price_display, item.price_in_cents, item.quantity, item.category_name, item.view_count, item.click_count);
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
} catch (error) {
logger.error('Database error in insertFlyer:', { error, flyerData });
// Check for a unique constraint violation on the 'checksum' column.
if (error instanceof Error && 'code' in error && error.code === '23505') {
throw new UniqueConstraintError('A flyer with this checksum already exists.');
}
throw new Error('Failed to insert flyer into database.');
}
}
const query = `
INSERT INTO flyer_items (
flyer_id, item, price_display, price_in_cents, quantity, category_name, view_count, click_count
)
VALUES ${valueStrings.join(', ')}
RETURNING *;
`;
/**
* Inserts multiple flyer items into the database for a given flyer ID.
* @param flyerId - The ID of the parent flyer.
* @param items - An array of item data to insert.
* @returns An array of the newly created flyer item records.
*/
async insertFlyerItems(flyerId: number, items: FlyerItemInsert[]): Promise<FlyerItem[]> {
try {
if (!items || items.length === 0) {
return [];
}
const result = await db.query<FlyerItem>(query, values);
return result.rows;
const values: (string | number | null)[] = [];
const valueStrings: string[] = [];
let paramIndex = 1;
for (const item of items) {
valueStrings.push(`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`);
values.push(flyerId, item.item, item.price_display, item.price_in_cents, item.quantity, item.category_name ?? null, item.view_count, item.click_count);
}
const query = `
INSERT INTO flyer_items (
flyer_id, item, price_display, price_in_cents, quantity, category_name, view_count, click_count
)
VALUES ${valueStrings.join(', ')}
RETURNING *;
`;
const result = await this.db.query<FlyerItem>(query, values);
return result.rows;
} catch (error) {
logger.error('Database error in insertFlyerItems:', { error, flyerId });
// Check for a foreign key violation, which would mean the flyerId is invalid.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified flyer does not exist.');
}
throw new Error('Failed to insert flyer items into database.');
}
}
/**
* Retrieves all distinct brands from the stores table.
* @returns A promise that resolves to an array of Brand objects.
*/
async getAllBrands(): Promise<Brand[]> {
try {
const query = `
SELECT s.store_id as brand_id, s.name, s.logo_url
FROM public.stores s
ORDER BY s.name;
`;
const res = await this.db.query<Brand>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getAllBrands:', { error });
throw new Error('Failed to retrieve brands from database.');
}
}
/**
* Retrieves a single flyer by its ID.
* @param flyerId The ID of the flyer to retrieve.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async getFlyerById(flyerId: number): Promise<Flyer | undefined> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [flyerId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getFlyerById:', { error, flyerId });
throw new Error('Failed to retrieve flyer from database.');
}
}
/**
* Retrieves all flyers from the database, ordered by creation date.
* @param limit The maximum number of flyers to return.
* @param offset The number of flyers to skip.
* @returns A promise that resolves to an array of Flyer objects.
*/
async getFlyers(limit: number = 20, offset: number = 0): Promise<Flyer[]> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyers:', { error, limit, offset });
throw new Error('Failed to retrieve flyers from database.');
}
}
/**
* Retrieves all items for a specific flyer.
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
async getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
try {
const res = await this.db.query<FlyerItem>('SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC', [flyerId]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyerItems:', { error, flyerId });
throw new Error('Failed to retrieve flyer items from database.');
}
}
/**
* Retrieves all flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to an array of all matching FlyerItem objects.
*/
async getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
try {
const res = await this.db.query<FlyerItem>('SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC', [flyerIds]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyerItemsForFlyers:', { error, flyerIds });
throw new Error('Failed to retrieve flyer items in batch from database.');
}
}
/**
* Counts the total number of flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to the total count of items.
*/
async countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
try {
if (flyerIds.length === 0) {
return 0;
}
const res = await this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])', [flyerIds]);
return parseInt(res.rows[0].count, 10);
} catch (error) {
logger.error('Database error in countFlyerItemsForFlyers:', { error, flyerIds });
throw new Error('Failed to count flyer items in batch from database.');
}
}
/**
* Finds a single flyer by its SHA-256 checksum.
* @param checksum The checksum of the flyer file to find.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
return res.rows[0];
} catch (error) {
logger.error('Database error in findFlyerByChecksum:', { error, checksum });
throw new Error('Failed to find flyer by checksum in database.');
}
}
}
/**
@@ -92,120 +218,20 @@ export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: Fl
const client = await getPool().connect();
try {
await client.query('BEGIN');
const newFlyer = await insertFlyer(flyerData, client);
const newItems = await insertFlyerItems(newFlyer.flyer_id, itemsForDb, client);
// Use a repository instance with the transactional client
const flyerRepo = new FlyerRepository(client);
const newFlyer = await flyerRepo.insertFlyer(flyerData);
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb);
await client.query('COMMIT');
return { flyer: newFlyer, items: newItems };
} catch (error) {
await client.query('ROLLBACK');
logger.error('Database transaction error in createFlyerAndItems:', { error });
throw error; // Re-throw the error to be handled by the calling service.
} finally {
client.release();
}
}
/**
* Retrieves all distinct brands from the stores table.
* In this application's context, a "store" (e.g., Walmart, Sobeys) is synonymous
* with a "brand". This function fetches those entities.
* @returns A promise that resolves to an array of Brand objects.
*/
export async function getAllBrands(): Promise<Brand[]> {
const query = `
SELECT
s.store_id as brand_id,
s.name,
s.logo_url
FROM public.stores s
ORDER BY s.name;
`;
const res = await getPool().query<Brand>(query);
return res.rows;
}
/**
* Retrieves a single flyer by its ID.
* @param flyerId The ID of the flyer to retrieve.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
export async function getFlyerById(flyerId: number): Promise<Flyer | undefined> {
const res = await getPool().query<Flyer>(
'SELECT * FROM public.flyers WHERE flyer_id = $1',
[flyerId]
);
return res.rows[0];
}
/**
* Retrieves all flyers from the database, ordered by creation date.
* Supports pagination.
* @param limit The maximum number of flyers to return.
* @param offset The number of flyers to skip.
* @returns A promise that resolves to an array of Flyer objects.
*/
export async function getFlyers(limit: number = 20, offset: number = 0): Promise<Flyer[]> {
const res = await getPool().query<Flyer>(
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
);
return res.rows;
}
/**
* Retrieves all items for a specific flyer.
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
export async function getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
const res = await getPool().query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
[flyerId]
);
return res.rows;
}
/**
* Retrieves all flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to an array of all matching FlyerItem objects.
*/
export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
const res = await getPool().query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC',
[flyerIds]
);
return res.rows;
}
/**
* Counts the total number of flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to the total count of items.
*/
export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
if (flyerIds.length === 0) {
return 0;
}
const res = await getPool().query<{ count: string }>(
'SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])',
[flyerIds]
);
// The COUNT(*) result from pg is a string, so it needs to be parsed.
return parseInt(res.rows[0].count, 10);
}
/**
* Finds a single flyer by its SHA-256 checksum.
* @param checksum The checksum of the flyer file to find.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
export async function findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
const res = await getPool().query<Flyer>(
'SELECT * FROM public.flyers WHERE checksum = $1',
[checksum]
);
return res.rows[0];
}

View File

@@ -5,12 +5,7 @@ import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
// FIX 2: Un-mock the module we are testing.
vi.unmock('./gamification.db');
import {
getAllAchievements,
getUserAchievements,
awardAchievement,
getLeaderboard,
} from './gamification.db';
import { GamificationRepository } from './gamification.db';
import type { Achievement, UserAchievement, LeaderboardUser } from '../../types';
// Mock the logger
@@ -24,9 +19,13 @@ vi.mock('../logger', () => ({
}));
describe('Gamification DB Service', () => {
let gamificationRepo: GamificationRepository;
beforeEach(() => {
// Reset the global mock's call history before each test.
vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test
gamificationRepo = new GamificationRepository(mockPoolInstance as any);
});
describe('getAllAchievements', () => {
@@ -36,7 +35,7 @@ describe('Gamification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockAchievements });
const result = await getAllAchievements();
const result = await gamificationRepo.getAllAchievements();
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
expect(result).toEqual(mockAchievements);
@@ -45,7 +44,7 @@ describe('Gamification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getAllAchievements()).rejects.toThrow('Failed to retrieve achievements.');
await expect(gamificationRepo.getAllAchievements()).rejects.toThrow('Failed to retrieve achievements.');
});
});
@@ -56,7 +55,7 @@ describe('Gamification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockUserAchievements });
const result = await getUserAchievements('user-123');
const result = await gamificationRepo.getUserAchievements('user-123');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.user_achievements ua'), ['user-123']);
expect(result).toEqual(mockUserAchievements);
@@ -65,14 +64,14 @@ describe('Gamification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getUserAchievements('user-123')).rejects.toThrow('Failed to retrieve user achievements.');
await expect(gamificationRepo.getUserAchievements('user-123')).rejects.toThrow('Failed to retrieve user achievements.');
});
});
describe('awardAchievement', () => {
it('should call the award_achievement database function with the correct parameters', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] }); // The function returns void
await awardAchievement('user-123', 'Test Achievement');
await gamificationRepo.awardAchievement('user-123', 'Test Achievement');
expect(mockPoolInstance.query).toHaveBeenCalledWith("SELECT public.award_achievement($1, $2)", ['user-123', 'Test Achievement']);
});
@@ -81,13 +80,13 @@ describe('Gamification DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(awardAchievement('non-existent-user', 'Non-existent Achievement')).rejects.toThrow('The specified user or achievement does not exist.');
await expect(gamificationRepo.awardAchievement('non-existent-user', 'Non-existent Achievement')).rejects.toThrow('The specified user or achievement does not exist.');
});
it('should re-throw a generic error if the database query fails', async () => {
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(awardAchievement('user-123', 'Test Achievement')).rejects.toThrow('DB Error');
await expect(gamificationRepo.awardAchievement('user-123', 'Test Achievement')).rejects.toThrow('Failed to award achievement.');
});
});
@@ -99,7 +98,7 @@ describe('Gamification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockLeaderboard });
const result = await getLeaderboard(10);
const result = await gamificationRepo.getLeaderboard(10);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('RANK() OVER (ORDER BY points DESC)'), [10]);
@@ -109,7 +108,7 @@ describe('Gamification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getLeaderboard(10)).rejects.toThrow('Failed to retrieve leaderboard.');
await expect(gamificationRepo.getLeaderboard(10)).rejects.toThrow('Failed to retrieve leaderboard.');
});
});
});

View File

@@ -1,80 +1,90 @@
// src/services/db/gamification.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger';
import { logger } from '../logger.server';
import { Achievement, UserAchievement, LeaderboardUser } from '../../types';
/**
* Retrieves the master list of all available achievements.
* @returns A promise that resolves to an array of Achievement objects.
*/
export async function getAllAchievements(): Promise<Achievement[]> {
try {
const res = await getPool().query<Achievement>('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getAllAchievements:', { error });
throw new Error('Failed to retrieve achievements.');
}
}
export class GamificationRepository {
private db: Pool | PoolClient;
/**
* Retrieves all achievements earned by a specific user.
* Joins with the achievements table to include details like name and description.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Achievement objects earned by the user.
*/
export async function getUserAchievements(userId: string): Promise<(UserAchievement & Achievement)[]> {
try {
const query = `
SELECT
ua.user_id,
ua.achievement_id,
ua.achieved_at,
a.name,
a.description,
a.icon,
a.points_value
FROM public.user_achievements ua
JOIN public.achievements a ON ua.achievement_id = a.achievement_id
WHERE ua.user_id = $1
ORDER BY ua.achieved_at DESC;
`;
const res = await getPool().query<(UserAchievement & Achievement)>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserAchievements:', { error, userId });
throw new Error('Failed to retrieve user achievements.');
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
}
/**
* Manually awards a specific achievement to a user.
* This calls a database function that handles the logic of checking if the user
* already has the achievement and awarding points if they don't.
* @param userId The UUID of the user to award the achievement to.
* @param achievementName The name of the achievement to award.
* @returns A promise that resolves when the operation is complete.
*/
export async function awardAchievement(userId: string, achievementName: string): Promise<void> {
try {
await getPool().query("SELECT public.award_achievement($1, $2)", [userId, achievementName]);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or achievement does not exist.');
/**
* Retrieves the master list of all available achievements.
* @returns A promise that resolves to an array of Achievement objects.
*/
async getAllAchievements(): Promise<Achievement[]> {
try {
const res = await this.db.query<Achievement>('SELECT * FROM public.achievements ORDER BY points_value ASC, name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getAllAchievements:', { error });
throw new Error('Failed to retrieve achievements.');
}
throw error; // Re-throw other errors
}
}
/**
* Retrieves a leaderboard of users with the most points.
* @param limit The number of users to return.
* @returns A promise that resolves to an array of leaderboard user objects.
*/
export async function getLeaderboard(limit: number): Promise<LeaderboardUser[]> {
try {
const query = `
/**
* Retrieves all achievements earned by a specific user.
* Joins with the achievements table to include details like name and description.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Achievement objects earned by the user.
*/
async getUserAchievements(userId: string): Promise<(UserAchievement & Achievement)[]> {
try {
const query = `
SELECT
ua.user_id,
ua.achievement_id,
ua.achieved_at,
a.name,
a.description,
a.icon,
a.points_value
FROM public.user_achievements ua
JOIN public.achievements a ON ua.achievement_id = a.achievement_id
WHERE ua.user_id = $1
ORDER BY ua.achieved_at DESC;
`;
const res = await this.db.query<(UserAchievement & Achievement)>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserAchievements:', { error, userId });
throw new Error('Failed to retrieve user achievements.');
}
}
/**
* Manually awards a specific achievement to a user.
* This calls a database function that handles the logic of checking if the user
* already has the achievement and awarding points if they don't.
* @param userId The UUID of the user to award the achievement to.
* @param achievementName The name of the achievement to award.
* @returns A promise that resolves when the operation is complete.
*/
async awardAchievement(userId: string, achievementName: string): Promise<void> {
try {
await this.db.query("SELECT public.award_achievement($1, $2)", [userId, achievementName]);
} catch (error) {
// Check for a foreign key violation, which would mean the user or achievement name is invalid.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or achievement does not exist.');
}
logger.error('Database error in awardAchievement:', { error, userId, achievementName });
throw new Error('Failed to award achievement.');
}
}
/**
* Retrieves a leaderboard of users with the most points.
* @param limit The number of users to return.
* @returns A promise that resolves to an array of leaderboard user objects.
*/
async getLeaderboard(limit: number): Promise<LeaderboardUser[]> {
try {
const query = `
SELECT
user_id,
full_name,
@@ -85,10 +95,11 @@ export async function getLeaderboard(limit: number): Promise<LeaderboardUser[]>
ORDER BY points DESC, full_name ASC
LIMIT $1;
`;
const res = await getPool().query<LeaderboardUser>(query, [limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getLeaderboard:', { error, limit });
throw new Error('Failed to retrieve leaderboard.');
const res = await this.db.query<LeaderboardUser>(query, [limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getLeaderboard:', { error, limit });
throw new Error('Failed to retrieve leaderboard.');
}
}
}

View File

@@ -1,13 +1,24 @@
// src/services/db/index.db.ts
export * from './connection.db';
export * from './errors.db';
export * from './flyer.db';
export * from './shopping.db';
export * from './personalization.db';
export * from './recipe.db';
export * from './admin.db';
export * from './notification.db';
export * from './gamification.db';
export * from './budget.db';
export * from './address.db';
export * from './user.db';
import { UserRepository } from './user.db';
import { FlyerRepository } from './flyer.db';
import { AddressRepository } from './address.db';
import { ShoppingRepository } from './shopping.db';
import { PersonalizationRepository } from './personalization.db';
import { RecipeRepository } from './recipe.db';
import { NotificationRepository } from './notification.db';
import { BudgetRepository } from './budget.db';
import { GamificationRepository } from './gamification.db';
import { AdminRepository } from './admin.db';
const userRepo = new UserRepository();
const flyerRepo = new FlyerRepository();
const addressRepo = new AddressRepository();
const shoppingRepo = new ShoppingRepository();
const personalizationRepo = new PersonalizationRepository();
const recipeRepo = new RecipeRepository();
const notificationRepo = new NotificationRepository();
const budgetRepo = new BudgetRepository();
const gamificationRepo = new GamificationRepository();
const adminRepo = new AdminRepository();
export { userRepo, flyerRepo, addressRepo, shoppingRepo, personalizationRepo, recipeRepo, notificationRepo, budgetRepo, gamificationRepo, adminRepo };

View File

@@ -4,18 +4,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./notification.db');
import {
getNotificationsForUser,
createNotification,
createBulkNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
} from './notification.db';
import { NotificationRepository } from './notification.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { ForeignKeyConstraintError } from './errors.db';
import type { Notification } from '../../types';
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
@@ -25,8 +20,12 @@ vi.mock('../logger', () => ({
}));
describe('Notification DB Service', () => {
let notificationRepo: NotificationRepository;
beforeEach(() => {
vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test
notificationRepo = new NotificationRepository(mockPoolInstance as any);
});
describe('getNotificationsForUser', () => {
@@ -36,7 +35,7 @@ describe('Notification DB Service', () => {
];
mockPoolInstance.query.mockResolvedValue({ rows: mockNotifications });
const result = await getNotificationsForUser('user-123', 10, 5);
const result = await notificationRepo.getNotificationsForUser('user-123', 10, 5);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.notifications'),
@@ -48,7 +47,7 @@ describe('Notification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getNotificationsForUser('user-123', 10, 5)).rejects.toThrow('Failed to retrieve notifications.');
await expect(notificationRepo.getNotificationsForUser('user-123', 10, 5)).rejects.toThrow('Failed to retrieve notifications.');
});
});
@@ -57,21 +56,21 @@ describe('Notification DB Service', () => {
const mockNotification: Notification = { notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification] });
const result = await createNotification('user-123', 'Test');
const result = await notificationRepo.createNotification('user-123', 'Test');
expect(result).toEqual(mockNotification);
});
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createNotification('non-existent-user', 'Test')).rejects.toThrow('The specified user does not exist.');
mockPoolInstance.query.mockRejectedValueOnce(dbError);
await expect(notificationRepo.createNotification('non-existent-user', 'Test')).rejects.toThrow(ForeignKeyConstraintError);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createNotification('user-123', 'Test')).rejects.toThrow('Failed to create notification.');
await expect(notificationRepo.createNotification('user-123', 'Test')).rejects.toThrow('Failed to create notification.');
});
});
@@ -80,19 +79,16 @@ describe('Notification DB Service', () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const notificationsToCreate = [{ user_id: 'u1', content: "msg" }];
await createBulkNotifications(notificationsToCreate);
expect(mockPoolInstance.connect).toHaveBeenCalledTimes(1);
await notificationRepo.createBulkNotifications(notificationsToCreate);
// Check that the query was called with the correct unnest structure
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])'),
[['u1'], ['msg'], [null]]
);
// Check that the client was released
expect(vi.mocked(mockPoolInstance.connect).mock.results[0].value.release).toHaveBeenCalled();
});
it('should not query the database if the notifications array is empty', async () => {
await createBulkNotifications([]);
await notificationRepo.createBulkNotifications([]);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
});
@@ -101,14 +97,14 @@ describe('Notification DB Service', () => {
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
const notificationsToCreate = [{ user_id: 'non-existent', content: "msg" }];
await expect(createBulkNotifications(notificationsToCreate)).rejects.toThrow('One or more of the specified users do not exist.');
await expect(notificationRepo.createBulkNotifications(notificationsToCreate)).rejects.toThrow('One or more of the specified users do not exist.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
const notificationsToCreate = [{ user_id: 'u1', content: "msg" }];
await expect(createBulkNotifications(notificationsToCreate)).rejects.toThrow('Failed to create bulk notifications.');
await expect(notificationRepo.createBulkNotifications(notificationsToCreate)).rejects.toThrow('Failed to create bulk notifications.');
});
});
@@ -117,29 +113,29 @@ describe('Notification DB Service', () => {
const mockNotification: Notification = { notification_id: 123, user_id: 'abc', content: 'msg', is_read: true, created_at: '' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockNotification], rowCount: 1 });
const result = await markNotificationAsRead(123, 'user-abc');
const result = await notificationRepo.markNotificationAsRead(123, 'user-abc');
expect(result).toEqual(mockNotification);
});
it('should throw an error if the notification is not found or does not belong to the user', async () => {
// FIX: Ensure rowCount is 0
mockPoolInstance.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(markNotificationAsRead(999, 'user-abc'))
await expect(notificationRepo.markNotificationAsRead(999, 'user-abc'))
.rejects.toThrow('Notification not found or user does not have permission.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(markNotificationAsRead(123, 'user-abc')).rejects.toThrow('Failed to mark notification as read.');
await expect(notificationRepo.markNotificationAsRead(123, 'user-abc')).rejects.toThrow('Failed to mark notification as read.');
});
});
describe('markAllNotificationsAsRead', () => {
it('should execute an UPDATE query to mark all notifications as read for a user', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 3 });
await markAllNotificationsAsRead('user-xyz');
await notificationRepo.markAllNotificationsAsRead('user-xyz');
// Fix expected arguments to match what the implementation actually sends
// The implementation likely passes the user ID
@@ -152,7 +148,7 @@ describe('Notification DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(markAllNotificationsAsRead('user-xyz')).rejects.toThrow('Failed to mark notifications as read.');
await expect(notificationRepo.markAllNotificationsAsRead('user-xyz')).rejects.toThrow('Failed to mark notifications as read.');
});
});
});

View File

@@ -1,130 +1,137 @@
// src/services/db/notification.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger';
import { logger } from '../logger.server';
import { Notification } from '../../types';
/**
* Inserts a single notification into the database.
* @param userId The ID of the user to notify.
* @param content The text content of the notification.
* @param linkUrl An optional URL for the notification to link to.
* @returns A promise that resolves to the newly created Notification object.
*/
export async function createNotification(userId: string, content: string, linkUrl?: string): Promise<Notification> {
try {
const res = await getPool().query<Notification>(
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
[userId, content, linkUrl || null]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
export class NotificationRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
/**
* Inserts a single notification into the database.
* @param userId The ID of the user to notify.
* @param content The text content of the notification.
* @param linkUrl An optional URL for the notification to link to.
* @returns A promise that resolves to the newly created Notification object.
*/
async createNotification(userId: string, content: string, linkUrl?: string): Promise<Notification> {
try {
const res = await this.db.query<Notification>(
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
[userId, content, linkUrl || null]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createNotification:', { error });
throw new Error('Failed to create notification.');
}
logger.error('Database error in createNotification:', { error });
throw new Error('Failed to create notification.');
}
}
/**
* Inserts multiple notifications into the database in a single query.
* This is more efficient than inserting one by one.
* @param notifications An array of notification objects to be inserted.
*/
export async function createBulkNotifications(notifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[]): Promise<void> {
if (notifications.length === 0) {
return;
}
const client = await getPool().connect();
try {
// This is the secure way to perform bulk inserts.
// We use the `unnest` function in PostgreSQL to turn arrays of parameters
// into a set of rows that can be inserted. This avoids string concatenation
// and completely prevents SQL injection.
const query = `
INSERT INTO public.notifications (user_id, content, link_url)
SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])
`;
const userIds = notifications.map(n => n.user_id);
const contents = notifications.map(n => n.content);
const linkUrls = notifications.map(n => n.link_url || null);
await client.query(query, [userIds, contents, linkUrls]);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or more of the specified users do not exist.');
/**
* Inserts multiple notifications into the database in a single query.
* This is more efficient than inserting one by one.
* @param notifications An array of notification objects to be inserted.
*/
async createBulkNotifications(notifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[]): Promise<void> {
if (notifications.length === 0) {
return;
}
logger.error('Database error in createBulkNotifications:', { error });
throw new Error('Failed to create bulk notifications.');
} finally {
client.release();
}
}
// This method assumes it might be part of a larger transaction, so it uses `this.db`.
// The calling service is responsible for acquiring and releasing a client if needed.
try {
// This is the secure way to perform bulk inserts.
// We use the `unnest` function in PostgreSQL to turn arrays of parameters
// into a set of rows that can be inserted. This avoids string concatenation
// and completely prevents SQL injection.
const query = `
INSERT INTO public.notifications (user_id, content, link_url)
SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])
`;
/**
* Retrieves a paginated list of notifications for a specific user.
* @param userId The ID of the user whose notifications are to be retrieved.
* @param limit The maximum number of notifications to return.
* @param offset The number of notifications to skip for pagination.
* @returns A promise that resolves to an array of Notification objects.
*/
export async function getNotificationsForUser(userId: string, limit: number, offset: number): Promise<Notification[]> {
try {
const res = await getPool().query<Notification>(
`SELECT * FROM public.notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[userId, limit, offset]
);
return res.rows;
} catch (error) {
logger.error('Database error in getNotificationsForUser:', { error, userId });
throw new Error('Failed to retrieve notifications.');
}
}
const userIds = notifications.map(n => n.user_id);
const contents = notifications.map(n => n.content);
const linkUrls = notifications.map(n => n.link_url || null);
/**
* Marks all unread notifications for a user as read.
* @param userId The ID of the user whose notifications should be marked as read.
* @returns A promise that resolves when the operation is complete.
*/
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
try {
await getPool().query(
`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
[userId]
);
} catch (error) {
logger.error('Database error in markAllNotificationsAsRead:', { error, userId });
throw new Error('Failed to mark notifications as read.');
}
}
/**
* Marks a single notification as read for a specific user.
* Ensures that a user can only mark their own notifications.
* @param notificationId The ID of the notification to mark as read.
* @param userId The ID of the user who owns the notification.
* @returns A promise that resolves to the updated Notification object.
* @throws An error if the notification is not found or does not belong to the user.
*/
export async function markNotificationAsRead(notificationId: number, userId: string): Promise<Notification> {
let res;
try {
res = await getPool().query<Notification>(
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
[notificationId, userId]
);
} catch (error) {
logger.error('Database error in markNotificationAsRead:', { error, notificationId, userId });
throw new Error('Failed to mark notification as read.');
await this.db.query(query, [userIds, contents, linkUrls]);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or more of the specified users do not exist.');
}
logger.error('Database error in createBulkNotifications:', { error });
throw new Error('Failed to create bulk notifications.');
}
}
if (res.rowCount === 0) {
throw new Error('Notification not found or user does not have permission.');
/**
* Retrieves a paginated list of notifications for a specific user.
* @param userId The ID of the user whose notifications are to be retrieved.
* @param limit The maximum number of notifications to return.
* @param offset The number of notifications to skip for pagination.
* @returns A promise that resolves to an array of Notification objects.
*/
async getNotificationsForUser(userId: string, limit: number, offset: number): Promise<Notification[]> {
try {
const res = await this.db.query<Notification>(
`SELECT * FROM public.notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[userId, limit, offset]
);
return res.rows;
} catch (error) {
logger.error('Database error in getNotificationsForUser:', { error, userId });
throw new Error('Failed to retrieve notifications.');
}
}
/**
* Marks all unread notifications for a user as read.
* @param userId The ID of the user whose notifications should be marked as read.
* @returns A promise that resolves when the operation is complete.
*/
async markAllNotificationsAsRead(userId: string): Promise<void> {
try {
await this.db.query(
`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
[userId]
);
} catch (error) {
logger.error('Database error in markAllNotificationsAsRead:', { error, userId });
throw new Error('Failed to mark notifications as read.');
}
}
/**
* Marks a single notification as read for a specific user.
* Ensures that a user can only mark their own notifications.
* @param notificationId The ID of the notification to mark as read.
* @param userId The ID of the user who owns the notification.
* @returns A promise that resolves to the updated Notification object.
* @throws An error if the notification is not found or does not belong to the user.
*/
async markNotificationAsRead(notificationId: number, userId: string): Promise<Notification> {
try {
const res = await this.db.query<Notification>(
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
[notificationId, userId]
);
if (res.rowCount === 0) {
throw new Error('Notification not found or user does not have permission.');
}
return res.rows[0];
} catch (error) {
if (error instanceof Error && error.message.startsWith('Notification not found')) throw error;
logger.error('Database error in markNotificationAsRead:', { error, notificationId, userId });
throw new Error('Failed to mark notification as read.');
}
}
return res.rows[0];
}

View File

@@ -2,25 +2,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/mock-db';
import {
getAllMasterItems,
getWatchedItems,
addWatchedItem,
removeWatchedItem,
findRecipesFromPantry,
recommendRecipesForUser,
getBestSalePricesForUser,
getBestSalePricesForAllUsers,
suggestPantryItemConversions,
findPantryItemOwner,
getDietaryRestrictions,
getUserDietaryRestrictions,
setUserDietaryRestrictions,
getAppliances,
getUserAppliances,
setUserAppliances,
getRecipesForUserDiets,
} from './personalization.db';
import type { MasterGroceryItem, UserAppliance } from '../../types';
PersonalizationRepository} from './personalization.db';
import type { MasterGroceryItem, UserAppliance, DietaryRestriction, Appliance } from '../../types';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./personalization.db');
@@ -28,6 +11,7 @@ vi.unmock('./personalization.db');
const mockQuery = mockPoolInstance.query;
const mockConnect = mockPoolInstance.connect;
import { ForeignKeyConstraintError } from './errors.db';
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
@@ -39,19 +23,23 @@ vi.mock('../logger', () => ({
}));
describe('Personalization DB Service', () => {
let personalizationRepo: PersonalizationRepository;
beforeEach(() => {
vi.clearAllMocks();
// Simulate the client returned by connect() having a release method
const mockClient = { ...mockPoolInstance, release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Instantiate the repository with the mock pool for each test
personalizationRepo = new PersonalizationRepository(mockPoolInstance as any);
});
describe('getAllMasterItems', () => {
it('should execute the correct query and return master items', async () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
mockQuery.mockResolvedValue({ rows: mockItems });
const result = await getAllMasterItems();
const result = await personalizationRepo.getAllMasterItems();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
expect(result).toEqual(mockItems);
@@ -60,7 +48,7 @@ describe('Personalization DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getAllMasterItems()).rejects.toThrow('Failed to retrieve master grocery items.');
await expect(personalizationRepo.getAllMasterItems()).rejects.toThrow('Failed to retrieve master grocery items.');
});
});
@@ -69,8 +57,8 @@ describe('Personalization DB Service', () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
mockQuery.mockResolvedValue({ rows: mockItems });
const result = await getWatchedItems('user-123');
const result = await personalizationRepo.getWatchedItems('user-123');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items mgi'), ['user-123']);
expect(result).toEqual(mockItems);
});
@@ -78,7 +66,7 @@ describe('Personalization DB Service', () => {
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getWatchedItems('user-123')).rejects.toThrow('Failed to retrieve watched items.');
await expect(personalizationRepo.getWatchedItems('user-123')).rejects.toThrow('Failed to retrieve watched items.');
});
});
@@ -91,14 +79,11 @@ describe('Personalization DB Service', () => {
.mockResolvedValueOnce({ rows: [mockItem] }) // Find/create master item
.mockResolvedValueOnce({ rows: [] }) // Insert into watchlist
await addWatchedItem('user-123', 'New Item', 'Produce');
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce');
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT category_id FROM public.categories'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM public.master_grocery_items'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_watched_items'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
});
it('should create a new master item if it does not exist', async () => {
@@ -109,45 +94,73 @@ describe('Personalization DB Service', () => {
.mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
const result = await addWatchedItem('user-123', 'Brand New Item', 'Produce');
const result = await personalizationRepo.addWatchedItem('user-123', 'Brand New Item', 'Produce');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.master_grocery_items'), ['Brand New Item', 1]);
expect(result).toEqual(mockNewItem);
});
it('should not throw an error if the item is already in the watchlist', async () => {
const mockExistingItem: MasterGroceryItem = { master_grocery_item_id: 1, name: 'Existing Item', created_at: '' };
mockQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [mockExistingItem] }) // Find master item
.mockResolvedValueOnce({ rows: [] }); // INSERT...ON CONFLICT DO NOTHING
// The function should resolve successfully without throwing an error.
await expect(personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce')).resolves.toEqual(mockExistingItem);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT (user_id, master_item_id) DO NOTHING'), expect.any(Array));
});
it('should throw an error if the category is not found', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] }); // Find category (not found)
await expect(addWatchedItem('user-123', 'Some Item', 'Fake Category')).rejects.toThrow("Category 'Fake Category' not found.");
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
await expect(personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category')).rejects.toThrow("Category 'Fake Category' not found.");
});
it('should rollback the transaction on a generic error', async () => {
it('should throw a generic error on failure', async () => {
const dbError = new Error('DB Error');
mockQuery.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }).mockRejectedValueOnce(dbError);
await expect(addWatchedItem('user-123', 'Failing Item', 'Produce')).rejects.toThrow('Failed to add item to watchlist.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
await expect(personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce')).rejects.toThrow('Failed to add item to watchlist.');
});
});
it('should throw ForeignKeyConstraintError on invalid user or category', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
// Mock the category lookup to fail with a foreign key error
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce')).rejects.toThrow(
'The specified user or category does not exist.'
);
});
describe('removeWatchedItem', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await removeWatchedItem('user-123', 1);
await personalizationRepo.removeWatchedItem('user-123', 1);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]);
});
it('should complete without error if the item to remove is not in the watchlist', async () => {
// Simulate the DB returning 0 rows affected
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(personalizationRepo.removeWatchedItem('user-123', 999)).resolves.toBeUndefined();
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(removeWatchedItem('user-123', 1)).rejects.toThrow('Failed to remove item from watchlist.');
await expect(personalizationRepo.removeWatchedItem('user-123', 1)).rejects.toThrow('Failed to remove item from watchlist.');
});
});
describe('removeWatchedItem', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await removeWatchedItem('user-123', 1);
await personalizationRepo.removeWatchedItem('user-123', 1);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]);
});
});
@@ -155,113 +168,137 @@ describe('Personalization DB Service', () => {
describe('findRecipesFromPantry', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await findRecipesFromPantry('user-123');
await personalizationRepo.findRecipesFromPantry('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']);
});
it('should return an empty array if no recipes are found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.findRecipesFromPantry('user-123');
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(findRecipesFromPantry('user-123')).rejects.toThrow('Failed to find recipes from pantry.');
await expect(personalizationRepo.findRecipesFromPantry('user-123')).rejects.toThrow('Failed to find recipes from pantry.');
});
});
describe('recommendRecipesForUser', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recommendRecipesForUser('user-123', 5);
await personalizationRepo.recommendRecipesForUser('user-123', 5);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(recommendRecipesForUser('user-123', 5)).rejects.toThrow('Failed to recommend recipes.');
await expect(personalizationRepo.recommendRecipesForUser('user-123', 5)).rejects.toThrow('Failed to recommend recipes.');
});
});
describe('getBestSalePricesForUser', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getBestSalePricesForUser('user-123');
await personalizationRepo.getBestSalePricesForUser('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getBestSalePricesForUser('user-123')).rejects.toThrow('Failed to get best sale prices.');
await expect(personalizationRepo.getBestSalePricesForUser('user-123')).rejects.toThrow('Failed to get best sale prices.');
});
});
describe('getBestSalePricesForAllUsers', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getBestSalePricesForAllUsers();
await personalizationRepo.getBestSalePricesForAllUsers();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getBestSalePricesForAllUsers()).rejects.toThrow('Failed to get best sale prices for all users.');
await expect(personalizationRepo.getBestSalePricesForAllUsers()).rejects.toThrow('Failed to get best sale prices for all users.');
});
});
describe('suggestPantryItemConversions', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await suggestPantryItemConversions(1);
await personalizationRepo.suggestPantryItemConversions(1);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(suggestPantryItemConversions(1)).rejects.toThrow('Failed to suggest pantry item conversions.');
await expect(personalizationRepo.suggestPantryItemConversions(1)).rejects.toThrow('Failed to suggest pantry item conversions.');
});
});
describe('findPantryItemOwner', () => {
it('should execute a SELECT query to find the owner', async () => {
mockQuery.mockResolvedValue({ rows: [{ user_id: 'user-123' }] });
const result = await findPantryItemOwner(1);
const result = await personalizationRepo.findPantryItemOwner(1);
expect(mockQuery).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]);
expect(result?.user_id).toBe('user-123');
});
it('should return undefined if the pantry item is not found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.findPantryItemOwner(999);
expect(result).toBeUndefined();
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(findPantryItemOwner(1)).rejects.toThrow('Failed to retrieve pantry item owner from database.');
await expect(personalizationRepo.findPantryItemOwner(1)).rejects.toThrow('Failed to retrieve pantry item owner from database.');
});
});
describe('getDietaryRestrictions', () => {
it('should execute a SELECT query to get all restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getDietaryRestrictions();
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
await personalizationRepo.getDietaryRestrictions();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getDietaryRestrictions()).rejects.toThrow('Failed to get dietary restrictions.');
await expect(personalizationRepo.getDietaryRestrictions()).rejects.toThrow('Failed to get dietary restrictions.');
});
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getDietaryRestrictions()).rejects.toThrow('Failed to get dietary restrictions.');
});
describe('getUserDietaryRestrictions', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getUserDietaryRestrictions('user-123');
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
await personalizationRepo.getUserDietaryRestrictions('user-123');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.dietary_restrictions dr'), ['user-123']);
});
it('should return an empty array if the user has no restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] as DietaryRestriction[] });
const result = await personalizationRepo.getUserDietaryRestrictions('user-123');
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getUserDietaryRestrictions('user-123')).rejects.toThrow('Failed to get user dietary restrictions.');
await expect(personalizationRepo.getUserDietaryRestrictions('user-123')).rejects.toThrow('Failed to get user dietary restrictions.');
});
});
@@ -269,7 +306,7 @@ describe('Personalization DB Service', () => {
it('should execute a transaction to set restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await setUserDietaryRestrictions('user-123', [1, 2]);
await personalizationRepo.setUserDietaryRestrictions('user-123', [1, 2]);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
@@ -289,12 +326,12 @@ describe('Personalization DB Service', () => {
(dbError as any).code = '23503';
mockQuery.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError); // Mock DELETE success, INSERT fail
await expect(setUserDietaryRestrictions('user-123', [999])).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [999])).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
});
it('should handle an empty array of restriction IDs', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await setUserDietaryRestrictions('user-123', []);
await personalizationRepo.setUserDietaryRestrictions('user-123', []);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
expect(mockQuery).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'));
@@ -302,36 +339,54 @@ describe('Personalization DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
mockQuery.mockRejectedValueOnce(new Error('DB Error')); // Mock the DELETE to fail
await expect(setUserDietaryRestrictions('user-123', [1])).rejects.toThrow('Failed to set user dietary restrictions.');
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [1])).rejects.toThrow('Failed to set user dietary restrictions.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
});
it('should throw a generic error if the database query fails', async () => {
mockQuery.mockRejectedValueOnce(new Error('DB Error')); // Mock the DELETE to fail
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [1])).rejects.toThrow('Failed to set user dietary restrictions.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('getAppliances', () => {
it('should execute a SELECT query to get all appliances', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getAppliances();
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
await personalizationRepo.getAppliances();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.appliances ORDER BY name');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getAppliances()).rejects.toThrow('Failed to get appliances.');
await expect(personalizationRepo.getAppliances()).rejects.toThrow('Failed to get appliances.');
});
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(personalizationRepo.getAppliances()).rejects.toThrow('Failed to get appliances.');
});
describe('getUserAppliances', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getUserAppliances('user-123');
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
await personalizationRepo.getUserAppliances('user-123');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']);
});
it('should return an empty array if the user has no appliances', async () => {
mockQuery.mockResolvedValue({ rows: [] as Appliance[] });
const result = await personalizationRepo.getUserAppliances('user-123');
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getUserAppliances('user-123')).rejects.toThrow('Failed to get user appliances.');
await expect(personalizationRepo.getUserAppliances('user-123')).rejects.toThrow('Failed to get user appliances.');
});
});
@@ -345,7 +400,7 @@ describe('Personalization DB Service', () => {
.mockResolvedValueOnce({ rows: [] }) // DELETE
.mockResolvedValueOnce({ rows: mockNewAppliances }); // INSERT ... RETURNING
const result = await setUserAppliances('user-123', [1, 2]);
const result = await personalizationRepo.setUserAppliances('user-123', [1, 2]);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
@@ -359,12 +414,12 @@ describe('Personalization DB Service', () => {
(dbError as any).code = '23503';
mockQuery.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError); // Mock DELETE success, INSERT fail
await expect(setUserAppliances('user-123', [999])).rejects.toThrow('One or more of the specified appliance IDs are invalid.');
await expect(personalizationRepo.setUserAppliances('user-123', [999])).rejects.toThrow(ForeignKeyConstraintError);
});
it('should handle an empty array of appliance IDs', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await setUserAppliances('user-123', []);
const result = await personalizationRepo.setUserAppliances('user-123', []);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
@@ -376,22 +431,29 @@ describe('Personalization DB Service', () => {
it('should rollback transaction on generic error', async () => {
mockQuery.mockRejectedValueOnce(new Error('DB Error'));
await expect(setUserAppliances('user-123', [1])).rejects.toThrow('Failed to set user appliances.');
await expect(personalizationRepo.setUserAppliances('user-123', [1])).rejects.toThrow('Failed to set user appliances.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('getRecipesForUserDiets', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipesForUserDiets('user-123');
await personalizationRepo.getRecipesForUserDiets('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']);
});
it('should return an empty array if no recipes match the diet', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await personalizationRepo.getRecipesForUserDiets('user-123');
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getRecipesForUserDiets('user-123')).rejects.toThrow('Failed to get recipes compatible with user diet.');
await expect(personalizationRepo.getRecipesForUserDiets('user-123')).rejects.toThrow('Failed to get recipes compatible with user diet.');
});
});
});

View File

@@ -1,7 +1,8 @@
// src/services/db/personalization.db.ts
import { getPool } from './connection.db';
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger';
import { logger } from '../logger.server';
import {
MasterGroceryItem,
PantryRecipe,
@@ -14,357 +15,357 @@ import {
Recipe,
} from '../../types';
/**
* Retrieves all master grocery items from the database.
* This is used to provide a list of known items to the AI for better matching.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
export async function getAllMasterItems(): Promise<MasterGroceryItem[]> {
try {
const res = await getPool().query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getAllMasterItems:', { error });
throw new Error('Failed to retrieve master grocery items.');
export class PersonalizationRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
}
/**
* Retrieves all watched master items for a specific user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
// prettier-ignore
export async function getWatchedItems(userId: string): Promise<MasterGroceryItem[]> {
try {
const query = `
SELECT mgi.*
FROM public.master_grocery_items mgi
JOIN public.user_watched_items uwi ON mgi.master_grocery_item_id = uwi.master_item_id
WHERE uwi.user_id = $1
ORDER BY mgi.name ASC;
`;
const res = await getPool().query<MasterGroceryItem>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getWatchedItems:', { error, userId });
throw new Error('Failed to retrieve watched items.');
}
}
/**
* Adds an item to a user's watchlist. If the master item doesn't exist, it creates it.
* @param userId The UUID of the user.
* @param itemName The name of the item to watch.
* @param categoryName The category of the item.
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
*/
// prettier-ignore
export async function addWatchedItem(userId: string, itemName: string, categoryName: string): Promise<MasterGroceryItem> {
const client = await getPool().connect();
try {
await client.query('BEGIN');
// Find category ID
const categoryRes = await client.query<{ category_id: number }>('SELECT category_id FROM public.categories WHERE name = $1', [categoryName]);
const categoryId = categoryRes.rows[0]?.category_id;
if (!categoryId) {
throw new Error(`Category '${categoryName}' not found.`);
}
// Find or create master item
let masterItem: MasterGroceryItem;
const masterItemRes = await client.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]);
if (masterItemRes.rows.length > 0) {
masterItem = masterItemRes.rows[0];
} else {
const newMasterItemRes = await client.query<MasterGroceryItem>(
'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *',
[itemName, categoryId]
);
masterItem = newMasterItemRes.rows[0];
}
// Add to user's watchlist, ignoring if it's already there.
await client.query(
'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING',
[userId, masterItem.master_grocery_item_id]
);
await client.query('COMMIT');
return masterItem;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23505') {
// This case is handled by ON CONFLICT, but it's good practice for other functions.
throw new UniqueConstraintError('This item is already in the watchlist.');
}
await client.query('ROLLBACK');
logger.error('Database transaction error in addWatchedItem:', { error });
throw new Error('Failed to add item to watchlist.');
} finally {
client.release();
}
}
/**
* Removes an item from a user's watchlist.
* @param userId The UUID of the user.
* @param masterItemId The ID of the master item to remove.
*/
// prettier-ignore
export async function removeWatchedItem(userId: string, masterItemId: number): Promise<void> {
try {
await getPool().query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]);
} catch (error) {
logger.error('Database error in removeWatchedItem:', { error });
throw new Error('Failed to remove item from watchlist.');
}
}
/**
* Calls a database function to find recipes that can be made from a user's pantry.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of recipes.
*/
export async function findRecipesFromPantry(userId: string): Promise<PantryRecipe[]> {
try {
const res = await getPool().query<PantryRecipe>('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesFromPantry:', { error, userId });
throw new Error('Failed to find recipes from pantry.');
}
}
/**
* Calls a database function to recommend recipes for a user.
* @param userId The ID of the user.
* @param limit The maximum number of recipes to recommend.
* @returns A promise that resolves to an array of recommended recipes.
*/
export async function recommendRecipesForUser(userId: string, limit: number): Promise<RecommendedRecipe[]> {
try {
const res = await getPool().query<RecommendedRecipe>('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in recommendRecipesForUser:', { error, userId });
throw new Error('Failed to recommend recipes.');
}
}
/**
* Calls a database function to get the best current sale prices for a user's watched items.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the best deals.
*/
export async function getBestSalePricesForUser(userId: string): Promise<WatchedItemDeal[]> {
try {
const res = await getPool().query<WatchedItemDeal>('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForUser:', { error, userId });
throw new Error('Failed to get best sale prices.');
}
}
/**
* Calls a database function to get the best current sale prices for all watched items across all users.
* This is much more efficient than calling getBestSalePricesForUser for each user individually.
* @returns A promise that resolves to an array of deals, each augmented with user information.
*/
export async function getBestSalePricesForAllUsers(): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> {
try {
// This function assumes a corresponding PostgreSQL function `get_best_sale_prices_for_all_users` exists.
const res = await getPool().query<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForAllUsers:', { error });
throw new Error('Failed to get best sale prices for all users.');
}
}
/**
* Calls a database function to suggest unit conversions for a pantry item.
* @param pantryItemId The ID of the pantry item.
* @returns A promise that resolves to an array of suggested conversions.
*/
export async function suggestPantryItemConversions(pantryItemId: number): Promise<PantryItemConversion[]> {
try {
const res = await getPool().query<PantryItemConversion>('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
return res.rows;
} catch (error) {
logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId });
throw new Error('Failed to suggest pantry item conversions.');
}
}
/**
* Finds the owner of a specific pantry item.
* @param pantryItemId The ID of the pantry item.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
// prettier-ignore
export async function findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> {
try {
const res = await getPool().query<{ user_id: string }>(
'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1',
[pantryItemId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findPantryItemOwner:', { error, pantryItemId });
throw new Error('Failed to retrieve pantry item owner from database.');
}
}
/**
* Retrieves the master list of all available dietary restrictions.
* @returns A promise that resolves to an array of DietaryRestriction objects.
*/
export async function getDietaryRestrictions(): Promise<DietaryRestriction[]> {
/**
* Retrieves all master grocery items from the database.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
async getAllMasterItems(): Promise<MasterGroceryItem[]> {
try {
const res = await getPool().query<DietaryRestriction>('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
return res.rows;
const res = await this.db.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getDietaryRestrictions:', { error });
throw new Error('Failed to get dietary restrictions.');
logger.error('Database error in getAllMasterItems:', { error });
throw new Error('Failed to retrieve master grocery items.');
}
}
}
/**
* Retrieves the dietary restrictions for a specific user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's selected DietaryRestriction objects.
*/
export async function getUserDietaryRestrictions(userId: string): Promise<DietaryRestriction[]> {
/**
* Retrieves all watched master items for a specific user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
async getWatchedItems(userId: string): Promise<MasterGroceryItem[]> {
try {
const query = `
const query = `
SELECT mgi.*
FROM public.master_grocery_items mgi
JOIN public.user_watched_items uwi ON mgi.master_grocery_item_id = uwi.master_item_id
WHERE uwi.user_id = $1
ORDER BY mgi.name ASC;
`;
const res = await this.db.query<MasterGroceryItem>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getWatchedItems:', { error, userId });
throw new Error('Failed to retrieve watched items.');
}
}
/**
* Removes an item from a user's watchlist.
* @param userId The UUID of the user.
* @param masterItemId The ID of the master item to remove.
*/
async removeWatchedItem(userId: string, masterItemId: number): Promise<void> {
try {
await this.db.query('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', [userId, masterItemId]);
} catch (error) {
logger.error('Database error in removeWatchedItem:', { error });
throw new Error('Failed to remove item from watchlist.');
}
}
/**
* Finds the owner of a specific pantry item.
* @param pantryItemId The ID of the pantry item.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
async findPantryItemOwner(pantryItemId: number): Promise<{ user_id: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string }>(
'SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1',
[pantryItemId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findPantryItemOwner:', { error, pantryItemId });
throw new Error('Failed to retrieve pantry item owner from database.');
}
}
/**
* Adds an item to a user's watchlist. If the master item doesn't exist, it creates it.
* This method should be wrapped in a transaction by the calling service if other operations depend on it.
* @param userId The UUID of the user.
* @param itemName The name of the item to watch.
* @param categoryName The category of the item.
* @returns A promise that resolves to the MasterGroceryItem that was added to the watchlist.
*/
async addWatchedItem(userId: string, itemName: string, categoryName: string): Promise<MasterGroceryItem> {
// This method assumes it might be part of a larger transaction, so it uses `this.db`.
// The calling service is responsible for acquiring and releasing a client if needed.
try {
// Find category ID
const categoryRes = await this.db.query<{ category_id: number }>('SELECT category_id FROM public.categories WHERE name = $1', [categoryName]);
const categoryId = categoryRes.rows[0]?.category_id;
if (!categoryId) {
throw new Error(`Category '${categoryName}' not found.`);
}
// Find or create master item
let masterItem: MasterGroceryItem;
const masterItemRes = await this.db.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items WHERE name = $1', [itemName]);
if (masterItemRes.rows.length > 0) {
masterItem = masterItemRes.rows[0];
} else {
const newMasterItemRes = await this.db.query<MasterGroceryItem>(
'INSERT INTO public.master_grocery_items (name, category_id) VALUES ($1, $2) RETURNING *',
[itemName, categoryId]
);
masterItem = newMasterItemRes.rows[0];
}
// Add to user's watchlist, ignoring if it's already there.
await this.db.query(
'INSERT INTO public.user_watched_items (user_id, master_item_id) VALUES ($1, $2) ON CONFLICT (user_id, master_item_id) DO NOTHING',
[userId, masterItem.master_grocery_item_id]
);
return masterItem;
} catch (error) {
if (error instanceof Error && 'code' in error) {
if (error.code === '23505') { // unique_violation
// This case is handled by ON CONFLICT, but it's good practice for other functions.
throw new UniqueConstraintError('This item is already in the watchlist.');
} else if (error.code === '23503') { // foreign_key_violation
throw new ForeignKeyConstraintError('The specified user or category does not exist.');
}
}
logger.error('Database error in addWatchedItem:', { error });
throw new Error('Failed to add item to watchlist.');
}
}
/**
* Calls a database function to get the best current sale prices for all watched items across all users.
* This is much more efficient than calling getBestSalePricesForUser for each user individually.
* @returns A promise that resolves to an array of deals, each augmented with user information.
*/
async getBestSalePricesForAllUsers(): Promise<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })[]> {
try {
// This function assumes a corresponding PostgreSQL function `get_best_sale_prices_for_all_users` exists.
const res = await this.db.query<(WatchedItemDeal & { user_id: string; email: string; full_name: string | null })>('SELECT * FROM public.get_best_sale_prices_for_all_users()');
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForAllUsers:', { error });
throw new Error('Failed to get best sale prices for all users.');
}
}
/**
* Retrieves the master list of all available kitchen appliances.
* @returns A promise that resolves to an array of Appliance objects.
*/
async getAppliances(): Promise<Appliance[]> {
try {
const res = await this.db.query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
return res.rows;
} catch (error) {
logger.error('Database error in getAppliances:', { error });
throw new Error('Failed to get appliances.');
}
}
/**
* Retrieves the master list of all available dietary restrictions.
* @returns A promise that resolves to an array of DietaryRestriction objects.
*/
async getDietaryRestrictions(): Promise<DietaryRestriction[]> {
try {
const res = await this.db.query<DietaryRestriction>('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
return res.rows;
} catch (error) {
logger.error('Database error in getDietaryRestrictions:', { error });
throw new Error('Failed to get dietary restrictions.');
}
}
/**
* Retrieves the dietary restrictions for a specific user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's selected DietaryRestriction objects.
*/
async getUserDietaryRestrictions(userId: string): Promise<DietaryRestriction[]> {
try {
const query = `
SELECT dr.* FROM public.dietary_restrictions dr
JOIN public.user_dietary_restrictions udr ON dr.dietary_restriction_id = udr.restriction_id
WHERE udr.user_id = $1 ORDER BY dr.type, dr.name;
`;
const res = await getPool().query<DietaryRestriction>(query, [userId]);
return res.rows;
const res = await this.db.query<DietaryRestriction>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserDietaryRestrictions:', { error, userId });
throw new Error('Failed to get user dietary restrictions.');
logger.error('Database error in getUserDietaryRestrictions:', { error, userId });
throw new Error('Failed to get user dietary restrictions.');
}
}
}
/**
* Sets the dietary restrictions for a user, replacing any existing ones.
* @param userId The ID of the user.
* @param restrictionIds An array of IDs for the selected dietary restrictions.
* @returns A promise that resolves when the operation is complete.
*/
export async function setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise<void> {
const client = await getPool().connect();
/**
* Sets the dietary restrictions for a user, replacing any existing ones.
* @param userId The ID of the user.
* @param restrictionIds An array of IDs for the selected dietary restrictions.
* @returns A promise that resolves when the operation is complete.
*/
async setUserDietaryRestrictions(userId: string, restrictionIds: number[]): Promise<void> {
const client = await (this.db as Pool).connect();
try {
await client.query('BEGIN');
// 1. Clear existing restrictions for the user
await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [userId]);
// 2. Insert new ones if any are provided
if (restrictionIds.length > 0) {
// Using unnest is safer than string concatenation and prevents SQL injection.
const insertQuery = `INSERT INTO public.user_dietary_restrictions (user_id, restriction_id) SELECT $1, unnest($2::int[])`;
await client.query(insertQuery, [userId, restrictionIds]);
}
// 3. Commit the transaction
await client.query('COMMIT');
await client.query('BEGIN');
// 1. Clear existing restrictions for the user
await client.query('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', [userId]);
// 2. Insert new ones if any are provided
if (restrictionIds.length > 0) {
// Using unnest is safer than string concatenation and prevents SQL injection.
const insertQuery = `INSERT INTO public.user_dietary_restrictions (user_id, restriction_id) SELECT $1, unnest($2::int[])`;
await client.query(insertQuery, [userId, restrictionIds]);
}
// 3. Commit the transaction
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or more of the specified restriction IDs are invalid.');
}
logger.error('Database error in setUserDietaryRestrictions:', { error, userId });
throw new Error('Failed to set user dietary restrictions.');
await client.query('ROLLBACK');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or more of the specified restriction IDs are invalid.');
}
logger.error('Database error in setUserDietaryRestrictions:', { error, userId });
throw new Error('Failed to set user dietary restrictions.');
} finally {
client.release();
client.release();
}
}
}
/**
* Retrieves the master list of all available kitchen appliances.
* @returns A promise that resolves to an array of Appliance objects.
*/
export async function getAppliances(): Promise<Appliance[]> {
/**
* Sets the kitchen appliances for a user, replacing any existing ones.
* @param userId The ID of the user.
* @param applianceIds An array of IDs for the selected appliances.
* @returns A promise that resolves when the operation is complete.
*/
async setUserAppliances(userId: string, applianceIds: number[]): Promise<UserAppliance[]> {
const client = await (this.db as Pool).connect();
try {
const res = await getPool().query<Appliance>('SELECT * FROM public.appliances ORDER BY name');
return res.rows;
} catch (error) {
logger.error('Database error in getAppliances:', { error });
throw new Error('Failed to get appliances.');
}
}
await client.query('BEGIN'); // Start transaction
/**
* Retrieves the kitchen appliances for a specific user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's selected Appliance objects.
*/
export async function getUserAppliances(userId: string): Promise<Appliance[]> {
try {
const query = `
// 1. Clear existing appliances for the user
await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]);
let newAppliances: UserAppliance[] = [];
// 2. Insert new ones if any are provided
if (applianceIds.length > 0) {
const insertQuery = `INSERT INTO public.user_appliances (user_id, appliance_id) SELECT $1, unnest($2::int[]) RETURNING *`;
const res = await client.query<UserAppliance>(insertQuery, [userId, applianceIds]);
newAppliances = res.rows;
}
// 3. Commit the transaction
await client.query('COMMIT');
return newAppliances;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Database error in setUserAppliances:', { error, userId });
throw new Error('Failed to set user appliances.');
} finally {
client.release();
}
}
/**
* Retrieves the kitchen appliances for a specific user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's selected Appliance objects.
*/
async getUserAppliances(userId: string): Promise<Appliance[]> {
try {
const query = `
SELECT a.* FROM public.appliances a
JOIN public.user_appliances ua ON a.appliance_id = ua.appliance_id
WHERE ua.user_id = $1 ORDER BY a.name;
`;
const res = await getPool().query<Appliance>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserAppliances:', { error, userId });
throw new Error('Failed to get user appliances.');
const res = await this.db.query<Appliance>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserAppliances:', { error, userId });
throw new Error('Failed to get user appliances.');
}
}
}
/**
* Sets the kitchen appliances for a user, replacing any existing ones.
* @param userId The ID of the user.
* @param applianceIds An array of IDs for the selected appliances.
* @returns A promise that resolves when the operation is complete.
*/
export async function setUserAppliances(userId: string, applianceIds: number[]): Promise<UserAppliance[]> {
const client = await getPool().connect();
try {
await client.query('BEGIN'); // Start transaction
/**
* Calls a database function to find recipes that can be made from a user's pantry.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of recipes.
*/
async findRecipesFromPantry(userId: string): Promise<PantryRecipe[]> {
try {
const res = await this.db.query<PantryRecipe>('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesFromPantry:', { error, userId });
throw new Error('Failed to find recipes from pantry.');
}
}
// 1. Clear existing appliances for the user
await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]);
let newAppliances: UserAppliance[] = [];
// 2. Insert new ones if any are provided
if (applianceIds.length > 0) {
const insertQuery = `INSERT INTO public.user_appliances (user_id, appliance_id) SELECT $1, unnest($2::int[]) RETURNING *`;
const res = await client.query<UserAppliance>(insertQuery, [userId, applianceIds]);
newAppliances = res.rows;
}
// 3. Commit the transaction
await client.query('COMMIT');
return newAppliances;
} catch (error) {
await client.query('ROLLBACK');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or more of the specified appliance IDs are invalid.');
}
logger.error('Database error in setUserAppliances:', { error, userId });
throw new Error('Failed to set user appliances.');
} finally {
client.release();
}
}
/**
* Calls a database function to recommend recipes for a user.
* @param userId The ID of the user.
* @param limit The maximum number of recipes to recommend.
* @returns A promise that resolves to an array of recommended recipes.
*/
async recommendRecipesForUser(userId: string, limit: number): Promise<RecommendedRecipe[]> {
try {
const res = await this.db.query<RecommendedRecipe>('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in recommendRecipesForUser:', { error, userId, limit });
throw new Error('Failed to recommend recipes.');
}
}
/**
* Calls a database function to get recipes that are compatible with a user's dietary restrictions.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of compatible Recipe objects.
*/
export async function getRecipesForUserDiets(userId: string): Promise<Recipe[]> {
try {
const res = await getPool().query<Recipe>('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesForUserDiets:', { error, userId });
throw new Error('Failed to get recipes compatible with user diet.');
}
/**
* Calls a database function to get the best current sale prices for a user's watched items.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the best deals.
*/
async getBestSalePricesForUser(userId: string): Promise<WatchedItemDeal[]> {
try {
const res = await this.db.query<WatchedItemDeal>('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForUser:', { error, userId });
throw new Error('Failed to get best sale prices.');
}
}
/**
* Calls a database function to suggest unit conversions for a pantry item.
* @param pantryItemId The ID of the pantry item.
* @returns A promise that resolves to an array of suggested conversions.
*/
async suggestPantryItemConversions(pantryItemId: number): Promise<PantryItemConversion[]> {
try {
const res = await this.db.query<PantryItemConversion>('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
return res.rows;
} catch (error) {
logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId });
throw new Error('Failed to suggest pantry item conversions.');
}
}
/**
* Calls a database function to get recipes that are compatible with a user's dietary restrictions.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of compatible Recipe objects.
*/
async getRecipesForUserDiets(userId: string): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_for_user_diets($1)', [userId]); // This is a standalone function, no change needed here.
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesForUserDiets:', { error, userId });
throw new Error('Failed to get recipes compatible with user diet.');
}
}
}

View File

@@ -1,18 +1,7 @@
// src/services/db/recipe.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/mock-db';
import {
getRecipesBySalePercentage,
getRecipesByMinSaleIngredients,
findRecipesByIngredientAndTag,
getUserFavoriteRecipes,
addFavoriteRecipe,
removeFavoriteRecipe,
getRecipeById,
getRecipeComments,
addRecipeComment,
forkRecipe,
} from './recipe.db';
import { RecipeRepository } from './recipe.db';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./recipe.db');
@@ -20,6 +9,7 @@ vi.unmock('./recipe.db');
const mockQuery = mockPoolInstance.query;
import type { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
import { ForeignKeyConstraintError } from './errors.db';
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
@@ -31,63 +21,76 @@ vi.mock('../logger', () => ({
}));
describe('Recipe DB Service', () => {
let recipeRepo: RecipeRepository;
beforeEach(() => {
vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test
recipeRepo = new RecipeRepository(mockPoolInstance as any);
});
describe('getRecipesBySalePercentage', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipesBySalePercentage(50);
await recipeRepo.getRecipesBySalePercentage(50);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [50]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(getRecipesBySalePercentage(50)).rejects.toThrow('Failed to get recipes by sale percentage.');
await expect(recipeRepo.getRecipesBySalePercentage(50)).rejects.toThrow('Failed to get recipes by sale percentage.');
});
});
describe('getRecipesByMinSaleIngredients', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipesByMinSaleIngredients(3);
await recipeRepo.getRecipesByMinSaleIngredients(3);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [3]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(getRecipesByMinSaleIngredients(3)).rejects.toThrow('Failed to get recipes by minimum sale ingredients.');
await expect(recipeRepo.getRecipesByMinSaleIngredients(3)).rejects.toThrow('Failed to get recipes by minimum sale ingredients.');
});
});
describe('findRecipesByIngredientAndTag', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await findRecipesByIngredientAndTag('chicken', 'quick');
await recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', ['chicken', 'quick']);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(findRecipesByIngredientAndTag('chicken', 'quick')).rejects.toThrow('Failed to find recipes by ingredient and tag.');
await expect(recipeRepo.findRecipesByIngredientAndTag('chicken', 'quick')).rejects.toThrow('Failed to find recipes by ingredient and tag.');
});
});
describe('getUserFavoriteRecipes', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getUserFavoriteRecipes('user-123');
await recipeRepo.getUserFavoriteRecipes('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', ['user-123']);
});
it('should return an empty array if user has no favorites', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getUserFavoriteRecipes('user-123');
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(getUserFavoriteRecipes('user-123')).rejects.toThrow('Failed to get favorite recipes.');
await expect(recipeRepo.getUserFavoriteRecipes('user-123')).rejects.toThrow('Failed to get favorite recipes.');
});
});
@@ -96,7 +99,7 @@ describe('Recipe DB Service', () => {
const mockFavorite: FavoriteRecipe = { user_id: 'user-123', recipe_id: 1, created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockFavorite] });
const result = await addFavoriteRecipe('user-123', 1);
const result = await recipeRepo.addFavoriteRecipe('user-123', 1);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.favorite_recipes'), ['user-123', 1]);
expect(result).toEqual(mockFavorite);
@@ -106,40 +109,40 @@ describe('Recipe DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockQuery.mockRejectedValue(dbError);
await expect(addFavoriteRecipe('user-123', 999)).rejects.toThrow('The specified user or recipe does not exist.');
await expect(recipeRepo.addFavoriteRecipe('user-123', 999)).rejects.toThrow('The specified user or recipe does not exist.');
});
it('should return undefined if the favorite already exists (ON CONFLICT)', async () => {
// When ON CONFLICT DO NOTHING happens, the RETURNING clause does not execute, so rows is empty.
mockQuery.mockResolvedValue({ rows: [] });
const result = await addFavoriteRecipe('user-123', 1);
const result = await recipeRepo.addFavoriteRecipe('user-123', 1);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(addFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to add favorite recipe.');
await expect(recipeRepo.addFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to add favorite recipe.');
});
});
describe('removeFavoriteRecipe', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rowCount: 1 });
await removeFavoriteRecipe('user-123', 1);
await recipeRepo.removeFavoriteRecipe('user-123', 1);
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]);
});
it('should throw an error if the favorite recipe is not found', async () => {
// Simulate the DB returning 0 rows affected
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(removeFavoriteRecipe('user-123', 999)).rejects.toThrow('Favorite recipe not found for this user.');
await expect(recipeRepo.removeFavoriteRecipe('user-123', 999)).rejects.toThrow('Favorite recipe not found for this user.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(removeFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to remove favorite recipe.');
await expect(recipeRepo.removeFavoriteRecipe('user-123', 1)).rejects.toThrow('Failed to remove favorite recipe.');
});
});
@@ -147,29 +150,41 @@ describe('Recipe DB Service', () => {
it('should execute a SELECT query and return the recipe', async () => {
const mockRecipe: Recipe = { recipe_id: 1, name: 'Test Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: '' };
mockQuery.mockResolvedValue({ rows: [mockRecipe] });
const result = await getRecipeById(1);
const result = await recipeRepo.getRecipeById(1);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipes r'), [1]);
expect(result).toEqual(mockRecipe);
});
it('should return undefined if recipe is not found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getRecipeById(999);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(getRecipeById(1)).rejects.toThrow('Failed to retrieve recipe.');
await expect(recipeRepo.getRecipeById(1)).rejects.toThrow('Failed to retrieve recipe.');
});
});
describe('getRecipeComments', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipeComments(1);
await recipeRepo.getRecipeComments(1);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipe_comments rc'), [1]);
});
it('should return an empty array if recipe has no comments', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getRecipeComments(1);
expect(result).toEqual([]);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(getRecipeComments(1)).rejects.toThrow('Failed to get recipe comments.');
await expect(recipeRepo.getRecipeComments(1)).rejects.toThrow('Failed to get recipe comments.');
});
});
@@ -178,7 +193,7 @@ describe('Recipe DB Service', () => {
const mockComment: RecipeComment = { recipe_comment_id: 1, recipe_id: 1, user_id: 'user-123', content: 'Great!', status: 'visible', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockComment] });
const result = await addRecipeComment(1, 'user-123', 'Great!');
const result = await recipeRepo.addRecipeComment(1, 'user-123', 'Great!');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.recipe_comments'), [1, 'user-123', 'Great!', undefined]);
expect(result).toEqual(mockComment);
@@ -188,13 +203,13 @@ describe('Recipe DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockQuery.mockRejectedValue(dbError);
await expect(addRecipeComment(999, 'user-123', 'Fail')).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
await expect(recipeRepo.addRecipeComment(999, 'user-123', 'Fail')).rejects.toThrow('The specified recipe, user, or parent comment does not exist.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(addRecipeComment(1, 'user-123', 'Fail')).rejects.toThrow('Failed to add recipe comment.');
await expect(recipeRepo.addRecipeComment(1, 'user-123', 'Fail')).rejects.toThrow('Failed to add recipe comment.');
});
});
@@ -203,15 +218,23 @@ describe('Recipe DB Service', () => {
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);
const result = await recipeRepo.forkRecipe('user-123', 1);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', ['user-123', 1]);
expect(result).toEqual(mockRecipe);
});
it('should re-throw the specific error message from the database function', async () => {
const dbError = new Error('Recipe is not public and cannot be forked.');
(dbError as any).code = 'P0001'; // raise_exception
mockQuery.mockRejectedValue(dbError);
await expect(recipeRepo.forkRecipe('user-123', 1)).rejects.toThrow('Recipe is not public and cannot be forked.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);
await expect(forkRecipe('user-123', 1)).rejects.toThrow('Failed to fork recipe.');
await expect(recipeRepo.forkRecipe('user-123', 1)).rejects.toThrow('Failed to fork recipe.');
});
});
});

View File

@@ -1,202 +1,213 @@
// src/services/db/recipe.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger';
import { logger } from '../logger.server';
import { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
/**
* Calls a database function to get recipes based on the percentage of their ingredients on sale.
* @param minPercentage The minimum percentage of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
export async function getRecipesBySalePercentage(minPercentage: number): Promise<Recipe[]> {
try {
const res = await getPool().query<Recipe>('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesBySalePercentage:', { error });
throw new Error('Failed to get recipes by sale percentage.');
}
}
export class RecipeRepository {
private db: Pool | PoolClient;
/**
* Calls a database function to get recipes by the minimum number of sale ingredients.
* @param minIngredients The minimum number of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
export async function getRecipesByMinSaleIngredients(minIngredients: number): Promise<Recipe[]> {
try {
const res = await getPool().query<Recipe>('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesByMinSaleIngredients:', { error });
throw new Error('Failed to get recipes by minimum sale ingredients.');
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
}
/**
* Calls a database function to find recipes by a specific ingredient and tag.
* @param ingredient The name of the ingredient to search for.
* @param tag The name of the tag to search for.
* @returns A promise that resolves to an array of matching recipes.
*/
export async function findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<Recipe[]> {
try {
const res = await getPool().query<Recipe>('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesByIngredientAndTag:', { error });
throw new Error('Failed to find recipes by ingredient and tag.');
}
}
/**
* Calls a database function to get a user's favorite recipes.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's favorite recipes.
*/
export async function getUserFavoriteRecipes(userId: string): Promise<Recipe[]> {
try {
const res = await getPool().query<Recipe>('SELECT * FROM public.get_user_favorite_recipes($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserFavoriteRecipes:', { error, userId });
throw new Error('Failed to get favorite recipes.');
}
}
/**
* Adds a recipe to a user's favorites.
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to favorite.
* @returns A promise that resolves to the created favorite record.
*/
export async function addFavoriteRecipe(userId: string, recipeId: number): Promise<FavoriteRecipe> {
try {
const res = await getPool().query<FavoriteRecipe>(
'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *',
[userId, recipeId]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or recipe does not exist.');
}
logger.error('Database error in addFavoriteRecipe:', { error, userId, recipeId });
throw new Error('Failed to add favorite recipe.');
}
}
/**
* Removes a recipe from a user's favorites.
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to unfavorite.
*/
export async function removeFavoriteRecipe(userId: string, recipeId: number): Promise<void> {
try {
const res = await getPool().query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
if (res.rowCount === 0) {
// This indicates the favorite relationship did not exist.
throw new Error('Favorite recipe not found for this user.');
}
} catch (error) {
logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId });
throw new Error('Failed to remove favorite recipe.');
}
}
/**
* Retrieves a single recipe by its ID, including its ingredients and tags.
* @param recipeId The ID of the recipe to retrieve.
* @returns A promise that resolves to the Recipe object or undefined if not found.
*/
export async function getRecipeById(recipeId: number): Promise<Recipe | undefined> {
try {
// This query uses json_agg to fetch the recipe and its related ingredients and tags in one go,
// preventing the N+1 query problem.
const query = `
SELECT
r.*,
COALESCE(json_agg(DISTINCT jsonb_build_object('recipe_ingredient_id', ri.recipe_ingredient_id, 'master_item_name', mgi.name, 'quantity', ri.quantity, 'unit', ri.unit)) FILTER (WHERE ri.recipe_ingredient_id IS NOT NULL), '[]') AS ingredients,
COALESCE(json_agg(DISTINCT jsonb_build_object('tag_id', t.tag_id, 'name', t.name)) FILTER (WHERE t.tag_id IS NOT NULL), '[]') AS tags
FROM public.recipes r
LEFT JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id
LEFT JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id
LEFT JOIN public.recipe_tags rt ON r.recipe_id = rt.recipe_id
LEFT JOIN public.tags t ON rt.tag_id = t.tag_id
WHERE r.recipe_id = $1
GROUP BY r.recipe_id;
`;
const res = await getPool().query<Recipe>(query, [recipeId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getRecipeById:', { error, recipeId });
throw new Error('Failed to retrieve recipe.');
}
}
/**
* Retrieves all comments for a specific recipe.
* @param recipeId The ID of the recipe.
* @returns A promise that resolves to an array of RecipeComment objects.
*/
export async function getRecipeComments(recipeId: number): Promise<RecipeComment[]> {
try {
const query = `
SELECT
rc.*,
p.full_name as user_full_name,
p.avatar_url as user_avatar_url
FROM public.recipe_comments rc
LEFT JOIN public.profiles p ON rc.user_id = p.user_id
WHERE rc.recipe_id = $1
ORDER BY rc.created_at ASC;
`;
const res = await getPool().query<RecipeComment>(query, [recipeId]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipeComments:', { error, recipeId });
throw new Error('Failed to get recipe comments.');
}
}
/**
* Adds a new comment to a recipe.
* @param recipeId The ID of the recipe to comment on.
* @param userId The ID of the user posting the comment.
* @param content The text content of the comment.
* @param parentCommentId Optional ID of the parent comment for threaded replies.
* @returns A promise that resolves to the newly created RecipeComment object.
*/
export async function addRecipeComment(recipeId: number, userId: string, content: string, parentCommentId?: number): Promise<RecipeComment> {
try {
const res = await getPool().query<RecipeComment>(
'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *',
[recipeId, userId, content, parentCommentId]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified recipe, user, or parent comment does not exist.');
}
logger.error('Database error in addRecipeComment:', { error });
throw new Error('Failed to add recipe comment.');
}
}
/**
* Creates a personal, editable copy (a "fork") of a public recipe for a user.
* @param userId The ID of the user forking the recipe.
* @param originalRecipeId The ID of the recipe to fork.
* @returns A promise that resolves to the newly created forked Recipe object.
*/
export async function forkRecipe(userId: string, originalRecipeId: number): Promise<Recipe> {
/**
* Calls a database function to get recipes based on the percentage of their ingredients on sale.
* @param minPercentage The minimum percentage of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
async getRecipesBySalePercentage(minPercentage: number): Promise<Recipe[]> {
try {
// The entire forking logic is now encapsulated in a single, atomic database function.
const res = await getPool().query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [userId, originalRecipeId]);
return res.rows[0];
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
return res.rows;
} catch (error) {
logger.error('Database error in forkRecipe:', { error });
throw new Error('Failed to fork recipe.');
logger.error('Database error in getRecipesBySalePercentage:', { error });
throw new Error('Failed to get recipes by sale percentage.');
}
}
/**
* Calls a database function to get recipes by the minimum number of sale ingredients.
* @param minIngredients The minimum number of ingredients that must be on sale.
* @returns A promise that resolves to an array of recipes.
*/
async getRecipesByMinSaleIngredients(minIngredients: number): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesByMinSaleIngredients:', { error });
throw new Error('Failed to get recipes by minimum sale ingredients.');
}
}
/**
* Calls a database function to find recipes by a specific ingredient and tag.
* @param ingredient The name of the ingredient to search for.
* @param tag The name of the tag to search for.
* @returns A promise that resolves to an array of matching recipes.
*/
async findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesByIngredientAndTag:', { error });
throw new Error('Failed to find recipes by ingredient and tag.');
}
}
/**
* Calls a database function to get a user's favorite recipes.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the user's favorite recipes.
*/
async getUserFavoriteRecipes(userId: string): Promise<Recipe[]> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.get_user_favorite_recipes($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserFavoriteRecipes:', { error, userId });
throw new Error('Failed to get favorite recipes.');
}
}
/**
* Adds a recipe to a user's favorites.
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to favorite.
* @returns A promise that resolves to the created favorite record.
*/
async addFavoriteRecipe(userId: string, recipeId: number): Promise<FavoriteRecipe> {
try {
const res = await this.db.query<FavoriteRecipe>(
'INSERT INTO public.favorite_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO NOTHING RETURNING *',
[userId, recipeId]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or recipe does not exist.');
}
logger.error('Database error in addFavoriteRecipe:', { error, userId, recipeId });
throw new Error('Failed to add favorite recipe.');
}
}
/**
* Removes a recipe from a user's favorites.
* @param userId The ID of the user.
* @param recipeId The ID of the recipe to unfavorite.
*/
async removeFavoriteRecipe(userId: string, recipeId: number): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
if (res.rowCount === 0) {
throw new Error('Favorite recipe not found for this user.');
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('Favorite recipe not found')) throw error;
logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId });
throw new Error('Failed to remove favorite recipe.');
}
}
/**
* Retrieves a single recipe by its ID, including its ingredients and tags.
* @param recipeId The ID of the recipe to retrieve.
* @returns A promise that resolves to the Recipe object or undefined if not found.
*/
async getRecipeById(recipeId: number): Promise<Recipe | undefined> {
try {
const query = `
SELECT
r.*,
COALESCE(json_agg(DISTINCT jsonb_build_object('recipe_ingredient_id', ri.recipe_ingredient_id, 'master_item_name', mgi.name, 'quantity', ri.quantity, 'unit', ri.unit)) FILTER (WHERE ri.recipe_ingredient_id IS NOT NULL), '[]') AS ingredients,
COALESCE(json_agg(DISTINCT jsonb_build_object('tag_id', t.tag_id, 'name', t.name)) FILTER (WHERE t.tag_id IS NOT NULL), '[]') AS tags
FROM public.recipes r
LEFT JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id
LEFT JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.master_grocery_item_id
LEFT JOIN public.recipe_tags rt ON r.recipe_id = rt.recipe_id
LEFT JOIN public.tags t ON rt.tag_id = t.tag_id
WHERE r.recipe_id = $1
GROUP BY r.recipe_id;
`;
const res = await this.db.query<Recipe>(query, [recipeId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getRecipeById:', { error, recipeId });
throw new Error('Failed to retrieve recipe.');
}
}
/**
* Retrieves all comments for a specific recipe.
* @param recipeId The ID of the recipe.
* @returns A promise that resolves to an array of RecipeComment objects.
*/
async getRecipeComments(recipeId: number): Promise<RecipeComment[]> {
try {
const query = `
SELECT
rc.*,
p.full_name as user_full_name,
p.avatar_url as user_avatar_url
FROM public.recipe_comments rc
LEFT JOIN public.profiles p ON rc.user_id = p.user_id
WHERE rc.recipe_id = $1
ORDER BY rc.created_at ASC;
`;
const res = await this.db.query<RecipeComment>(query, [recipeId]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipeComments:', { error, recipeId });
throw new Error('Failed to get recipe comments.');
}
}
/**
* Adds a new comment to a recipe.
* @param recipeId The ID of the recipe to comment on.
* @param userId The ID of the user posting the comment.
* @param content The text content of the comment.
* @param parentCommentId Optional ID of the parent comment for threaded replies.
* @returns A promise that resolves to the newly created RecipeComment object.
*/
async addRecipeComment(recipeId: number, userId: string, content: string, parentCommentId?: number): Promise<RecipeComment> {
try {
const res = await this.db.query<RecipeComment>(
'INSERT INTO public.recipe_comments (recipe_id, user_id, content, parent_comment_id) VALUES ($1, $2, $3, $4) RETURNING *',
[recipeId, userId, content, parentCommentId]
);
return res.rows[0];
} catch (error) {
// Check for specific PostgreSQL error codes
if (error instanceof Error && 'code' in error && error.code === '23503') { // foreign_key_violation
throw new ForeignKeyConstraintError('The specified recipe, user, or parent comment does not exist.');
}
logger.error('Database error in addRecipeComment:', { error });
throw new Error('Failed to add recipe comment.');
}
}
/**
* Creates a personal, editable copy (a "fork") of a public recipe for a user.
* @param userId The ID of the user forking the recipe.
* @param originalRecipeId The ID of the recipe to fork.
* @returns A promise that resolves to the newly created forked Recipe object.
*/
async forkRecipe(userId: string, originalRecipeId: number): Promise<Recipe> {
try {
const res = await this.db.query<Recipe>('SELECT * FROM public.fork_recipe($1, $2)', [userId, originalRecipeId]);
return res.rows[0];
} catch (error) {
// The fork_recipe function could fail if the original recipe doesn't exist or isn't public.
if (error instanceof Error && 'code' in error && error.code === 'P0001') { // raise_exception
throw new Error(error.message); // Re-throw the user-friendly message from the DB function.
}
logger.error('Database error in forkRecipe:', { error, userId, originalRecipeId });
throw new Error('Failed to fork recipe.');
}
}
}

View File

@@ -6,24 +6,8 @@ import { createMockShoppingList, createMockShoppingListItem } from '../../tests/
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./shopping.db');
import {
getShoppingLists,
createShoppingList,
deleteShoppingList,
addShoppingListItem,
updateShoppingListItem,
removeShoppingListItem,
completeShoppingList,
generateShoppingListForMenuPlan,
addMenuPlanToShoppingList,
getPantryLocations,
createPantryLocation,
getShoppingTripHistory,
createReceipt,
findReceiptOwner,
processReceiptItems,
findDealsForReceipt,
} from './shopping.db';
import { ShoppingRepository } from './shopping.db';
import { ForeignKeyConstraintError, UniqueConstraintError } from './errors.db';
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
@@ -36,24 +20,35 @@ vi.mock('../logger', () => ({
}));
describe('Shopping DB Service', () => {
let shoppingRepo: ShoppingRepository;
beforeEach(() => {
vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test
shoppingRepo = new ShoppingRepository(mockPoolInstance as any);
});
describe('getShoppingLists', () => {
it('should execute the correct query and return shopping lists', async () => {
const mockLists = [createMockShoppingList({ user_id: 'user-1' })];
mockPoolInstance.query.mockResolvedValue({ rows: mockLists });
const result = await getShoppingLists('user-1');
const result = await shoppingRepo.getShoppingLists('user-1');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-1']);
expect(result).toEqual(mockLists);
});
it('should return an empty array if a user has no shopping lists', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.getShoppingLists('user-with-no-lists');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-with-no-lists']);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
await expect(getShoppingLists('user-1')).rejects.toThrow('Failed to retrieve shopping lists.');
await expect(shoppingRepo.getShoppingLists('user-1')).rejects.toThrow('Failed to retrieve shopping lists.');
});
});
@@ -62,8 +57,8 @@ describe('Shopping DB Service', () => {
const mockList = createMockShoppingList({ name: 'New List' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockList] });
const result = await createShoppingList('user-1', 'New List');
const result = await shoppingRepo.createShoppingList('user-1', 'New List');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_lists'), ['user-1', 'New List']);
expect(result).toEqual(mockList);
});
@@ -72,32 +67,32 @@ describe('Shopping DB Service', () => {
const dbError = new Error('insert or update on table "shopping_lists" violates foreign key constraint "shopping_lists_user_id_fkey"');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createShoppingList('non-existent-user', 'Wont work')).rejects.toThrow('The specified user does not exist.');
await expect(shoppingRepo.createShoppingList('non-existent-user', 'Wont work')).rejects.toThrow('The specified user does not exist.');
});
it('should throw a generic error if the database query fails for other reasons', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createShoppingList('user-1', 'New List')).rejects.toThrow('Failed to create shopping list.');
await expect(shoppingRepo.createShoppingList('user-1', 'New List')).rejects.toThrow('Failed to create shopping list.');
});
});
describe('deleteShoppingList', () => {
it('should delete a shopping list if rowCount is 1', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
await expect(deleteShoppingList(1, 'user-1')).resolves.toBeUndefined();
await expect(shoppingRepo.deleteShoppingList(1, 'user-1')).resolves.toBeUndefined();
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [1, 'user-1']);
});
it('should throw an error if no rows are deleted (list not found or wrong user)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
await expect(deleteShoppingList(999, 'user-1')).rejects.toThrow('Shopping list not found or user does not have permission to delete.');
await expect(shoppingRepo.deleteShoppingList(999, 'user-1')).rejects.toThrow('Shopping list not found or user does not have permission to delete.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(deleteShoppingList(1, 'user-1')).rejects.toThrow('Failed to delete shopping list.');
await expect(shoppingRepo.deleteShoppingList(1, 'user-1')).rejects.toThrow('Failed to delete shopping list.');
});
});
@@ -106,8 +101,8 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ custom_item_name: 'Custom Item' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
const result = await addShoppingListItem(1, { customItemName: 'Custom Item' });
const result = await shoppingRepo.addShoppingListItem(1, { customItemName: 'Custom Item' });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, null, 'Custom Item']);
expect(result).toEqual(mockItem);
});
@@ -116,27 +111,37 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ master_item_id: 123 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
const result = await addShoppingListItem(1, { masterItemId: 123 });
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123 });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, 123, null]);
expect(result).toEqual(mockItem);
});
it('should add an item with both masterItemId and customItemName', async () => {
const mockItem = createMockShoppingListItem({ master_item_id: 123, custom_item_name: 'Organic Apples' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem] });
const result = await shoppingRepo.addShoppingListItem(1, { masterItemId: 123, customItemName: 'Organic Apples' });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, 123, 'Organic Apples']);
expect(result).toEqual(mockItem);
});
it('should throw an error if both masterItemId and customItemName are missing', async () => {
await expect(addShoppingListItem(1, {})).rejects.toThrow('Either masterItemId or customItemName must be provided.');
await expect(shoppingRepo.addShoppingListItem(1, {})).rejects.toThrow('Either masterItemId or customItemName must be provided.');
});
it('should throw ForeignKeyConstraintError if list or master item does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addShoppingListItem(999, { masterItemId: 999 })).rejects.toThrow('The specified shopping list or master item does not exist.');
await expect(shoppingRepo.addShoppingListItem(999, { masterItemId: 999 })).rejects.toThrow(ForeignKeyConstraintError);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(addShoppingListItem(1, { customItemName: 'Test' })).rejects.toThrow('Failed to add item to shopping list.');
await expect(shoppingRepo.addShoppingListItem(1, { customItemName: 'Test' })).rejects.toThrow('Failed to add item to shopping list.');
});
});
@@ -145,8 +150,8 @@ describe('Shopping DB Service', () => {
const mockItem = createMockShoppingListItem({ shopping_list_item_id: 1, is_purchased: true });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
const result = await updateShoppingListItem(1, { is_purchased: true });
const result = await shoppingRepo.updateShoppingListItem(1, { is_purchased: true });
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'UPDATE public.shopping_list_items SET is_purchased = $1 WHERE shopping_list_item_id = $2 RETURNING *',
[true, 1]
@@ -154,46 +159,60 @@ describe('Shopping DB Service', () => {
expect(result).toEqual(mockItem);
});
it('should update multiple fields at once', async () => {
const updates = { is_purchased: true, quantity: 5, notes: 'Get the green ones' };
const mockItem = createMockShoppingListItem({ shopping_list_item_id: 1, ...updates });
mockPoolInstance.query.mockResolvedValue({ rows: [mockItem], rowCount: 1 });
const result = await shoppingRepo.updateShoppingListItem(1, updates);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'UPDATE public.shopping_list_items SET quantity = $1, is_purchased = $2, notes = $3 WHERE shopping_list_item_id = $4 RETURNING *',
[updates.quantity, updates.is_purchased, updates.notes, 1]
);
expect(result).toEqual(mockItem);
});
it('should throw an error if the item to update is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'UPDATE' });
await expect(updateShoppingListItem(999, { quantity: 5 })).rejects.toThrow('Shopping list item not found.');
await expect(shoppingRepo.updateShoppingListItem(999, { quantity: 5 })).rejects.toThrow('Shopping list item not found.');
});
it('should throw an error if no valid fields are provided to update', async () => {
// The function should throw before even querying the database.
await expect(updateShoppingListItem(1, {})).rejects.toThrow('No valid fields to update.');
await expect(shoppingRepo.updateShoppingListItem(1, {})).rejects.toThrow('No valid fields to update.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateShoppingListItem(1, { is_purchased: true })).rejects.toThrow('Failed to update shopping list item.');
await expect(shoppingRepo.updateShoppingListItem(1, { is_purchased: true })).rejects.toThrow('Failed to update shopping list item.');
});
});
describe('removeShoppingListItem', () => {
it('should delete an item if rowCount is 1', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 1, rows: [], command: 'DELETE' });
await expect(removeShoppingListItem(1)).resolves.toBeUndefined();
await expect(shoppingRepo.removeShoppingListItem(1)).resolves.toBeUndefined();
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [1]);
});
it('should throw an error if no rows are deleted (item not found)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [], command: 'DELETE' });
await expect(removeShoppingListItem(999)).rejects.toThrow('Shopping list item not found.');
await expect(shoppingRepo.removeShoppingListItem(999)).rejects.toThrow('Shopping list item not found.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(removeShoppingListItem(1)).rejects.toThrow('Failed to remove item from shopping list.');
await expect(shoppingRepo.removeShoppingListItem(1)).rejects.toThrow('Failed to remove item from shopping list.');
});
});
describe('completeShoppingList', () => {
it('should call the complete_shopping_list database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ complete_shopping_list: 1 }] });
const result = await completeShoppingList(1, 'user-123', 5000);
const result = await shoppingRepo.completeShoppingList(1, 'user-123', 5000);
expect(result).toBe(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT public.complete_shopping_list($1, $2, $3)', [1, 'user-123', 5000]);
});
@@ -202,13 +221,13 @@ describe('Shopping DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(completeShoppingList(999, 'user-123')).rejects.toThrow('The specified shopping list does not exist.');
await expect(shoppingRepo.completeShoppingList(999, 'user-123')).rejects.toThrow('The specified shopping list does not exist.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Function Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(completeShoppingList(1, 'user-123')).rejects.toThrow('Failed to complete shopping list.');
await expect(shoppingRepo.completeShoppingList(1, 'user-123')).rejects.toThrow('Failed to complete shopping list.');
});
});
@@ -216,14 +235,14 @@ describe('Shopping DB Service', () => {
it('should call the correct database function and return items', async () => {
const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await generateShoppingListForMenuPlan(1, 'user-1');
const result = await shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1');
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [1, 'user-1']);
expect(result).toEqual(mockItems);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(generateShoppingListForMenuPlan(1, 'user-1')).rejects.toThrow('Failed to generate shopping list for menu plan.');
await expect(shoppingRepo.generateShoppingListForMenuPlan(1, 'user-1')).rejects.toThrow('Failed to generate shopping list for menu plan.');
});
});
@@ -231,14 +250,14 @@ describe('Shopping DB Service', () => {
it('should call the correct database function and return added items', async () => {
const mockItems = [{ master_item_id: 1, item_name: 'Apples', quantity_needed: 2 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
const result = await addMenuPlanToShoppingList(1, 10, 'user-1');
const result = await shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1');
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [1, 10, 'user-1']);
expect(result).toEqual(mockItems);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(addMenuPlanToShoppingList(1, 10, 'user-1')).rejects.toThrow('Failed to add menu plan to shopping list.');
await expect(shoppingRepo.addMenuPlanToShoppingList(1, 10, 'user-1')).rejects.toThrow('Failed to add menu plan to shopping list.');
});
});
@@ -246,14 +265,14 @@ describe('Shopping DB Service', () => {
it('should return a list of pantry locations for a user', async () => {
const mockLocations = [{ pantry_location_id: 1, name: 'Fridge', user_id: 'user-1' }];
mockPoolInstance.query.mockResolvedValue({ rows: mockLocations });
const result = await getPantryLocations('user-1');
const result = await shoppingRepo.getPantryLocations('user-1');
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', ['user-1']);
expect(result).toEqual(mockLocations);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(getPantryLocations('user-1')).rejects.toThrow('Failed to get pantry locations.');
await expect(shoppingRepo.getPantryLocations('user-1')).rejects.toThrow('Failed to get pantry locations.');
});
});
@@ -261,7 +280,7 @@ describe('Shopping DB Service', () => {
it('should insert a new pantry location and return it', async () => {
const mockLocation = { pantry_location_id: 1, name: 'Freezer', user_id: 'user-1' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockLocation] });
const result = await createPantryLocation('user-1', 'Freezer');
const result = await shoppingRepo.createPantryLocation('user-1', 'Freezer');
expect(mockPoolInstance.query).toHaveBeenCalledWith('INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *', ['user-1', 'Freezer']);
expect(result).toEqual(mockLocation);
});
@@ -270,12 +289,19 @@ describe('Shopping DB Service', () => {
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createPantryLocation('user-1', 'Fridge')).rejects.toThrow('A pantry location with this name already exists.');
await expect(shoppingRepo.createPantryLocation('user-1', 'Fridge')).rejects.toThrow(UniqueConstraintError);
});
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.createPantryLocation('non-existent-user', 'Pantry')).rejects.toThrow('The specified user does not exist.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(createPantryLocation('user-1', 'Pantry')).rejects.toThrow('Failed to create pantry location.');
await expect(shoppingRepo.createPantryLocation('user-1', 'Pantry')).rejects.toThrow('Failed to create pantry location.');
});
});
@@ -283,14 +309,20 @@ describe('Shopping DB Service', () => {
it('should return a list of shopping trips for a user', async () => {
const mockTrips = [{ shopping_trip_id: 1, user_id: 'user-1', items: [] }];
mockPoolInstance.query.mockResolvedValue({ rows: mockTrips });
const result = await getShoppingTripHistory('user-1');
const result = await shoppingRepo.getShoppingTripHistory('user-1');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_trips st'), ['user-1']);
expect(result).toEqual(mockTrips);
});
it('should return an empty array if a user has no shopping trips', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.getShoppingTripHistory('user-no-trips');
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(getShoppingTripHistory('user-1')).rejects.toThrow('Failed to retrieve shopping trip history.');
await expect(shoppingRepo.getShoppingTripHistory('user-1')).rejects.toThrow('Failed to retrieve shopping trip history.');
});
});
@@ -298,7 +330,7 @@ describe('Shopping DB Service', () => {
it('should insert a new receipt and return it', async () => {
const mockReceipt = { receipt_id: 1, user_id: 'user-1', receipt_image_url: 'url', status: 'pending' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });
const result = await createReceipt('user-1', 'url');
const result = await shoppingRepo.createReceipt('user-1', 'url');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.receipts'), ['user-1', 'url']);
expect(result).toEqual(mockReceipt);
});
@@ -307,12 +339,12 @@ describe('Shopping DB Service', () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createReceipt('non-existent-user', 'url')).rejects.toThrow('The specified user does not exist.');
await expect(shoppingRepo.createReceipt('non-existent-user', 'url')).rejects.toThrow('The specified user does not exist.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(createReceipt('user-1', 'url')).rejects.toThrow('Failed to create receipt record.');
await expect(shoppingRepo.createReceipt('user-1', 'url')).rejects.toThrow('Failed to create receipt record.');
});
});
@@ -320,15 +352,21 @@ describe('Shopping DB Service', () => {
it('should return the user_id of the receipt owner', async () => {
const mockOwner = { user_id: 'owner-123' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockOwner] });
const result = await findReceiptOwner(1);
const result = await shoppingRepo.findReceiptOwner(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT user_id FROM public.receipts WHERE receipt_id = $1', [1]);
expect(result).toEqual(mockOwner);
});
it('should return undefined if the receipt is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.findReceiptOwner(999);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(findReceiptOwner(1)).rejects.toThrow('Failed to retrieve receipt owner from database.');
await expect(shoppingRepo.findReceiptOwner(1)).rejects.toThrow('Failed to retrieve receipt owner from database.');
});
});
@@ -336,9 +374,9 @@ describe('Shopping DB Service', () => {
it('should call the process_receipt_items database function with correct parameters', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await processReceiptItems(1, items);
await shoppingRepo.processReceiptItems(1, items);
const expectedItemsWithQuantity = [{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 }];
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT public.process_receipt_items($1, $2, $3)',
@@ -354,8 +392,8 @@ describe('Shopping DB Service', () => {
mockPoolInstance.query.mockResolvedValueOnce({ rows: [] });
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await expect(processReceiptItems(1, items)).rejects.toThrow('Failed to process and save receipt items.');
await expect(shoppingRepo.processReceiptItems(1, items)).rejects.toThrow('Failed to process and save receipt items.');
// Verify that the status was updated to 'failed' in the catch block
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [1]);
});
@@ -366,9 +404,21 @@ describe('Shopping DB Service', () => {
const mockDeals = [{ receipt_item_id: 1, master_item_id: 10, item_name: 'Milk', price_paid_cents: 399, current_best_price_in_cents: 350, potential_savings_cents: 49, deal_store_name: 'Grocer', flyer_id: 101 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockDeals });
const result = await findDealsForReceipt(1);
const result = await shoppingRepo.findDealsForReceipt(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.find_deals_for_receipt_items($1)', [1]);
expect(result).toEqual(mockDeals);
});
it('should return an empty array if no deals are found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.findDealsForReceipt(1);
expect(result).toEqual([]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(shoppingRepo.findDealsForReceipt(1)).rejects.toThrow('Failed to find deals for receipt.');
});
});
});

View File

@@ -1,7 +1,8 @@
// src/services/db/shopping.db.ts
import { getPool } from './connection.db';
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError, UniqueConstraintError } from './errors.db';
import { logger } from '../logger';
import { logger } from '../logger.server';
import {
ShoppingList,
ShoppingListItem,
@@ -13,389 +14,385 @@ import {
ReceiptDeal,
} from '../../types';
/**
* Retrieves all shopping lists and their items for a user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of ShoppingList objects.
*/
// prettier-ignore
export async function getShoppingLists(userId: string): Promise<ShoppingList[]> {
export class ShoppingRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
/**
* Retrieves all shopping lists and their items for a user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of ShoppingList objects.
*/
async getShoppingLists(userId: string): Promise<ShoppingList[]> {
try {
const query = `
SELECT
sl.shopping_list_id, sl.name, sl.created_at,
COALESCE(json_agg(
json_build_object(
'shopping_list_item_id', sli.shopping_list_item_id,
'shopping_list_id', sli.shopping_list_id,
'master_item_id', sli.master_item_id,
'custom_item_name', sli.custom_item_name,
'quantity', sli.quantity,
'is_purchased', sli.is_purchased,
'added_at', sli.added_at,
'master_item', json_build_object('name', mgi.name)
)
) FILTER (WHERE sli.shopping_list_item_id IS NOT NULL), '[]'::json) as items
FROM public.shopping_lists sl
LEFT JOIN public.shopping_list_items sli ON sl.shopping_list_id = sli.shopping_list_id
LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.master_grocery_item_id
WHERE sl.user_id = $1
GROUP BY sl.shopping_list_id
ORDER BY sl.created_at ASC;
`;
const res = await this.db.query<ShoppingList>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getShoppingLists:', { error, userId });
throw new Error('Failed to retrieve shopping lists.');
}
}
/**
* Creates a new shopping list for a user.
* @param userId The ID of the user creating the list.
* @param name The name of the new shopping list.
* @returns A promise that resolves to the newly created ShoppingList object.
*/
async createShoppingList(userId: string, name: string): Promise<ShoppingList> {
try {
// This query uses LEFT JOINs to bring all related data into a flat structure,
// then GROUPs by the main record (the shopping list) and uses `json_agg`
// to roll up the "many" side (the items) into a nested JSON array.
// This is the standard and most performant way to solve the N+1 query problem in PostgreSQL.
const query = `
SELECT
sl.shopping_list_id,
sl.name,
sl.created_at,
-- 1. Aggregate all joined shopping list items into a single JSON array.
COALESCE(json_agg(
-- 2. Build a JSON object for each item.
json_build_object(
'shopping_list_item_id', sli.shopping_list_item_id,
'shopping_list_id', sli.shopping_list_id,
'master_item_id', sli.master_item_id,
'custom_item_name', sli.custom_item_name,
'quantity', sli.quantity,
'is_purchased', sli.is_purchased,
'added_at', sli.added_at,
'master_item', json_build_object('name', mgi.name)
)
) FILTER (WHERE sli.shopping_list_item_id IS NOT NULL), '[]'::json) as items
FROM public.shopping_lists sl
LEFT JOIN public.shopping_list_items sli ON sl.shopping_list_id = sli.shopping_list_id
LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.master_grocery_item_id
WHERE sl.user_id = $1
-- 3. Group by the main record to create one row per shopping list.
GROUP BY sl.shopping_list_id
ORDER BY sl.created_at ASC;
`;
const res = await getPool().query<ShoppingList>(query, [userId]);
return res.rows;
const res = await this.db.query<ShoppingList>(
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id, user_id, name, created_at',
[userId, name]
);
return { ...res.rows[0], items: [] };
} catch (error) {
logger.error('Database error in getShoppingLists:', { error, userId });
throw new Error('Failed to retrieve shopping lists.');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createShoppingList:', { error });
throw new Error('Failed to create shopping list.');
}
}
/**
* Creates a new shopping list for a user.
* @param userId The ID of the user creating the list.
* @param name The name of the new shopping list.
* @returns A promise that resolves to the newly created ShoppingList object.
*/
export async function createShoppingList(userId: string, name: string): Promise<ShoppingList> {
try {
const res = await getPool().query<ShoppingList>(
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id, user_id, name, created_at',
[userId, name]
);
// Return a complete ShoppingList object with an empty items array
return { ...res.rows[0], items: [] };
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createShoppingList:', { error });
throw new Error('Failed to create shopping list.');
}
}
/**
* Deletes a shopping list owned by a specific user.
* @param listId The ID of the shopping list to delete.
* @param userId The ID of the user who owns the list, for an ownership check.
*/
export async function deleteShoppingList(listId: number, userId: string): Promise<void> {
try {
const res = await getPool().query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
if (res.rowCount === 0) {
throw new Error('Shopping list not found or user does not have permission to delete.');
}
} catch (error) {
// If it's our custom error, re-throw it. Otherwise, wrap it.
if (error instanceof Error && error.message.startsWith('Shopping list not found')) throw error;
logger.error('Database error in deleteShoppingList:', { error });
throw new Error('Failed to delete shopping list.');
}
}
/**
* Adds a new item to a shopping list.
* @param listId The ID of the shopping list to add the item to.
* @param item An object containing either a `masterItemId` or a `customItemName`.
* @returns A promise that resolves to the newly created ShoppingListItem object.
*/
export async function addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
if (!item.masterItemId && !item.customItemName) {
throw new Error('Either masterItemId or customItemName must be provided.');
}
try {
const res = await getPool().query<ShoppingListItem>(
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
[listId, item.masterItemId ?? null, item.customItemName ?? null]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified shopping list or master item does not exist.');
}
logger.error('Database error in addShoppingListItem:', { error });
throw new Error('Failed to add item to shopping list.');
}
}
/**
* Removes an item from a shopping list.
* @param itemId The ID of the shopping list item to remove.
*/
export async function removeShoppingListItem(itemId: number): Promise<void> {
let res;
try {
res = await getPool().query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
} catch (error) {
logger.error('Database error in removeShoppingListItem:', { error });
throw new Error('Failed to remove item from shopping list.');
}
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
}
/**
* Calls a database function to generate a shopping list from a menu plan.
* @param menuPlanId The ID of the menu plan.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of items for the shopping list.
*/
export async function generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await getPool().query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
throw new Error('Failed to generate shopping list for menu plan.');
}
}
/**
* Calls a database function to add items from a menu plan to a shopping list.
* @param menuPlanId The ID of the menu plan.
* @param shoppingListId The ID of the shopping list to add items to.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the items that were added.
*/
export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await getPool().query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
throw new Error('Failed to add menu plan to shopping list.');
}
}
/**
* Retrieves all pantry locations defined by a user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of PantryLocation objects.
*/
export async function getPantryLocations(userId: string): Promise<PantryLocation[]> {
/**
* Deletes a shopping list owned by a specific user.
* @param listId The ID of the shopping list to delete.
* @param userId The ID of the user who owns the list, for an ownership check.
*/
async deleteShoppingList(listId: number, userId: string): Promise<void> {
try {
const res = await getPool().query<PantryLocation>('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]);
return res.rows;
const res = await this.db.query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
if (res.rowCount === 0) {
throw new Error('Shopping list not found or user does not have permission to delete.');
}
} catch (error) {
logger.error('Database error in getPantryLocations:', { error, userId });
throw new Error('Failed to get pantry locations.');
logger.error('Database error in deleteShoppingList:', { error, listId, userId });
throw new Error('Failed to delete shopping list.');
}
}
/**
* Creates a new pantry location for a user.
* @param userId The ID of the user.
* @param name The name of the new location (e.g., "Fridge").
* @returns A promise that resolves to the newly created PantryLocation object.
*/
export async function createPantryLocation(userId: string, name: string): Promise<PantryLocation> {
try {
const res = await getPool().query<PantryLocation>(
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
[userId, name]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23505') {
throw new UniqueConstraintError('A pantry location with this name already exists.');
}
logger.error('Database error in createPantryLocation:', { error });
throw new Error('Failed to create pantry location.');
}
}
/**
* Updates an existing item in a shopping list.
* @param itemId The ID of the shopping list item to update.
* @param updates A partial object of the fields to update (e.g., quantity, is_purchased).
* @returns A promise that resolves to the updated ShoppingListItem object.
*/
export async function updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> {
try {
// Build the update query dynamically to handle various fields
const setClauses = [];
const values = [];
let valueIndex = 1;
if ('quantity' in updates) {
setClauses.push(`quantity = $${valueIndex++}`);
values.push(updates.quantity);
}
if ('is_purchased' in updates) {
setClauses.push(`is_purchased = $${valueIndex++}`);
values.push(updates.is_purchased);
}
if ('notes' in updates) {
setClauses.push(`notes = $${valueIndex++}`);
values.push(updates.notes);
}
if (setClauses.length === 0) {
throw new Error("No valid fields to update.");
}
values.push(itemId);
const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE shopping_list_item_id = $${valueIndex} RETURNING *`;
const res = await getPool().query<ShoppingListItem>(query, values);
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateShoppingListItem:', { error });
// If it's our custom error, re-throw it. Otherwise, wrap it.
if (error instanceof Error && error.message.startsWith('Shopping list item not found')) throw error;
throw new Error('Failed to update shopping list item.');
}
}
/**
* Archives a shopping list into a historical shopping trip.
* @param shoppingListId The ID of the shopping list to complete.
* @param userId The ID of the user owning the list.
* @param totalSpentCents Optional total amount spent on the trip.
* @returns A promise that resolves to the ID of the newly created shopping trip.
*/
export async function completeShoppingList(shoppingListId: number, userId: string, totalSpentCents?: number): Promise<number> {
try {
const res = await getPool().query<{ complete_shopping_list: number }>(
'SELECT public.complete_shopping_list($1, $2, $3)',
[shoppingListId, userId, totalSpentCents]
);
return res.rows[0].complete_shopping_list;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified shopping list does not exist.');
}
logger.error('Database error in completeShoppingList:', { error });
throw new Error('Failed to complete shopping list.');
/**
* Adds a new item to a shopping list.
* @param listId The ID of the shopping list to add the item to.
* @param item An object containing either a `masterItemId` or a `customItemName`.
* @returns A promise that resolves to the newly created ShoppingListItem object.
*/
async addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
if (!item.masterItemId && !item.customItemName) {
throw new Error('Either masterItemId or customItemName must be provided.');
}
}
/**
* Retrieves the historical shopping trips for a user, including all purchased items.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of ShoppingTrip objects.
*/
export async function getShoppingTripHistory(userId: string): Promise<ShoppingTrip[]> {
try {
// This query uses a LEFT JOIN to bring trip items into the main query,
// then GROUPs by the shopping trip and uses json_agg to roll up the items.
// This is more performant than a correlated subquery for each trip.
const query = `
SELECT
st.shopping_trip_id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents,
COALESCE(
json_agg(
json_build_object(
'shopping_trip_item_id', sti.shopping_trip_item_id,
'master_item_id', sti.master_item_id,
'custom_item_name', sti.custom_item_name,
'quantity', sti.quantity,
'price_paid_cents', sti.price_paid_cents,
'master_item_name', mgi.name
) ORDER BY mgi.name ASC, sti.custom_item_name ASC -- Order items within the JSON array
) FILTER (WHERE sti.shopping_trip_item_id IS NOT NULL),
'[]'::json
) as items
FROM public.shopping_trips st
LEFT JOIN public.shopping_trip_items sti ON st.shopping_trip_id = sti.shopping_trip_id
LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.master_grocery_item_id
WHERE st.user_id = $1
GROUP BY st.shopping_trip_id
ORDER BY st.completed_at DESC;
`;
const res = await getPool().query<ShoppingTrip>(query, [userId]);
return res.rows;
const res = await this.db.query<ShoppingListItem>(
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
[listId, item.masterItemId ?? null, item.customItemName ?? null]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in getShoppingTripHistory:', { error, userId });
throw new Error('Failed to retrieve shopping trip history.');
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified shopping list or master item does not exist.');
}
logger.error('Database error in addShoppingListItem:', { error });
throw new Error('Failed to add item to shopping list.');
}
}
}
/**
* Creates a new receipt record in the database.
* @param userId The ID of the user uploading the receipt.
* @param receiptImageUrl The URL where the receipt image is stored.
* @returns A promise that resolves to the newly created Receipt object.
*/
export async function createReceipt(userId: string, receiptImageUrl: string): Promise<Receipt> {
/**
* Removes an item from a shopping list.
* @param itemId The ID of the shopping list item to remove.
*/
async removeShoppingListItem(itemId: number): Promise<void> {
try {
const res = await getPool().query<Receipt>(
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
VALUES ($1, $2, 'pending')
RETURNING *`,
[userId, receiptImageUrl]
);
return res.rows[0];
const res = await this.db.query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createReceipt:', { error, userId });
throw new Error('Failed to create receipt record.');
if (error instanceof Error && error.message.startsWith('Shopping list item not found')) throw error;
logger.error('Database error in removeShoppingListItem:', { error, itemId });
throw new Error('Failed to remove item from shopping list.');
}
}
}
/**
* Calls a database function to generate a shopping list from a menu plan.
* @param menuPlanId The ID of the menu plan.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of items for the shopping list.
*/
async generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
throw new Error('Failed to generate shopping list for menu plan.');
}
}
/**
* Processes extracted receipt items, updates the receipt status, and saves the items.
* @param receiptId The ID of the receipt being processed.
* @param items An array of items extracted from the receipt.
* @returns A promise that resolves when the operation is complete.
*/
export async function processReceiptItems(
receiptId: number,
/**
* Calls a database function to add items from a menu plan to a shopping list.
* @param menuPlanId The ID of the menu plan.
* @param shoppingListId The ID of the shopping list to add items to.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the items that were added.
*/
async addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
throw new Error('Failed to add menu plan to shopping list.');
}
}
/**
* Retrieves all pantry locations defined by a user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of PantryLocation objects.
*/
async getPantryLocations(userId: string): Promise<PantryLocation[]> {
try {
const res = await this.db.query<PantryLocation>('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getPantryLocations:', { error, userId });
throw new Error('Failed to get pantry locations.');
}
}
/**
* Creates a new pantry location for a user.
* @param userId The ID of the user.
* @param name The name of the new location (e.g., "Fridge").
* @returns A promise that resolves to the newly created PantryLocation object.
*/
async createPantryLocation(userId: string, name: string): Promise<PantryLocation> {
try {
const res = await this.db.query<PantryLocation>(
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
[userId, name]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23505') {
throw new UniqueConstraintError('A pantry location with this name already exists.');
}
logger.error('Database error in createPantryLocation:', { error });
throw new Error('Failed to create pantry location.');
}
}
/**
* Updates an existing item in a shopping list.
* @param itemId The ID of the shopping list item to update.
* @param updates A partial object of the fields to update (e.g., quantity, is_purchased).
* @returns A promise that resolves to the updated ShoppingListItem object.
*/
async updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> {
try {
const setClauses = [];
const values = [];
let valueIndex = 1;
if ('quantity' in updates) {
setClauses.push(`quantity = $${valueIndex++}`);
values.push(updates.quantity);
}
if ('is_purchased' in updates) {
setClauses.push(`is_purchased = $${valueIndex++}`);
values.push(updates.is_purchased);
}
if ('notes' in updates) {
setClauses.push(`notes = $${valueIndex++}`);
values.push(updates.notes);
}
if (setClauses.length === 0) {
throw new Error("No valid fields to update.");
}
values.push(itemId);
const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE shopping_list_item_id = $${valueIndex} RETURNING *`;
const res = await this.db.query<ShoppingListItem>(query, values);
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
return res.rows[0];
} catch (error) {
if (error instanceof Error && error.message.startsWith('Shopping list item not found')) throw error;
logger.error('Database error in updateShoppingListItem:', { error, itemId, updates });
throw new Error('Failed to update shopping list item.');
}
}
/**
* Archives a shopping list into a historical shopping trip.
* @param shoppingListId The ID of the shopping list to complete.
* @param userId The ID of the user owning the list.
* @param totalSpentCents Optional total amount spent on the trip.
* @returns A promise that resolves to the ID of the newly created shopping trip.
*/
async completeShoppingList(shoppingListId: number, userId: string, totalSpentCents?: number): Promise<number> {
try {
const res = await this.db.query<{ complete_shopping_list: number }>(
'SELECT public.complete_shopping_list($1, $2, $3)',
[shoppingListId, userId, totalSpentCents]
);
return res.rows[0].complete_shopping_list;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified shopping list does not exist.');
}
logger.error('Database error in completeShoppingList:', { error });
throw new Error('Failed to complete shopping list.');
}
}
/**
* Retrieves the historical shopping trips for a user, including all purchased items.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of ShoppingTrip objects.
*/
async getShoppingTripHistory(userId: string): Promise<ShoppingTrip[]> {
try {
const query = `
SELECT
st.shopping_trip_id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents,
COALESCE(
json_agg(
json_build_object(
'shopping_trip_item_id', sti.shopping_trip_item_id,
'master_item_id', sti.master_item_id,
'custom_item_name', sti.custom_item_name,
'quantity', sti.quantity,
'price_paid_cents', sti.price_paid_cents,
'master_item_name', mgi.name
) ORDER BY mgi.name ASC, sti.custom_item_name ASC
) FILTER (WHERE sti.shopping_trip_item_id IS NOT NULL),
'[]'::json
) as items
FROM public.shopping_trips st
LEFT JOIN public.shopping_trip_items sti ON st.shopping_trip_id = sti.shopping_trip_id
LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.master_grocery_item_id
WHERE st.user_id = $1
GROUP BY st.shopping_trip_id
ORDER BY st.completed_at DESC;
`;
const res = await this.db.query<ShoppingTrip>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getShoppingTripHistory:', { error, userId });
throw new Error('Failed to retrieve shopping trip history.');
}
}
/**
* Creates a new receipt record in the database.
* @param userId The ID of the user uploading the receipt.
* @param receiptImageUrl The URL where the receipt image is stored.
* @returns A promise that resolves to the newly created Receipt object.
*/
async createReceipt(userId: string, receiptImageUrl: string): Promise<Receipt> {
try {
const res = await this.db.query<Receipt>(
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
VALUES ($1, $2, 'pending')
RETURNING *`,
[userId, receiptImageUrl]
);
return res.rows[0];
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createReceipt:', { error, userId });
throw new Error('Failed to create receipt record.');
}
}
/**
* Processes extracted receipt items, updates the receipt status, and saves the items.
* @param receiptId The ID of the receipt being processed.
* @param items An array of items extracted from the receipt.
* @returns A promise that resolves when the operation is complete.
*/
async processReceiptItems(
receiptId: number,
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
): Promise<void> {
): Promise<void> {
const client = await (this.db as Pool).connect();
try {
await client.query('BEGIN');
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));
// Use the transactional client for this operation
await client.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]);
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
await client.query('COMMIT');
} catch (error) {
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [receiptId]);
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
throw new Error('Failed to process and save receipt items.');
}
}
/**
* Finds better deals for items on a recently processed receipt.
* @param receiptId The ID of the receipt to check.
* @returns A promise that resolves to an array of potential deals.
*/
async findDealsForReceipt(receiptId: number): Promise<ReceiptDeal[]> {
try {
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));
await getPool().query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]);
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
const res = await this.db.query<ReceiptDeal>('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]);
return res.rows;
} catch (error) {
// On error, we should also update the receipt status to 'failed'
await getPool().query("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [receiptId]);
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
throw new Error('Failed to process and save receipt items.');
logger.error('Database error in findDealsForReceipt:', { error, receiptId });
throw new Error('Failed to find deals for receipt.');
}
}
}
/**
* Finds better deals for items on a recently processed receipt.
* @param receiptId The ID of the receipt to check.
* @returns A promise that resolves to an array of potential deals.
*/
export async function findDealsForReceipt(receiptId: number): Promise<ReceiptDeal[]> {
const res = await getPool().query<ReceiptDeal>('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]);
return res.rows;
}
/**
* Finds the owner of a specific receipt.
* @param receiptId The ID of the receipt.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
// prettier-ignore
export async function findReceiptOwner(receiptId: number): Promise<{ user_id: string } | undefined> {
try {
const res = await getPool().query<{ user_id: string }>(
'SELECT user_id FROM public.receipts WHERE receipt_id = $1',
[receiptId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findReceiptOwner:', { error, receiptId });
throw new Error('Failed to retrieve receipt owner from database.');
/**
* Finds the owner of a specific receipt.
* @param receiptId The ID of the receipt.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
async findReceiptOwner(receiptId: number): Promise<{ user_id: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string }>(
'SELECT user_id FROM public.receipts WHERE receipt_id = $1',
[receiptId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findReceiptOwner:', { error, receiptId });
throw new Error('Failed to retrieve receipt owner from database.');
}
}
}

View File

@@ -1,48 +1,34 @@
// src/services/db/user.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// FIX 2: Un-mock the module we are testing to ensure we use the real implementation.
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./user.db');
import {
findUserByEmail,
createUser,
findUserById,
findUserWithPasswordHashById,
findUserProfileById,
updateUserProfile,
updateUserPreferences,
updateUserPassword,
deleteUserById,
saveRefreshToken,
findUserByRefreshToken,
createPasswordResetToken,
getValidResetTokens,
deleteResetToken,
exportUserData,
followUser,
unfollowUser,
getUserFeed,
logSearchQuery,
deleteRefreshToken,
} from './user.db';
import { resetFailedLoginAttempts } from '../db/user.db';
import { UserRepository, exportUserData } from './user.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { UniqueConstraintError } from './errors.db';
import type { Profile } from '../../types';
// Mock other db services that are used by functions in user.db.ts
vi.mock('./shopping.db', () => ({
getShoppingLists: vi.fn(),
ShoppingRepository: class {
getShoppingLists = vi.fn();
},
}));
vi.mock('./personalization.db', () => ({
getWatchedItems: vi.fn(),
PersonalizationRepository: class {
getWatchedItems = vi.fn();
},
}));
describe('User DB Service', () => {
// Instantiate the repository with the mock pool for each test
let userRepo: UserRepository;
beforeEach(() => {
vi.clearAllMocks();
userRepo = new UserRepository(mockPoolInstance as any);
});
describe('findUserByEmail', () => {
@@ -50,16 +36,23 @@ describe('User DB Service', () => {
const mockUser = { user_id: '123', email: 'test@example.com' };
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser] });
const result = await findUserByEmail('test@example.com');
const result = await userRepo.findUserByEmail('test@example.com');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['test@example.com']);
expect(result).toEqual(mockUser);
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserByEmail('notfound@example.com');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE email = $1'), ['notfound@example.com']);
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(findUserByEmail('test@example.com')).rejects.toThrow('Failed to retrieve user from database.');
await expect(userRepo.findUserByEmail('test@example.com')).rejects.toThrow('Failed to retrieve user from database.');
});
});
@@ -67,78 +60,110 @@ describe('User DB Service', () => {
it('should execute a transaction to create a user and profile', async () => {
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
const mockProfile = { ...mockUser, role: 'user' };
// For transactional methods, we mock the client returned by `connect()`
const mockClient = {
query: vi.fn(),
release: vi.fn(),
};
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockPoolInstance.query
// Mock the sequence of queries within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user RETURNING
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockResolvedValueOnce({ rows: [mockProfile] }) // SELECT profile
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await createUser('new@example.com', 'hashedpass', { full_name: 'New User' });
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' });
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
// The implementation returns the profile, not just the user row
expect(result).toEqual(mockProfile);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
});
it('should rollback the transaction if creating the user fails', async () => {
// Arrange: Mock the user insert query to fail
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Arrange: Mock the user insert query to fail after BEGIN and set_config
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockRejectedValueOnce(new Error('User insert failed')); // INSERT fails
// Act & Assert
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('Failed to create user in database.');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
it('should rollback the transaction if fetching the final profile fails', async () => {
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
const repoWithTransaction = new UserRepository({ query: vi.fn(), release: vi.fn() } as any);
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockRejectedValueOnce(new Error('User insert failed')); // INSERT user fails
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockRejectedValueOnce(new Error('Profile fetch failed')); // SELECT profile fails
// Act & Assert
await expect(createUser('fail@example.com', 'badpass', {})).rejects.toThrow('Failed to create user in database.');
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith('ROLLBACK');
await expect(repoWithTransaction.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Failed to create user in database.');
});
it('should throw UniqueConstraintError if the email already exists', async () => {
const dbError = new Error('duplicate key value violates unique constraint');
(dbError as any).code = '23505';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(createUser('exists@example.com', 'pass', {})).rejects.toThrow('A user with this email address already exists.');
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query.mockRejectedValue(dbError);
await expect(userRepo.createUser('exists@example.com', 'pass', {})).rejects.toThrow(UniqueConstraintError);
await expect(userRepo.createUser('exists@example.com', 'pass', {})).rejects.toThrow('A user with this email address already exists.');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('findUserById', () => {
it('should query for a user by their ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
await findUserById('123');
await userRepo.findUserById('123');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']);
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserById('not-found-id');
expect(result).toBeUndefined();
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(findUserById('123')).rejects.toThrow('Failed to retrieve user by ID from database.');
await expect(userRepo.findUserById('123')).rejects.toThrow('Failed to retrieve user by ID from database.');
});
});
describe('findUserWithPasswordHashById', () => {
it('should query for a user and their password hash by ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123', password_hash: 'hash' }] });
await findUserWithPasswordHashById('123');
await userRepo.findUserWithPasswordHashById('123');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('SELECT user_id, email, password_hash'), ['123']);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(findUserWithPasswordHashById('123')).rejects.toThrow('Failed to retrieve user with sensitive data by ID from database.');
await expect(userRepo.findUserWithPasswordHashById('123')).rejects.toThrow('Failed to retrieve user with sensitive data by ID from database.');
});
});
describe('findUserProfileById', () => {
it('should query for a user profile by user ID', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
await findUserProfileById('123');
await userRepo.findUserProfileById('123');
// The actual query uses 'p.user_id' due to the join alias
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']);
});
@@ -146,7 +171,7 @@ describe('User DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(findUserProfileById('123')).rejects.toThrow('Failed to retrieve user profile from database.');
await expect(userRepo.findUserProfileById('123')).rejects.toThrow('Failed to retrieve user profile from database.');
});
});
@@ -155,89 +180,108 @@ describe('User DB Service', () => {
const mockProfile: Profile = { user_id: '123', full_name: 'Updated Name', role: 'user', points: 0 };
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
await updateUserProfile('123', { full_name: 'Updated Name' });
await userRepo.updateUserProfile('123', { full_name: 'Updated Name' });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.profiles'), expect.any(Array));
});
it('should fetch the current profile if no update fields are provided', async () => {
const mockProfile: Profile = { user_id: '123', full_name: 'Current Name', role: 'user', points: 0 };
it('should execute an UPDATE query for avatar_url', async () => {
const mockProfile: Profile = { user_id: '123', avatar_url: 'new-avatar.png', role: 'user', points: 0 };
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
const result = await updateUserProfile('123', {});
await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('avatar_url = $1'), ['new-avatar.png', '123']);
});
it('should execute an UPDATE query for address_id', async () => {
const mockProfile: Profile = { user_id: '123', address_id: 99, role: 'user', points: 0 };
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
await userRepo.updateUserProfile('123', { address_id: 99 });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('address_id = $1'), [99, '123']);
});
it('should fetch the current profile if no update fields are provided', async () => {
const mockProfile: Profile = { user_id: '123', full_name: 'Current Name', role: 'user', points: 0 };
// The implementation calls findUserProfileById, so we mock that method's underlying query.
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockProfile);
const result = await userRepo.updateUserProfile('123', { full_name: undefined });
expect(userRepo.findUserProfileById).toHaveBeenCalledWith('123');
expect(result).toEqual(mockProfile);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(updateUserProfile('123', { full_name: 'Fail' })).rejects.toThrow('Failed to update user profile in database.');
await expect(userRepo.updateUserProfile('123', { full_name: 'Fail' })).rejects.toThrow('Failed to update user profile in database.');
});
});
describe('updateUserPreferences', () => {
it('should execute an UPDATE query for user preferences', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{}] });
await updateUserPreferences('123', { darkMode: true });
await userRepo.updateUserPreferences('123', { darkMode: true });
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("SET preferences = COALESCE(preferences, '{}'::jsonb) || $1"), [{ darkMode: true }, '123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(updateUserPreferences('123', { darkMode: true })).rejects.toThrow('Failed to update user preferences in database.');
await expect(userRepo.updateUserPreferences('123', { darkMode: true })).rejects.toThrow('Failed to update user preferences in database.');
});
});
describe('updateUserPassword', () => {
it('should execute an UPDATE query for the user password', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await updateUserPassword('123', 'newhash');
await userRepo.updateUserPassword('123', 'newhash');
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET password_hash = $1 WHERE user_id = $2', ['newhash', '123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(updateUserPassword('123', 'newhash')).rejects.toThrow('Failed to update user password in database.');
await expect(userRepo.updateUserPassword('123', 'newhash')).rejects.toThrow('Failed to update user password in database.');
});
});
describe('deleteUserById', () => {
it('should execute a DELETE query for the user', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await deleteUserById('123');
await userRepo.deleteUserById('123');
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.users WHERE user_id = $1', ['123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(deleteUserById('123')).rejects.toThrow('Failed to delete user from database.');
await expect(userRepo.deleteUserById('123')).rejects.toThrow('Failed to delete user from database.');
});
});
describe('saveRefreshToken', () => {
it('should execute an UPDATE query to save the refresh token', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await saveRefreshToken('123', 'new-token');
await userRepo.saveRefreshToken('123', 'new-token');
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.users SET refresh_token = $1 WHERE user_id = $2', ['new-token', '123']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(saveRefreshToken('123', 'new-token')).rejects.toThrow('Failed to save refresh token.');
await expect(userRepo.saveRefreshToken('123', 'new-token')).rejects.toThrow('Failed to save refresh token.');
});
});
describe('findUserByRefreshToken', () => {
it('should query for a user by their refresh token', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] });
await findUserByRefreshToken('a-token');
await userRepo.findUserByRefreshToken('a-token');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE refresh_token = $1'), ['a-token']);
});
it('should return undefined if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const result = await findUserByRefreshToken('a-token');
const result = await userRepo.findUserByRefreshToken('a-token');
expect(result).toBeUndefined();
});
});
@@ -245,18 +289,26 @@ describe('User DB Service', () => {
describe('deleteRefreshToken', () => {
it('should execute an UPDATE query to set the refresh token to NULL', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await deleteRefreshToken('a-token');
await userRepo.deleteRefreshToken('a-token');
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', ['a-token']
);
});
it('should not throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
// The function is designed to swallow errors, so we expect it to resolve.
await expect(userRepo.deleteRefreshToken('a-token')).resolves.toBeUndefined();
// We can still check that the query was attempted.
expect(mockPoolInstance.query).toHaveBeenCalled();
});
});
describe('createPasswordResetToken', () => {
it('should execute DELETE and INSERT queries', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const expires = new Date();
await createPasswordResetToken('123', 'token-hash', expires);
await userRepo.createPasswordResetToken('123', 'token-hash', expires);
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE user_id = $1', ['123']);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.password_reset_tokens'), ['123', 'token-hash', expires]);
});
@@ -264,46 +316,55 @@ describe('User DB Service', () => {
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const expires = new Date();
await expect(createPasswordResetToken('123', 'token-hash', expires)).rejects.toThrow('Failed to create password reset token.');
await expect(userRepo.createPasswordResetToken('123', 'token-hash', expires)).rejects.toThrow('Failed to create password reset token.');
});
});
describe('getValidResetTokens', () => {
it('should query for tokens where expires_at > NOW()', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getValidResetTokens();
await userRepo.getValidResetTokens();
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE expires_at > NOW()'));
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(getValidResetTokens()).rejects.toThrow('Failed to retrieve valid reset tokens.');
await expect(userRepo.getValidResetTokens()).rejects.toThrow('Failed to retrieve valid reset tokens.');
});
});
describe('deleteResetToken', () => {
it('should execute a DELETE query for the token hash', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await deleteResetToken('token-hash');
await userRepo.deleteResetToken('token-hash');
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', ['token-hash']);
});
});
describe('exportUserData', () => {
it('should call profile, watched items, and shopping list functions', async () => {
// This test requires mocking the other db functions that exportUserData calls.
const { getShoppingLists } = await import('./shopping.db');
const { getWatchedItems } = await import('./personalization.db');
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockPoolInstance.query.mockResolvedValue({ rows: [{ user_id: '123' }] }); // For findUserProfileById
vi.mocked(getWatchedItems).mockResolvedValue([]);
vi.mocked(getShoppingLists).mockResolvedValue([]);
const { ShoppingRepository } = await import('./shopping.db');
const { PersonalizationRepository } = await import('./personalization.db');
// Mock the individual repo methods that will be called with the transactional client
vi.mocked(new UserRepository(mockClient as any).findUserProfileById).mockResolvedValue({ user_id: '123' } as Profile);
vi.mocked(new PersonalizationRepository().getWatchedItems).mockResolvedValue([]);
vi.mocked(new ShoppingRepository().getShoppingLists).mockResolvedValue([]);
await exportUserData('123');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.profiles'), ['123']);
expect(getWatchedItems).toHaveBeenCalledWith('123');
expect(getShoppingLists).toHaveBeenCalledWith('123');
expect(new UserRepository(mockClient as any).findUserProfileById).toHaveBeenCalledWith('123');
expect(new PersonalizationRepository().getWatchedItems).toHaveBeenCalledWith('123');
expect(new ShoppingRepository().getShoppingLists).toHaveBeenCalledWith('123');
});
it('should throw an error if the user profile is not found', async () => {
// Mock findUserProfileById to return undefined
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(exportUserData('123')).rejects.toThrow('User profile not found for data export.');
});
it('should throw an error if the database query fails', async () => {
@@ -311,82 +372,4 @@ describe('User DB Service', () => {
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
});
});
describe('followUser', () => {
it('should execute an INSERT query to create a follow relationship', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await followUser('user-1', 'user-2');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_follows'), ['user-1', 'user-2']);
});
it('should throw an error if a user tries to follow themselves', async () => {
await expect(followUser('user-1', 'user-1')).rejects.toThrow('A user cannot follow themselves.');
});
it('should throw ForeignKeyConstraintError if a user does not exist', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(followUser('user-1', 'non-existent-user')).rejects.toThrow('One or both of the specified users do not exist.');
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(followUser('user-1', 'user-2')).rejects.toThrow('Failed to follow user.');
});
});
describe('unfollowUser', () => {
it('should execute a DELETE query to remove a follow relationship', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await unfollowUser('user-1', 'user-2');
expect(mockPoolInstance.query).toHaveBeenCalledWith('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', ['user-1', 'user-2']);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(unfollowUser('user-1', 'user-2')).rejects.toThrow('Failed to unfollow user.');
});
});
describe('getUserFeed', () => {
it('should call the get_user_feed database function', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await getUserFeed('user-123', 10, 20);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_user_feed($1, $2, $3)', ['user-123', 10, 20]);
});
it('should throw a generic error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(getUserFeed('user-123', 10, 20)).rejects.toThrow('Failed to retrieve user feed.');
});
});
describe('logSearchQuery', () => {
it('should insert a search query into the log', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const queryData = { userId: 'user-123', queryText: 'apples', resultCount: 5, wasSuccessful: true };
await logSearchQuery(queryData);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.search_queries'), [queryData.userId, queryData.queryText, queryData.resultCount, queryData.wasSuccessful]);
});
it('should not throw an error if the database query fails (non-critical)', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
const queryData = { userId: 'user-123', queryText: 'apples', resultCount: 5, wasSuccessful: true };
await expect(logSearchQuery(queryData)).resolves.toBeUndefined();
});
});
describe('resetFailedLoginAttempts', () => {
it('should execute an UPDATE query to reset failed attempts and set last_login_ip', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await resetFailedLoginAttempts('user-123', '192.168.1.1');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.users SET failed_login_attempts = 0'), ['user-123', '192.168.1.1']);
});
it('should not throw an error if the database query fails (non-critical)', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Error'));
await expect(resetFailedLoginAttempts('user-123', '192.168.1.1')).resolves.toBeUndefined();
});
});
});

View File

@@ -1,10 +1,11 @@
// src/services/db/user.db.ts
import { getPool } from './connection.db';
import { logger } from '../logger';
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile } from '../../types';
import { getShoppingLists } from './shopping.db';
import { getWatchedItems } from './personalization.db';
import { ShoppingRepository } from './shopping.db';
import { PersonalizationRepository } from './personalization.db';
/**
* Defines the structure of a user object as returned from the database.
@@ -19,393 +20,359 @@ interface DbUser {
last_failed_login: string | null; // This will be a date string from the DB
}
/**
* Finds a user by their email in the public.users table.
* @param email The email of the user to find.
* @returns A promise that resolves to the user object or undefined if not found.
*/
export async function findUserByEmail(email: string): Promise<DbUser | undefined> {
logger.debug(`[DB findUserByEmail] Searching for user with email: ${email}`);
try {
const res = await getPool().query<DbUser>(
'SELECT user_id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1',
[email]
);
const userFound = res.rows[0];
logger.debug(`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserByEmail:', { error });
throw new Error('Failed to retrieve user from database.');
export class UserRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
}
/**
* Creates a new user in the public.users table.
* @param email The user's email.
* @param passwordHash The bcrypt hashed password.
* @param profileData An object containing optional full_name and avatar_url for the profile.
* @returns A promise that resolves to the newly created user object (id, email).
*/
export async function createUser(
email: string,
passwordHash: string | null,
profileData: { full_name?: string; avatar_url?: string }
): Promise<UserProfile> {
// Use a client from the pool to run multiple queries in a transaction
const client = await getPool().connect();
// Guard clause: If the pool fails to return a client (e.g., in a bad mock), throw immediately
// to avoid confusing "undefined reading release" errors later.
if (!client) {
throw new Error('Database connection failed: Pool returned no client.');
}
try {
logger.debug(`[DB createUser] Starting transaction for email: ${email}`);
// Start the transaction
await client.query('BEGIN');
// Use 'set_config' to safely pass parameters to a configuration variable.
// The third argument 'true' makes the setting local to the transaction.
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify(profileData)]);
logger.debug(`[DB createUser] Session metadata set for ${email}.`);
// Insert the new user into the 'users' table. This will fire the trigger.
const userInsertRes = await client.query<{ user_id: string }>(
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
[email, passwordHash]
);
const newUserId = userInsertRes.rows[0].user_id;
logger.debug(`[DB createUser] Inserted into users table. New user ID: ${newUserId}`);
// After the trigger has run, fetch the complete profile data.
// This is the crucial step to ensure we return the full object.
const profileQuery = `
SELECT u.user_id, u.email, p.full_name, p.avatar_url, p.role, p.points, p.preferences
FROM public.users u
JOIN public.profiles p ON u.user_id = p.user_id
WHERE u.user_id = $1;
`;
const finalProfileRes = await client.query<UserProfile>(profileQuery, [newUserId]);
const fullUserProfile = finalProfileRes.rows[0];
logger.debug(`[DB createUser] Fetched full profile for new user:`, { user: fullUserProfile });
// Commit the transaction
await client.query('COMMIT');
logger.debug(`[DB createUser] Transaction committed for ${email}.`);
return fullUserProfile;
} catch (error) {
// If any query fails, roll back the entire transaction
/**
* Finds a user by their email in the public.users table.
* @param email The email of the user to find.
* @returns A promise that resolves to the user object or undefined if not found.
*/
async findUserByEmail(email: string): Promise<DbUser | undefined> {
logger.debug(`[DB findUserByEmail] Searching for user with email: ${email}`);
try {
if (client) await client.query('ROLLBACK');
} catch (rollbackError) {
logger.error('Failed to rollback transaction:', { rollbackError });
const res = await this.db.query<DbUser>(
'SELECT user_id, email, password_hash, refresh_token, failed_login_attempts, last_failed_login FROM public.users WHERE email = $1',
[email]
);
const userFound = res.rows[0];
logger.debug(`[DB findUserByEmail] Query for ${email} result: ${userFound ? `FOUND user ID ${userFound.user_id}` : 'NOT FOUND'}`);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserByEmail:', { error });
throw new Error('Failed to retrieve user from database.');
}
}
// Check for specific PostgreSQL error codes
if (error instanceof Error && 'code' in error && error.code === '23505') { // unique_violation
logger.warn(`Attempted to create a user with an existing email: ${email}`);
throw new UniqueConstraintError('A user with this email address already exists.');
}
logger.error('Database transaction error in createUser:', { error });
throw new Error('Failed to create user in database.');
} finally {
// Release the client back to the pool
if (client) {
/**
* Creates a new user in the public.users table.
* This method expects to be run within a transaction, so it requires a PoolClient.
* @param email The user's email.
* @param passwordHash The bcrypt hashed password.
* @param profileData An object containing optional full_name and avatar_url for the profile.
* @returns A promise that resolves to the newly created user object (id, email).
*/
async createUser(
email: string,
passwordHash: string | null,
profileData: { full_name?: string; avatar_url?: string }
): Promise<UserProfile> {
// This method now manages its own transaction to ensure atomicity.
const client = await (this.db as Pool).connect();
try {
logger.debug(`[DB createUser] Starting transaction for email: ${email}`);
await client.query('BEGIN');
// Use 'set_config' to safely pass parameters to a configuration variable.
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [JSON.stringify(profileData)]);
logger.debug(`[DB createUser] Session metadata set for ${email}.`);
// Insert the new user into the 'users' table. This will fire the trigger.
const userInsertRes = await client.query<{ user_id: string }>(
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
[email, passwordHash]
);
const newUserId = userInsertRes.rows[0].user_id;
logger.debug(`[DB createUser] Inserted into users table. New user ID: ${newUserId}`);
// After the trigger has run, fetch the complete profile data.
const profileQuery = `
SELECT u.user_id, u.email, p.full_name, p.avatar_url, p.role, p.points, p.preferences
FROM public.users u
JOIN public.profiles p ON u.user_id = p.user_id
WHERE u.user_id = $1;
`;
const finalProfileRes = await client.query<UserProfile>(profileQuery, [newUserId]);
const fullUserProfile = finalProfileRes.rows[0];
logger.debug(`[DB createUser] Fetched full profile for new user:`, { user: fullUserProfile });
await client.query('COMMIT');
return fullUserProfile;
} catch (error) {
await client.query('ROLLBACK');
// Check for specific PostgreSQL error codes
if (error instanceof Error && 'code' in error && error.code === '23505') { // unique_violation
logger.warn(`Attempted to create a user with an existing email: ${email}`);
throw new UniqueConstraintError('A user with this email address already exists.');
}
logger.error('Database transaction error in createUser:', { error });
throw new Error('Failed to create user in database.');
} finally {
client.release();
}
}
}
/**
* Finds a user by their ID. Used by the JWT strategy to validate tokens.
* @param id The UUID of the user to find.
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
export async function findUserById(userId: string): Promise<{ user_id: string; email: string } | undefined> {
try {
const res = await getPool().query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE user_id = $1',
[userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserById:', { error });
throw new Error('Failed to retrieve user by ID from database.');
}
}
/**
* Finds a user by their ID, including their password hash.
* This should only be used in contexts where password verification is required, like account deletion.
* @param id The UUID of the user to find.
* @returns A promise that resolves to the user object (id, email, password_hash) or undefined if not found.
*/
// prettier-ignore
export async function findUserWithPasswordHashById(userId: string): Promise<{ user_id: string; email: string; password_hash: string | null } | undefined> {
try {
const res = await getPool().query<{ user_id: string; email: string; password_hash: string | null }>(
'SELECT user_id, email, password_hash FROM public.users WHERE user_id = $1',
[userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserWithPasswordHashById:', { error });
throw new Error('Failed to retrieve user with sensitive data by ID from database.');
}
}
/**
* Finds a user's profile by their user ID.
* @param id The UUID of the user.
* @returns A promise that resolves to the user's profile object or undefined if not found.
*/
// prettier-ignore
export async function findUserProfileById(userId: string): Promise<Profile | undefined> {
try {
// This query assumes your 'profiles' table has a foreign key 'id' referencing 'users.user_id'
// It now joins with the addresses table to fetch the user's address as a nested object.
const res = await getPool().query<Profile>(
`SELECT
p.user_id, p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points,
CASE
WHEN a.address_id IS NOT NULL THEN json_build_object(
'address_id', a.address_id,
'address_line_1', a.address_line_1,
'address_line_2', a.address_line_2,
'city', a.city,
'province_state', a.province_state,
'postal_code', a.postal_code,
'country', a.country
)
ELSE NULL
END as address
FROM public.profiles p
LEFT JOIN public.addresses a ON p.address_id = a.address_id
WHERE p.user_id = $1`,
[userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserProfileById:', { error });
throw new Error('Failed to retrieve user profile from database.');
}
}
/**
* Updates the profile for a given user.
* @param id The UUID of the user.
* @param profileData The profile data to update (e.g., full_name, avatar_url).
* @returns A promise that resolves to the updated profile object.
*/
// prettier-ignore
export async function updateUserProfile(userId: string, profileData: Partial<Pick<Profile, 'full_name' | 'avatar_url' | 'address_id'>>): Promise<Profile> {
try {
const { full_name, avatar_url, address_id } = profileData;
const fieldsToUpdate = [];
const values = [];
let paramIndex = 1;
if (full_name !== undefined) { fieldsToUpdate.push(`full_name = $${paramIndex++}`); values.push(full_name); }
if (avatar_url !== undefined) { fieldsToUpdate.push(`avatar_url = $${paramIndex++}`); values.push(avatar_url); }
if (address_id !== undefined) { fieldsToUpdate.push(`address_id = $${paramIndex++}`); values.push(address_id); }
if (fieldsToUpdate.length === 0) {
// If no fields are being updated, just fetch and return the current profile.
return findUserProfileById(userId) as Promise<Profile>;
/**
* Finds a user by their ID. Used by the JWT strategy to validate tokens.
* @param id The UUID of the user to find.
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
async findUserById(userId: string): Promise<{ user_id: string; email: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE user_id = $1',
[userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserById:', { error });
throw new Error('Failed to retrieve user by ID from database.');
}
values.push(userId);
const query = `
UPDATE public.profiles
SET ${fieldsToUpdate.join(', ')}, updated_at = now()
WHERE user_id = $${paramIndex}
RETURNING *;
`;
const res = await getPool().query<Profile>(
query, values
);
return res.rows[0];
} catch (error) {
logger.error('Database error in updateUserProfile:', { error });
throw new Error('Failed to update user profile in database.');
}
}
/**
* Updates the preferences for a given user.
* The `pg` driver automatically handles serializing the JS object to JSONB.
* @param id The UUID of the user.
* @param preferences The preferences object to save.
* @returns A promise that resolves to the updated profile object.
*/
// prettier-ignore
export async function updateUserPreferences(userId: string, preferences: Profile['preferences']): Promise<Profile> {
try {
const res = await getPool().query<Profile>(
`UPDATE public.profiles
-- The '||' operator correctly merges the new preferences JSONB object
-- with the existing one, overwriting keys in the original with new values.
SET preferences = COALESCE(preferences, '{}'::jsonb) || $1, updated_at = now()
WHERE user_id = $2
RETURNING user_id, full_name, avatar_url, preferences, role`,
[preferences, userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in updateUserPreferences:', { error });
throw new Error('Failed to update user preferences in database.');
/**
* Finds a user by their ID, including their password hash.
* This should only be used in contexts where password verification is required, like account deletion.
* @param id The UUID of the user to find.
* @returns A promise that resolves to the user object (id, email, password_hash) or undefined if not found.
*/
// prettier-ignore
async findUserWithPasswordHashById(userId: string): Promise<{ user_id: string; email: string; password_hash: string | null } | undefined> {
try {
const res = await this.db.query<{ user_id: string; email: string; password_hash: string | null }>(
'SELECT user_id, email, password_hash FROM public.users WHERE user_id = $1',
[userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserWithPasswordHashById:', { error });
throw new Error('Failed to retrieve user with sensitive data by ID from database.');
}
}
}
/**
* Updates the password hash for a given user.
* @param id The UUID of the user.
* @param passwordHash The new bcrypt hashed password.
*/
// prettier-ignore
export async function updateUserPassword(userId: string, passwordHash: string): Promise<void> {
try {
await getPool().query(
'UPDATE public.users SET password_hash = $1 WHERE user_id = $2',
[passwordHash, userId]
);
} catch (error) {
logger.error('Database error in updateUserPassword:', { error });
throw new Error('Failed to update user password in database.');
/**
* Finds a user's profile by their user ID.
* @param id The UUID of the user.
* @returns A promise that resolves to the user's profile object or undefined if not found.
*/
// prettier-ignore
async findUserProfileById(userId: string): Promise<Profile | undefined> {
try {
const res = await this.db.query<Profile>(
`SELECT
p.user_id, p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points,
CASE
WHEN a.address_id IS NOT NULL THEN json_build_object(
'address_id', a.address_id,
'address_line_1', a.address_line_1,
'address_line_2', a.address_line_2,
'city', a.city,
'province_state', a.province_state,
'postal_code', a.postal_code,
'country', a.country
)
ELSE NULL
END as address
FROM public.profiles p
LEFT JOIN public.addresses a ON p.address_id = a.address_id
WHERE p.user_id = $1`,
[userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserProfileById:', { error });
throw new Error('Failed to retrieve user profile from database.');
}
}
}
/**
* Deletes a user from the database by their ID.
* @param id The UUID of the user to delete.
*/
// prettier-ignore
export async function deleteUserById(userId: string): Promise<void> {
try {
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]);
} catch (error) {
logger.error('Database error in deleteUserById:', { error });
throw new Error('Failed to delete user from database.');
/**
* Updates the profile for a given user.
* @param id The UUID of the user.
* @param profileData The profile data to update (e.g., full_name, avatar_url).
* @returns A promise that resolves to the updated profile object.
*/
// prettier-ignore
async updateUserProfile(userId: string, profileData: Partial<Pick<Profile, 'full_name' | 'avatar_url' | 'address_id'>>): Promise<Profile> {
try {
const { full_name, avatar_url, address_id } = profileData;
const fieldsToUpdate = [];
const values = [];
let paramIndex = 1;
if (full_name !== undefined) { fieldsToUpdate.push(`full_name = $${paramIndex++}`); values.push(full_name); }
if (avatar_url !== undefined) { fieldsToUpdate.push(`avatar_url = $${paramIndex++}`); values.push(avatar_url); }
if (address_id !== undefined) { fieldsToUpdate.push(`address_id = $${paramIndex++}`); values.push(address_id); }
if (fieldsToUpdate.length === 0) {
return this.findUserProfileById(userId) as Promise<Profile>;
}
values.push(userId);
const query = `
UPDATE public.profiles
SET ${fieldsToUpdate.join(', ')}, updated_at = now()
WHERE user_id = $${paramIndex}
RETURNING *;
`;
const res = await this.db.query<Profile>(
query, values
);
return res.rows[0];
} catch (error) {
logger.error('Database error in updateUserProfile:', { error });
throw new Error('Failed to update user profile in database.');
}
}
}
/**
* Saves or updates a refresh token for a user.
* @param userId The UUID of the user.
* @param refreshToken The new refresh token to save.
*/
// prettier-ignore
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
try {
// For simplicity, we store one token per user. For multi-device support, a separate table is better.
await getPool().query(
'UPDATE public.users SET refresh_token = $1 WHERE user_id = $2',
[refreshToken, userId]
);
} catch (error) {
logger.error('Database error in saveRefreshToken:', { error });
throw new Error('Failed to save refresh token.');
/**
* Updates the preferences for a given user.
* @param id The UUID of the user.
* @param preferences The preferences object to save.
* @returns A promise that resolves to the updated profile object.
*/
// prettier-ignore
async updateUserPreferences(userId: string, preferences: Profile['preferences']): Promise<Profile> {
try {
const res = await this.db.query<Profile>(
`UPDATE public.profiles
SET preferences = COALESCE(preferences, '{}'::jsonb) || $1, updated_at = now()
WHERE user_id = $2
RETURNING user_id, full_name, avatar_url, preferences, role`,
[preferences, userId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in updateUserPreferences:', { error });
throw new Error('Failed to update user preferences in database.');
}
}
}
/**
* Finds a user by their refresh token.
* @param refreshToken The refresh token to look up.
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
export async function findUserByRefreshToken(refreshToken: string): Promise<{ user_id: string; email: string } | undefined> {
try {
const res = await getPool().query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
[refreshToken]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserByRefreshToken:', { error });
return undefined; // Return undefined on error to prevent token leakage
/**
* Updates the password hash for a given user.
* @param id The UUID of the user.
* @param passwordHash The new bcrypt hashed password.
*/
// prettier-ignore
async updateUserPassword(userId: string, passwordHash: string): Promise<void> {
try {
await this.db.query(
'UPDATE public.users SET password_hash = $1 WHERE user_id = $2',
[passwordHash, userId]
);
} catch (error) {
logger.error('Database error in updateUserPassword:', { error });
throw new Error('Failed to update user password in database.');
}
}
}
/**
* Deletes a refresh token from the database by setting it to NULL.
* This is used to invalidate a user's session during logout.
* @param refreshToken The refresh token to delete.
*/
export async function deleteRefreshToken(refreshToken: string): Promise<void> {
try {
// Set the refresh_token to NULL for the user who has this token.
await getPool().query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [refreshToken]);
} catch (error) {
logger.error('Database error in deleteRefreshToken:', { error });
// Do not re-throw, as failing to invalidate a token is not a critical user-facing error.
/**
* Deletes a user from the database by their ID.
* @param id The UUID of the user to delete.
*/
// prettier-ignore
async deleteUserById(userId: string): Promise<void> {
try {
await this.db.query('DELETE FROM public.users WHERE user_id = $1', [userId]);
} catch (error) {
logger.error('Database error in deleteUserById:', { error });
throw new Error('Failed to delete user from database.');
}
}
}
/**
* Creates a password reset token for a user.
* @param userId The UUID of the user.
* @param tokenHash The hashed version of the reset token.
* @param expiresAt The timestamp when the token expires.
*/
// prettier-ignore
export async function createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
const client = await getPool().connect();
try {
await client.query('BEGIN');
// First, delete any existing tokens for this user to ensure only one is active.
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
// Then, insert the new token.
await client.query(
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[userId, tokenHash, expiresAt]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
logger.error('Database error in createPasswordResetToken:', { error });
throw new Error('Failed to create password reset token.');
} finally {
client.release();
/**
* Saves or updates a refresh token for a user.
* @param userId The UUID of the user.
* @param refreshToken The new refresh token to save.
*/
// prettier-ignore
async saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
try {
await this.db.query(
'UPDATE public.users SET refresh_token = $1 WHERE user_id = $2',
[refreshToken, userId]
);
} catch (error) {
logger.error('Database error in saveRefreshToken:', { error });
throw new Error('Failed to save refresh token.');
}
}
}
/**
* Finds a user and token details by the token hash.
* It only returns a result if the token has not expired.
* @returns A promise that resolves to an array of valid token records.
*/
// prettier-ignore
export async function getValidResetTokens(): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> {
try {
const res = await getPool().query<{ user_id: string; token_hash: string; expires_at: Date }>(
'SELECT user_id, token_hash, expires_at FROM public.password_reset_tokens WHERE expires_at > NOW()'
);
return res.rows;
} catch (error) {
logger.error('Database error in getValidResetTokens:', { error });
throw new Error('Failed to retrieve valid reset tokens.');
/**
* Finds a user by their refresh token.
* @param refreshToken The refresh token to look up.
* @returns A promise that resolves to the user object (id, email) or undefined if not found.
*/
// prettier-ignore
async findUserByRefreshToken(refreshToken: string): Promise<{ user_id: string; email: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
[refreshToken]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserByRefreshToken:', { error });
return undefined; // Return undefined on error to prevent token leakage
}
}
}
/**
* Deletes a password reset token by its hash.
* This is used after a token has been successfully used to reset a password.
* @param tokenHash The hashed token to delete.
*/
// prettier-ignore
export async function deleteResetToken(tokenHash: string): Promise<void> {
try {
await getPool().query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
} catch (error) {
logger.error('Database error in deleteResetToken:', { error });
// We don't throw here, as failing to delete an expired token is not a critical failure for the user flow.
/**
* Deletes a refresh token from the database by setting it to NULL.
* @param refreshToken The refresh token to delete.
*/
async deleteRefreshToken(refreshToken: string): Promise<void> {
try {
await this.db.query('UPDATE public.users SET refresh_token = NULL WHERE refresh_token = $1', [refreshToken]);
} catch (error) {
logger.error('Database error in deleteRefreshToken:', { error });
}
}
/**
* Creates a password reset token for a user.
* @param userId The UUID of the user.
* @param tokenHash The hashed version of the reset token.
* @param expiresAt The timestamp when the token expires.
*/
// prettier-ignore
async createPasswordResetToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
const client = this.db as PoolClient;
try {
await client.query('DELETE FROM public.password_reset_tokens WHERE user_id = $1', [userId]);
await client.query(
'INSERT INTO public.password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[userId, tokenHash, expiresAt]
);
} catch (error) {
logger.error('Database error in createPasswordResetToken:', { error });
throw new Error('Failed to create password reset token.');
}
}
/**
* Finds a user and token details by the token hash.
* @returns A promise that resolves to an array of valid token records.
*/
// prettier-ignore
async getValidResetTokens(): Promise<{ user_id: string; token_hash: string; expires_at: Date }[]> {
try {
const res = await this.db.query<{ user_id: string; token_hash: string; expires_at: Date }>(
'SELECT user_id, token_hash, expires_at FROM public.password_reset_tokens WHERE expires_at > NOW()'
);
return res.rows;
} catch (error) {
logger.error('Database error in getValidResetTokens:', { error });
throw new Error('Failed to retrieve valid reset tokens.');
}
}
/**
* Deletes a password reset token by its hash.
* @param tokenHash The hashed token to delete.
*/
// prettier-ignore
async deleteResetToken(tokenHash: string): Promise<void> {
try {
await this.db.query('DELETE FROM public.password_reset_tokens WHERE token_hash = $1', [tokenHash]);
} catch (error) {
logger.error('Database error in deleteResetToken:', { error });
}
}
}
@@ -421,10 +388,14 @@ export async function exportUserData(userId: string): Promise<{ profile: Profile
throw new Error('Database connection failed: Pool returned no client.');
}
try {
const userRepo = new UserRepository(client);
const shoppingRepo = new ShoppingRepository(client); // Pass client for transaction
const personalizationRepo = new PersonalizationRepository(client);
// Run queries in parallel for efficiency
const profileQuery = findUserProfileById(userId);
const watchedItemsQuery = getWatchedItems(userId);
const shoppingListsQuery = getShoppingLists(userId);
const profileQuery = userRepo.findUserProfileById(userId);
// Use the repository instance to call the method
const watchedItemsQuery = personalizationRepo.getWatchedItems(userId);
const shoppingListsQuery = shoppingRepo.getShoppingLists(userId);
const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]);
@@ -439,90 +410,4 @@ export async function exportUserData(userId: string): Promise<{ profile: Profile
} finally {
if (client) client.release();
}
}
/**
* Creates a following relationship between two users.
* @param followerId The ID of the user who is following.
* @param followingId The ID of the user being followed.
*/
export async function followUser(followerId: string, followingId: string): Promise<void> {
if (followerId === followingId) {
throw new Error('A user cannot follow themselves.');
}
try {
await getPool().query(
'INSERT INTO public.user_follows (follower_id, following_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[followerId, followingId]
);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('One or both of the specified users do not exist.');
}
logger.error('Database error in followUser:', { error });
throw new Error('Failed to follow user.');
}
}
/**
* Removes a following relationship between two users.
* @param followerId The ID of the user who is unfollowing.
* @param followingId The ID of the user being unfollowed.
*/
export async function unfollowUser(followerId: string, followingId: string): Promise<void> {
try {
await getPool().query('DELETE FROM public.user_follows WHERE follower_id = $1 AND following_id = $2', [followerId, followingId]);
} catch (error) {
logger.error('Database error in unfollowUser:', { error });
throw new Error('Failed to unfollow user.');
}
}
/**
* Retrieves a personalized activity feed for a user based on who they follow.
* @param userId The ID of the user.
* @param limit The number of feed items to retrieve.
* @param offset The number of feed items to skip for pagination.
* @returns A promise that resolves to an array of ActivityLogItem objects.
*/
export async function getUserFeed(userId: string, limit: number, offset: number): Promise<ActivityLogItem[]> {
try {
const res = await getPool().query<ActivityLogItem>('SELECT * FROM public.get_user_feed($1, $2, $3)', [userId, limit, offset]);
return res.rows;
} catch (error) {
logger.error('Database error in getUserFeed:', { error, userId });
throw new Error('Failed to retrieve user feed.');
}
}
/**
* Logs a user's search query for analytics purposes.
* @param query An object containing the search query details.
*/
export async function logSearchQuery(query: { userId?: string, queryText: string, resultCount: number, wasSuccessful: boolean }): Promise<void> {
try {
await getPool().query(
'INSERT INTO public.search_queries (user_id, query_text, result_count, was_successful) VALUES ($1, $2, $3, $4)',
[query.userId, query.queryText, query.resultCount, query.wasSuccessful]
);
} catch (error) {
logger.error('Database error in logSearchQuery:', { error });
// Also a non-critical operation.
}
}
/**
* Resets the failed login attempt counter for a user upon successful login.
* @param userId The ID of the user.
*/
// prettier-ignore
export async function resetFailedLoginAttempts(userId: string, loginIp: string): Promise<void> {
try {
await getPool().query(
`UPDATE public.users SET failed_login_attempts = 0, last_failed_login = NULL, last_login_ip = $2 WHERE user_id = $1`,
[userId, loginIp]
);
} catch (error) {
logger.error('Database error in resetFailedLoginAttempts:', { error, userId });
}
}

View File

@@ -0,0 +1,53 @@
// src/services/flyerDataTransformer.ts
import path from 'path';
import type { z } from 'zod';
import type { FlyerInsert, FlyerItemInsert } from '../types';
import type { AiFlyerDataSchema } from './flyerProcessingService.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
/**
* This class is responsible for transforming the validated data from the AI service
* into the structured format required for database insertion (FlyerInsert and FlyerItemInsert).
*/
export class FlyerDataTransformer {
/**
* Transforms AI-extracted data into database-ready flyer and item records.
* @param extractedData The validated data from the AI.
* @param imagePaths The paths to the flyer images.
* @param originalFileName The original name of the uploaded file.
* @param checksum The checksum of the file.
* @param userId The ID of the user who uploaded the file, if any.
* @returns A promise that resolves to an object containing the prepared flyer and item data.
*/
async transform(
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
originalFileName: string,
checksum: string,
userId?: string
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
const firstImage = imagePaths[0].path;
const iconFileName = await generateFlyerIcon(firstImage, path.join(path.dirname(firstImage), 'icons'));
const flyerData: FlyerInsert = {
file_name: originalFileName,
image_url: `/flyer-images/${path.basename(firstImage)}`,
icon_url: `/flyer-images/icons/${iconFileName}`,
checksum,
store_name: extractedData.store_name || 'Unknown Store (auto)',
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address,
item_count: 0, // This will be updated by a database trigger after items are inserted.
uploaded_by: userId,
};
const itemsForDb: FlyerItemInsert[] = extractedData.items.map(item => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id, // Convert null to undefined
view_count: 0, click_count: 0, updated_at: new Date().toISOString()
}));
return { flyerData, itemsForDb };
}
}

View File

@@ -26,6 +26,7 @@ import { FlyerProcessingService, type FlyerJobData } from './flyerProcessingServ
import * as aiService from './aiService.server';
import * as db from './db/index.db';
import * as imageProcessor from '../utils/imageProcessor';
import { FlyerDataTransformer } from './flyerDataTransformer';
// Mock dependencies
vi.mock('./aiService.server');
@@ -33,7 +34,8 @@ vi.mock('./db/index.db');
vi.mock('../utils/imageProcessor');
vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }
}));
}));
vi.mock('./flyerDataTransformer');
const mockedAiService = aiService as Mocked<typeof aiService>;
const mockedDb = db as Mocked<typeof db>;
@@ -66,7 +68,8 @@ describe('FlyerProcessingService', () => {
mockedDb,
mockFs,
mocks.execAsync,
mockCleanupQueue
mockCleanupQueue,
new FlyerDataTransformer()
);
// Provide default successful mock implementations for dependencies
@@ -82,7 +85,7 @@ describe('FlyerProcessingService', () => {
items: [],
});
mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
mockedDb.getAllMasterItems.mockResolvedValue([]);
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
mockedDb.logActivity.mockResolvedValue();
});
@@ -182,128 +185,4 @@ describe('FlyerProcessingService', () => {
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
});
describe('prepareImageInputs (Step 1)', () => {
it('should return the original path for an image file', async () => {
const job = createMockJob({});
const result = await service.prepareImageInputs('/tmp/flyer.jpg', job);
expect(result.imagePaths).toEqual([{ path: '/tmp/flyer.jpg', mimetype: 'image/jpg' }]);
expect(result.createdImagePaths).toEqual([]);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it('should convert a PDF and return paths to generated images', async () => {
const job = createMockJob({});
mocks.readdir.mockResolvedValue([
{ name: 'flyer-1.jpg' }, { name: 'flyer-2.jpg' }
] as Dirent[]);
const result = await service.prepareImageInputs('/tmp/flyer.pdf', job);
expect(mocks.execAsync).toHaveBeenCalledWith('pdftocairo -jpeg -r 150 "/tmp/flyer.pdf" "/tmp/flyer"');
expect(result.imagePaths).toHaveLength(2);
expect(result.imagePaths[0].path).toContain('flyer-1.jpg');
expect(result.createdImagePaths).toHaveLength(2);
expect(result.createdImagePaths[0]).toContain('flyer-1.jpg');
});
it('should throw an error if PDF conversion command fails', async () => {
const job = createMockJob({});
const execError = new Error('pdftocairo not found');
mocks.execAsync.mockRejectedValue(execError);
await expect(service.prepareImageInputs('/tmp/flyer.pdf', job)).rejects.toThrow(execError);
});
it('should throw an error if PDF conversion produces no images', async () => {
const job = createMockJob({});
// Mock readdir to return an empty array
mocks.readdir.mockResolvedValue([]);
await expect(service.prepareImageInputs('/tmp/flyer.pdf', job)).rejects.toThrow(
'PDF conversion resulted in 0 images for file: /tmp/flyer.pdf. The PDF might be blank or corrupt.'
);
});
});
describe('extractFlyerDataWithAI (Step 2)', () => {
it('should call the AI service with the correct parameters', async () => {
const imagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
const jobData: FlyerJobData = {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
submitterIp: '127.0.0.1',
userProfileAddress: '123 Main St',
};
await service.extractFlyerDataWithAI(imagePaths, jobData);
expect(mockedDb.getAllMasterItems).toHaveBeenCalledTimes(1);
expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
imagePaths,
[], // Mocked master items
'127.0.0.1',
'123 Main St'
);
});
it('should throw an error if AI response fails Zod validation', async () => {
const imagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
const jobData: FlyerJobData = { filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg', checksum: 'checksum-123' };
const invalidAiResponse = {
store_name: 'Invalid Store',
items: [{ item: 'Bad Item', price_in_cents: 'not-a-number' }], // price_in_cents should be a number
};
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidAiResponse as any);
await expect(service.extractFlyerDataWithAI(imagePaths, jobData)).rejects.toThrow(
'AI response validation failed. The returned data structure is incorrect.'
);
});
});
describe('saveProcessedFlyerData (Step 3)', () => {
it('should call db.createFlyerAndItems and db.logActivity with correct data', async () => {
const extractedData = {
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Test St',
items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Test Category', master_item_id: 1 }],
};
const imagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
const jobData: FlyerJobData = {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
userId: 'user-abc',
};
await service.saveProcessedFlyerData(extractedData, imagePaths, jobData);
// Verify flyer data preparation
expect(mockedImageProcessor.generateFlyerIcon).toHaveBeenCalledTimes(1);
// Verify database call
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
const [flyerDataArg, itemsForDbArg] = mockedDb.createFlyerAndItems.mock.calls[0];
expect(flyerDataArg.store_name).toBe('Test Store');
expect(flyerDataArg.checksum).toBe('checksum-123');
expect(flyerDataArg.uploaded_by).toBe('user-abc');
expect(itemsForDbArg).toHaveLength(1);
expect(itemsForDbArg[0].item).toBe('Test Item');
// Verify activity logging
expect(mockedDb.logActivity).toHaveBeenCalledTimes(1);
expect(mockedDb.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-abc',
action: 'flyer_processed',
})
);
});
});
});

View File

@@ -7,7 +7,8 @@ import { z } from 'zod';
import { logger } from './logger.server';
import type { AIService } from './aiService.server';
import type * as db from './db/index.db';
import { generateFlyerIcon } from '../utils/imageProcessor';
import { PdfConversionError, AiDataValidationError } from './processingErrors';
import { FlyerDataTransformer } from './flyerDataTransformer';
// --- Start: Interfaces for Dependency Injection ---
@@ -43,7 +44,7 @@ interface ICleanupQueue {
add(name: string, data: CleanupJobData, opts?: JobsOptions): Promise<Job<CleanupJobData>>;
}
// --- Zod Schemas for AI Response Validation ---
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
const ExtractedFlyerItemSchema = z.object({
item: z.string(),
price_display: z.string(),
@@ -53,7 +54,7 @@ const ExtractedFlyerItemSchema = z.object({
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
const AiFlyerDataSchema = z.object({
export const AiFlyerDataSchema = z.object({
store_name: z.string().min(1, { message: "Store name cannot be empty" }),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
@@ -72,67 +73,85 @@ export class FlyerProcessingService {
private fs: IFileSystem,
private exec: ICommandExecutor,
private cleanupQueue: ICleanupQueue,
private transformer: FlyerDataTransformer,
) {}
async prepareImageInputs(filePath: string, job: Job<FlyerJobData>): Promise<{ imagePaths: { path: string; mimetype: string }[], createdImagePaths: string[] }> {
const fileExt = path.extname(filePath).toLowerCase();
const imagePaths: { path: string; mimetype: string }[] = [];
const createdImagePaths: string[] = [];
/**
* Converts a PDF file to a series of JPEG images using an external tool.
* @param filePath The path to the PDF file.
* @param job The BullMQ job instance for progress updates.
* @returns A promise that resolves to an array of paths to the created image files.
*/
private async _convertPdfToImages(filePath: string, job: Job<FlyerJobData>): Promise<string[]> {
logger.info(`[Worker] Starting PDF conversion for: ${filePath}`);
await job.updateProgress({ message: 'Converting PDF to images...' });
if (fileExt === '.pdf') {
logger.info(`[Worker] Starting PDF conversion for: ${filePath}`);
await job.updateProgress({ message: 'Converting PDF to images...' });
const outputDir = path.dirname(filePath);
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
logger.debug(`[Worker] PDF output directory: ${outputDir}`);
logger.debug(`[Worker] PDF output file prefix: ${outputFilePrefix}`);
const outputDir = path.dirname(filePath);
const outputFilePrefix = path.join(outputDir, path.basename(filePath, '.pdf'));
logger.debug(`[Worker] PDF output directory: ${outputDir}`);
logger.debug(`[Worker] PDF output file prefix: ${outputFilePrefix}`);
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`;
logger.info(`[Worker] Executing PDF conversion command: ${command}`);
const { stdout, stderr } = await this.exec(command);
const command = `pdftocairo -jpeg -r 150 "${filePath}" "${outputFilePrefix}"`; // TODO: Extract to a config/constant
logger.info(`[Worker] Executing PDF conversion command: ${command}`);
const { stdout, stderr } = await this.exec(command);
if (stdout) logger.debug(`[Worker] pdftocairo stdout for ${filePath}:`, { stdout });
if (stderr) logger.warn(`[Worker] pdftocairo stderr for ${filePath}:`, { stderr });
if (stdout) {
logger.debug(`[Worker] pdftocairo produced output on stdout for ${filePath}:`, { stdout });
}
if (stderr) {
logger.warn(`[Worker] pdftocairo produced output on stderr for ${filePath}:`, { stderr });
}
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
logger.debug(`[Worker] Reading contents of output directory: ${outputDir}`);
const filesInDir = await this.fs.readdir(outputDir, { withFileTypes: true });
logger.debug(`[Worker] Found ${filesInDir.length} total entries in output directory.`);
const generatedImages = filesInDir
.filter(f => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
const generatedImages = filesInDir
.filter(f => f.name.startsWith(path.basename(outputFilePrefix)) && f.name.endsWith('.jpg'))
.sort();
logger.debug(`[Worker] Filtered down to ${generatedImages.length} generated JPGs.`, {
imageNames: generatedImages.map(f => f.name),
});
logger.debug(`[Worker] Filtered down to ${generatedImages.length} generated JPGs.`, {
imageNames: generatedImages.map(f => f.name),
});
for (const img of generatedImages) {
const imagePath = path.join(outputDir, img.name);
imagePaths.push({ path: imagePath, mimetype: 'image/jpeg' });
createdImagePaths.push(imagePath);
}
logger.info(`[Worker] Converted PDF to ${imagePaths.length} images.`);
if (imagePaths.length === 0) {
throw new Error(`PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`);
}
} else {
logger.info(`[Worker] Processing as a single image file: ${filePath}`);
imagePaths.push({ path: filePath, mimetype: `image/${fileExt.slice(1)}` });
if (generatedImages.length === 0) {
const errorMessage = `PDF conversion resulted in 0 images for file: ${filePath}. The PDF might be blank or corrupt.`;
logger.error(`[Worker] PdfConversionError: ${errorMessage}`, { stderr });
throw new PdfConversionError(errorMessage, stderr);
}
return { imagePaths, createdImagePaths };
return generatedImages.map(img => path.join(outputDir, img.name));
}
async extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData) {
/**
* Prepares the input images for the AI service. If the input is a PDF, it's converted to images.
* @param filePath The path to the original uploaded file.
* @param job The BullMQ job instance.
* @returns An object containing the final image paths for the AI and a list of any newly created image files.
*/
private async _prepareImageInputs(filePath: string, job: Job<FlyerJobData>): Promise<{ imagePaths: { path: string; mimetype: string }[], createdImagePaths: string[] }> {
const fileExt = path.extname(filePath).toLowerCase();
if (fileExt === '.pdf') {
const createdImagePaths = await this._convertPdfToImages(filePath, job);
const imagePaths = createdImagePaths.map(p => ({ path: p, mimetype: 'image/jpeg' }));
logger.info(`[Worker] Converted PDF to ${imagePaths.length} images.`);
return { imagePaths, createdImagePaths };
} else {
logger.info(`[Worker] Processing as a single image file: ${filePath}`);
const imagePaths = [{ path: filePath, mimetype: `image/${fileExt.slice(1)}` }];
return { imagePaths, createdImagePaths: [] };
}
}
/**
* Calls the AI service to extract structured data from the flyer images.
* @param imagePaths An array of paths and mimetypes for the images.
* @param jobData The data from the BullMQ job.
* @returns A promise that resolves to the validated, structured flyer data.
*/
private async _extractFlyerDataWithAI(imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData) {
logger.info(`[Worker] Starting AI data extraction for job ${jobData.checksum}.`);
const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.database.getAllMasterItems();
const masterItems = await this.database.personalizationRepo.getAllMasterItems();
logger.debug(`[Worker] Retrieved ${masterItems.length} master items for AI matching.`);
const extractedData = await this.ai.extractCoreDataFromFlyerImage(
imagePaths,
masterItems,
@@ -142,89 +161,116 @@ export class FlyerProcessingService {
const validationResult = AiFlyerDataSchema.safeParse(extractedData);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
logger.error('[Worker] AI response failed validation.', {
errors: validationResult.error.flatten(),
errors,
rawData: extractedData,
});
throw new Error('AI response validation failed. The returned data structure is incorrect.');
throw new AiDataValidationError('AI response validation failed. The returned data structure is incorrect.', errors, extractedData);
}
logger.info(`[Worker] AI extracted ${extractedData.items.length} items.`);
return validationResult.data;
}
async saveProcessedFlyerData(
/**
* Saves the extracted flyer data to the database.
* @param extractedData The structured data from the AI.
* @param imagePaths The paths to the flyer images.
* @param jobData The data from the BullMQ job.
* @returns A promise that resolves to the newly created flyer record.
*/
private async _saveProcessedFlyerData(
extractedData: z.infer<typeof AiFlyerDataSchema>,
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData
) {
const { originalFileName, checksum, userId } = jobData;
const firstImage = imagePaths[0].path;
const iconFileName = await generateFlyerIcon(firstImage, path.join(path.dirname(firstImage), 'icons'));
logger.info(`[Worker] Preparing to save extracted data to database for job ${jobData.checksum}.`);
const flyerData = {
file_name: originalFileName,
image_url: `/flyer-images/${path.basename(firstImage)}`,
icon_url: `/flyer-images/icons/${iconFileName}`,
checksum,
store_name: extractedData.store_name || 'Unknown Store (auto)',
valid_from: extractedData.valid_from,
valid_to: extractedData.valid_to,
store_address: extractedData.store_address,
item_count: 0,
uploaded_by: userId,
};
const itemsForDb = extractedData.items.map(item => ({
...item,
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
view_count: 0, click_count: 0, updated_at: new Date().toISOString()
}));
// 1. Transform the AI data into database-ready records.
const { flyerData, itemsForDb } = await this.transformer.transform(
extractedData,
imagePaths,
jobData.originalFileName,
jobData.checksum,
jobData.userId
);
// 2. Save the transformed data to the database.
const { flyer: newFlyer } = await this.database.createFlyerAndItems(flyerData, itemsForDb);
logger.info(`[Worker] Successfully saved new flyer ID: ${newFlyer.flyer_id}`);
await this.database.logActivity({ userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } });
await this.database.logActivity({ userId: jobData.userId, action: 'flyer_processed', displayText: `Processed a new flyer for ${flyerData.store_name}.`, details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name } });
return newFlyer;
}
/**
* Enqueues a job to clean up temporary files associated with a flyer upload.
* @param flyerId The ID of the processed flyer.
* @param paths An array of file paths to be deleted.
*/
private async _enqueueCleanup(flyerId: number, paths: string[]): Promise<void> {
if (paths.length === 0) return;
await this.cleanupQueue.add('cleanup-flyer-upload', { flyerId, paths }, {
jobId: `cleanup-flyer-${flyerId}`,
removeOnComplete: true,
});
logger.info(`[Worker] Enqueued cleanup job for flyer ${flyerId}.`);
}
async processJob(job: Job<FlyerJobData>) {
const { filePath, originalFileName } = job.data;
const createdImagePaths: string[] = [];
let newFlyerId: number | undefined;
logger.info(`[Worker] Picked up job ${job.id} for file: ${originalFileName} (Checksum: ${job.data.checksum})`);
try {
await job.updateProgress({ message: 'Starting process...' });
const { imagePaths, createdImagePaths: tempImagePaths } = await this.prepareImageInputs(filePath, job);
const { imagePaths, createdImagePaths: tempImagePaths } = await this._prepareImageInputs(filePath, job);
createdImagePaths.push(...tempImagePaths);
await job.updateProgress({ message: 'Extracting data...' });
const extractedData = await this.extractFlyerDataWithAI(imagePaths, job.data);
const extractedData = await this._extractFlyerDataWithAI(imagePaths, job.data);
await job.updateProgress({ message: 'Saving to database...' });
const newFlyer = await this.saveProcessedFlyerData(extractedData, imagePaths, job.data);
const newFlyer = await this._saveProcessedFlyerData(extractedData, imagePaths, job.data);
newFlyerId = newFlyer.flyer_id;
logger.info(`[Worker] Job ${job.id} for ${originalFileName} processed successfully. Flyer ID: ${newFlyerId}`);
return { flyerId: newFlyer.flyer_id };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error(`[Worker] Job ${job.id} failed for file ${originalFileName}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
jobData: job.data,
});
let errorMessage = 'An unknown error occurred';
if (error instanceof PdfConversionError) {
errorMessage = error.message;
logger.error(`[Worker] PDF Conversion failed for job ${job.id}.`, {
error: errorMessage,
stderr: error.stderr,
jobData: job.data,
});
} else if (error instanceof AiDataValidationError) {
errorMessage = error.message;
logger.error(`[Worker] AI Data Validation failed for job ${job.id}.`, {
error: errorMessage,
validationErrors: error.validationErrors,
rawData: error.rawData,
jobData: job.data,
});
} else if (error instanceof Error) {
errorMessage = error.message;
logger.error(`[Worker] A generic error occurred in job ${job.id}. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: errorMessage, stack: error.stack, jobData: job.data,
});
}
await job.updateProgress({ message: `Error: ${errorMessage}` });
throw error;
} finally {
if (newFlyerId) {
const pathsToClean = [filePath, ...createdImagePaths];
await this.cleanupQueue.add('cleanup-flyer-upload', { flyerId: newFlyerId, paths: pathsToClean }, {
jobId: `cleanup-flyer-${newFlyerId}`,
removeOnComplete: true,
});
logger.info(`[Worker] Enqueued cleanup job for flyer ${newFlyerId}.`);
await this._enqueueCleanup(newFlyerId, pathsToClean);
} else {
logger.warn(`[Worker] Job ${job.id} for ${originalFileName} failed. Temporary files will NOT be cleaned up to allow for manual inspection.`);
}

View File

@@ -71,7 +71,7 @@ describe('Geocoding Service', () => {
expect(mocks.mockGeocodeWithNominatim).not.toHaveBeenCalled();
});
it('should log a warning but continue if Redis GET fails', async () => {
it('should log an error but continue if Redis GET fails', async () => {
// Arrange: Mock Redis 'get' to fail, but Google API to succeed
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockRejectedValue(new Error('Redis down'));
@@ -85,10 +85,32 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Redis GET command failed'), expect.any(Object));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Redis GET or JSON.parse command failed'), expect.any(Object));
expect(fetch).toHaveBeenCalled(); // Should still proceed to fetch
});
it('should proceed to fetch if cached data is invalid JSON', async () => {
// Arrange: Mock Redis to return a malformed JSON string
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue('{ "lat": 45.0, "lng": -75.0 '); // Missing closing brace
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }),
} as Response);
// Act
const result = await geocodeAddress(address);
// Assert
expect(result).toEqual(coordinates);
expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
// The service should log the JSON parsing error and continue
expect(logger.error).toHaveBeenCalledWith( // This is now the expected behavior
expect.stringContaining('Redis GET or JSON.parse command failed'), expect.any(Object)
);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('should fetch from Google, return coordinates, and cache the result on cache miss', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
@@ -140,7 +162,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Geocoding with Google failed'), expect.any(Object));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Falling back to Nominatim'), expect.any(Object));
expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address);
});
@@ -156,7 +178,7 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toEqual(coordinates);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('An error occurred while calling the Google Maps'), expect.any(Object));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'), expect.any(Object));
expect(mocks.mockGeocodeWithNominatim).toHaveBeenCalledWith(address);
});
@@ -172,8 +194,30 @@ describe('Geocoding Service', () => {
// Assert
expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('All geocoding providers failed'));
expect(mocks.mockRedis.set).not.toHaveBeenCalled(); // Should not cache a null result
});
it('should return coordinates even if Redis SET fails', async () => {
// Arrange
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ status: 'OK', results: [{ geometry: { location: coordinates } }] }),
} as Response);
// Mock Redis 'set' to fail
mocks.mockRedis.set.mockRejectedValue(new Error('Redis SET failed'));
// Act
const result = await geocodeAddress(address);
// Assert
expect(result).toEqual(coordinates); // The result should still be returned to the caller
expect(fetch).toHaveBeenCalledTimes(1);
expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Redis SET command failed'), expect.any(Object));
});
});
describe('clearGeocodeCache', () => {

View File

@@ -3,8 +3,64 @@ import { logger } from './logger.server';
import { geocodeWithNominatim } from './nominatimGeocodingService.server';
import { connection as redis } from './queueService.server'; // Import the configured Redis connection
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
const REDIS_CACHE_EXPIRATION_SECONDS = 60 * 60 * 24 * 30; // 30 days
const GEOCODE_CACHE_PREFIX = 'geocode:';
// --- Private Helper Functions ---
/**
* Tries to retrieve and parse geocoding data from the Redis cache.
* @param cacheKey The key for the cached data.
* @returns The parsed coordinates or null if not found or on error.
*/
async function getFromCache(cacheKey: string): Promise<{ lat: number; lng: number } | null> {
try {
const cachedResult = await redis.get(cacheKey);
if (cachedResult) {
logger.info(`[GeocodingService] Redis cache hit for key: "${cacheKey}"`);
return JSON.parse(cachedResult);
}
return null;
} catch (error) {
logger.error('[GeocodingService] Redis GET or JSON.parse command failed. Proceeding without cache.', { error, cacheKey });
return null;
}
}
/**
* Caches the geocoding result in Redis.
* @param cacheKey The key to store the data under.
* @param coordinates The coordinates to cache.
*/
async function setCache(cacheKey: string, coordinates: { lat: number; lng: number }): Promise<void> {
try {
await redis.set(cacheKey, JSON.stringify(coordinates), 'EX', REDIS_CACHE_EXPIRATION_SECONDS);
logger.info(`[GeocodingService] Successfully cached result for key: "${cacheKey}"`);
} catch (error) {
logger.error('[GeocodingService] Redis SET command failed. Result will not be cached.', { error, cacheKey });
}
}
/**
* Geocodes an address using the Google Maps API.
* @param address The address string to geocode.
* @returns A promise that resolves to coordinates or null if not found/failed.
*/
async function geocodeWithGoogle(address: string): Promise<{ lat: number; lng: number } | null> {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
logger.warn('[GeocodingService] GOOGLE_MAPS_API_KEY is not set. Cannot use Google Geocoding.');
return null;
}
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
const response = await fetch(url);
const data = await response.json();
if (data.status !== 'OK' || !data.results || data.results.length === 0) return null;
return data.results[0].geometry.location; // { lat, lng }
}
/**
* Geocodes a physical address into latitude and longitude coordinates.
@@ -15,71 +71,47 @@ const REDIS_CACHE_EXPIRATION_SECONDS = 60 * 60 * 24 * 30; // 30 days
* @returns A promise that resolves to an object with latitude and longitude, or null if not found.
*/
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
const cacheKey = `geocode:${address}`;
const cacheKey = `${GEOCODE_CACHE_PREFIX}${address}`;
// 1. Check Redis cache first.
try {
const cachedResult = await redis.get(cacheKey);
if (cachedResult) {
logger.info(`[GeocodingService] Redis cache hit for address: "${address}"`);
return JSON.parse(cachedResult);
}
} catch (error) {
logger.error('[GeocodingService] Redis GET command failed. Proceeding without cache.', { error });
const cachedCoordinates = await getFromCache(cacheKey);
if (cachedCoordinates) {
return cachedCoordinates;
}
logger.info(`[GeocodingService] Redis cache miss for address: "${address}". Fetching from API.`);
// Helper function to set cache and return result
const setCacheAndReturn = async (coordinates: { lat: number; lng: number } | null) => {
if (coordinates) {
try {
await redis.set(cacheKey, JSON.stringify(coordinates), 'EX', REDIS_CACHE_EXPIRATION_SECONDS);
logger.info(`[GeocodingService] Successfully cached result for address: "${address}"`);
} catch (error) {
logger.error('[GeocodingService] Redis SET command failed. Result will not be cached.', { error });
}
}
return coordinates;
};
if (!apiKey) {
logger.warn('[GeocodingService] GOOGLE_MAPS_API_KEY is not set. Geocoding is disabled.');
// If Google API key is not set, immediately fall back to Nominatim.
logger.info('[GeocodingService] Falling back to Nominatim due to missing Google API key.');
const result = await geocodeWithNominatim(address);
return setCacheAndReturn(result);
}
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
let coordinates: { lat: number; lng: number } | null = null;
// 2. Try primary provider (Google).
try {
logger.info(`[GeocodingService] Attempting geocoding with Google for address: "${address}"`);
const response = await fetch(url);
const data = await response.json();
if (data.status !== 'OK' || !data.results || data.results.length === 0) {
logger.warn('[GeocodingService] Geocoding with Google failed or returned no results.', { status: data.status, address });
// Fallback to Nominatim on Google API failure.
logger.info('[GeocodingService] Falling back to Nominatim due to Google API failure.');
const result = await geocodeWithNominatim(address);
return setCacheAndReturn(result);
}
const location = data.results[0].geometry.location;
logger.info(`[GeocodingService] Successfully geocoded address: "${address}"`, { location });
const coordinates = {
lat: location.lat,
lng: location.lng,
};
return setCacheAndReturn(coordinates);
coordinates = await geocodeWithGoogle(address);
} catch (error) {
logger.error('[GeocodingService] An error occurred while calling the Google Maps Geocoding API.', { error });
// Fallback to Nominatim on network or other unexpected errors.
logger.info('[GeocodingService] Falling back to Nominatim due to Google API error.');
const result = await geocodeWithNominatim(address);
return setCacheAndReturn(result);
// Fall through to the fallback provider.
}
// 3. If primary provider fails, try fallback provider (Nominatim).
if (!coordinates) {
try {
logger.info('[GeocodingService] Falling back to Nominatim due to Google API failure or missing key.');
coordinates = await geocodeWithNominatim(address);
} catch (error) {
logger.error('[GeocodingService] An error occurred while calling the Nominatim API.', { error });
}
}
// 4. If a result was found, cache it and return.
if (coordinates) {
logger.info(`[GeocodingService] Successfully geocoded address: "${address}"`, { coordinates });
await setCache(cacheKey, coordinates);
return coordinates;
}
// 5. If all providers fail, return null.
logger.warn(`[GeocodingService] All geocoding providers failed for address: "${address}"`);
return null;
}
/**
@@ -91,7 +123,7 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; ln
export async function clearGeocodeCache(): Promise<number> {
let cursor = '0';
let keysDeleted = 0;
const pattern = 'geocode:*';
const pattern = `${GEOCODE_CACHE_PREFIX}*`;
logger.info('[GeocodingService] Starting to clear geocode cache...');

View File

@@ -1,80 +0,0 @@
// src/services/logger.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// FIX: Explicitly unmock the logger module for this test file.
// This ensures we import the REAL implementation (which calls console.log),
// rather than the global mock defined in setup/tests-setup-unit.ts (which does nothing).
vi.unmock('./logger');
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)
// Note: In JSDOM/Vitest, process.pid usually exists. If it falls back to 'BROWSER', this regex might need adjustment,
// but the primary failure was 0 calls.
expect(spies.log).toHaveBeenCalledWith(
expect.stringMatching(/\[.+\] \[.+\] \[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(/\[.+\] \[.+\] \[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(/\[.+\] \[.+\] \[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(/\[.+\] \[.+\] \[DEBUG\] Debugging isomorphic data/),
{ key: 'value' }
);
});
});

View File

@@ -1,50 +0,0 @@
// src/services/logger.ts
/**
* A simple logger service that wraps the console.
* This provides a centralized place to manage logging behavior,
* such as adding timestamps, log levels, or sending logs to a remote service.
*/
const getTimestamp = () => new Date().toISOString();
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
/**
* The core logging function. It uses a generic rest parameter `<T extends any[]>`
* to safely accept any number of arguments of any type, just like the native console
* methods. Using `unknown[]` is more type-safe than `any[]` and satisfies the linter.
*/
const log = <T extends unknown[]>(level: LogLevel, message: string, ...args: T) => {
// Check if `process` is available (Node.js) vs. browser environment.
// This makes the logger "isomorphic" and prevents runtime errors on the client.
const envIdentifier = typeof process !== 'undefined' && process.pid
? `PID:${process.pid}`
: 'BROWSER';
const timestamp = getTimestamp();
// We construct the log message with a timestamp, PID, and level for better context.
const logMessage = `[${timestamp}] [${envIdentifier}] [${level}] ${message}`;
switch (level) {
case 'INFO':
console.log(logMessage, ...args);
break;
case 'WARN':
console.warn(logMessage, ...args);
break;
case 'ERROR':
console.error(logMessage, ...args);
break;
case 'DEBUG':
// For now, we can show debug logs in development. This could be controlled by an environment variable.
console.debug(logMessage, ...args);
break;
}
};
// Export the logger object for use throughout the application.
export const logger = {
info: <T extends unknown[]>(message: string, ...args: T) => log('INFO', message, ...args),
warn: <T extends unknown[]>(message: string, ...args: T) => log('WARN', message, ...args),
error: <T extends unknown[]>(message: string, ...args: T) => log('ERROR', message, ...args),
debug: <T extends unknown[]>(message: string, ...args: T) => log('DEBUG', message, ...args),
};

View File

@@ -66,6 +66,22 @@ describe('Notification Service', () => {
})
);
});
it('should not throw an error and should log a warning if the toaster is invalid', async () => {
// Arrange
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const invalidToaster = { success: undefined, error: vi.fn() }; // Missing success method
const message = 'This should not appear';
const { notifySuccess } = await import('./notificationService');
// Act
notifySuccess(message, invalidToaster as any);
// Assert
expect(consoleWarnSpy).toHaveBeenCalledWith('[NotificationService] toast.success is not available. Message:', message);
consoleWarnSpy.mockRestore();
});
});
describe('notifyError', () => {
@@ -92,5 +108,21 @@ describe('Notification Service', () => {
})
);
});
it('should not throw an error and should log a warning if the toaster is invalid', async () => {
// Arrange
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const invalidToaster = { success: vi.fn(), error: undefined }; // Missing error method
const message = 'This error should not appear';
const { notifyError } = await import('./notificationService');
// Act
notifyError(message, invalidToaster as any);
// Assert
expect(consoleWarnSpy).toHaveBeenCalledWith('[NotificationService] toast.error is not available. Message:', message);
consoleWarnSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,33 @@
// src/services/processingErrors.ts
/**
* Base class for all flyer processing errors.
* This allows for catching all processing-related errors with a single `catch` block.
*/
export class FlyerProcessingError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Error thrown when PDF to image conversion fails (e.g., pdftocairo produces no output).
*/
export class PdfConversionError extends FlyerProcessingError {
public stderr?: string;
constructor(message: string, stderr?: string) {
super(message);
this.stderr = stderr;
}
}
/**
* Error thrown when the data returned from the AI service fails Zod validation.
*/
export class AiDataValidationError extends FlyerProcessingError {
constructor(message: string, public validationErrors: object, public rawData: unknown) {
super(message);
}
}

View File

@@ -103,6 +103,44 @@ describe('Queue Service Setup and Lifecycle', () => {
}
});
describe('Worker Event Listeners', () => {
it('should log a message when a job is completed', () => {
// The 'on' method is mocked on our MockWorker. We can find the callback.
const completedCallback = vi.mocked(flyerWorker.on).mock.calls.find((call: [string, Function]) => call[0] === 'completed')?.[1];
// Ensure the callback was found before trying to call it
expect(completedCallback).toBeDefined();
const mockJob = { id: 'job-abc' };
const mockReturnValue = { flyerId: 123 };
// Call the captured callback
completedCallback!(mockJob, mockReturnValue);
expect(logger.info).toHaveBeenCalledWith(
'[flyer-processing] Job job-abc completed successfully.',
{ returnValue: mockReturnValue }
);
});
it('should log an error when a job has ultimately failed', () => {
// Capture the 'failed' callback from any worker, e.g., emailWorker
const failedCallback = vi.mocked(emailWorker.on).mock.calls.find((call: [string, Function]) => call[0] === 'failed')?.[1];
expect(failedCallback).toBeDefined();
const mockJob = { id: 'job-xyz', data: { to: 'test@example.com' } };
const mockError = new Error('SMTP Server Down');
// Call the captured callback
failedCallback!(mockJob, mockError);
expect(logger.error).toHaveBeenCalledWith(
'[email-sending] Job job-xyz has ultimately failed after all attempts.',
{ error: mockError.message, stack: mockError.stack, jobData: mockJob.data }
);
});
});
describe('gracefulShutdown', () => {
let processExitSpy: any;

View File

@@ -11,6 +11,7 @@ import { aiService } from './aiService.server';
import * as emailService from './emailService.server';
import * as db from './db/index.db';
import { FlyerProcessingService, type FlyerJobData, type IFileSystem } from './flyerProcessingService.server';
import { FlyerDataTransformer } from './flyerDataTransformer';
// --- Start: Interfaces for Dependency Injection ---
// --- End: Interfaces for Dependency Injection ---
@@ -68,6 +69,19 @@ export const analyticsQueue = new Queue<AnalyticsJobData>('analytics-reporting',
},
});
export const weeklyAnalyticsQueue = new Queue<WeeklyAnalyticsJobData>('weekly-analytics-reporting', {
connection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 3600000, // 1 hour delay for retries
},
removeOnComplete: true,
removeOnFail: 50,
},
});
export const cleanupQueue = new Queue<CleanupJobData>('file-cleanup', {
connection,
defaultJobOptions: {
@@ -95,6 +109,14 @@ interface AnalyticsJobData {
reportDate: string; // e.g., '2024-10-26'
}
/**
* Defines the data for a weekly analytics job.
*/
interface WeeklyAnalyticsJobData {
reportYear: number;
reportWeek: number; // ISO week number (1-53)
}
interface CleanupJobData {
flyerId: number;
// An array of absolute file paths to be deleted. Made optional for manual cleanup triggers.
@@ -115,7 +137,8 @@ const flyerProcessingService = new FlyerProcessingService(
db,
fsAdapter,
execAsync,
cleanupQueue // Inject the cleanup queue to break the circular dependency
cleanupQueue, // Inject the cleanup queue to break the circular dependency
new FlyerDataTransformer() // Inject the new transformer
);
/**
@@ -154,9 +177,12 @@ export const emailWorker = new Worker<EmailJobData>(
try {
await emailService.sendEmail(job.data);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown email error occurred';
// Standardize error logging to capture the full error object, including the stack trace.
// This provides more context for debugging than just logging the message.
logger.error(`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: errorMessage,
// Log the full error object for better diagnostics.
error: error instanceof Error ? error : new Error(String(error)),
// Also include the job data for context.
jobData: job.data,
});
// Re-throw to let BullMQ handle the failure and retry.
@@ -189,9 +215,9 @@ export const analyticsWorker = new Worker<AnalyticsJobData>(
await new Promise(resolve => setTimeout(resolve, 10000)); // Simulate a 10-second task
logger.info(`[AnalyticsWorker] Successfully generated report for ${reportDate}.`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown analytics error occurred';
// Standardize error logging.
logger.error(`[AnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: errorMessage,
error: error instanceof Error ? error : new Error(String(error)),
jobData: job.data,
});
throw error; // Re-throw to let BullMQ handle the failure and retry.
@@ -238,8 +264,10 @@ export const cleanupWorker = new Worker<CleanupJobData>(
}
logger.info(`[CleanupWorker] Successfully cleaned up ${paths.length} file(s) for flyer ${flyerId}.`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown cleanup error occurred';
logger.error(`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, { error: errorMessage });
// Standardize error logging.
logger.error(`[CleanupWorker] Job ${job.id} for flyer ${flyerId} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: error instanceof Error ? error : new Error(String(error)),
});
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
@@ -249,11 +277,40 @@ export const cleanupWorker = new Worker<CleanupJobData>(
}
);
/**
* A dedicated worker for generating weekly analytics reports.
* This is a placeholder for the actual report generation logic.
*/
export const weeklyAnalyticsWorker = new Worker<WeeklyAnalyticsJobData>(
'weekly-analytics-reporting',
async (job: Job<WeeklyAnalyticsJobData>) => {
const { reportYear, reportWeek } = job.data;
logger.info(`[WeeklyAnalyticsWorker] Starting weekly report generation for job ${job.id}`, { reportYear, reportWeek });
try {
// Simulate a longer-running task for weekly reports
await new Promise(resolve => setTimeout(resolve, 30000)); // Simulate 30-second task
logger.info(`[WeeklyAnalyticsWorker] Successfully generated weekly report for week ${reportWeek}, ${reportYear}.`);
} catch (error: unknown) {
// Standardize error logging.
logger.error(`[WeeklyAnalyticsWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`, {
error: error instanceof Error ? error : new Error(String(error)),
jobData: job.data,
});
throw error; // Re-throw to let BullMQ handle the failure and retry.
}
},
{
connection,
concurrency: parseInt(process.env.WEEKLY_ANALYTICS_WORKER_CONCURRENCY || '1', 10),
}
);
// --- Attach Event Listeners to All Workers ---
attachWorkerEventListeners(flyerWorker);
attachWorkerEventListeners(emailWorker);
attachWorkerEventListeners(analyticsWorker);
attachWorkerEventListeners(cleanupWorker);
attachWorkerEventListeners(weeklyAnalyticsWorker);
logger.info('All workers started and listening for jobs.');

View File

@@ -0,0 +1,151 @@
// src/tests/integration/public.routes.integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import supertest from 'supertest';
import type { Flyer, FlyerItem, Recipe, RecipeComment, DietaryRestriction, Appliance } from '../../types';
const API_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
const request = supertest(API_URL.replace('/api', '')); // supertest needs the server's base URL
describe('Public API Routes Integration Tests', () => {
// Shared state for tests
let flyers: Flyer[] = [];
let recipes: Recipe[] = [];
beforeAll(async () => {
// Pre-fetch some data to use in subsequent tests
const flyersRes = await request.get('/api/flyers');
flyers = flyersRes.body;
// The seed script creates a recipe, let's find it to test comments
const recipeRes = await request.get('/api/recipes/by-ingredient-and-tag?ingredient=Chicken&tag=Dinner');
recipes = recipeRes.body;
});
describe('Health Check Endpoints', () => {
it('GET /api/health/ping should return "pong"', async () => {
const response = await request.get('/api/health/ping');
expect(response.status).toBe(200);
expect(response.text).toBe('pong');
});
it('GET /api/health/db-schema should return success', async () => {
const response = await request.get('/api/health/db-schema');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('GET /api/health/storage should return success', async () => {
const response = await request.get('/api/health/storage');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
it('GET /api/health/db-pool should return success', async () => {
const response = await request.get('/api/health/db-pool');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
});
});
describe('Public Data Endpoints', () => {
it('GET /api/time should return the server time', async () => {
const response = await request.get('/api/time');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('currentTime');
expect(response.body).toHaveProperty('year');
expect(response.body).toHaveProperty('week');
});
it('GET /api/flyers should return a list of flyers', async () => {
expect(flyers.length).toBeGreaterThan(0);
expect(flyers[0]).toHaveProperty('flyer_id');
expect(flyers[0]).toHaveProperty('store');
});
it('GET /api/flyers/:id/items should return items for a specific flyer', async () => {
const testFlyer = flyers[0];
const response = await request.get(`/api/flyers/${testFlyer.flyer_id}/items`);
const items: FlyerItem[] = response.body;
expect(response.status).toBe(200);
expect(items).toBeInstanceOf(Array);
if (items.length > 0) {
expect(items[0].flyer_id).toBe(testFlyer.flyer_id);
}
});
it('POST /api/flyer-items/batch-fetch should return items for multiple flyers', async () => {
const flyerIds = flyers.map(f => f.flyer_id);
const response = await request.post('/api/flyer-items/batch-fetch').send({ flyerIds });
const items: FlyerItem[] = response.body;
expect(response.status).toBe(200);
expect(items).toBeInstanceOf(Array);
expect(items.length).toBeGreaterThan(0);
});
it('POST /api/flyer-items/batch-count should return a count for multiple flyers', async () => {
const flyerIds = flyers.map(f => f.flyer_id);
const response = await request.post('/api/flyer-items/batch-count').send({ flyerIds });
expect(response.status).toBe(200);
expect(response.body.count).toBeTypeOf('number');
expect(response.body.count).toBeGreaterThan(0);
});
it('GET /api/master-items should return a list of master grocery items', async () => {
const response = await request.get('/api/master-items');
const masterItems = response.body;
expect(response.status).toBe(200);
expect(masterItems).toBeInstanceOf(Array);
expect(masterItems.length).toBeGreaterThan(0);
expect(masterItems[0]).toHaveProperty('master_grocery_item_id');
});
it('GET /api/recipes/by-sale-percentage should return recipes', async () => {
const response = await request.get('/api/recipes/by-sale-percentage?minPercentage=10');
const recipes: Recipe[] = response.body;
expect(response.status).toBe(200);
expect(recipes).toBeInstanceOf(Array);
});
it('GET /api/recipes/by-ingredient-and-tag should return recipes', async () => {
const response = await request.get('/api/recipes/by-ingredient-and-tag?ingredient=Chicken&tag=Dinner');
const recipes: Recipe[] = response.body;
expect(response.status).toBe(200);
expect(recipes).toBeInstanceOf(Array);
expect(recipes.length).toBeGreaterThan(0); // Seed data includes this
});
it('GET /api/recipes/:recipeId/comments should return comments for a recipe', async () => {
const testRecipe = recipes[0];
expect(testRecipe).toBeDefined();
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
const comments: RecipeComment[] = response.body;
expect(response.status).toBe(200);
expect(comments).toBeInstanceOf(Array);
});
it('GET /api/stats/most-frequent-sales should return frequent items', async () => {
const response = await request.get('/api/stats/most-frequent-sales?days=365&limit=5');
const items = response.body;
expect(response.status).toBe(200);
expect(items).toBeInstanceOf(Array);
});
it('GET /api/dietary-restrictions should return a list of restrictions', async () => {
const response = await request.get('/api/dietary-restrictions');
const restrictions: DietaryRestriction[] = response.body;
expect(response.status).toBe(200);
expect(restrictions).toBeInstanceOf(Array);
expect(restrictions.length).toBeGreaterThan(0);
expect(restrictions[0]).toHaveProperty('dietary_restriction_id');
});
it('GET /api/appliances should return a list of appliances', async () => {
const response = await request.get('/api/appliances');
const appliances: Appliance[] = response.body;
expect(response.status).toBe(200);
expect(appliances).toBeInstanceOf(Array);
expect(appliances.length).toBeGreaterThan(0);
expect(appliances[0]).toHaveProperty('appliance_id');
});
});
});

View File

@@ -0,0 +1,133 @@
// src/tests/integration/user.routes.integration.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import supertest from 'supertest';
import { createMockShoppingList } from '../utils/mockFactories';
const API_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
const request = supertest(API_URL.replace('/api', '')); // supertest needs the server's base URL
let authToken = '';
let createdListId: number;
describe('User Routes Integration Tests (/api/users)', () => {
// Authenticate once before all tests in this suite to get a JWT.
beforeAll(async () => {
const loginResponse = await request
.post('/api/auth/login')
.send({
email: process.env.ADMIN_EMAIL || 'admin@test.com',
password: process.env.ADMIN_PASSWORD || 'password',
});
if (loginResponse.status !== 200) {
console.error('Login failed in beforeAll hook:', loginResponse.body);
}
expect(loginResponse.status).toBe(200);
expect(loginResponse.body.token).toBeDefined();
authToken = loginResponse.body.token;
});
describe('GET /api/users/profile', () => {
it('should return the profile for the authenticated user', async () => {
const response = await request
.get('/api/users/profile')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
expect(response.body.email).toBe(process.env.ADMIN_EMAIL || 'admin@test.com');
expect(response.body.role).toBe('admin');
});
it('should return 401 Unauthorized if no token is provided', async () => {
const response = await request.get('/api/users/profile');
expect(response.status).toBe(401);
});
});
describe('PUT /api/users/profile', () => {
it('should update the user profile', async () => {
const newName = `Test User ${Date.now()}`;
const response = await request
.put('/api/users/profile')
.set('Authorization', `Bearer ${authToken}`)
.send({ full_name: newName });
expect(response.status).toBe(200);
expect(response.body.full_name).toBe(newName);
// Verify the change by fetching the profile again
const verifyResponse = await request
.get('/api/users/profile')
.set('Authorization', `Bearer ${authToken}`);
expect(verifyResponse.body.full_name).toBe(newName);
});
});
describe('Shopping List CRUD', () => {
it('POST /api/users/shopping-lists should create a new shopping list', async () => {
const listName = `My Integration Test List ${Date.now()}`;
const response = await request
.post('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: listName });
expect(response.status).toBe(201);
expect(response.body.name).toBe(listName);
expect(response.body.shopping_list_id).toBeDefined();
createdListId = response.body.shopping_list_id; // Save for the next test
});
it('GET /api/users/shopping-lists should retrieve the created shopping list', async () => {
const response = await request
.get('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
const foundList = response.body.find((list: { shopping_list_id: number; }) => list.shopping_list_id === createdListId);
expect(foundList).toBeDefined();
});
it('DELETE /api/users/shopping-lists/:listId should delete the shopping list', async () => {
expect(createdListId).toBeDefined(); // Ensure the previous test ran and set the ID
const response = await request
.delete(`/api/users/shopping-lists/${createdListId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(204);
// Verify deletion
const verifyResponse = await request
.get('/api/users/shopping-lists')
.set('Authorization', `Bearer ${authToken}`);
const foundList = verifyResponse.body.find((list: { shopping_list_id: number; }) => list.shopping_list_id === createdListId);
expect(foundList).toBeUndefined();
});
});
describe('PUT /api/users/profile/preferences', () => {
it('should update user preferences', async () => {
const preferences = { darkMode: true, unitSystem: 'metric' };
const response = await request
.put('/api/users/profile/preferences')
.set('Authorization', `Bearer ${authToken}`)
.send(preferences);
expect(response.status).toBe(200);
expect(response.body.preferences).toEqual(preferences);
// Verify the change by fetching the profile again
const verifyResponse = await request
.get('/api/users/profile')
.set('Authorization', `Bearer ${authToken}`);
expect(verifyResponse.body.preferences.darkMode).toBe(true);
expect(verifyResponse.body.preferences.unitSystem).toBe('metric');
});
});
});

View File

@@ -0,0 +1,58 @@
// src/utils/dateUtils.test.ts
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { getSimpleWeekAndYear } from './dateUtils';
describe('dateUtils', () => {
describe('getSimpleWeekAndYear', () => {
beforeEach(() => {
// Use fake timers to control the current date in tests
vi.useFakeTimers();
});
afterEach(() => {
// Restore real timers after each test
vi.useRealTimers();
});
it('should return week 1 for the first day of the year', () => {
const date = new Date('2024-01-01T12:00:00Z');
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 });
});
it('should return week 1 for the 7th day of the year', () => {
const date = new Date('2024-01-07T12:00:00Z');
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 });
});
it('should return week 2 for the 8th day of the year', () => {
const date = new Date('2024-01-08T12:00:00Z');
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 2 });
});
it('should correctly calculate the week for a date in the middle of the year', () => {
// July 1st is the 183rd day of a leap year. 182 / 7 = 26. So it's week 27.
const date = new Date('2024-07-01T12:00:00Z');
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 27 });
});
it('should correctly calculate the week for the last day of a non-leap year', () => {
// Dec 31, 2023 is the 365th day. 364 / 7 = 52. So it's week 53.
const date = new Date('2023-12-31T12:00:00Z');
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2023, week: 53 });
});
it('should correctly calculate the week for the last day of a leap year', () => {
// Dec 31, 2024 is the 366th day. 365 / 7 = 52.14. floor(52.14) + 1 = 53.
const date = new Date('2024-12-31T12:00:00Z');
expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 53 });
});
it('should use the current date if no date is provided', () => {
const fakeCurrentDate = new Date('2025-02-10T12:00:00Z'); // 41st day of the year
vi.setSystemTime(fakeCurrentDate);
// 40 / 7 = 5.71. floor(5.71) + 1 = 6.
expect(getSimpleWeekAndYear()).toEqual({ year: 2025, week: 6 });
});
});
});

22
src/utils/dateUtils.ts Normal file
View File

@@ -0,0 +1,22 @@
// src/utils/dateUtils.ts
/**
* Calculates the current year and a simplified week number.
*
* Note: This is a simple calculation where week 1 starts on January 1st.
* For true ISO 8601 week numbers (where week 1 is the first week with a Thursday),
* a dedicated library like `date-fns` (`getISOWeek` and `getISOWeekYear`) is recommended.
*
* @param date The date to calculate the week for. Defaults to the current date.
* @returns An object containing the year and week number.
*/
export function getSimpleWeekAndYear(date: Date = new Date()): { year: number; week: number } {
const year = date.getFullYear();
const startOfYear = new Date(year, 0, 1);
// Calculate the difference in days from the start of the year (day 0 to 364/365)
const dayOfYear = (date.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24);
// Divide by 7, take the floor to get the zero-based week, and add 1 for a one-based week number.
const week = Math.floor(dayOfYear / 7) + 1;
return { year, week };
}

View File

@@ -113,6 +113,30 @@ describe('pdfConverter', () => {
expect(onProgress).toHaveBeenCalledWith(3, 3);
});
it('should handle a single-page PDF correctly', async () => {
// Arrange: Mock a single-page document
mockPdfDocument.numPages = 1;
const pdfFile = new File(['pdf-content'], 'single.pdf', { type: 'application/pdf' });
// Act
const { imageFiles, pageCount } = await convertPdfToImageFiles(pdfFile);
// Assert
expect(pageCount).toBe(1);
expect(imageFiles).toHaveLength(1);
expect(mockPdfDocument.getPage).toHaveBeenCalledTimes(1);
expect(mockPdfDocument.getPage).toHaveBeenCalledWith(1);
});
it('should handle a zero-page PDF gracefully', async () => {
// Arrange: Mock a zero-page document
mockPdfDocument.numPages = 0;
const pdfFile = new File(['pdf-content'], 'empty.pdf', { type: 'application/pdf' });
// Act & Assert: The function should resolve with empty results and not throw an error.
await expect(convertPdfToImageFiles(pdfFile)).resolves.toEqual({ imageFiles: [], pageCount: 0 });
});
it('should throw an error if getContext returns null', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
// Mock getContext to fail for the first page
@@ -146,4 +170,25 @@ describe('pdfConverter', () => {
'Failed to convert page 1 of PDF to blob.'
);
});
it('should throw an error if FileReader fails', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
// Simulate an environment where file.arrayBuffer does not exist to force FileReader
Object.defineProperty(pdfFile, 'arrayBuffer', { value: undefined });
// Mock FileReader to simulate an error
const mockReader = {
readAsArrayBuffer: vi.fn(),
onload: null,
onerror: null,
error: { message: 'Simulated FileReader error' },
};
vi.spyOn(window, 'FileReader').mockImplementation(() => mockReader as any);
// Trigger the onerror callback when readAsArrayBuffer is called
mockReader.readAsArrayBuffer.mockImplementation(() => {
(mockReader.onerror as any)();
});
await expect(convertPdfToImageFiles(pdfFile)).rejects.toThrow('FileReader error: Simulated FileReader error');
});
});

View File

@@ -1,5 +1,5 @@
// src/utils/processingTimer.ts
import { logger } from '../services/logger';
import { logger } from '../services/logger.client';
const PROCESSING_TIMES_KEY = 'flyerProcessingTimes';
const MAX_SAMPLES = 5;

View File

@@ -11,6 +11,11 @@ describe('formatUnitPrice', () => {
expect(formatUnitPrice(invalidInput, 'metric')).toEqual({ price: '—', unit: null });
});
it('should handle a value of zero correctly', () => {
const unitPrice: UnitPrice = { value: 0, unit: 'kg' };
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$0.00', unit: '/kg' });
});
// --- No Conversion Tests ---
it('should format a metric price correctly when the system is metric', () => {
const unitPrice: UnitPrice = { value: 220, unit: 'kg' }; // $2.20/kg
@@ -59,6 +64,12 @@ describe('formatUnitPrice', () => {
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$0.030', unit: '/fl oz' });
});
it('should convert a metric price (ml) to imperial (fl oz)', () => {
const unitPrice: UnitPrice = { value: 1, unit: 'ml' }; // $0.01/ml
// 0.01 / 0.033814 = 0.2957... -> $0.30/fl oz
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$0.30', unit: '/fl oz' });
});
// --- Formatting Tests ---
it('should use 3 decimal places for prices less than 10 cents', () => {
const unitPrice: UnitPrice = { value: 5, unit: 'g' }; // $0.05/g
@@ -90,7 +101,7 @@ describe('convertToMetric', () => {
it('should convert from lb to kg', () => {
const imperialPrice: UnitPrice = { value: 100, unit: 'lb' }; // $1.00/lb
const expectedValue = 100 * 0.453592; // 45.3592
const expectedValue = 45.3592;
expect(convertToMetric(imperialPrice)).toEqual({
value: expectedValue,
unit: 'kg',
@@ -99,7 +110,7 @@ describe('convertToMetric', () => {
it('should convert from oz to g', () => {
const imperialPrice: UnitPrice = { value: 10, unit: 'oz' }; // $0.10/oz
const expectedValue = 10 * 28.3495; // 283.495
const expectedValue = 283.495;
expect(convertToMetric(imperialPrice)).toEqual({
value: expectedValue,
unit: 'g',
@@ -108,10 +119,17 @@ describe('convertToMetric', () => {
it('should convert from fl oz to ml', () => {
const imperialPrice: UnitPrice = { value: 5, unit: 'fl oz' }; // $0.05/fl oz
const expectedValue = 5 * 29.5735; // 147.8675
const expectedValue = 147.8675;
expect(convertToMetric(imperialPrice)).toEqual({
value: expectedValue,
unit: 'ml',
});
});
it('should handle floating point inaccuracies during conversion', () => {
// A value that might produce a long floating point number when converted
const imperialPrice: UnitPrice = { value: 1, unit: 'oz' }; // $0.01/oz
const result = convertToMetric(imperialPrice);
expect(result?.value).toBeCloseTo(28.3495);
});
});