ADR1-3 on routes + db files
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 17m30s

This commit is contained in:
2025-12-12 16:09:59 -08:00
parent e37a32c890
commit 117f034b2b
32 changed files with 934 additions and 812 deletions

View File

@@ -0,0 +1,13 @@
// src/middleware/fileUpload.middleware.ts
import { Request, Response, NextFunction } from 'express';
/**
* Middleware to check if a file was uploaded by multer.
* @param fieldName The expected field name of the uploaded file.
*/
export const requireFileUpload = (fieldName: string) => (req: Request, res: Response, next: NextFunction) => {
if (!req.file || req.file.fieldname !== fieldName) {
return res.status(400).json({ message: `A file for the '${fieldName}' field is required.` });
}
next();
};

View File

@@ -208,7 +208,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
it('should return 404 if the queue name is invalid', async () => {
const response = await supertest(app).post(`/api/admin/jobs/invalid-queue/${jobId}/retry`);
expect(response.status).toBe(404);
expect(response.body.message).toBe("Queue 'invalid-queue' not found.");
expect(response.body.message).toBe("Queue 'invalid-queue' not found."); // This is now handled by the errorHandler
});
it('should return 404 if the job ID is not found in the queue', async () => {
@@ -229,7 +229,7 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(400);
expect(response.body.message).toBe("Job is not in a 'failed' state. Current state: completed.");
expect(response.body.message).toBe("Job is not in a 'failed' state. Current state: completed."); // This is now handled by the errorHandler
expect(mockJob.retry).not.toHaveBeenCalled();
});

View File

@@ -10,7 +10,8 @@ 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';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import { ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
// --- Bull Board (Job Queue UI) Imports ---
@@ -183,13 +184,14 @@ router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), asy
}
});
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), async (req, res, next: NextFunction) => {
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req, res, next: NextFunction) => {
const brandId = req.params.id as unknown as number;
if (!req.file) {
return res.status(400).json({ message: 'Logo image file is required.' });
}
try {
// Although requireFileUpload middleware should ensure the file exists,
// this check satisfies TypeScript and adds robustness.
if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = `/assets/${req.file.filename}`;
await db.adminRepo.updateBrandLogo(brandId, logoUrl);
@@ -291,10 +293,10 @@ router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req, res,
router.delete('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
if (adminUser.user.user_id === req.params.id) {
return res.status(400).json({ message: 'Admins cannot delete their own account.' });
}
try {
if (adminUser.user.user_id === req.params.id) {
throw new ValidationError([], 'Admins cannot delete their own account.');
}
await db.userRepo.deleteUserById(req.params.id);
res.status(204).send();
} catch (error) {
@@ -454,19 +456,16 @@ router.post('/jobs/:queueName/:jobId/retry', async (req, res, next: NextFunction
const queue = queueMap[queueName];
if (!queue) {
return res.status(404).json({ message: `Queue '${queueName}' not found.` });
// Throw a NotFoundError to be handled by the central error handler.
throw new NotFoundError(`Queue '${queueName}' not found.`);
}
try {
const job = await queue.getJob(jobId);
if (!job) {
return res.status(404).json({ message: `Job with ID '${jobId}' not found in queue '${queueName}'.` });
}
if (!job) throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
const jobState = await job.getState();
if (jobState !== 'failed') {
return res.status(400).json({ message: `Job is not in a 'failed' state. Current state: ${jobState}.` });
}
if (jobState !== 'failed') throw new ValidationError([], `Job is not in a 'failed' state. Current state: ${jobState}.`);
await job.retry();
logger.info(`[Admin] User ${adminUser.user_id} manually retried job ${jobId} in queue ${queueName}.`);

View File

@@ -166,7 +166,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
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(response.body.message).toBe('Admins cannot delete their own account.'); // This is now handled by the errorHandler
expect(mockedDb.userRepo.deleteUserById).not.toHaveBeenCalled();
});
});

View File

@@ -16,6 +16,7 @@ import { logger } from '../services/logger.server';
import { UserProfile, ExtractedCoreData } from '../types';
import { flyerQueue } from '../services/queueService.server';
import { validateRequest } from '../middleware/validation.middleware';
import { NotFoundError } from '../services/db/errors.db';
const router = Router();
@@ -50,6 +51,35 @@ const rescanAreaSchema = z.object({
}),
});
const insightsSchema = z.object({
body: z.object({
items: z.array(z.any()), // Define more strictly if item structure is known
}),
});
const planTripSchema = z.object({
body: z.object({
items: z.array(z.any()),
store: z.object({ name: z.string() }),
userLocation: z.object({
latitude: z.number(),
longitude: z.number(),
}),
}),
});
const generateImageSchema = z.object({
body: z.object({ prompt: z.string().min(1) }),
});
const generateSpeechSchema = z.object({
body: z.object({ text: z.string().min(1) }),
});
const searchWebSchema = z.object({
body: z.object({ query: z.string().min(1, 'A search query is required.') }),
});
// Helper to safely extract an error message from unknown `catch` values.
const errMsg = (e: unknown) => {
if (e instanceof Error) return e.message;
@@ -161,7 +191,8 @@ router.get('/jobs/:jobId/status', validateRequest(jobIdParamSchema), async (req,
try {
const job = await flyerQueue.getJob(jobId);
if (!job) {
// The queue's getJob method doesn't throw NotFoundError, so we keep this check.
// Adhere to ADR-001 by throwing a specific error to be handled centrally.
throw new NotFoundError('Job not found.');
return res.status(404).json({ message: 'Job not found.' });
}
const state = await job.getState();
@@ -354,7 +385,7 @@ router.post('/extract-logo', optionalAuth, uploadToDisk.array('images'), async (
}
});
router.post('/quick-insights', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
router.post('/quick-insights', passport.authenticate('jwt', { session: false }), validateRequest(insightsSchema), async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side quick insights requested.`);
res.status(200).json({ text: "This is a server-generated quick insight: buy the cheap stuff!" }); // Stubbed response
@@ -363,7 +394,7 @@ router.post('/quick-insights', passport.authenticate('jwt', { session: false }),
}
});
router.post('/deep-dive', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
router.post('/deep-dive', passport.authenticate('jwt', { session: false }), validateRequest(insightsSchema), async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side deep dive requested.`);
res.status(200).json({ text: "This is a server-generated deep dive analysis. It is very detailed." }); // Stubbed response
@@ -372,7 +403,7 @@ router.post('/deep-dive', passport.authenticate('jwt', { session: false }), asyn
}
});
router.post('/search-web', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
router.post('/search-web', passport.authenticate('jwt', { session: false }), validateRequest(searchWebSchema), async (req, res, next: NextFunction) => {
try {
logger.info(`Server-side web search requested.`);
res.status(200).json({ text: "The web says this is good.", sources: [] }); // Stubbed response
@@ -381,7 +412,7 @@ router.post('/search-web', passport.authenticate('jwt', { session: false }), asy
}
});
router.post('/plan-trip', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => {
router.post('/plan-trip', passport.authenticate('jwt', { session: false }), validateRequest(planTripSchema), async (req, res, next: NextFunction) => {
try {
const { items, store, userLocation } = req.body;
logger.info(`Server-side trip planning requested for user.`);
@@ -395,14 +426,14 @@ router.post('/plan-trip', passport.authenticate('jwt', { session: false }), asyn
// --- STUBBED AI Routes for Future Features ---
router.post('/generate-image', passport.authenticate('jwt', { session: false }), (req: Request, res: Response) => {
router.post('/generate-image', passport.authenticate('jwt', { session: false }), validateRequest(generateImageSchema), (req: Request, res: Response) => {
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
logger.info('Request received for unimplemented endpoint: /api/ai/generate-image');
res.status(501).json({ message: 'Image generation is not yet implemented.' });
});
router.post('/generate-speech', passport.authenticate('jwt', { session: false }), (req: Request, res: Response) => {
router.post('/generate-speech', passport.authenticate('jwt', { session: false }), validateRequest(generateSpeechSchema), (req: Request, res: Response) => {
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
logger.info('Request received for unimplemented endpoint: /api/ai/generate-speech');

View File

@@ -66,9 +66,6 @@ router.post('/', validateRequest(createBudgetSchema), async (req, res, next: Nex
const newBudget = await budgetRepo.createBudget(user.user_id, req.body);
res.status(201).json(newBudget);
} 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);
}

View File

@@ -148,12 +148,13 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(response.status).toBe(400);
});
it('should return an empty array if flyerIds is empty', async () => {
it('should return 400 if flyerIds is an empty array, as per schema validation', async () => {
const response = await supertest(app)
.post('/api/flyers/items/batch-fetch')
.send({ flyerIds: [] });
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
expect(response.status).toBe(400);
// Check for the specific Zod error message.
expect(response.body.errors[0].message).toBe('flyerIds must be a non-empty array.');
});
it('should return 500 if the database call fails', async () => {

View File

@@ -2,17 +2,49 @@
import { Router, Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import * as db from '../services/db/index.db';
import { z } from 'zod';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware';
const router = Router();
// --- Zod Schemas for Flyer Routes ---
const getFlyersSchema = z.object({
query: z.object({
limit: z.coerce.number().int().positive().optional().default(20),
offset: z.coerce.number().int().nonnegative().optional().default(0),
}),
});
const flyerIdParamSchema = z.object({
params: z.object({
id: z.coerce.number().int().positive('Invalid flyer ID provided.'),
}),
});
const batchFetchSchema = z.object({
body: z.object({
flyerIds: z.array(z.number().int().positive()).min(1, 'flyerIds must be a non-empty array.'),
}),
});
const trackItemSchema = z.object({
params: z.object({
itemId: z.coerce.number().int().positive('Invalid item ID provided.'),
}),
body: z.object({
type: z.enum(['view', 'click'], { message: 'A valid interaction type ("view" or "click") is required.' }),
}),
});
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
router.get('/', async (req, res, next: NextFunction) => {
router.get('/', validateRequest(getFlyersSchema), async (req, res, next: NextFunction) => {
try {
const limit = parseInt(req.query.limit as string, 10) || 20;
const offset = parseInt(req.query.offset as string, 10) || 0;
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
const flyers = await db.flyerRepo.getFlyers(limit, offset);
res.json(flyers);
} catch (error) {
@@ -24,12 +56,9 @@ router.get('/', async (req, res, next: NextFunction) => {
/**
* GET /api/flyers/:id - Get a single flyer by its ID.
*/
router.get('/:id', async (req, res, next: NextFunction) => {
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next: NextFunction) => {
try {
const flyerId = parseInt(req.params.id, 10);
if (isNaN(flyerId)) {
return res.status(400).json({ message: 'Invalid flyer ID provided.' });
}
const flyerId = req.params.id as unknown as number;
const flyer = await db.flyerRepo.getFlyerById(flyerId);
res.json(flyer);
} catch (error) {
@@ -40,12 +69,9 @@ router.get('/:id', async (req, res, next: NextFunction) => {
/**
* GET /api/flyers/:id/items - Get all items for a specific flyer.
*/
router.get('/:id/items', async (req, res, next: NextFunction) => {
router.get('/:id/items', validateRequest(flyerIdParamSchema), async (req, res, next: NextFunction) => {
try {
const flyerId = parseInt(req.params.id, 10);
if (isNaN(flyerId)) {
return res.status(400).json({ message: 'Invalid flyer ID provided.' });
}
const flyerId = req.params.id as unknown as number;
const items = await db.flyerRepo.getFlyerItems(flyerId);
res.json(items);
} catch (error) {
@@ -57,15 +83,8 @@ router.get('/:id/items', async (req, res, next: NextFunction) => {
/**
* POST /api/flyers/items/batch-fetch - Get all items for multiple flyers at once.
*/
router.post('/items/batch-fetch', async (req, res, next: NextFunction) => {
const { flyerIds } = req.body;
if (!Array.isArray(flyerIds)) {
return res.status(400).json({ message: 'flyerIds must be an array.' });
}
// If the array is empty, return an empty array immediately to avoid an unnecessary DB call.
if (flyerIds.length === 0) {
return res.json([]);
}
router.post('/items/batch-fetch', validateRequest(batchFetchSchema), async (req, res, next: NextFunction) => {
const { flyerIds } = req.body as { flyerIds: number[] };
try {
const items = await db.flyerRepo.getFlyerItemsForFlyers(flyerIds);
res.json(items);
@@ -77,13 +96,11 @@ router.post('/items/batch-fetch', async (req, res, next: NextFunction) => {
/**
* POST /api/flyers/items/batch-count - Get the total number of items for multiple flyers.
*/
router.post('/items/batch-count', async (req, res, next: NextFunction) => {
const { flyerIds } = req.body;
if (!Array.isArray(flyerIds)) {
return res.status(400).json({ message: 'flyerIds must be an array.' });
}
router.post('/items/batch-count', validateRequest(batchFetchSchema.partial()), async (req, res, next: NextFunction) => {
const { flyerIds } = req.body as { flyerIds?: number[] };
try {
const count = await db.flyerRepo.countFlyerItemsForFlyers(flyerIds);
// The DB function handles an empty array, so we can simplify.
const count = await db.flyerRepo.countFlyerItemsForFlyers(flyerIds ?? []);
res.json({ count });
} catch (error) {
next(error);
@@ -93,17 +110,9 @@ router.post('/items/batch-count', async (req, res, next: NextFunction) => {
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
*/
router.post('/items/:itemId/track', (req: Request, res: Response) => {
// This is a fire-and-forget endpoint. It's kept simple and doesn't need a full try/catch/next.
const itemId = parseInt(req.params.itemId, 10);
const { type } = req.body;
if (isNaN(itemId)) {
return res.status(400).json({ message: 'Invalid item ID provided.' });
}
if (type !== 'view' && type !== 'click') {
return res.status(400).json({ message: 'A valid interaction type ("view" or "click") is required.' });
}
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req: Request, res: Response) => {
const itemId = req.params.itemId as unknown as number;
const { type } = req.body as { type: 'view' | 'click' };
db.flyerRepo.trackFlyerItemInteraction(itemId, type);
res.status(202).send();
});

View File

@@ -531,9 +531,10 @@ describe('User Routes (/api/users)', () => {
it('GET /addresses/:addressId should return 404 if address not found', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 } as any);
vi.mocked(db.addressRepo.getAddressById).mockResolvedValue(undefined);
vi.mocked(db.addressRepo.getAddressById).mockRejectedValue(new NotFoundError('Address not found.'));
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Address not found.');
});
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {

View File

@@ -11,6 +11,7 @@ import { z } from 'zod';
import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { User, UserProfile, Address } from '../types';
import { userService } from '../services/userService';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
@@ -49,6 +50,13 @@ const addWatchedItemSchema = z.object({
const createShoppingListSchema = z.object({ body: z.object({ name: z.string().min(1, "Field 'name' is required.") }) });
// Apply the JWT authentication middleware to all routes in this file.
const notificationQuerySchema = z.object({
query: z.object({
limit: z.coerce.number().int().positive().optional().default(20),
offset: z.coerce.number().int().nonnegative().optional().default(0),
}),
});
// Any request to a /api/users/* endpoint will now require a valid JWT.
router.use(passport.authenticate('jwt', { session: false }));
@@ -103,12 +111,11 @@ router.post(
*/
router.get(
'/notifications',
async (req: Request, res: Response) => {
validateRequest(notificationQuerySchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
const limit = parseInt(req.query.limit as string, 10) || 20;
const offset = parseInt(req.query.offset as string, 10) || 0;
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset);
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset); // This was a duplicate, fixed.
res.json(notifications);
}
);
@@ -118,7 +125,7 @@ router.get(
*/
router.post(
'/notifications/mark-all-read',
async (req: Request, res: Response) => {
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
await db.notificationRepo.markAllNotificationsAsRead(user.user_id);
res.status(204).send(); // No Content
@@ -130,7 +137,7 @@ router.post(
*/
router.post(
'/notifications/:notificationId/mark-read', validateRequest(numericIdParam('notificationId')),
async (req: Request, res: Response) => {
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
const notificationId = req.params.notificationId as unknown as number;
@@ -148,10 +155,6 @@ router.get('/profile', async (req, res, next: NextFunction) => {
try {
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.' });
}
res.json(userProfile);
} catch (error) {
logger.error(`[ROUTE] GET /api/users/profile - ERROR`, { error });
@@ -215,6 +218,7 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
return res.status(404).json({ message: 'User not found or password not set.' });
}
// Per ADR-001, findUserWithPasswordHashById will throw if user or hash is missing.
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
if (!isMatch) {
return res.status(403).json({ message: 'Incorrect password.' });
@@ -307,9 +311,6 @@ router.get('/shopping-lists/:listId', validateRequest(numericIdParam('listId')),
try {
const list = await db.shoppingRepo.getShoppingListById(listId, user.user_id);
if (!list) {
return res.status(404).json({ message: 'Shopping list not found or you do not have permission to view it.' });
}
res.json(list);
} catch (error) {
logger.error(`[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`, { error, listId });
@@ -517,12 +518,8 @@ router.get('/addresses/:addressId', validateRequest(numericIdParam('addressId'))
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
}
try {
const address = await db.addressRepo.getAddressById(addressId);
res.json(address);
} catch (error) {
next(error);
}
const address = await db.addressRepo.getAddressById(addressId); // This will throw NotFoundError if not found
res.json(address);
});
/**
@@ -542,11 +539,10 @@ router.put('/profile/address', validateRequest(z.object({
const addressData = req.body as Partial<Address>;
try {
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.userRepo.updateUserProfile(user.user_id, { address_id: addressId });
}
// Per ADR-002, complex operations involving multiple database writes should be
// encapsulated in a single service method that manages the transaction.
// This ensures both the address upsert and the user profile update are atomic.
const addressId = await userService.upsertUserAddress(user, addressData);
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
} catch (error) {
next(error);

View File

@@ -1,43 +0,0 @@
# ADR-001: Standardized Error Handling for Service and Repository Layers
**Date**: 2025-12-12
**Status**: Accepted
## Context
Our application has experienced a recurring pattern of bugs and brittle tests related to error handling, specifically for "resource not found" scenarios. The root causes identified are:
1. **Inconsistent Return Types**: Database repository methods that fetch a single entity (e.g., `getUserById`, `getRecipeById`) had inconsistent behavior when an entity was not found. Some returned `undefined`, some returned `null`, and others threw a generic `Error`.
2. **Burden on Callers**: This inconsistency forced route handlers (the callers) to implement defensive checks for `undefined` or `null` before sending a response. These checks were often forgotten or implemented incorrectly.
3. **Incorrect HTTP Status Codes**: When a route handler forgot to check for an `undefined` result and passed it to `res.json()`, the Express framework would interpret this as a server-side failure, resulting in an incorrect `500 Internal Server Error` instead of the correct `404 Not Found`.
4. **Brittle Tests**: Unit and integration tests for routes were unreliable. Mocks often threw a generic `new Error()` when the actual implementation returned `undefined` or a specific custom error, leading to unexpected `500` status codes in test environments.
This pattern led to increased development friction, difficult-to-diagnose bugs, and a fragile test suite.
## Decision
We will adopt a strict, consistent error-handling contract for the service and repository layers.
1. **Always Throw on Not Found**: Any function or method responsible for fetching a single, specific resource (e.g., by ID, checksum, or other unique identifier) **MUST** throw a `NotFoundError` if that resource does not exist. It **MUST NOT** return `null` or `undefined` to signify absence.
2. **Use Specific, Custom Errors**: For other known, predictable failure modes (e.g., unique constraint violations, foreign key violations), the repository layer **MUST** throw the corresponding custom `DatabaseError` subclass (e.g., `UniqueConstraintError`, `ForeignKeyConstraintError`).
3. **Centralize HTTP Status Mapping**: The `errorHandler` middleware is the **single source of truth** for mapping these specific error types to their corresponding HTTP status codes (e.g., `NotFoundError` -> 404, `UniqueConstraintError` -> 409).
4. **Simplify Route Handlers**: Route handlers should be simplified to use a standard `try...catch` block. All errors caught from the service/repository layer should be passed directly to `next(error)`, relying on the `errorHandler` middleware to format the final response. No special `if (result === undefined)` checks are needed.
## Consequences
### Positive
**Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors.
**Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
**Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`.
**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the *exact* error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior.
**Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally.
### Negative
**Initial Refactoring**: Requires a one-time effort to audit and refactor all existing repository methods to conform to this new standard.
**Convention Adherence**: Developers must be aware of and adhere to this convention. This ADR serves as the primary documentation for this pattern.

View File

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { AddressRepository } from './address.db';
import type { Address } from '../../types';
import { UniqueConstraintError } from './errors.db';
import { UniqueConstraintError, NotFoundError } from './errors.db';
// Un-mock the module we are testing
vi.unmock('./address.db');
@@ -32,10 +32,10 @@ describe('Address DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.addresses WHERE address_id = $1', [1]);
});
it('should return undefined if no address is found', async () => {
it('should throw NotFoundError if no address is found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await addressRepo.getAddressById(999);
expect(result).toBeUndefined();
await expect(addressRepo.getAddressById(999)).rejects.toThrow(NotFoundError);
await expect(addressRepo.getAddressById(999)).rejects.toThrow('Address with ID 999 not found.');
});
it('should throw an error if the database query fails', async () => {
@@ -47,40 +47,31 @@ describe('Address DB Service', () => {
});
describe('upsertAddress', () => {
it('should perform an INSERT when no address_id is provided', async () => {
it('should INSERT a new address when no address_id is provided', async () => {
const newAddressData = { address_line_1: '456 New Ave', city: 'Newville' };
mockPoolInstance.query.mockResolvedValue({ rows: [{ address_id: 2 }] });
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);
expect(query).toContain('INSERT INTO public.addresses');
expect(query).toContain('ON CONFLICT (address_id) DO UPDATE');
expect(values).toEqual(['456 New Ave', 'Newville']);
});
it('should perform an UPDATE when an address_id is provided', async () => {
it('should UPDATE an existing address 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 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));
const [query, values] = mockPoolInstance.query.mock.calls[0];
expect(query).toContain('INSERT INTO public.addresses');
expect(query).toContain('ON CONFLICT (address_id) DO UPDATE');
// The values array should now include the address_id at the beginning
expect(values).toEqual([1, '789 Old Rd', 'Oldtown']);
});
it('should throw a generic error on INSERT failure', async () => {

View File

@@ -2,7 +2,7 @@
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import { UniqueConstraintError } from './errors.db';
import { UniqueConstraintError, NotFoundError } from './errors.db';
import { Address } from '../../types';
export class AddressRepository {
@@ -17,11 +17,17 @@ export class AddressRepository {
* @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> {
async getAddressById(addressId: number): Promise<Address> {
try {
const res = await this.db.query<Address>('SELECT * FROM public.addresses WHERE address_id = $1', [addressId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Address with ID ${addressId} not found.`);
}
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in getAddressById:', { error, addressId });
throw new Error('Failed to retrieve address.');
}
@@ -35,34 +41,38 @@ export class AddressRepository {
*/
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;
const { address_id, ...addressData } = address;
const columns = Object.keys(addressData);
const values = Object.values(addressData);
// If an ID is provided, it's an update. Otherwise, it's an insert.
// If an address_id is provided, include it for the ON CONFLICT clause.
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;
columns.unshift('address_id');
values.unshift(address_id);
}
// Dynamically build the parameter placeholders ($1, $2, etc.)
const valuePlaceholders = columns.map((_, i) => `$${i + 1}`).join(', ');
// Dynamically build the SET clause for the UPDATE part.
// EXCLUDED refers to the values from the failed INSERT attempt.
const updateSetClauses = columns
.filter(col => col !== 'address_id') // Don't update the primary key
.map(col => `${col} = EXCLUDED.${col}`)
.join(', ');
const query = `
INSERT INTO public.addresses (${columns.join(', ')})
VALUES (${valuePlaceholders})
ON CONFLICT (address_id) DO UPDATE
SET ${updateSetClauses},
updated_at = now()
RETURNING address_id;
`;
const res = await this.db.query<{ address_id: number }>(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.');

View File

@@ -1,7 +1,7 @@
// src/services/db/admin.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { ForeignKeyConstraintError } from './errors.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { AdminRepository } from './admin.db';
import type { SuggestedCorrection, AdminUserView, User } from '../../types';
@@ -12,12 +12,19 @@ vi.unmock('./admin.db');
vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
warn: vi.fn(), // Keep warn for other tests that might use it
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
import { withTransaction } from './connection.db';
describe('Admin DB Service', () => {
let adminRepo: AdminRepository;
@@ -25,10 +32,11 @@ describe('Admin DB Service', () => {
// Reset the global mock's call history before each test.
vi.clearAllMocks();
// For transactional methods, we mock the client returned by `connect()`
const mockClient = { ...mockPoolInstance, release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Reset the withTransaction mock before each test
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
return callback(mockClient as any);
});
// Instantiate the repository with the mock pool for each test
adminRepo = new AdminRepository(mockPoolInstance as any);
});
@@ -79,13 +87,10 @@ describe('Admin DB Service', () => {
);
});
it('should not throw an error if the correction is already processed (rowCount is 0)', async () => {
it('should throw NotFoundError if the correction is not found or not pending', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
// The function should complete without throwing an error.
await expect(adminRepo.rejectCorrection(123)).resolves.toBeUndefined();
// We can also check that a warning was logged.
const { logger } = await import('../logger.server');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('was not found or not in \'pending\' state'));
await expect(adminRepo.rejectCorrection(123)).rejects.toThrow(NotFoundError);
await expect(adminRepo.rejectCorrection(123)).rejects.toThrow('Correction with ID 123 not found or not in \'pending\' state.');
});
it('should throw an error if the database query fails', async () => {
@@ -110,7 +115,8 @@ 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(adminRepo.updateSuggestedCorrection(999, 'new value')).rejects.toThrow('Failed to update suggested correction.');
await expect(adminRepo.updateSuggestedCorrection(999, 'new value')).rejects.toThrow(NotFoundError);
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 () => {
@@ -249,7 +255,8 @@ describe('Admin DB Service', () => {
it('should throw an error if the recipe is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateRecipeStatus(999, 'public')).rejects.toThrow('Failed to update recipe status.');
await expect(adminRepo.updateRecipeStatus(999, 'public')).rejects.toThrow(NotFoundError);
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 () => {
@@ -261,55 +268,47 @@ describe('Admin DB Service', () => {
describe('resolveUnmatchedFlyerItem', () => {
it('should execute a transaction to resolve an unmatched item', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Mock the sequence of calls within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ flyer_item_id: 55 }] }) // SELECT flyer_item_id
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE flyer_items
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE unmatched_flyer_items
.mockResolvedValueOnce({ rows: [] }); // COMMIT
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ flyer_item_id: 55 }] }) // SELECT flyer_item_id
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE flyer_items
.mockResolvedValueOnce({ rowCount: 1 }); // UPDATE unmatched_flyer_items
return callback(mockClient as any);
});
await adminRepo.resolveUnmatchedFlyerItem(1, 101);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT flyer_item_id FROM public.unmatched_flyer_items'), [1]);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.flyer_items'), [101, 55]);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'resolved'"), [1]);
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
});
it('should throw an error if the unmatched item is not found', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
it('should throw NotFoundError if the unmatched item is not found', async () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockResolvedValueOnce({ rowCount: 0, rows: [] }); // SELECT finds nothing
await expect(callback(mockClient as any)).rejects.toThrow(NotFoundError);
throw new NotFoundError(`Unmatched flyer item with ID 999 not found.`); // Re-throw for the outer expect
});
// Mock the SELECT to find no item
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rowCount: 0, rows: [] }); // SELECT finds nothing
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101)).rejects.toThrow('Failed to resolve unmatched flyer item.');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101)).rejects.toThrow(NotFoundError);
await expect(adminRepo.resolveUnmatchedFlyerItem(999, 101)).rejects.toThrow('Unmatched flyer item with ID 999 not found.');
});
it('should rollback transaction on generic error', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
const dbError = new Error('DB Error');
// Mock the second UPDATE to fail
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ flyer_item_id: 55 }] }) // SELECT flyer_item_id
.mockRejectedValueOnce(dbError); // UPDATE flyer_items fails
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ flyer_item_id: 55 }] }) // SELECT flyer_item_id
.mockRejectedValueOnce(dbError); // UPDATE flyer_items fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError; // Re-throw for the outer expect
});
await expect(adminRepo.resolveUnmatchedFlyerItem(1, 101)).rejects.toThrow('Failed to resolve unmatched flyer item.');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
@@ -320,10 +319,17 @@ describe('Admin DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1", [1]);
});
it('should throw an error if the database query fails', async () => {
it('should throw NotFoundError if the unmatched item is not found or not pending', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
await expect(adminRepo.ignoreUnmatchedFlyerItem(999)).rejects.toThrow(NotFoundError);
await expect(adminRepo.ignoreUnmatchedFlyerItem(999)).rejects.toThrow('Unmatched flyer item with ID 999 not found or not in \'pending\' state.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(adminRepo.ignoreUnmatchedFlyerItem(1)).rejects.toThrow('Failed to ignore unmatched flyer item.');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'ignored'"), [1]);
});
});
@@ -373,10 +379,17 @@ describe('Admin DB Service', () => {
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 () => {
it('should throw NotFoundError if the brand is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0 });
await expect(adminRepo.updateBrandLogo(999, '/logo.png')).rejects.toThrow(NotFoundError);
await expect(adminRepo.updateBrandLogo(999, '/logo.png')).rejects.toThrow('Brand 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(adminRepo.updateBrandLogo(1, '/logo.png')).rejects.toThrow('Failed to update brand logo in database.');
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE public.brands SET logo_url'), ['/logo.png', 1]);
});
});
@@ -391,7 +404,8 @@ describe('Admin DB Service', () => {
it('should throw an error if the receipt is not found (rowCount is 0)', async () => {
mockPoolInstance.query.mockResolvedValue({ rowCount: 0, rows: [] });
await expect(adminRepo.updateReceiptStatus(999, 'completed')).rejects.toThrow('Failed to update receipt status.');
await expect(adminRepo.updateReceiptStatus(999, 'completed')).rejects.toThrow(NotFoundError);
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 () => {

View File

@@ -1,6 +1,6 @@
// src/services/db/admin.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User, AdminUserView } from '../../types';
@@ -77,13 +77,11 @@ export class AdminRepository {
[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}`);
throw new NotFoundError(`Correction with ID ${correctionId} not found or not in 'pending' state.`);
}
logger.info(`Successfully rejected correction ID: ${correctionId}`);
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in rejectCorrection:', { error, correctionId });
throw new Error('Failed to reject correction.');
}
@@ -308,16 +306,14 @@ export class AdminRepository {
'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *',
[status, recipeId]
);
if (res.rowCount === 0) {
throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
}
if (res.rowCount === 0) throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in updateRecipeStatus:', { error, recipeId, status });
throw new Error('Failed to update recipe status.');
throw new Error('Failed to update recipe status.'); // Keep generic for other DB errors
}
}
@@ -328,35 +324,31 @@ export class AdminRepository {
* @param masterItemId The ID of the `master_grocery_items` to link to.
*/
async resolveUnmatchedFlyerItem(unmatchedFlyerItemId: number, masterItemId: number): Promise<void> {
const client = await getPool().connect();
try {
await client.query('BEGIN');
await withTransaction(async (client) => {
// First, get the flyer_item_id from the unmatched record
const unmatchedRes = await client.query<{
flyer_item_id: number;
}>('SELECT flyer_item_id FROM public.unmatched_flyer_items WHERE unmatched_flyer_item_id = $1 FOR UPDATE', [
unmatchedFlyerItemId,
]);
// First, get the flyer_item_id from the unmatched record
const unmatchedRes = await client.query<{ flyer_item_id: number }>(
'SELECT flyer_item_id FROM public.unmatched_flyer_items WHERE unmatched_flyer_item_id = $1 FOR UPDATE',
[unmatchedFlyerItemId]
);
if (unmatchedRes.rowCount === 0) {
throw new NotFoundError(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`);
}
const { flyer_item_id } = unmatchedRes.rows[0];
if (unmatchedRes.rowCount === 0) {
throw new NotFoundError(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`);
}
const { flyer_item_id } = unmatchedRes.rows[0];
// Next, update the original flyer_items table with the correct master_item_id
await client.query('UPDATE public.flyer_items SET master_item_id = $1 WHERE flyer_item_id = $2', [masterItemId, flyer_item_id]);
// Next, update the original flyer_items table with the correct master_item_id
await client.query('UPDATE public.flyer_items SET master_item_id = $1 WHERE flyer_item_id = $2', [masterItemId, flyer_item_id]);
// Finally, update the status of the unmatched record to 'resolved'
await client.query("UPDATE public.unmatched_flyer_items SET status = 'resolved' WHERE unmatched_flyer_item_id = $1", [unmatchedFlyerItemId]);
// Finally, update the status of the unmatched record to 'resolved'
await client.query("UPDATE public.unmatched_flyer_items SET status = 'resolved' WHERE unmatched_flyer_item_id = $1", [unmatchedFlyerItemId]);
await client.query('COMMIT');
logger.info(`Successfully resolved unmatched item ${unmatchedFlyerItemId} to master item ${masterItemId}.`);
logger.info(`Successfully resolved unmatched item ${unmatchedFlyerItemId} to master item ${masterItemId}.`);
});
} catch (error) {
await client.query('ROLLBACK');
logger.error('Database transaction error in resolveUnmatchedFlyerItem:', { error, unmatchedFlyerItemId, masterItemId });
throw new Error('Failed to resolve unmatched flyer item.');
} finally {
client.release();
}
}
@@ -366,8 +358,12 @@ export class AdminRepository {
*/
async ignoreUnmatchedFlyerItem(unmatchedFlyerItemId: number): Promise<void> {
try {
await this.db.query("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1", [unmatchedFlyerItemId]);
const res = await this.db.query("UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'", [unmatchedFlyerItemId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found or not in 'pending' state.`);
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in ignoreUnmatchedFlyerItem:', { error, unmatchedFlyerItemId });
throw new Error('Failed to ignore unmatched flyer item.');
}
@@ -465,11 +461,15 @@ export class AdminRepository {
// prettier-ignore
async updateBrandLogo(brandId: number, logoUrl: string): Promise<void> {
try {
await this.db.query(
const res = await this.db.query(
'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2',
[logoUrl, brandId]
);
if (res.rowCount === 0) {
throw new NotFoundError(`Brand with ID ${brandId} not found.`);
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in updateBrandLogo:', { error, brandId });
throw new Error('Failed to update brand logo in database.');
}
@@ -487,11 +487,10 @@ export class AdminRepository {
`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 NotFoundError(`Receipt with ID ${receiptId} not found.`);
}
if (res.rowCount === 0) throw new NotFoundError(`Receipt with ID ${receiptId} not found.`);
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in updateReceiptStatus:', { error, receiptId, status });
throw new Error('Failed to update receipt status.');
}

View File

@@ -1,6 +1,6 @@
// src/services/db/budget.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ForeignKeyConstraintError } from './errors.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./budget.db');
@@ -14,11 +14,18 @@ vi.mock('../logger.server', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
error: vi.fn(), // Keep warn for other tests that might use it
debug: vi.fn(),
},
}));
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
import { withTransaction } from './connection.db';
// Mock the gamification repository, as createBudget calls it.
vi.mock('./gamification.db', () => ({
GamificationRepository: class { awardAchievement = vi.fn(); },
@@ -62,72 +69,65 @@ describe('Budget DB Service', () => {
it('should execute an INSERT query and return the new budget', 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 };
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // For the INSERT...RETURNING
.mockResolvedValueOnce({ rows: [] }); // For award_achievement
return callback(mockClient as any);
});
// 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: [] }) // For BEGIN
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // For the INSERT...RETURNING
.mockResolvedValueOnce({ rows: [] }); // For both award_achievement and COMMIT
const result = await budgetRepo.createBudget('user-123', budgetData);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.budgets'), expect.any(Array));
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("SELECT public.award_achievement($1, 'First Budget Created')"), ['user-123']);
expect(result).toEqual(mockCreatedBudget);
expect(withTransaction).toHaveBeenCalledTimes(1);
});
it('should throw ForeignKeyConstraintError if user does not exist', async () => {
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
const mockClient = {
query: vi.fn()
.mockResolvedValueOnce({ rows: [] }) // Allow BEGIN to succeed
.mockRejectedValueOnce(dbError), // Have the INSERT fail
release: vi.fn(),
};
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// The function should now correctly throw the custom error.
await expect(budgetRepo.createBudget('non-existent-user', budgetData))
.rejects.toThrow(new ForeignKeyConstraintError('The specified user does not exist.'));
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); // Verify rollback was called
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockRejectedValueOnce(dbError); // INSERT fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError; // Re-throw for the outer expect
});
await expect(budgetRepo.createBudget('non-existent-user', budgetData)).rejects.toThrow(ForeignKeyConstraintError);
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');
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // INSERT...RETURNING
.mockRejectedValueOnce(achievementError); // award_achievement fails
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [mockCreatedBudget] }) // INSERT...RETURNING
.mockRejectedValueOnce(achievementError); // award_achievement fails
await expect(callback(mockClient as any)).rejects.toThrow(achievementError);
throw achievementError; // Re-throw for the outer expect
});
await expect(budgetRepo.createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.query).not.toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
});
it('should throw a generic error if the database query fails', async () => {
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
const dbError = new Error('DB Error');
// Mock BEGIN to succeed, but the INSERT to fail
mockPoolInstance.query.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError);
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockRejectedValueOnce(dbError); // INSERT fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError; // Re-throw for the outer expect
});
await expect(budgetRepo.createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
});
});

View File

@@ -1,6 +1,6 @@
// src/services/db/budget.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import { Budget, SpendingByCategory } from '../../types';
@@ -38,29 +38,25 @@ export class BudgetRepository {
*/
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]
);
return await withTransaction(async (client) => {
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];
// 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]);
return res.rows[0];
});
} catch (error) {
await client.query('ROLLBACK');
// The patch requested this specific error handling.
if ((error as any).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();
}
}

View File

@@ -4,7 +4,7 @@
//
// --- END FIX REGISTRY ---
// src/services/db/connection.db.ts
import { Pool, PoolConfig, types } from 'pg';
import { Pool, PoolConfig, PoolClient, types } from 'pg';
import { logger } from '../logger.server';
// --- Singleton Pool Instance ---
@@ -51,6 +51,36 @@ export const getPool = (): Pool => {
return pool;
};
/**
* Executes a series of database operations within a single atomic transaction.
* This function implements the Unit of Work pattern as defined in ADR-002.
* It automatically handles acquiring a client, beginning the transaction,
* committing on success, rolling back on error, and releasing the client.
*
* @param callback A function that receives the transactional `PoolClient` and returns a Promise.
* @returns A promise that resolves with the return value of the callback.
*/
export async function withTransaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Transaction failed, rolling back.', {
// Safely access error message
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error; // Re-throw the original error to be handled by the caller
} finally {
// Always release the client back to the pool
client.release();
}
}
/**
* Checks for the existence of a list of tables in the public schema.
* @param tableNames An array of table names to check.

View File

@@ -7,7 +7,7 @@ import { createMockFlyer, createMockFlyerItem, createMockBrand } from '../../tes
vi.unmock('./flyer.db');
import { FlyerRepository, createFlyerAndItems } from './flyer.db';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { FlyerInsert, FlyerItemInsert, Brand, Flyer, FlyerItem, FlyerDbInsert } from '../../types';
// Mock dependencies
@@ -15,6 +15,13 @@ vi.mock('../logger.server', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
import { withTransaction } from './connection.db';
describe('Flyer DB Service', () => {
let flyerRepo: FlyerRepository;
@@ -182,7 +189,7 @@ describe('Flyer DB Service', () => {
});
describe('createFlyerAndItems', () => {
it('should execute a transaction with BEGIN, INSERTs, and COMMIT', async () => {
it('should use withTransaction to create a flyer and items', async () => {
const flyerData: FlyerInsert = { file_name: 'transact.jpg', store_name: 'Transaction Store' } as FlyerInsert;
const itemsData: FlyerItemInsert[] = [{
item: 'Transactional Item',
@@ -191,45 +198,36 @@ describe('Flyer DB Service', () => {
view_count: 0,
click_count: 0,
} as FlyerItemInsert];
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 99 });
const mockItems = [createMockFlyerItem({ ...itemsData[0], flyer_id: 99, flyer_item_id: 101 })];
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 99, store_id: 1 });
const mockItems = [createMockFlyerItem({ ...itemsData[0], flyer_id: 99, flyer_item_id: 101, master_item_id: undefined })];
// 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 calls within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems }) // insertFlyerItems
.mockResolvedValueOnce({ rows: [] }); // COMMIT
// Mock the withTransaction to execute the callback with a mock client
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
// Mock the sequence of calls within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems }); // insertFlyerItems
return callback(mockClient as any);
});
const result = await createFlyerAndItems(flyerData, itemsData);
// Use `objectContaining` to make the test more resilient to changes
// in the returned object structure (e.g., new columns added to the DB).
// This ensures the core data is correct without being overly brittle.
expect(result).toEqual({
flyer: mockFlyer,
items: expect.arrayContaining([
expect.objectContaining(mockItems[0])
])
items: mockItems
});
// Verify transaction control
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.query).not.toHaveBeenCalledWith('ROLLBACK');
expect(withTransaction).toHaveBeenCalledTimes(1);
// Verify the individual functions were called with the client
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT store_id FROM public.stores'), expect.any(Array));
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyers'), expect.any(Array)); // This is now the 2nd call
const callback = vi.mocked(withTransaction).mock.calls[0][0];
const mockClient = { query: vi.fn() };
mockClient.query.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }).mockResolvedValueOnce({ rows: [mockFlyer] }).mockResolvedValueOnce({ rows: mockItems });
await callback(mockClient as any);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT store_id FROM public.stores'), ['Transaction Store']);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyers'), expect.any(Array));
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyer_items'), expect.any(Array));
});
it('should ROLLBACK the transaction if an error occurs', async () => {
@@ -237,28 +235,21 @@ describe('Flyer DB Service', () => {
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
const dbError = new Error('DB connection lost');
// 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 insertFlyer to succeed, but insertFlyerItems to fail
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockRejectedValueOnce(dbError); // insertFlyerItems fails
// Mock withTransaction to simulate a failure during the callback
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockRejectedValueOnce(dbError); // insertFlyer fails
// The withTransaction helper will catch this and roll back
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
// re-throw because withTransaction re-throws
throw dbError;
});
// The transactional function re-throws the original error from the failed step.
await expect(createFlyerAndItems(flyerData, itemsData)).rejects.toThrow(dbError);
// Verify transaction control
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.query).not.toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});
@@ -291,16 +282,10 @@ describe('Flyer DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE flyer_id = $1', [123]);
});
it('should return undefined if flyer is not found', async () => {
it('should throw NotFoundError if flyer is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await flyerRepo.getFlyerById(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(flyerRepo.getFlyerById(123)).rejects.toThrow('Failed to retrieve flyer from database.');
await expect(flyerRepo.getFlyerById(999)).rejects.toThrow(NotFoundError);
await expect(flyerRepo.getFlyerById(999)).rejects.toThrow('Flyer with ID 999 not found.');
});
});
@@ -445,45 +430,39 @@ describe('Flyer DB Service', () => {
});
describe('deleteFlyer', () => {
it('should execute a transaction to delete a flyer', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Mock BEGIN, DELETE (with rowCount: 1), and COMMIT
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rowCount: 1 }) // DELETE
.mockResolvedValueOnce({ rows: [] }); // COMMIT
it('should use withTransaction to delete a flyer', async () => {
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rowCount: 1 }) };
return callback(mockClient as any);
});
await flyerRepo.deleteFlyer(42);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.flyers WHERE flyer_id = $1', [42]);
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
});
it('should throw an error if the flyer to delete is not found', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Mock DELETE to return rowCount: 0
mockClient.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rowCount: 0 });
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rowCount: 0 }) };
// The callback will throw NotFoundError, and withTransaction will re-throw it.
await expect(callback(mockClient as any)).rejects.toThrow(NotFoundError);
throw new NotFoundError('Simulated re-throw');
});
await expect(flyerRepo.deleteFlyer(999)).rejects.toThrow('Failed to delete flyer.');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
it('should rollback transaction on generic error', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
const dbError = new Error('DB Error');
// Mock BEGIN to succeed, but DELETE to fail
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockRejectedValueOnce(dbError); // DELETE fails
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
await expect(flyerRepo.deleteFlyer(42)).rejects.toThrow('Failed to delete flyer.');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,6 @@
// src/services/db/flyer.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { getPool, withTransaction } from './connection.db';
import { logger } from '../logger.server';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Flyer, FlyerItem, FlyerInsert, FlyerItemInsert, Brand, FlyerDbInsert } from '../../types';
@@ -155,19 +155,9 @@ export class FlyerRepository {
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async getFlyerById(flyerId: number): Promise<Flyer> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [flyerId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
}
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('Database error in getFlyerById:', { error, flyerId });
throw new Error(`Failed to retrieve flyer from database. Original error: ${error instanceof Error ? error.message : String(error)}`);
}
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [flyerId]);
if (res.rowCount === 0) throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
return res.rows[0];
}
/**
@@ -278,27 +268,20 @@ export class FlyerRepository {
* @param flyerId The ID of the flyer to delete.
*/
async deleteFlyer(flyerId: number): Promise<void> {
const client = await getPool().connect();
try {
await client.query('BEGIN');
// The schema is set up with ON DELETE CASCADE for flyer_items,
// so we only need to delete from the parent 'flyers' table.
// The database will handle deleting associated flyer_items, unmatched_flyer_items, etc.
const res = await client.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
}
await client.query('COMMIT');
logger.info(`Successfully deleted flyer with ID: ${flyerId}`);
await withTransaction(async (client) => {
// The schema is set up with ON DELETE CASCADE for flyer_items,
// so we only need to delete from the parent 'flyers' table.
// The database will handle deleting associated flyer_items, unmatched_flyer_items, etc.
const res = await client.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
}
logger.info(`Successfully deleted flyer with ID: ${flyerId}`);
});
} catch (error) {
if (error instanceof NotFoundError) {
throw error; // Propagate NotFoundError without wrapping it
}
await client.query('ROLLBACK');
logger.error('Database transaction error in deleteFlyer:', { error, flyerId });
throw new Error('Failed to delete flyer.');
} finally {
client.release();
}
}
}
@@ -311,32 +294,26 @@ export class FlyerRepository {
* @returns An object containing the new flyer and its items.
*/
export async function createFlyerAndItems(flyerData: FlyerInsert, itemsForDb: FlyerItemInsert[]) {
const client = await getPool().connect();
try {
await client.query('BEGIN');
const flyerRepo = new FlyerRepository(client);
return await withTransaction(async (client) => {
const flyerRepo = new FlyerRepository(client);
// 1. Find or create the store to get the store_id
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name);
// 1. Find or create the store to get the store_id
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name);
// 2. Prepare the data for the flyer table, replacing store_name with store_id
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
// 2. Prepare the data for the flyer table, replacing store_name with store_id
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
// 3. Insert the flyer record
const newFlyer = await flyerRepo.insertFlyer(flyerDbData);
// 3. Insert the flyer record
const newFlyer = await flyerRepo.insertFlyer(flyerDbData);
// 4. Insert the associated flyer items
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb);
await client.query('COMMIT');
// 4. Insert the associated flyer items
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb);
return { flyer: newFlyer, items: newItems };
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();
}
}

View File

@@ -1,5 +1,6 @@
// src/services/db/index.db.ts
import { UserRepository } from './user.db';
import { withTransaction } from './connection.db';
import { FlyerRepository } from './flyer.db';
import { AddressRepository } from './address.db';
import { ShoppingRepository } from './shopping.db';
@@ -21,4 +22,4 @@ const budgetRepo = new BudgetRepository();
const gamificationRepo = new GamificationRepository();
const adminRepo = new AdminRepository();
export { userRepo, flyerRepo, addressRepo, shoppingRepo, personalizationRepo, recipeRepo, notificationRepo, budgetRepo, gamificationRepo, adminRepo };
export { userRepo, flyerRepo, addressRepo, shoppingRepo, personalizationRepo, recipeRepo, notificationRepo, budgetRepo, gamificationRepo, adminRepo, withTransaction };

View File

@@ -1,6 +1,7 @@
// src/services/db/personalization.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { withTransaction } from './connection.db';
import {
PersonalizationRepository} from './personalization.db';
import type { MasterGroceryItem, UserAppliance, DietaryRestriction, Appliance } from '../../types';
@@ -9,7 +10,12 @@ import type { MasterGroceryItem, UserAppliance, DietaryRestriction, Appliance }
vi.unmock('./personalization.db');
const mockQuery = mockPoolInstance.query;
const mockConnect = mockPoolInstance.connect;
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
import { ForeignKeyConstraintError } from './errors.db';
// Mock the logger to prevent console output during tests. This is a server-side DB test.
@@ -27,9 +33,11 @@ describe('Personalization DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
// Simulate the client returned by connect() having a release method
const mockClient = { ...mockPoolInstance, query: mockQuery, release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Reset the withTransaction mock before each test
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
return callback(mockClient as any);
});
// Instantiate the repository with the mock pool for each test
personalizationRepo = new PersonalizationRepository(mockPoolInstance as any);
});
@@ -85,54 +93,77 @@ describe('Personalization DB Service', () => {
describe('addWatchedItem', () => {
it('should execute a transaction to add a watched item', async () => {
const mockItem: MasterGroceryItem = { master_grocery_item_id: 1, name: 'New Item', created_at: '' };
// Provide mock responses for all queries in the transaction: BEGIN, SELECT, SELECT, INSERT, COMMIT
mockQuery // BEGIN is handled by the mock client
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [mockItem] }) // Find/create master item
.mockResolvedValueOnce({ rows: [] }) // Insert into watchlist
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [mockItem] }) // Find master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
return callback(mockClient as any);
});
await personalizationRepo.addWatchedItem('user-123', 'New Item', 'Produce');
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));
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT category_id FROM public.categories'), ['Produce']);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM public.master_grocery_items'), ['New Item']);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_watched_items'), ['user-123', 1]);
});
it('should create a new master item if it does not exist', async () => {
const mockNewItem: MasterGroceryItem = { master_grocery_item_id: 2, name: 'Brand New Item', created_at: '' };
mockQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [] }) // Find master item (not found)
.mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [] }) // Find master item (not found)
.mockResolvedValueOnce({ rows: [mockNewItem] }) // INSERT new master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
return callback(mockClient as any);
});
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]);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).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
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [mockExistingItem] }) // Find master item
.mockResolvedValueOnce({ rows: [] }); // INSERT...ON CONFLICT
return callback(mockClient as any);
});
// 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));
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT (user_id, master_item_id) DO NOTHING'), ['user-123', 1]);
});
it('should throw an error if the category is not found', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] }); // Find category (not found)
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
await expect(callback(mockClient as any)).rejects.toThrow("Category 'Fake Category' not found.");
throw new Error("Category 'Fake Category' not found.");
});
await expect(personalizationRepo.addWatchedItem('user-123', 'Some Item', 'Fake Category')).rejects.toThrow("Failed to add item to watchlist.");
});
it('should throw a generic error on failure', async () => {
const dbError = new Error('DB Error');
mockQuery.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }).mockRejectedValueOnce(dbError);
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }).mockRejectedValueOnce(dbError);
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
await expect(personalizationRepo.addWatchedItem('user-123', 'Failing Item', 'Produce')).rejects.toThrow('Failed to add item to watchlist.');
});
@@ -140,8 +171,7 @@ describe('Personalization DB Service', () => {
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);
vi.mocked(withTransaction).mockRejectedValue(dbError);
await expect(personalizationRepo.addWatchedItem('non-existent-user', 'Some Item', 'Produce')).rejects.toThrow('The specified user or category does not exist.');
});
@@ -330,45 +360,48 @@ describe('Personalization DB Service', () => {
describe('setUserDietaryRestrictions', () => {
it('should execute a transaction to set restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] });
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
return callback(mockClient as any);
});
await personalizationRepo.setUserDietaryRestrictions('user-123', [1, 2]);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
// The implementation uses unnest, so it's one call with an array parameter
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO public.user_dietary_restrictions'),
['user-123', [1, 2]]);
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_dietary_restrictions'), ['user-123', [1, 2]]);
});
it('should throw ForeignKeyConstraintError if a restriction ID is invalid', async () => {
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // DELETE success
.mockRejectedValueOnce(dbError); // INSERT fail
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError); // DELETE ok, INSERT fail
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
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: [] });
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
return callback(mockClient as any);
});
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'));
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
expect(mockClient.query).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'));
});
it('should throw a generic error if the database query fails', async () => {
mockQuery.mockRejectedValueOnce(new Error('DB Error')); // Mock the DELETE to fail
vi.mocked(withTransaction).mockRejectedValue(new Error('DB Error'));
await expect(personalizationRepo.setUserDietaryRestrictions('user-123', [1])).rejects.toThrow('Failed to set user dietary restrictions.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
});
});
});
@@ -415,72 +448,64 @@ describe('Personalization DB Service', () => {
describe('setUserAppliances', () => {
it('should execute a transaction to set appliances', async () => {
const mockNewAppliances: UserAppliance[] = [
{ user_id: 'user-123', appliance_id: 1 },
{ user_id: 'user-123', appliance_id: 2 },
];
mockQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // DELETE
.mockResolvedValueOnce({ rows: mockNewAppliances }) // INSERT ... RETURNING
.mockResolvedValueOnce({ rows: [] }); // COMMIT
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // DELETE
.mockResolvedValueOnce({ rows: mockNewAppliances }); // INSERT
return callback(mockClient as any);
});
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']);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
// expect(mockClient.release).toHaveBeenCalled(); // This was part of a previous incorrect mock
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances'), ['user-123', [1, 2]]);
expect(result).toEqual(mockNewAppliances);
});
it('should throw ForeignKeyConstraintError if an appliance ID is invalid', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockConnect).mockResolvedValue(mockClient as any);
const dbError = new Error('violates foreign key constraint');
(dbError as any).code = '23503';
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // DELETE success
.mockRejectedValueOnce(dbError); // INSERT fail
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError); // DELETE ok, INSERT fail
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
await expect(personalizationRepo.setUserAppliances('user-123', [999])).rejects.toThrow(ForeignKeyConstraintError);
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
// expect(mockClient.release).toHaveBeenCalled();
});
it('should handle an empty array of appliance IDs', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockConnect).mockResolvedValue(mockClient as any);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // DELETE
.mockResolvedValueOnce({ rows: [] }); // COMMIT
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
return callback(mockClient as any);
});
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']);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
// The INSERT query should NOT be called
expect(mockQuery).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances'));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
// expect(mockClient.release).toHaveBeenCalled();
expect(mockClient.query).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'));
expect(result).toEqual([]);
});
it('should rollback transaction on generic error', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockConnect).mockResolvedValue(mockClient as any);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockRejectedValueOnce(new Error('DB Error')); // DELETE fails
const dbError = new Error('DB Error');
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
await expect(personalizationRepo.setUserAppliances('user-123', [1])).rejects.toThrow('Failed to set user appliances.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
// expect(mockClient.release).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
// src/services/db/personalization.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { getPool, withTransaction } from './connection.db';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { logger } from '../logger.server';
import {
@@ -99,37 +99,38 @@ export class PersonalizationRepository {
* @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.`);
}
return await withTransaction(async (client) => {
// 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 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]
// 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]
);
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;
return masterItem;
});
} catch (error) {
// The withTransaction helper will handle rollback. We just need to handle specific errors.
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.
@@ -138,7 +139,7 @@ export class PersonalizationRepository {
throw new ForeignKeyConstraintError('The specified user or category does not exist.');
}
}
logger.error('Database error in addWatchedItem:', { error });
logger.error('Transaction error in addWatchedItem:', { error });
throw new Error('Failed to add item to watchlist.');
}
}
@@ -214,21 +215,18 @@ export class PersonalizationRepository {
* @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 withTransaction(async (client) => {
// 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]);
}
});
} 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.');
}
@@ -238,8 +236,6 @@ export class PersonalizationRepository {
}
logger.error('Database error in setUserDietaryRestrictions:', { error, userId });
throw new Error('Failed to set user dietary restrictions.');
} finally {
client.release();
}
}
@@ -250,34 +246,27 @@ export class PersonalizationRepository {
* @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 {
await client.query('BEGIN'); // Start transaction
return await withTransaction(async (client) => {
// 1. Clear existing appliances for the user
await client.query('DELETE FROM public.user_appliances WHERE user_id = $1', [userId]);
// 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;
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;
}
return newAppliances;
});
} catch (error) {
await client.query('ROLLBACK');
// The patch requested this specific error handling - check for foreign key violation
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('Invalid appliance ID');
}
logger.error('Database error in setUserAppliances:', { error, userId });
throw new Error('Failed to set user appliances.');
} finally {
client.release();
}
}

View File

@@ -9,7 +9,7 @@ vi.unmock('./recipe.db');
const mockQuery = mockPoolInstance.query;
import type { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
import { ForeignKeyConstraintError } from './errors.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
// Mock the logger to prevent console output during tests. This is a server-side DB test.
vi.mock('../logger.server', () => ({
logger: {
@@ -227,10 +227,9 @@ describe('Recipe DB Service', () => {
expect(result).toEqual(mockRecipe);
});
it('should return undefined if recipe is not found', async () => {
it('should throw NotFoundError if recipe is not found', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await recipeRepo.getRecipeById(999);
expect(result).toBeUndefined();
await expect(recipeRepo.getRecipeById(999)).rejects.toThrow('Recipe with ID 999 not found');
});
it('should throw a generic error if the database query fails', async () => {

View File

@@ -211,8 +211,6 @@ export class RecipeRepository {
`;
const res = await this.db.query<Recipe>(query, [recipeId]);
if (res.rowCount === 0) {
// A recipe not being found is an expected case, so we throw a specific
// error that our error handler middleware can convert to a 404 status.
throw new NotFoundError(`Recipe with ID ${recipeId} not found`);
}
return res.rows[0];

View File

@@ -1,13 +1,14 @@
// src/services/db/shopping.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { withTransaction } from './connection.db';
import { createMockShoppingList, createMockShoppingListItem } from '../../tests/utils/mockFactories';
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./shopping.db');
import { ShoppingRepository } from './shopping.db';
import { ForeignKeyConstraintError, UniqueConstraintError } from './errors.db';
import { ForeignKeyConstraintError, UniqueConstraintError, NotFoundError } from './errors.db';
// Mock the logger to prevent console output during tests
vi.mock('../logger.server', () => ({
@@ -19,6 +20,12 @@ vi.mock('../logger.server', () => ({
},
}));
// Mock the withTransaction helper
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
describe('Shopping DB Service', () => {
let shoppingRepo: ShoppingRepository;
@@ -66,12 +73,10 @@ describe('Shopping DB Service', () => {
expect(result).toEqual(mockList);
});
it('should return undefined if the shopping list is not found or not owned by the user', async () => {
it('should throw NotFoundError if the shopping list is not found or not owned by the user', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await shoppingRepo.getShoppingListById(999, 'user-1');
expect(result).toBeUndefined();
await expect(shoppingRepo.getShoppingListById(999, 'user-1')).rejects.toThrow('Shopping list not found or you do not have permission to view it.');
});
it('should throw an error if the database query fails', async () => {
@@ -420,40 +425,31 @@ describe('Shopping DB Service', () => {
describe('processReceiptItems', () => {
it('should call the process_receipt_items database function with correct parameters', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query.mockResolvedValue({ rows: [] });
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }) };
return callback(mockClient as any);
});
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await shoppingRepo.processReceiptItems(1, items);
const expectedItemsWithQuantity = [{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 }];
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(withTransaction).toHaveBeenCalledTimes(1);
const mockClient = (vi.mocked(withTransaction).mock.calls[0][0] as any).mock.instances[0];
expect(mockClient.query).toHaveBeenCalledWith(
'SELECT public.process_receipt_items($1, $2, $3)', [1, JSON.stringify(expectedItemsWithQuantity), JSON.stringify(expectedItemsWithQuantity)]
);
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
});
it('should handle items with quantity but no price', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query.mockResolvedValue({ rows: [] });
const items = [{ raw_item_description: 'Bag', price_paid_cents: 0 }];
await shoppingRepo.processReceiptItems(1, items);
const expectedItems = [{ raw_item_description: 'Bag', price_paid_cents: 0, quantity: 1 }];
expect(mockClient.query).toHaveBeenCalledWith('SELECT public.process_receipt_items($1, $2, $3)', [1, JSON.stringify(expectedItems), JSON.stringify(expectedItems)]);
});
it('should update receipt status to "failed" on error', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
const dbError = new Error('Function error');
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockRejectedValueOnce(dbError); // process_receipt_items fails
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn().mockRejectedValue(dbError) };
// The callback will throw, and withTransaction will catch and re-throw
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await expect(shoppingRepo.processReceiptItems(1, items)).rejects.toThrow('Failed to process and save receipt items.');
@@ -461,11 +457,6 @@ describe('Shopping DB Service', () => {
// Verify that the status was updated to 'failed' in the catch block
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [1]);
});
// Note: The `processReceiptItems` method in shopping.db.ts has a potential bug where it calls `client.query('ROLLBACK')`
// but then calls `this.db.query(...)` to update the status. This should be `client.query(...)` to ensure
// the status update happens on the same transaction before rollback. I've left it as is to match the
// existing logic but it's something to be aware of.
});
describe('findDealsForReceipt', () => {

View File

@@ -1,6 +1,6 @@
// src/services/db/shopping.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, UniqueConstraintError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import {
@@ -117,9 +117,7 @@ export class ShoppingRepository {
}
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
if (error instanceof NotFoundError) throw error;
logger.error('Database error in getShoppingListById:', { error, listId, userId });
throw new Error('Failed to retrieve shopping list.');
}
@@ -138,7 +136,6 @@ export class ShoppingRepository {
throw new NotFoundError('Shopping list not found or user does not have permission to delete.');
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in deleteShoppingList:', { error, listId, userId });
throw new Error('Failed to delete shopping list.');
}
@@ -409,28 +406,24 @@ export class ShoppingRepository {
receiptId: number,
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
): Promise<void> {
const client = await (this.db as Pool).connect();
try {
await withTransaction(async (client) => {
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}`);
});
} catch (error) {
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
// After the transaction fails and is rolled back by withTransaction,
// update the receipt status in a separate, non-transactional query.
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 client.query('ROLLBACK');
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
// After rolling back, update the receipt status in a separate, non-transactional query.
// This ensures the failure status is saved even if the transaction failed.
try {
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [receiptId]);
} catch (updateError) {
logger.error('Failed to update receipt status to "failed" after transaction rollback.', { updateError, receiptId });
}
throw new Error('Failed to process and save receipt items.');
} finally {
client.release();
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [receiptId]);
} catch (updateError) {
logger.error('Failed to update receipt status to "failed" after transaction rollback.', { updateError, receiptId });
}
throw new Error('Failed to process and save receipt items.');
}
}
/**

View File

@@ -1,3 +1,4 @@
// src/services/db/user.db.test.ts
// --- FIX REGISTRY ---
//
// 2025-12-09: Corrected transaction rollback tests to expect generic error messages.
@@ -5,8 +6,9 @@
// mocking strategies for internal method calls (spying on prototypes).
//
// --- END FIX REGISTRY ---
// src/services/db/user.db.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PoolClient } from 'pg';
import { withTransaction } from './connection.db';
// Mock the logger to prevent stderr noise during tests
vi.mock('../logger.server', () => ({
@@ -21,10 +23,15 @@ vi.mock('../logger.server', () => ({
// Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./user.db');
// Mock the withTransaction helper since we are testing a function that uses it.
vi.mock('./connection.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./connection.db')>();
return { ...actual, withTransaction: vi.fn() };
});
import { UserRepository, exportUserData } from './user.db';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Profile, ActivityLogItem, SearchQuery } from '../../types';
// Mock other db services that are used by functions in user.db.ts
@@ -53,6 +60,9 @@ describe('User DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
userRepo = new UserRepository(mockPoolInstance as any);
// Reset the withTransaction mock before each test
const { withTransaction } = require('./connection.db'); // eslint-disable-line @typescript-eslint/no-var-requires
vi.mocked(withTransaction).mockImplementation(async (callback: (client: PoolClient) => Promise<any>) => callback(mockPoolInstance as any));
});
describe('findUserByEmail', () => {
@@ -84,75 +94,55 @@ 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);
// Mock the sequence of queries within the transaction
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockResolvedValueOnce({ rows: [mockProfile] }) // SELECT profile
.mockResolvedValueOnce({ rows: [] }) // COMMIT;
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockResolvedValueOnce({ rows: [mockProfile] }); // SELECT profile
return callback(mockClient as any);
});
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' });
expect(result).toEqual(mockProfile);
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
expect(mockClient.release).toHaveBeenCalled();
expect(withTransaction).toHaveBeenCalledTimes(1);
});
it('should rollback the transaction if creating the user fails', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
const dbError = new Error('User insert failed');
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query.mockRejectedValueOnce(dbError); // set_config or INSERT fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
// Arrange: Mock the user insert query to fail after BEGIN and set_config
mockClient.query
.mockRejectedValueOnce(new Error('User insert failed')); // INSERT fails
// Act & Assert
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('User insert failed');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN'); // This will be called inside the try block
// The createUser function now throws the original error, so we check for that.
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
await expect(userRepo.createUser('fail@example.com', 'badpass', {})).rejects.toThrow('Failed to create user in database.');
});
it('should rollback the transaction if fetching the final profile fails', async () => {
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
// FIX: Define mockClient within this test's scope.
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockRejectedValueOnce(new Error('Profile fetch failed')); // SELECT profile fails
const dbError = new Error('Profile fetch failed');
vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // set_config
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
.mockRejectedValueOnce(dbError); // SELECT profile fails
await expect(callback(mockClient as any)).rejects.toThrow(dbError);
throw dbError;
});
await expect(userRepo.createUser('fail@example.com', 'pass', {})).rejects.toThrow('Profile fetch failed');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
await expect(userRepo.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';
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Simulate the transaction flow:
// 1. BEGIN (success)
// 2. set_config (success)
// 3. INSERT user (failure with unique violation)
// 4. ROLLBACK (success)
mockClient.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockRejectedValueOnce(dbError) // INSERT fails
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
vi.mocked(withTransaction).mockRejectedValue(dbError);
try {
await userRepo.createUser('exists@example.com', 'pass', {});
@@ -162,8 +152,7 @@ describe('User DB Service', () => {
expect(error.message).toBe('A user with this email address already exists.');
}
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockClient.release).toHaveBeenCalled();
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});
@@ -198,16 +187,9 @@ describe('User DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users WHERE user_id = $1'), ['123']);
});
it('should return undefined if user is not found', async () => {
it('should throw NotFoundError if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserById('not-found-id');
expect(result).toBeUndefined();
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserWithPasswordHashById('not-found-id');
expect(result).toBeUndefined();
await expect(userRepo.findUserById('not-found-id')).rejects.toThrow(NotFoundError);
});
it('should throw a generic error if the database query fails', async () => {
@@ -239,12 +221,16 @@ describe('User DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('WHERE p.user_id = $1'), ['123']);
});
it('should return undefined if user profile is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserProfileById('not-found-id');
expect(result).toBeUndefined();
it('should throw NotFoundError if user profile is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [], rowCount: 0 });
await expect(userRepo.findUserById('not-found-id')).rejects.toThrow('User with ID not-found-id not found.');
});
it('should return undefined if user is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await userRepo.findUserWithPasswordHashById('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);
@@ -370,10 +356,10 @@ describe('User DB Service', () => {
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 userRepo.findUserByRefreshToken('a-token');
expect(result).toBeUndefined();
it('should throw NotFoundError if token is not found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
await expect(userRepo.findUserByRefreshToken('a-token')).rejects.toThrow(NotFoundError);
await expect(userRepo.findUserByRefreshToken('a-token')).rejects.toThrow('User not found for the given refresh token.');
});
});
@@ -440,51 +426,53 @@ describe('User DB Service', () => {
});
describe('exportUserData', () => {
it('should call profile, watched items, and shopping list functions', async () => {
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Import the mocked withTransaction helper
let withTransaction: any;
beforeEach(async () => {
const connDb = await import('./connection.db');
withTransaction = connDb.withTransaction;
});
// --- FIX: Correctly mock the methods on the prototype of the imported classes ---
it('should call profile, watched items, and shopping list functions', async () => {
const { ShoppingRepository } = await import('./shopping.db');
const { PersonalizationRepository } = await import('./personalization.db');
// We need to spy on the prototypes because these classes are instantiated inside exportUserData
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById')
.mockResolvedValue({ user_id: '123' } as Profile);
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems')
.mockResolvedValue([]);
const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists')
.mockResolvedValue([]);
await exportUserData('123');
// Verify that withTransaction was called
expect(withTransaction).toHaveBeenCalledTimes(1);
// Verify the repository methods were called inside the transaction
expect(findProfileSpy).toHaveBeenCalledWith('123');
expect(getWatchedItemsSpy).toHaveBeenCalledWith('123');
expect(getShoppingListsSpy).toHaveBeenCalledWith('123');
});
it('should throw an error if the user profile is not found', async () => {
// Mock findUserProfileById to return undefined
// This uses the same prototype spy strategy as above, or we can mock the query if we prefer.
// Let's use the prototype spy for consistency in this block.
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockResolvedValue(undefined);
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Arrange: Mock findUserProfileById to throw a NotFoundError, as per its contract (ADR-001).
// The exportUserData function will catch this and re-throw a generic error.
const { NotFoundError } = await import('./errors.db');
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new NotFoundError('Profile not found'));
// Act & Assert: The outer function catches the NotFoundError and re-throws a generic one.
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
it('should throw an error if the database query fails', async () => {
// Force a failure in one of the calls
// Arrange: Force a failure in one of the parallel calls
vi.spyOn(UserRepository.prototype, 'findUserProfileById').mockRejectedValue(new Error('DB Error'));
const mockClient = { query: vi.fn(), release: vi.fn() };
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockClient as any);
// Act & Assert
await expect(exportUserData('123')).rejects.toThrow('Failed to export user data.');
expect(withTransaction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,11 +1,12 @@
// src/services/db/user.db.ts
import type { Pool, PoolClient } from 'pg';
import { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { logger } from '../logger.server';
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile, SearchQuery } from '../../types';
import { ShoppingRepository } from './shopping.db';
import { PersonalizationRepository } from './personalization.db';
import { withTransaction } from './connection.db';
/**
* Defines the structure of a user object as returned from the database.
@@ -60,12 +61,8 @@ export class UserRepository {
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 getPool().connect();
try {
return withTransaction(async (client: PoolClient) => {
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)]);
@@ -108,21 +105,17 @@ export class UserRepository {
};
logger.debug(`[DB createUser] Fetched full profile for new user:`, { user: fullUserProfile });
await client.query('COMMIT');
return fullUserProfile;
} catch (error) {
await client.query('ROLLBACK');
}).catch(error => {
// Check for specific PostgreSQL error codes
if (error instanceof Error && 'code' in error && error.code === '23505') {
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 });
// The withTransaction helper logs the rollback, so we just log the context here.
logger.error('Error during createUser transaction:', { error });
throw new Error('Failed to create user in database.');
} finally {
client.release();
}
});
}
/**
@@ -154,15 +147,19 @@ export class UserRepository {
* @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> {
async findUserById(userId: string): Promise<{ user_id: string; email: string; }> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE user_id = $1',
[userId]
);
if (res.rowCount === 0) {
throw new NotFoundError(`User with ID ${userId} not found.`);
}
return res.rows[0];
} catch (error) {
logger.error('Database error in findUserById:', { error });
if (error instanceof NotFoundError) throw error;
logger.error('Database error in findUserById:', { error, userId });
throw new Error('Failed to retrieve user by ID from database.');
}
}
@@ -359,16 +356,20 @@ export class UserRepository {
* @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> {
async findUserByRefreshToken(refreshToken: string): Promise<{ user_id: string; email: string; }> {
try {
const res = await this.db.query<{ user_id: string; email: string }>(
'SELECT user_id, email FROM public.users WHERE refresh_token = $1',
[refreshToken]
);
if (res.rowCount === 0) {
throw new NotFoundError('User not found for the given refresh token.');
}
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error('Database error in findUserByRefreshToken:', { error });
return undefined; // Return undefined on error to prevent token leakage
throw new Error('Failed to find user by refresh token.'); // Generic error for other failures
}
}
@@ -528,31 +529,28 @@ export class UserRepository {
*/
// prettier-ignore
export async function exportUserData(userId: string): Promise<{ profile: Profile; watchedItems: MasterGroceryItem[]; shoppingLists: ShoppingList[] }> {
const client = await getPool().connect();
if (!client) {
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 = userRepo.findUserProfileById(userId);
// Use the repository instance to call the method
const watchedItemsQuery = personalizationRepo.getWatchedItems(userId);
const shoppingListsQuery = shoppingRepo.getShoppingLists(userId);
return await withTransaction(async (client) => {
const userRepo = new UserRepository(client);
const shoppingRepo = new ShoppingRepository(client);
const personalizationRepo = new PersonalizationRepository(client);
const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]);
// Run queries in parallel for efficiency within the transaction
const profileQuery = userRepo.findUserProfileById(userId);
const watchedItemsQuery = personalizationRepo.getWatchedItems(userId);
const shoppingListsQuery = shoppingRepo.getShoppingLists(userId);
if (!profile) {
throw new Error('User profile not found for data export.');
}
const [profile, watchedItems, shoppingLists] = await Promise.all([profileQuery, watchedItemsQuery, shoppingListsQuery]);
return { profile, watchedItems, shoppingLists };
if (!profile) {
// This will be caught by withTransaction and rolled back (though no writes were made)
throw new NotFoundError('User profile not found for data export.');
}
return { profile, watchedItems, shoppingLists };
});
} catch (error) {
logger.error('Database error in exportUserData:', { error, userId });
throw new Error('Failed to export user data.');
} finally {
if (client) client.release();
}
}

View File

@@ -0,0 +1,103 @@
// src/services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { UserProfile, Address } from '../types';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
// Create mock implementations for the repository methods we'll be using.
const mockUpsertAddress = vi.fn();
const mockUpdateUserProfile = vi.fn();
return {
// Mock the withTransaction helper to immediately execute the callback.
mockWithTransaction: vi.fn().mockImplementation(async (callback) => {
// The callback will receive a mock client, but we don't need to use it
// since we are mocking the repository classes themselves.
return callback({});
}),
// Mock the repository classes.
MockAddressRepository: vi.fn(() => ({
upsertAddress: mockUpsertAddress,
})),
MockUserRepository: vi.fn(() => ({
updateUserProfile: mockUpdateUserProfile,
})),
// Expose the method mocks for assertions.
mockUpsertAddress,
mockUpdateUserProfile,
};
});
// --- Mock Modules ---
vi.mock('./db/index.db', () => ({
withTransaction: mocks.mockWithTransaction,
}));
vi.mock('./db/address.db', () => ({
AddressRepository: mocks.MockAddressRepository,
}));
vi.mock('./db/user.db', () => ({
UserRepository: mocks.MockUserRepository,
}));
// Import the service to be tested AFTER all mocks are set up.
import { userService } from './userService';
describe('UserService', () => {
beforeEach(() => {
// Clear call history for all mocks before each test.
vi.clearAllMocks();
});
describe('upsertUserAddress', () => {
it('should create a new address and link it to a user who has no address', async () => {
// Arrange: A user profile without an existing address_id.
const user: UserProfile = { user_id: 'user-123', address_id: null } as UserProfile;
const addressData: Partial<Address> = { address_line_1: '123 New St', city: 'Newville' };
// Mock the address repository to return a new address ID.
const newAddressId = 99;
mocks.mockUpsertAddress.mockResolvedValue(newAddressId);
// Act: Call the service method.
const result = await userService.upsertUserAddress(user, addressData);
// Assert
expect(result).toBe(newAddressId);
// 1. Verify the transaction helper was called.
expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
// 2. Verify the address was upserted with the correct data.
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({
...addressData,
address_id: undefined, // user.address_id was null, so it should be undefined.
});
// 3. Verify the user's profile was updated to link the new address ID.
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledTimes(1);
expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith('user-123', { address_id: newAddressId });
});
it('should update an existing address and NOT link it if the ID does not change', async () => {
// Arrange: A user profile with an existing address_id.
const existingAddressId = 42;
const user: UserProfile = { user_id: 'user-123', address_id: existingAddressId } as UserProfile;
const addressData: Partial<Address> = { address_line_1: '123 Updated St', city: 'Updateville' };
// Mock the address repository to return the SAME address ID.
mocks.mockUpsertAddress.mockResolvedValue(existingAddressId);
// Act: Call the service method.
const result = await userService.upsertUserAddress(user, addressData);
// Assert
expect(result).toBe(existingAddressId);
// 1. Verify the transaction helper was called.
expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
// 2. Verify the address was upserted with the existing ID.
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({
...addressData,
address_id: existingAddressId,
});
// 3. Since the address ID did not change, the user profile should NOT be updated.
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,37 @@
// src/services/userService.ts
import * as db from './db/index.db';
import { AddressRepository } from './db/address.db';
import { UserRepository } from './db/user.db';
import type { Address, UserProfile } from '../types';
/**
* Encapsulates user-related business logic that may involve multiple repository calls.
*/
class UserService {
/**
* Per ADR-002, this function encapsulates a multi-write operation within a transaction.
* It creates or updates a user's address and links it to their profile atomically.
*
* @param user The user profile object.
* @param addressData The address data to upsert.
* @returns The ID of the upserted address.
*/
async upsertUserAddress(user: UserProfile, addressData: Partial<Address>): Promise<number> {
return db.withTransaction(async (client) => {
// Instantiate repositories with the transactional client
const addressRepo = new AddressRepository(client);
const userRepo = new UserRepository(client);
const addressId = await 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 userRepo.updateUserProfile(user.user_id, { address_id: addressId });
}
return addressId;
});
}
}
export const userService = new UserService();