DB refactor for easier testsing
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m16s
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:
@@ -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'));
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
53
src/services/flyerDataTransformer.ts
Normal file
53
src/services/flyerDataTransformer.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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...');
|
||||
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
33
src/services/processingErrors.ts
Normal file
33
src/services/processingErrors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
|
||||
151
src/tests/integration/public.routes.integration.test.ts
Normal file
151
src/tests/integration/public.routes.integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
133
src/tests/integration/user.routes.integration.test.ts
Normal file
133
src/tests/integration/user.routes.integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/utils/dateUtils.test.ts
Normal file
58
src/utils/dateUtils.test.ts
Normal 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
22
src/utils/dateUtils.ts
Normal 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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user