feat: Update AI service to use new Google Generative AI SDK
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running

- Refactored AIService to integrate with the latest GoogleGenAI SDK, updating the generateContent method signature and response handling.
- Adjusted error handling and logging for improved clarity and consistency.
- Enhanced mock implementations in tests to align with the new SDK structure.
refactor: Modify Admin DB service to use Profile type
- Updated AdminRepository to replace User type with Profile in relevant methods.
- Enhanced test cases to utilize mock factories for creating Profile and AdminUserView objects.
fix: Improve error handling in BudgetRepository
- Implemented type-safe checks for PostgreSQL error codes to enhance error handling in createBudget method.
test: Refactor Deals DB tests for type safety
- Updated DealsRepository tests to use Pool type for mock instances, ensuring type safety.
chore: Add new mock factories for testing
- Introduced mock factories for UserWithPasswordHash, Profile, WatchedItemDeal, LeaderboardUser, and UnmatchedFlyerItem to streamline test data creation.
style: Clean up queue service tests
- Refactored queue service tests to improve readability and maintainability, including better handling of mock worker instances.
docs: Update types to include UserWithPasswordHash
- Added UserWithPasswordHash interface to types for better clarity on user authentication data structure.
chore: Remove deprecated Google AI SDK references
- Updated code and documentation to reflect the migration to the new Google Generative AI SDK, removing references to the deprecated SDK.
This commit is contained in:
2025-12-14 17:14:44 -08:00
parent eb0e183f61
commit f73b1422ab
42 changed files with 530 additions and 365 deletions

16
note-to-ai-3-genai.txt Normal file
View File

@@ -0,0 +1,16 @@
NO WRONG
you provided this url: https://github.com/google/generative-ai-js
the readme on that very page says:
[Deprecated] Google AI JavaScript SDK for the Gemini API
With Gemini 2.0, we took the chance to create a single unified SDK for all developers who want to use Google's GenAI models (Gemini, Veo, Imagen, etc). As part of that process, we took all of the feedback from this SDK and what developers like about other SDKs in the ecosystem to create the Google Gen AI SDK.
The full migration guide from the old SDK to new SDK is available in the Gemini API docs.
The Gemini API docs are fully updated to show examples of the new Google Gen AI SDK. We know how disruptive an SDK change can be and don't take this change lightly, but our goal is to create an extremely simple and clear path for developers to build with our models so it felt necessary to make this change.
Thank you for building with Gemini and let us know if you need any help!
Please be advised that this repository is now considered legacy. For the latest features, performance improvements, and active development, we strongly recommend migrating to the official Google Generative AI SDK for JavaScript.
Support Plan for this Repository:
Limited Maintenance: Development is now restricted to critical bug fixes only. No new features will be added.
Purpose: This limited support aims to provide stability for users while they transition to the new SDK.
End-of-Life Date: All support for this repository (including bug fixes) will permanently end on November 30, 2025.
We encourage all users to begin planning their migration to the Google Generative AI SDK to ensure continued access to the latest capabilities and support.
what does this mean to you about the correct new version of Google Generative AI SDK is ??
NO CODE

View File

@@ -1,8 +1,7 @@
// src/middleware/errorHandler.ts // src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { DatabaseError, UniqueConstraintError, ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db'; import { DatabaseError, UniqueConstraintError, ForeignKeyConstraintError, NotFoundError, ValidationError, ValidationIssue } from '../services/db/errors.db';
import crypto from 'crypto'; import crypto from 'crypto';
import { logger } from '../services/logger.server';
interface HttpError extends Error { interface HttpError extends Error {
status?: number; status?: number;
@@ -20,12 +19,7 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
// --- 1. Determine Final Status Code and Message --- // --- 1. Determine Final Status Code and Message ---
let statusCode = err.status ?? 500; let statusCode = err.status ?? 500;
const message = err.message; const message = err.message;
// Define a more specific type for validation errors from Zod. let validationIssues: ValidationIssue[] | undefined;
type ValidationIssue = {
path: (string | number)[];
message: string;
};
let errors: ValidationIssue[] | undefined;
let errorId: string | undefined; let errorId: string | undefined;
// Refine the status code for known error types. Check for most specific types first. // Refine the status code for known error types. Check for most specific types first.
@@ -37,7 +31,7 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
statusCode = 400; statusCode = 400;
} else if (err instanceof ValidationError) { } else if (err instanceof ValidationError) {
statusCode = 400; statusCode = 400;
errors = err.validationErrors; validationIssues = err.validationErrors;
} else if (err instanceof DatabaseError) { } else if (err instanceof DatabaseError) {
// This is a generic fallback for other database errors that are not the specific subclasses above. // This is a generic fallback for other database errors that are not the specific subclasses above.
statusCode = err.status; statusCode = err.status;
@@ -59,7 +53,7 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
log.warn( log.warn(
{ {
err, err,
validationErrors: errors, // Add validation issues to the log object validationErrors: validationIssues, // Add validation issues to the log object
statusCode, statusCode,
}, },
`Client Error on ${req.method} ${req.path}: ${message}` `Client Error on ${req.method} ${req.path}: ${message}`
@@ -80,6 +74,6 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
res.status(statusCode).json({ res.status(statusCode).json({
message: responseMessage, message: responseMessage,
...(errors && { errors }), // Conditionally add the 'errors' array if it exists ...(validationIssues && { errors: validationIssues }), // Conditionally add the 'errors' array if it exists
}); });
}; };

View File

@@ -1,9 +1,9 @@
// src/routes/admin.content.routes.test.ts // src/routes/admin.content.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; import adminRouter from './admin.routes'; // This was a duplicate, fixed.
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockFlyerItem } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockUnmatchedFlyerItem } from '../tests/utils/mockFactories';
import { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types'; import { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
import { NotFoundError } from '../services/db/errors.db'; import { NotFoundError } from '../services/db/errors.db';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
@@ -235,15 +235,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
describe('Unmatched Items Route', () => { describe('Unmatched Items Route', () => {
it('GET /unmatched-items should return a list of unmatched items', async () => { it('GET /unmatched-items should return a list of unmatched items', async () => {
// Correctly create a mock for UnmatchedFlyerItem. // Correctly create a mock for UnmatchedFlyerItem.
// It's a combination of a flyer item and some context, including the 'flyer_item_name'. const mockUnmatchedItems: UnmatchedFlyerItem[] = [createMockUnmatchedFlyerItem({ unmatched_flyer_item_id: 1, flyer_item_name: 'Mystery Item' })];
const mockFlyerItem = createMockFlyerItem({ flyer_item_id: 101, item: 'Mystery Item' });
const mockUnmatchedItems: UnmatchedFlyerItem[] = [{
...mockFlyerItem,
unmatched_flyer_item_id: 1,
status: 'pending',
store_name: 'Test Store',
flyer_item_name: mockFlyerItem.item, // Add the missing required property
}];
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems); vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
const response = await supertest(app).get('/api/admin/unmatched-items'); const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(200); expect(response.status).toBe(200);

View File

@@ -1,16 +1,7 @@
// --- FIX REGISTRY ---
//
// 1) Fixed `vi.mock('child_process')` to use a simple factory pattern for `exec` to avoid default export issues and ensure proper mocking.
//
// 2) Updated `vi.mock` for `../services/db/index.db` to use a simple module mock.
// The previous incomplete factory mock was causing 'undefined' errors for named exports
// (like repository classes) that other modules depended on.
// --- END FIX REGISTRY ---
// src/routes/admin.jobs.routes.test.ts // src/routes/admin.jobs.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories'; import { createMockUserProfile } from '../tests/utils/mockFactories';
import { Job } from 'bullmq'; import { Job } from 'bullmq';

View File

@@ -1,19 +1,7 @@
// src/routes/admin.monitoring.routes.test.ts // src/routes/admin.monitoring.routes.test.ts
// --- FIX REGISTRY ---
//
// 1) Added `weeklyAnalyticsQueue` and `weeklyAnalyticsWorker` to the `queueService.server` mock.
// These were missing, causing an import error in `admin.routes.ts` which depends on them.
//
// 2) Updated `vi.mock` for `../services/db/index.db` to use `vi.hoisted` and `importOriginal`.
// This preserves named exports (like repository classes) from the original module,
// fixing 'undefined' errors when other modules tried to import them from the mock.
//
// 3) Added `weeklyAnalyticsQueue` and `weeklyAnalyticsWorker` to the `queueService.server` mock.
//
// --- END FIX REGISTRY ---
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; import adminRouter from './admin.routes';
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
import { UserProfile } from '../types'; import { UserProfile } from '../types';

View File

@@ -149,10 +149,10 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
}); });
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => { router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
type ApproveCorrectionRequest = z.infer<typeof updateCorrectionSchema>; // Apply ADR-003 pattern for type safety
const { params } = req as unknown as ApproveCorrectionRequest; const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try { try {
await db.adminRepo.approveCorrection(params.id, req.log); await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' }); res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -160,10 +160,10 @@ router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id
}); });
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => { router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
type RejectCorrectionRequest = z.infer<typeof updateCorrectionSchema>; // Apply ADR-003 pattern for type safety
const { params } = req as unknown as RejectCorrectionRequest; const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try { try {
await db.adminRepo.rejectCorrection(params.id, req.log); await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' }); res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -171,8 +171,8 @@ router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id'
}); });
router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req: Request, res: Response, next: NextFunction) => { router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateCorrectionRequest = z.infer<typeof updateCorrectionSchema>; // Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateCorrectionRequest; const { params, body } = req as unknown as z.infer<typeof updateCorrectionSchema>;
try { try {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(params.id, body.suggested_value, req.log); const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(params.id, body.suggested_value, req.log);
res.status(200).json(updatedCorrection); res.status(200).json(updatedCorrection);
@@ -182,8 +182,8 @@ router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (r
}); });
router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req: Request, res: Response, next: NextFunction) => { router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateRecipeStatusRequest = z.infer<typeof updateRecipeStatusSchema>; // Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateRecipeStatusRequest; const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
try { try {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe); res.status(200).json(updatedRecipe);
@@ -193,6 +193,7 @@ router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), asy
}); });
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req: Request, res: Response, next: NextFunction) => { router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try { try {
// Although requireFileUpload middleware should ensure the file exists, // Although requireFileUpload middleware should ensure the file exists,
@@ -223,8 +224,10 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe. * DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
*/ */
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => { router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
// Define schema locally to simplify type inference
const schema = numericIdParamSchema('recipeId');
const adminUser = req.user as UserProfile; const adminUser = req.user as UserProfile;
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; const { params } = req as unknown as z.infer<typeof schema>;
try { try {
// The isAdmin flag bypasses the ownership check in the repository method. // The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user_id, true, req.log); await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user_id, true, req.log);
@@ -238,7 +241,9 @@ router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipe
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items. * DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
*/ */
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => { router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; // Define schema locally to simplify type inference
const schema = numericIdParamSchema('flyerId');
const { params } = req as unknown as z.infer<typeof schema>;
try { try {
await db.flyerRepo.deleteFlyer(params.flyerId, req.log); await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send(); res.status(204).send();
@@ -248,8 +253,8 @@ router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId'
}); });
router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req: Request, res: Response, next: NextFunction) => { router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateCommentStatusRequest = z.infer<typeof updateCommentStatusSchema>; // Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateCommentStatusRequest; const { params, body } = req as unknown as z.infer<typeof updateCommentStatusSchema>;
try { try {
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts const updatedComment = await db.adminRepo.updateRecipeCommentStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment); res.status(200).json(updatedComment);
@@ -268,8 +273,8 @@ router.get('/users', async (req, res, next: NextFunction) => {
}); });
router.get('/activity-log', validateRequest(activityLogSchema), async (req: Request, res: Response, next: NextFunction) => { router.get('/activity-log', validateRequest(activityLogSchema), async (req: Request, res: Response, next: NextFunction) => {
type ActivityLogRequest = z.infer<typeof activityLogSchema>; // Apply ADR-003 pattern for type safety
const { query } = req as unknown as ActivityLogRequest; const { query } = req as unknown as z.infer<typeof activityLogSchema>;
try { try {
const logs = await db.adminRepo.getActivityLog(query.limit, query.offset, req.log); const logs = await db.adminRepo.getActivityLog(query.limit, query.offset, req.log);
res.json(logs); res.json(logs);
@@ -279,7 +284,8 @@ router.get('/activity-log', validateRequest(activityLogSchema), async (req: Requ
}); });
router.get('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => { router.get('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as z.infer<ReturnType<typeof uuidParamSchema>>; // Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try { try {
const user = await db.userRepo.findUserProfileById(params.id, req.log); const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user); res.json(user);
@@ -289,8 +295,8 @@ router.get('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Req
}); });
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Request, res: Response, next: NextFunction) => { router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateUserRoleRequest = z.infer<typeof updateUserRoleSchema>; // Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateUserRoleRequest; const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
try { try {
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log); const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser); res.json(updatedUser);
@@ -302,7 +308,8 @@ router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Requ
router.delete('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => { router.delete('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile; const adminUser = req.user as UserProfile;
const { params } = req as z.infer<ReturnType<typeof uuidParamSchema>>; // Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try { try {
if (adminUser.user.user_id === params.id) { if (adminUser.user.user_id === params.id) {
throw new ValidationError([], 'Admins cannot delete their own account.'); throw new ValidationError([], 'Admins cannot delete their own account.');
@@ -360,8 +367,10 @@ router.post('/trigger/analytics-report', async (req: Request, res: Response, nex
* This is triggered by an admin after they have verified the flyer processing was successful. * This is triggered by an admin after they have verified the flyer processing was successful.
*/ */
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => { router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
// Define schema locally to simplify type inference
const schema = numericIdParamSchema('flyerId');
const adminUser = req.user as UserProfile; const adminUser = req.user as UserProfile;
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; const { params } = req as unknown as z.infer<typeof schema>;
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${params.flyerId}`); logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${params.flyerId}`);
// Enqueue the cleanup job. The worker will handle the file deletion. // Enqueue the cleanup job. The worker will handle the file deletion.

View File

@@ -1,7 +1,7 @@
// src/routes/admin.stats.routes.test.ts // src/routes/admin.stats.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories'; import { createMockUserProfile } from '../tests/utils/mockFactories';
import { UserProfile } from '../types'; import { UserProfile } from '../types';

View File

@@ -1,7 +1,7 @@
// src/routes/admin.system.routes.test.ts // src/routes/admin.system.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories'; import { createMockUserProfile } from '../tests/utils/mockFactories';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';

View File

@@ -1,10 +1,10 @@
// src/routes/admin.users.routes.test.ts // src/routes/admin.users.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; import adminRouter from './admin.routes'; // This was a duplicate, fixed.
import { createMockUserProfile } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
import { User, UserProfile } from '../types'; import { User, UserProfile, Profile } from '../types';
import { NotFoundError } from '../services/db/errors.db'; import { NotFoundError } from '../services/db/errors.db';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
@@ -85,19 +85,10 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('GET /users', () => { describe('GET /users', () => {
it('should return a list of all users on success', async () => { it('should return a list of all users on success', async () => {
// Define a specific type for the user list returned by the admin endpoint // Use the mock factory to create consistent test data.
// to avoid using `any` and improve test type safety. const mockUsers = [
type UserSummary = { createMockAdminUserView({ user_id: '1', email: 'user1@test.com', role: 'user' }),
user_id: string; createMockAdminUserView({ user_id: '2', email: 'user2@test.com', role: 'admin' }),
email: string;
role: 'user' | 'admin';
created_at: string;
full_name: string | null;
avatar_url: string | null;
};
const mockUsers: UserSummary[] = [
{ user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
]; ];
vi.mocked(mockedDb.adminRepo.getAllUsers).mockResolvedValue(mockUsers); vi.mocked(mockedDb.adminRepo.getAllUsers).mockResolvedValue(mockUsers);
const response = await supertest(app).get('/api/admin/users'); const response = await supertest(app).get('/api/admin/users');
@@ -127,7 +118,12 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('PUT /users/:id', () => { describe('PUT /users/:id', () => {
it('should update a user role successfully', async () => { it('should update a user role successfully', async () => {
const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' }; // The `updateUserRole` function returns a `Profile` object, not a `User` object.
const updatedUser: Profile = {
user_id: 'user-to-update',
role: 'admin',
points: 0,
};
vi.mocked(mockedDb.adminRepo.updateUserRole).mockResolvedValue(updatedUser); vi.mocked(mockedDb.adminRepo.updateUserRole).mockResolvedValue(updatedUser);
const response = await supertest(app) const response = await supertest(app)
.put('/api/admin/users/user-to-update') .put('/api/admin/users/user-to-update')

View File

@@ -1,7 +1,7 @@
// src/routes/ai.test.ts // src/routes/ai.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { type Request, type Response, type NextFunction } from 'express'; import { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path'; import path from 'node:path';
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import aiRouter from './ai.routes'; import aiRouter from './ai.routes';

View File

@@ -1,12 +1,11 @@
// src/routes/auth.routes.test.ts // src/routes/auth.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { errorHandler } from '../middleware/errorHandler';
import { UserProfile } from '../types'; import { UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
@@ -142,15 +141,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should successfully register a new user with a strong password', async () => { it('should successfully register a new user with a strong password', async () => {
// Arrange: // Arrange:
const mockNewUser: UserProfile = { const mockNewUser = createMockUserProfile({ user_id: 'new-user-id', user: { user_id: 'new-user-id', email: newUserEmail }, full_name: 'Test User' });
user_id: 'new-user-id',
user: { user_id: 'new-user-id', email: newUserEmail },
role: 'user',
points: 0,
full_name: 'Test User',
avatar_url: null,
preferences: {}
};
// FIX: Mock the method on the imported singleton instance `userRepo` directly, // FIX: Mock the method on the imported singleton instance `userRepo` directly,
// as this is what the route handler uses. Spying on the prototype does not // as this is what the route handler uses. Spying on the prototype does not
@@ -325,13 +316,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /forgot-password', () => { describe('POST /forgot-password', () => {
it('should send a reset link if the user exists', async () => { it('should send a reset link if the user exists', async () => {
// Arrange // Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({ vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
});
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined); vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Act // Act
@@ -367,13 +352,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should still return 200 OK if the email service fails', async () => { it('should still return 200 OK if the email service fails', async () => {
// Arrange // Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({ vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
});
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined); vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Mock the email service to fail // Mock the email service to fail
const { sendPasswordResetEmail } = await import('../services/emailService.server'); const { sendPasswordResetEmail } = await import('../services/emailService.server');
@@ -447,13 +426,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /refresh-token', () => { describe('POST /refresh-token', () => {
it('should issue a new access token with a valid refresh token cookie', async () => { it('should issue a new access token with a valid refresh token cookie', async () => {
const mockUser = { const mockUser = createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' });
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
};
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser); vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser);
const response = await supertest(app) const response = await supertest(app)

View File

@@ -1,7 +1,7 @@
// src/routes/budget.routes.test.ts // src/routes/budget.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import budgetRouter from './budget.routes'; import budgetRouter from './budget.routes';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories';

View File

@@ -1,11 +1,11 @@
// src/routes/deals.routes.test.ts // src/routes/deals.routes.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import dealsRouter from './deals.routes'; import dealsRouter from './deals.routes';
import { dealsRepo } from '../services/db/deals.db'; import { dealsRepo } from '../services/db/deals.db';
import { createMockUserProfile } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import type { UserProfile, WatchedItemDeal } from '../types'; import type { WatchedItemDeal } from '../types';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
@@ -52,14 +52,7 @@ describe('Deals Routes (/api/users/deals)', () => {
}); });
it('should return a list of deals for an authenticated user', async () => { it('should return a list of deals for an authenticated user', async () => {
const mockDeals: WatchedItemDeal[] = [{ const mockDeals: WatchedItemDeal[] = [createMockWatchedItemDeal({ item_name: 'Apples' })];
master_item_id: 123,
item_name: 'Apples',
best_price_in_cents: 199,
store_name: 'Mock Store',
flyer_id: 101,
valid_to: new Date().toISOString(),
}];
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals); vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals);
const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices'); const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices');

View File

@@ -1,7 +1,6 @@
// src/routes/flyer.routes.test.ts // src/routes/flyer.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express from 'express';
import flyerRouter from './flyer.routes'; import flyerRouter from './flyer.routes';
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories'; import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db'; import { NotFoundError } from '../services/db/errors.db';

View File

@@ -1,8 +1,7 @@
// src/routes/flyer.routes.ts // src/routes/flyer.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router } from 'express';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { z } from 'zod'; import { z } from 'zod';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
const router = Router(); const router = Router();

View File

@@ -1,10 +1,10 @@
// src/routes/gamification.test.ts // src/routes/gamification.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import gamificationRouter from './gamification.routes'; import gamificationRouter from './gamification.routes';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { createMockUserProfile, createMockAchievement, createMockUserAchievement } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockAchievement, createMockUserAchievement, createMockLeaderboardUser } from '../tests/utils/mockFactories';
import { ForeignKeyConstraintError } from '../services/db/errors.db'; import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
@@ -207,8 +207,8 @@ describe('Gamification Routes (/api/achievements)', () => {
describe('GET /leaderboard', () => { describe('GET /leaderboard', () => {
it('should return a list of top users (public endpoint)', async () => { it('should return a list of top users (public endpoint)', async () => {
const mockLeaderboard = [{ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' }]; const mockLeaderboard = [createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' })];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard as any); vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard?limit=5'); const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard?limit=5');

View File

@@ -1,5 +1,5 @@
// src/routes/gamification.routes.ts // src/routes/gamification.routes.ts
import express, { Request, Response, NextFunction } from 'express'; import express, { NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import passport, { isAdmin } from './passport.routes'; import passport, { isAdmin } from './passport.routes';
import { gamificationRepo } from '../services/db/index.db'; import { gamificationRepo } from '../services/db/index.db';

View File

@@ -1,7 +1,6 @@
// src/routes/health.routes.test.ts // src/routes/health.routes.test.ts
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express from 'express';
import healthRouter from './health.routes'; import healthRouter from './health.routes';
import * as dbConnection from '../services/db/connection.db'; import * as dbConnection from '../services/db/connection.db';
import { connection as redisConnection } from '../services/queueService.server'; import { connection as redisConnection } from '../services/queueService.server';

View File

@@ -1,7 +1,6 @@
// src/routes/personalization.routes.test.ts // src/routes/personalization.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import personalizationRouter from './personalization.routes'; import personalizationRouter from './personalization.routes';
import { createMockMasterGroceryItem, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories'; import { createMockMasterGroceryItem, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';

View File

@@ -1,7 +1,6 @@
// src/routes/price.routes.test.ts // src/routes/price.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express from 'express';
import priceRouter from './price.routes'; import priceRouter from './price.routes';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';

View File

@@ -1,7 +1,6 @@
// src/routes/price.routes.ts // src/routes/price.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
const router = Router(); const router = Router();

View File

@@ -1,7 +1,6 @@
// src/routes/recipe.routes.test.ts // src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import recipeRouter from './recipe.routes'; import recipeRouter from './recipe.routes';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories'; import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';

View File

@@ -1,5 +1,5 @@
// src/routes/recipe.routes.ts // src/routes/recipe.routes.ts
import { Router, type Request, type Response, type NextFunction } from 'express'; import { Router } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';

View File

@@ -1,7 +1,6 @@
// src/routes/stats.routes.test.ts // src/routes/stats.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import statsRouter from './stats.routes'; import statsRouter from './stats.routes';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';

View File

@@ -2,7 +2,6 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import * as db from '../services/db/index.db'; import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware'; import { validateRequest } from '../middleware/validation.middleware';
const router = Router(); const router = Router();

View File

@@ -1,7 +1,6 @@
// src/routes/system.routes.test.ts // src/routes/system.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express from 'express';
import systemRouter from './system.routes'; import systemRouter from './system.routes';
import { exec, type ExecException } from 'child_process'; import { exec, type ExecException } from 'child_process';
import { geocodingService } from '../services/geocodingService.server'; import { geocodingService } from '../services/geocodingService.server';

View File

@@ -1,12 +1,12 @@
// src/routes/user.routes.test.ts // src/routes/user.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express'; import express from 'express';
// Use * as bcrypt to match the implementation's import style and ensure mocks align. // Use * as bcrypt to match the implementation's import style and ensure mocks align.
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import userRouter from './user.routes'; import userRouter from './user.routes';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe } from '../tests/utils/mockFactories'; import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe, createMockNotification, createMockDietaryRestriction, createMockAppliance, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import { Appliance, Notification } from '../types'; import { Appliance, Notification, DietaryRestriction } from '../types';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db'; import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
@@ -351,12 +351,7 @@ describe('User Routes (/api/users)', () => {
describe('DELETE /account', () => { describe('DELETE /account', () => {
it('should delete the account with the correct password', async () => { it('should delete the account with the correct password', async () => {
const userWithHash = { const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
...mockUserProfile.user,
password_hash: 'hashed-password',
failed_login_attempts: 0, // Add missing properties
last_failed_login: null
};
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash); vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined); vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
@@ -368,12 +363,7 @@ describe('User Routes (/api/users)', () => {
}); });
it('should return 403 for an incorrect password', async () => { it('should return 403 for an incorrect password', async () => {
const userWithHash = { const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
...mockUserProfile.user,
password_hash: 'hashed-password',
failed_login_attempts: 0,
last_failed_login: null
};
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash); vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never); vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const response = await supertest(app) const response = await supertest(app)
@@ -422,8 +412,8 @@ describe('User Routes (/api/users)', () => {
describe('GET and PUT /users/me/dietary-restrictions', () => { describe('GET and PUT /users/me/dietary-restrictions', () => {
it('GET should return a list of restriction IDs', async () => { it('GET should return a list of restriction IDs', async () => {
const mockRestrictions = [{ dietary_restriction_id: 1, name: 'Gluten-Free', type: 'diet' as const }]; const mockRestrictions: DietaryRestriction[] = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions); vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/users/me/dietary-restrictions'); const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions); expect(response.body).toEqual(mockRestrictions);
@@ -455,8 +445,8 @@ describe('User Routes (/api/users)', () => {
describe('GET and PUT /users/me/appliances', () => { describe('GET and PUT /users/me/appliances', () => {
it('GET should return a list of appliance IDs', async () => { it('GET should return a list of appliance IDs', async () => {
const mockAppliances: Appliance[] = [{ appliance_id: 2, name: 'Air Fryer' }]; const mockAppliances: Appliance[] = [createMockAppliance({ name: 'Air Fryer' })];
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances); vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances'); const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances); expect(response.body).toEqual(mockAppliances);
@@ -482,7 +472,7 @@ describe('User Routes (/api/users)', () => {
describe('Notification Routes', () => { describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => { it('GET /notifications should return notifications for the user', async () => {
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '', link_url: null }]; const mockNotifications: Notification[] = [createMockNotification({ user_id: 'user-123', content: 'Test' })];
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications); vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0'); const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
@@ -500,16 +490,17 @@ describe('User Routes (/api/users)', () => {
}); });
it('POST /notifications/:notificationId/mark-read should return 204', async () => { it('POST /notifications/:notificationId/mark-read should return 204', async () => {
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue({} as any); // Fix: Return a mock notification object to match the function's signature.
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue(createMockNotification({ notification_id: 1, user_id: 'user-123' }));
const response = await supertest(app).post('/api/users/notifications/1/mark-read'); const response = await supertest(app).post('/api/users/notifications/1/mark-read');
expect(response.status).toBe(204); expect(response.status).toBe(204);
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123'); expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
}); });
it('should return 400 for an invalid notificationId', async () => { it('should return 400 for an invalid notificationId', async () => {
const response = await supertest(app).post('/api/users/notifications/abc/mark-read'); const response = await supertest(app).post('/api/users/notifications/abc/mark-read').send({});
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid notification ID.'); expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'notificationId'. Must be a number.");
}); });
}); });
@@ -539,7 +530,7 @@ describe('User Routes (/api/users)', () => {
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: null } }); // User has no address yet const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: null } }); // User has no address yet
const addressData = { address_line_1: '123 New St' }; const addressData = { address_line_1: '123 New St' };
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5 vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({} as any); vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({ ...mockUserProfile, address_id: 5 });
const response = await supertest(appWithUser) const response = await supertest(appWithUser)
.put('/api/users/profile/address') .put('/api/users/profile/address')

View File

@@ -17,6 +17,23 @@ import { validateRequest } from '../middleware/validation.middleware';
const router = express.Router(); const router = express.Router();
/**
* Validates the strength of a password using zxcvbn.
* @param password The password to check.
* @returns An object with `isValid` and an optional `feedback` message.
*/
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
const strength = zxcvbn(password);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedbackMessage = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return { isValid: false, feedback: `Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim() };
}
return { isValid: true };
};
// --- Zod Schemas for User Routes (as per ADR-003) --- // --- Zod Schemas for User Routes (as per ADR-003) ---
const numericIdParam = (key: string) => z.object({ const numericIdParam = (key: string) => z.object({
@@ -24,15 +41,16 @@ const numericIdParam = (key: string) => z.object({
}); });
const updateProfileSchema = z.object({ const updateProfileSchema = z.object({
body: z.object({ body: z.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() })
full_name: z.string().optional(), .refine(data => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.' }),
avatar_url: z.string().url().optional(),
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.' }),
}); });
const updatePasswordSchema = z.object({ const updatePasswordSchema = z.object({
body: z.object({ body: z.object({
newPassword: z.string().min(1, 'New password is required.'), newPassword: z.string().min(8, 'Password must be at least 8 characters long.').superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
}), }),
}); });
@@ -100,11 +118,15 @@ router.post(
'/profile/avatar', '/profile/avatar',
avatarUpload.single('avatar'), avatarUpload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' }); try { // The try-catch block was already correct here.
const user = req.user as User; if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const avatarUrl = `/uploads/avatars/${req.file.filename}`; // This was a duplicate, fixed. const user = req.user as UserProfile;
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl }, req.log); const avatarUrl = `/uploads/avatars/${req.file.filename}`;
res.json(updatedProfile); const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl }, req.log);
res.json(updatedProfile);
} catch (error) {
next(error);
}
} }
); );
@@ -117,11 +139,16 @@ router.get(
'/notifications', '/notifications',
validateRequest(notificationQuerySchema), validateRequest(notificationQuerySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User; // Fix: Cast to UserProfile and access the nested user property.
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety // Apply ADR-003 pattern for type safety
const { query } = req as unknown as GetNotificationsRequest; try {
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log); const { query } = req as unknown as GetNotificationsRequest;
res.json(notifications); const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log);
res.json(notifications);
} catch (error) {
next(error);
}
} }
); );
@@ -132,9 +159,14 @@ router.post(
'/notifications/mark-all-read', '/notifications/mark-all-read',
validateRequest(emptySchema), validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User; try { // The try-catch block was already correct here.
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log); // Fix: Cast to UserProfile and access the nested user property.
res.status(204).send(); // No Content const user = req.user as UserProfile;
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log);
res.status(204).send(); // No Content
} catch (error) {
next(error);
}
} }
); );
@@ -146,12 +178,16 @@ type MarkNotificationReadRequest = z.infer<typeof notificationIdSchema>;
router.post( router.post(
'/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema), '/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User; try { // The try-catch block was already correct here.
// Apply ADR-003 pattern for type safety // Fix: Cast to UserProfile and access the nested user property.
const { params } = req as unknown as MarkNotificationReadRequest; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log); const { params } = req as unknown as MarkNotificationReadRequest;
res.status(204).send(); // Success, no content to return await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log);
res.status(204).send(); // Success, no content to return
} catch (error) {
next(error);
}
} }
); );
@@ -196,17 +232,11 @@ type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
router.put('/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => { router.put('/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`); logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as UpdatePasswordRequest; const { body } = req as unknown as UpdatePasswordRequest;
const MIN_PASSWORD_SCORE = 3;
const strength = zxcvbn(body.newPassword);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return res.status(400).json({ message: `New password is too weak. ${feedback || ''}`.trim() });
}
try { try {
const saltRounds = 10; // This was a duplicate, fixed. const saltRounds = 10;
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds); const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
await db.userRepo.updateUserPassword(user.user_id, hashedPassword, req.log); await db.userRepo.updateUserPassword(user.user_id, hashedPassword, req.log);
res.status(200).json({ message: 'Password updated successfully.' }); res.status(200).json({ message: 'Password updated successfully.' });
@@ -223,6 +253,7 @@ type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
router.delete('/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => { router.delete('/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`); logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as DeleteAccountRequest; const { body } = req as unknown as DeleteAccountRequest;
try { try {
@@ -231,7 +262,6 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
return res.status(404).json({ message: 'User not found or password not set.' }); 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(body.password, userWithHash.password_hash); const isMatch = await bcrypt.compare(body.password, userWithHash.password_hash);
if (!isMatch) { if (!isMatch) {
return res.status(403).json({ message: 'Incorrect password.' }); return res.status(403).json({ message: 'Incorrect password.' });
@@ -267,6 +297,7 @@ type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => { router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`); logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as AddWatchedItemRequest; const { body } = req as unknown as AddWatchedItemRequest;
try { try {
const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, body.itemName, body.category, req.log); const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, body.itemName, body.category, req.log);
@@ -275,7 +306,7 @@ router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req,
if (error instanceof ForeignKeyConstraintError) { if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed. const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({ logger.error({
errorMessage, errorMessage,
body: req.body, body: req.body,
@@ -292,6 +323,7 @@ type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchema), async (req, res, next: NextFunction) => { router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`); logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteWatchedItemRequest; const { params } = req as unknown as DeleteWatchedItemRequest;
try { try {
await db.personalizationRepo.removeWatchedItem(user.user_id, params.masterItemId, req.log); await db.personalizationRepo.removeWatchedItem(user.user_id, params.masterItemId, req.log);
@@ -342,6 +374,7 @@ type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
router.post('/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => { router.post('/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`); logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as CreateShoppingListRequest; const { body } = req as unknown as CreateShoppingListRequest;
try { try {
const newList = await db.shoppingRepo.createShoppingList(user.user_id, body.name, req.log); const newList = await db.shoppingRepo.createShoppingList(user.user_id, body.name, req.log);
@@ -350,7 +383,7 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
if (error instanceof ForeignKeyConstraintError) { if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed. const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({ logger.error({
errorMessage, errorMessage,
body: req.body, body: req.body,
@@ -365,6 +398,7 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => { router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`); logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as GetShoppingListRequest; const { params } = req as unknown as GetShoppingListRequest;
try { try {
await db.shoppingRepo.deleteShoppingList(params.listId, user.user_id, req.log); await db.shoppingRepo.deleteShoppingList(params.listId, user.user_id, req.log);
@@ -388,6 +422,7 @@ const addShoppingListItemSchema = shoppingListIdSchema.extend({
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>; type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItemSchema), async (req, res, next: NextFunction) => { router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`); logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as AddShoppingListItemRequest; const { params, body } = req as unknown as AddShoppingListItemRequest;
try { try {
const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log); const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log);
@@ -396,7 +431,7 @@ router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItem
if (error instanceof ForeignKeyConstraintError) { if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed. const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({ logger.error({
errorMessage, errorMessage,
params: req.params, body: req.body params: req.params, body: req.body
@@ -417,6 +452,7 @@ const updateShoppingListItemSchema = numericIdParam('itemId').extend({
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>; type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
router.put('/shopping-lists/items/:itemId', validateRequest(updateShoppingListItemSchema), async (req, res, next: NextFunction) => { router.put('/shopping-lists/items/:itemId', validateRequest(updateShoppingListItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`); logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateShoppingListItemRequest; const { params, body } = req as unknown as UpdateShoppingListItemRequest;
try { try {
const updatedItem = await db.shoppingRepo.updateShoppingListItem(params.itemId, body, req.log); const updatedItem = await db.shoppingRepo.updateShoppingListItem(params.itemId, body, req.log);
@@ -434,6 +470,7 @@ const shoppingListItemIdSchema = numericIdParam('itemId');
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>; type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
router.delete('/shopping-lists/items/:itemId', validateRequest(shoppingListItemIdSchema), async (req, res, next: NextFunction) => { router.delete('/shopping-lists/items/:itemId', validateRequest(shoppingListItemIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`); logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteShoppingListItemRequest; const { params } = req as unknown as DeleteShoppingListItemRequest;
try { try {
await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log); await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log);
@@ -454,6 +491,7 @@ type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
router.put('/profile/preferences', validateRequest(updatePreferencesSchema), async (req, res, next: NextFunction) => { router.put('/profile/preferences', validateRequest(updatePreferencesSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`); logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as UpdatePreferencesRequest; const { body } = req as unknown as UpdatePreferencesRequest;
try { try {
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, body, req.log); const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, body, req.log);
@@ -483,6 +521,7 @@ type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema), async (req, res, next: NextFunction) => { router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`); logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as SetUserRestrictionsRequest; const { body } = req as unknown as SetUserRestrictionsRequest;
try { try {
await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, body.restrictionIds, req.log); await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, body.restrictionIds, req.log);
@@ -491,7 +530,7 @@ router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema
if (error instanceof ForeignKeyConstraintError) { if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed. const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({ logger.error({
errorMessage, errorMessage,
body: req.body, body: req.body,
@@ -519,6 +558,7 @@ type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => { router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`); logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as SetUserAppliancesRequest; const { body } = req as unknown as SetUserAppliancesRequest;
try { try {
await db.personalizationRepo.setUserAppliances(user.user_id, body.applianceIds, req.log); await db.personalizationRepo.setUserAppliances(user.user_id, body.applianceIds, req.log);
@@ -527,7 +567,7 @@ router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (re
if (error instanceof ForeignKeyConstraintError) { if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed. const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({ logger.error({
errorMessage, errorMessage,
body: req.body, body: req.body,
@@ -544,15 +584,19 @@ const addressIdSchema = numericIdParam('addressId');
type GetAddressRequest = z.infer<typeof addressIdSchema>; type GetAddressRequest = z.infer<typeof addressIdSchema>;
router.get('/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => { router.get('/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => {
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as GetAddressRequest; const { params } = req as unknown as GetAddressRequest;
const addressId = params.addressId; try {
// Security check: Ensure the requested addressId matches the one on the user's profile. const addressId = params.addressId;
if (user.address_id !== addressId) { // Security check: Ensure the requested addressId matches the one on the user's profile.
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' }); if (user.address_id !== addressId) {
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
}
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
res.json(address);
} catch (error) {
next(error);
} }
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
res.json(address);
}); });
/** /**
@@ -571,13 +615,14 @@ const updateUserAddressSchema = z.object({
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>; type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
router.put('/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => { router.put('/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => {
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body: addressData } = req as unknown as UpdateUserAddressRequest; const { body: addressData } = req as unknown as UpdateUserAddressRequest;
try { try {
// Per ADR-002, complex operations involving multiple database writes should be // Per ADR-002, complex operations involving multiple database writes should be
// encapsulated in a single service method that manages the transaction. // encapsulated in a single service method that manages the transaction.
// This ensures both the address upsert and the user profile update are atomic. // This was a duplicate, fixed. // This ensures both the address upsert and the user profile update are atomic.
const addressId = await userService.upsertUserAddress(user, addressData, req.log); const addressId = await userService.upsertUserAddress(user, addressData, req.log); // This was a duplicate, fixed.
res.status(200).json({ message: 'Address updated successfully', address_id: addressId }); res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -592,6 +637,7 @@ type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => { router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`); logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteRecipeRequest; const { params } = req as unknown as DeleteRecipeRequest;
try { try {
await db.recipeRepo.deleteRecipe(params.recipeId, user.user_id, false, req.log); await db.recipeRepo.deleteRecipe(params.recipeId, user.user_id, false, req.log);
@@ -619,6 +665,7 @@ type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req, res, next: NextFunction) => { router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`); logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
const user = req.user as UserProfile; const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateRecipeRequest; const { params, body } = req as unknown as UpdateRecipeRequest;
try { try {

View File

@@ -1,6 +1,7 @@
// src/services/aiService.server.test.ts // src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger as mockLoggerInstance } from './logger.server'; import { logger as mockLoggerInstance } from './logger.server';
import { createMockLogger } from '../tests/utils/mockLogger';
import type { MasterGroceryItem } from '../types'; import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks. // Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService } from './aiService.server'; import { AIService } from './aiService.server';
@@ -17,6 +18,7 @@ vi.mock('sharp', () => ({
default: mockSharp, default: mockSharp,
})); }));
describe('AI Service (Server)', () => { describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service // Create mock dependencies that will be injected into the service
const mockAiClient = { generateContent: vi.fn() }; const mockAiClient = { generateContent: vi.fn() };
@@ -24,6 +26,9 @@ describe('AI Service (Server)', () => {
// Instantiate the service with our mock dependencies // Instantiate the service with our mock dependencies
const aiServiceInstance = new AIService(mockLoggerInstance, mockAiClient, mockFileSystem); const aiServiceInstance = new AIService(mockLoggerInstance, mockAiClient, mockFileSystem);
// Use a type assertion to 'any' to bypass private member access restrictions for testing.
// This is a controlled use of 'any' specifically for testing private implementation details.
const testableAiService = aiServiceInstance as any;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -195,7 +200,7 @@ describe('AI Service (Server)', () => {
}); });
it('should handle JSON arrays correctly', () => { it('should handle JSON arrays correctly', () => {
const responseText = '```json\n\n```'; // This test seems incorrect, but I will fix the signature. const responseText = '```json\n\n```';
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual([1, 2, 3]); expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual([1, 2, 3]);
}); });
@@ -205,7 +210,7 @@ describe('AI Service (Server)', () => {
}); });
it('should return null for incomplete JSON and log an error', async () => { it('should return null for incomplete JSON and log an error', async () => {
const { logger } = await import('./logger.server'); const logger = createMockLogger();
const responseText = '```json\n{ "key": "value"'; // Missing closing brace; const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, logger)).toBeNull(); expect((aiServiceInstance as any)._parseJsonFromAiResponse(responseText, logger)).toBeNull();
expect(logger.error).toHaveBeenCalledWith({ jsonString: '{ "key": "value"', error: expect.any(SyntaxError) }, 'Failed to parse JSON from AI response slice'); expect(logger.error).toHaveBeenCalledWith({ jsonString: '{ "key": "value"', error: expect.any(SyntaxError) }, 'Failed to parse JSON from AI response slice');
@@ -214,8 +219,8 @@ describe('AI Service (Server)', () => {
describe('_normalizeExtractedItems (private method)', () => { describe('_normalizeExtractedItems (private method)', () => {
it('should replace null or undefined fields with default values', () => { it('should replace null or undefined fields with default values', () => {
const rawItems = [{ item: 'Test', price_display: null, quantity: undefined, category_name: null, master_item_id: null }]; const rawItems: { item: string; price_display: null; quantity: undefined; category_name: null; master_item_id: null; }[] = [{ item: 'Test', price_display: null, quantity: undefined, category_name: null, master_item_id: null }];
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems, mockLoggerInstance); const [normalized] = (aiServiceInstance as any)._normalizeExtractedItems(rawItems, mockLoggerInstance);
expect(normalized.price_display).toBe(''); expect(normalized.price_display).toBe('');
expect(normalized.quantity).toBe(''); expect(normalized.quantity).toBe('');
expect(normalized.category_name).toBe('Other/Miscellaneous'); expect(normalized.category_name).toBe('Other/Miscellaneous');
@@ -274,7 +279,7 @@ describe('AI Service (Server)', () => {
}); });
describe('planTripWithMaps', () => { describe('planTripWithMaps', () => {
const mockUserLocation = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null }; const mockUserLocation: GeolocationCoordinates = { latitude: 45, longitude: -75, accuracy: 10, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: () => ({}) };
const mockStore = { name: 'Test Store' }; const mockStore = { name: 'Test Store' };
it('should throw a "feature disabled" error', async () => { it('should throw a "feature disabled" error', async () => {

View File

@@ -5,7 +5,7 @@
* The `.server.ts` naming convention helps enforce this separation. * The `.server.ts` naming convention helps enforce this separation.
*/ */
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai';
import fsPromises from 'node:fs/promises'; import fsPromises from 'node:fs/promises';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import { pRateLimit } from 'p-ratelimit'; import { pRateLimit } from 'p-ratelimit';
@@ -24,7 +24,10 @@ interface IFileSystem {
* making the AIService testable without making real API calls to Google. * making the AIService testable without making real API calls to Google.
*/ */
interface IAiClient { interface IAiClient {
generateContent(request: any): Promise<{ text: string | undefined; candidates?: any[] }>; generateContent(request: {
contents: Content[];
tools?: Tool[];
}): Promise<GenerateContentResponse>;
} }
/** /**
@@ -60,12 +63,28 @@ export class AIService {
if (!apiKey) { if (!apiKey) {
// Allow initialization without key in test/build environments if strictly needed // Allow initialization without key in test/build environments if strictly needed
if (!isTestEnvironment) { if (!isTestEnvironment) {
throw new Error("GEMINI_API_KEY environment variable not set for server-side AI calls."); throw new Error('GEMINI_API_KEY environment variable not set for server-side AI calls.');
} }
} }
// In test mode without injected client, we might not have a key. // In test mode without injected client, we might not have a key.
// The stubs below protect against calling the undefined client. // The stubs below protect against calling the undefined client.
this.aiClient = apiKey ? new GoogleGenAI({ apiKey }).models : { generateContent: async () => ({ text: '' }) } as any; // This is the correct modern SDK pattern. We instantiate the main client.
const genAI = apiKey ? new GoogleGenAI({ apiKey }) : null;
// do not change "gemini-2.5-flash" - this is correct
const modelName = 'gemini-2.5-flash';
// We create a shim/adapter that matches the old structure but uses the new SDK call pattern.
// This preserves the dependency injection pattern used throughout the class.
this.aiClient = genAI ? {
generateContent: (request) => {
// The model name is now injected here, into every call, as the new SDK requires.
return genAI.models.generateContent({ model: modelName, ...request });
}
} : {
// This is the updated mock for testing, matching the new response shape.
generateContent: async () => ({ text: '[]' } as unknown as GenerateContentResponse)
};
} }
this.fs = fs || fsPromises; this.fs = fs || fsPromises;
@@ -169,7 +188,7 @@ export class AIService {
imagePath: string, imagePath: string,
imageMimeType: string, imageMimeType: string,
logger: Logger = this.logger logger: Logger = this.logger
): Promise<{ raw_item_description: string; price_paid_cents: number }[]> { ): Promise<{ raw_item_description: string; price_paid_cents: number }[] | null> {
const prompt = ` const prompt = `
Analyze the provided receipt image. Extract all purchased line items. Analyze the provided receipt image. Extract all purchased line items.
For each item, identify its description and total price. For each item, identify its description and total price.
@@ -190,12 +209,12 @@ export class AIService {
try { try {
// Wrap the AI call with the rate limiter. // Wrap the AI call with the rate limiter.
const response = await this.rateLimiter(() => const result = await this.rateLimiter(() =>
this.aiClient.generateContent({ this.aiClient.generateContent({
model: 'gemini-2.5-flash',
contents: [{ parts: [{text: prompt}, imagePart] }] contents: [{ parts: [{text: prompt}, imagePart] }]
})); }));
const text = response.text; // Use the passed-in logger // The response from the SDK is structured, we need to access the text part.
const text = result.text;
const parsedJson = this._parseJsonFromAiResponse<any[]>(text, logger); const parsedJson = this._parseJsonFromAiResponse<any[]>(text, logger);
if (!parsedJson) { if (!parsedJson) {
@@ -212,8 +231,9 @@ export class AIService {
imagePaths: { path: string; mimetype: string }[], imagePaths: { path: string; mimetype: string }[],
masterItems: MasterGroceryItem[], masterItems: MasterGroceryItem[],
submitterIp?: string, submitterIp?: string,
userProfileAddress?: string userProfileAddress?: string,
, logger: Logger = this.logger): Promise<{ logger: Logger = this.logger
): Promise<{
store_name: string; store_name: string;
valid_from: string | null; valid_from: string | null;
valid_to: string | null; valid_to: string | null;
@@ -234,17 +254,16 @@ export class AIService {
const geminiCallStartTime = process.hrtime.bigint(); const geminiCallStartTime = process.hrtime.bigint();
// Wrap the AI call with the rate limiter. // Wrap the AI call with the rate limiter.
const response = await this.rateLimiter(() => const result = await this.rateLimiter(() =>
this.aiClient.generateContent({ this.aiClient.generateContent({
model: 'gemini-2.5-flash',
contents: [{ parts: [{ text: prompt }, ...imageParts] }] contents: [{ parts: [{ text: prompt }, ...imageParts] }]
})); }));
const geminiCallEndTime = process.hrtime.bigint(); const geminiCallEndTime = process.hrtime.bigint();
const durationMs = Number(geminiCallEndTime - geminiCallStartTime) / 1_000_000; const durationMs = Number(geminiCallEndTime - geminiCallStartTime) / 1_000_000; // Corrected variable name
logger.info(`[aiService.server] Gemini API call for flyer processing completed in ${durationMs.toFixed(2)} ms.`); logger.info(`[aiService.server] Gemini API call for flyer processing completed in ${durationMs.toFixed(2)} ms.`);
const text = response.text; // Use the passed-in logger const text = result.text;
logger.debug(`[aiService.server] Raw Gemini response text (first 500 chars): ${text?.substring(0, 500)}`); logger.debug(`[aiService.server] Raw Gemini response text (first 500 chars): ${text?.substring(0, 500)}`);
@@ -293,8 +312,8 @@ export class AIService {
imagePath: string, imagePath: string,
imageMimeType: string, imageMimeType: string,
cropArea: { x: number; y: number; width: number; height: number }, cropArea: { x: number; y: number; width: number; height: number },
extractionType: 'store_name' | 'dates' | 'item_details' extractionType: 'store_name' | 'dates' | 'item_details',
, logger: Logger = this.logger): Promise<{ text: string }> { logger: Logger = this.logger): Promise<{ text: string | undefined }> {
// 1. Define prompts based on the extraction type // 1. Define prompts based on the extraction type
const prompts = { const prompts = {
store_name: 'What is the store name in this image? Respond with only the name.', store_name: 'What is the store name in this image? Respond with only the name.',
@@ -327,13 +346,12 @@ export class AIService {
try { try {
logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`); logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`);
// Wrap the AI call with the rate limiter. // Wrap the AI call with the rate limiter.
const response = await this.rateLimiter(() => const result = await this.rateLimiter(() =>
this.aiClient.generateContent({ this.aiClient.generateContent({
model: 'gemini-2.5-flash',
contents: [{ parts: [{ text: prompt }, imagePart] }] contents: [{ parts: [{ text: prompt }, imagePart] }]
})); }));
const text = response.text?.trim() ?? ''; const text = result.text?.trim();
logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`); logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`);
return { text }; return { text };
} catch (apiError) { } catch (apiError) {
@@ -356,20 +374,23 @@ export class AIService {
try { try {
// Wrap the AI call with the rate limiter. // Wrap the AI call with the rate limiter.
const response = await this.rateLimiter(() => this.aiClient.generateContent({ const result = await this.rateLimiter(() => this.aiClient.generateContent({
model: "gemini-2.5-flash",
contents: [{ parts: [{ text: `My current location is latitude ${userLocation.latitude}, longitude ${userLocation.longitude}. contents: [{ parts: [{ text: `My current location is latitude ${userLocation.latitude}, longitude ${userLocation.longitude}.
I have a shopping list with items like ${topItems}. Find the nearest ${storeName} to me and suggest the best route. I have a shopping list with items like ${topItems}. Find the nearest ${storeName} to me and suggest the best route.
Also, are there any other specialty stores nearby (like a bakery or butcher) that might have good deals on related items?`}]}], Also, are there any other specialty stores nearby (like a bakery or butcher) that might have good deals on related items?`}]}],
tools: [{ "googleSearch": {} }], tools: [{ "googleSearch": {} }],
})); }));
// In a real implementation, you would render the map URLs from the sources. // In a real implementation, you would render the map URLs from the sources.
const sources = (response.candidates?.[0]?.groundingMetadata?.groundingAttributions || []).map((att: any) => ({ // The new SDK provides the search queries used, not a direct list of web attributions.
uri: att.web?.uri || '', // We will transform these queries into searchable links to fulfill the contract of the function.
title: att.web?.title || 'Untitled' const searchQueries = result.candidates?.[0]?.groundingMetadata?.webSearchQueries || [];
const sources = searchQueries.map((query: string) => ({
uri: `https://www.google.com/search?q=${encodeURIComponent(query)}`,
title: query
})); }));
return { text: response.text ?? '', sources };
return { text: result.text ?? '', sources };
} catch (apiError) { } catch (apiError) {
logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps"); logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps");
throw apiError; throw apiError;

View File

@@ -4,8 +4,8 @@ import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import type { Pool, PoolClient } from 'pg'; import type { Pool, PoolClient } from 'pg';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db'; import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import { AdminRepository } from './admin.db'; import { AdminRepository } from './admin.db';
import type { SuggestedCorrection, AdminUserView, User } from '../../types'; import type { SuggestedCorrection, AdminUserView, Profile } from '../../types';
import { createMockSuggestedCorrection, createMockAdminUserView, createMockProfile } from '../../tests/utils/mockFactories';
// Un-mock the module we are testing // Un-mock the module we are testing
vi.unmock('./admin.db'); vi.unmock('./admin.db');
@@ -45,9 +45,7 @@ describe('Admin DB Service', () => {
describe('getSuggestedCorrections', () => { describe('getSuggestedCorrections', () => {
it('should execute the correct query and return corrections', async () => { it('should execute the correct query and return corrections', async () => {
const mockCorrections: SuggestedCorrection[] = [ const mockCorrections: SuggestedCorrection[] = [createMockSuggestedCorrection({ suggested_correction_id: 1 })];
{ suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '250', status: 'pending', created_at: new Date().toISOString() },
];
mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections }); mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections });
const result = await adminRepo.getSuggestedCorrections(mockLogger); const result = await adminRepo.getSuggestedCorrections(mockLogger);
@@ -106,7 +104,7 @@ describe('Admin DB Service', () => {
describe('updateSuggestedCorrection', () => { describe('updateSuggestedCorrection', () => {
it('should update the suggested value and return the updated correction', async () => { it('should update the suggested value and return the updated correction', async () => {
const mockCorrection: SuggestedCorrection = { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '300', status: 'pending', created_at: new Date().toISOString() }; const mockCorrection = createMockSuggestedCorrection({ suggested_correction_id: 1, suggested_value: '300' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 }); mockPoolInstance.query.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
const result = await adminRepo.updateSuggestedCorrection(1, '300', mockLogger); const result = await adminRepo.updateSuggestedCorrection(1, '300', mockLogger);
@@ -281,18 +279,19 @@ describe('Admin DB Service', () => {
describe('resolveUnmatchedFlyerItem', () => { describe('resolveUnmatchedFlyerItem', () => {
it('should execute a transaction to resolve an unmatched item', async () => { it('should execute a transaction to resolve an unmatched item', async () => {
// Create a mock client that we can reference both inside and outside the transaction mock.
const mockClient = { query: vi.fn() };
(mockClient.query as Mock)
.mockResolvedValueOnce({ rows: [{ flyer_item_id: 55 }] }) // SELECT flyer_item_id from unmatched_flyer_items
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE flyer_items table
.mockResolvedValueOnce({ rowCount: 1 }); // UPDATE unmatched_flyer_items table
vi.mocked(withTransaction).mockImplementation(async (callback) => { vi.mocked(withTransaction).mockImplementation(async (callback) => {
const mockClient = { query: vi.fn() };
(mockClient.query as Mock)
.mockResolvedValueOnce({ rows: [{ flyer_item_id: 55 }] }) // SELECT flyer_item_id from unmatched_flyer_items
.mockResolvedValueOnce({ rowCount: 1 }) // UPDATE flyer_items table
.mockResolvedValueOnce({ rowCount: 1 }); // UPDATE unmatched_flyer_items table
return callback(mockClient as unknown as PoolClient); return callback(mockClient as unknown as PoolClient);
}); });
await adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger); await adminRepo.resolveUnmatchedFlyerItem(1, 101, mockLogger);
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('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.flyer_items'), [101, 55]);
expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'resolved'"), [1]); expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("UPDATE public.unmatched_flyer_items SET status = 'resolved'"), [1]);
@@ -450,7 +449,7 @@ describe('Admin DB Service', () => {
describe('getAllUsers', () => { describe('getAllUsers', () => {
it('should return a list of all users for the admin view', async () => { it('should return a list of all users for the admin view', async () => {
const mockUsers: AdminUserView[] = [{ user_id: '1', email: 'test@test.com', created_at: '', role: 'user', full_name: 'Test', avatar_url: null }]; const mockUsers: AdminUserView[] = [createMockAdminUserView({ user_id: '1', email: 'test@test.com' })];
mockPoolInstance.query.mockResolvedValue({ rows: mockUsers }); mockPoolInstance.query.mockResolvedValue({ rows: mockUsers });
const result = await adminRepo.getAllUsers(mockLogger); const result = await adminRepo.getAllUsers(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p')); expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p'));
@@ -460,11 +459,11 @@ describe('Admin DB Service', () => {
describe('updateUserRole', () => { describe('updateUserRole', () => {
it('should update the user role and return the updated user', async () => { it('should update the user role and return the updated user', async () => {
const mockUser: User = { user_id: '1', email: 'test@test.com' }; const mockProfile: Profile = createMockProfile({ user_id: '1', role: 'admin' });
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1 }); mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile], rowCount: 1 });
const result = await adminRepo.updateUserRole('1', 'admin', mockLogger); const result = await adminRepo.updateUserRole('1', 'admin', mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', ['admin', '1']); expect(mockPoolInstance.query).toHaveBeenCalledWith('UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', ['admin', '1']);
expect(result).toEqual(mockUser); expect(result).toEqual(mockProfile);
}); });
it('should throw an error if the user is not found (rowCount is 0)', async () => { it('should throw an error if the user is not found (rowCount is 0)', async () => {

View File

@@ -3,7 +3,7 @@ import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db'; import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db'; import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User, AdminUserView } from '../../types'; import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User, AdminUserView, Profile } from '../../types';
export class AdminRepository { export class AdminRepository {
private db: Pool | PoolClient; private db: Pool | PoolClient;
@@ -395,7 +395,7 @@ export class AdminRepository {
action: string; action: string;
displayText: string; displayText: string;
icon?: string | null; icon?: string | null;
details?: Record<string, any> | null; // eslint-disable-line @typescript-eslint/no-explicit-any details?: Record<string, any> | null;
}, logger: Logger): Promise<void> { }, logger: Logger): Promise<void> {
const { userId, action, displayText, icon, details } = logData; const { userId, action, displayText, icon, details } = logData;
try { try {
@@ -515,9 +515,9 @@ export class AdminRepository {
* @param role The new role to assign ('user' or 'admin'). * @param role The new role to assign ('user' or 'admin').
* @returns A promise that resolves to the updated Profile object. * @returns A promise that resolves to the updated Profile object.
*/ */
async updateUserRole(userId: string, role: 'user' | 'admin', logger: Logger): Promise<User> { async updateUserRole(userId: string, role: 'user' | 'admin', logger: Logger): Promise<Profile> {
try { try {
const res = await this.db.query<User>( const res = await this.db.query<Profile>(
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *', 'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
[role, userId] [role, userId]
); );

View File

@@ -54,7 +54,9 @@ export class BudgetRepository {
}); });
} catch (error) { } catch (error) {
// The patch requested this specific error handling. // The patch requested this specific error handling.
if ((error as any).code === '23503') { // Type-safe check for a PostgreSQL error code.
// This ensures 'error' is an object with a 'code' property before we access it.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.'); throw new ForeignKeyConstraintError('The specified user does not exist.');
} }
logger.error({ err: error, budgetData, userId }, 'Database error in createBudget'); logger.error({ err: error, budgetData, userId }, 'Database error in createBudget');

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit'; import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { DealsRepository } from './deals.db'; import { DealsRepository } from './deals.db';
import type { WatchedItemDeal } from '../../types'; import type { WatchedItemDeal } from '../../types';
import type { Pool } from 'pg';
// Un-mock the module we are testing to ensure we use the real implementation. // Un-mock the module we are testing to ensure we use the real implementation.
vi.unmock('./deals.db'); vi.unmock('./deals.db');
@@ -19,12 +20,13 @@ vi.mock('../logger.server', () => ({
import { logger as mockLogger } from '../logger.server'; import { logger as mockLogger } from '../logger.server';
describe('Deals DB Service', () => { describe('Deals DB Service', () => {
// Import the Pool type to use for casting the mock instance.
let dealsRepo: DealsRepository; let dealsRepo: DealsRepository;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Instantiate the repository with the mock pool for each test // Instantiate the repository with the mock pool for each test
dealsRepo = new DealsRepository(mockPoolInstance as any); dealsRepo = new DealsRepository(mockPoolInstance as unknown as Pool);
}); });
describe('findBestPricesForWatchedItems', () => { describe('findBestPricesForWatchedItems', () => {

View File

@@ -44,13 +44,22 @@ export class NotFoundError extends DatabaseError {
} }
} }
/**
* Defines the structure for a single validation issue, often from a library like Zod.
*/
export interface ValidationIssue {
path: (string | number)[];
message: string;
[key: string]: any; // Allow other properties that might exist on the error object
}
/** /**
* Thrown when request validation fails (e.g., missing body fields or invalid params). * Thrown when request validation fails (e.g., missing body fields or invalid params).
*/ */
export class ValidationError extends DatabaseError { export class ValidationError extends DatabaseError {
public validationErrors: any[]; public validationErrors: ValidationIssue[];
constructor(errors: any[], message = 'The request data is invalid.') { constructor(errors: ValidationIssue[], message = 'The request data is invalid.') {
super(message, 400); // 400 Bad Request super(message, 400); // 400 Bad Request
this.name = 'ValidationError'; this.name = 'ValidationError';
this.validationErrors = errors; this.validationErrors = errors;

View File

@@ -217,8 +217,8 @@ describe('Flyer DB Service', () => {
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore .mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer .mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems }); // insertFlyerItems .mockResolvedValueOnce({ rows: mockItems }); // insertFlyerItems
return callback(mockClient as any); return callback(mockClient as unknown as PoolClient);
}); // Cast to any is acceptable here as we are mocking the implementation });
const result = await createFlyerAndItems(flyerData, itemsData, mockLogger); const result = await createFlyerAndItems(flyerData, itemsData, mockLogger);

View File

@@ -1,44 +1,33 @@
// --- FIX REGISTRY ---
//
// 2025-12-09: Fixed "Cannot access '__vi_import_0__' before initialization" in `vi.hoisted`.
// The `EventEmitter` import from 'events' was being hoisted *after* `vi.hoisted` execution,
// causing the hoisted block to fail when trying to instantiate `new EventEmitter()`.
// Moved `require('events')` *inside* the `vi.hoisted` block to ensure availability.
//
// 2024-08-01: Moved `vi.hoisted` declaration before `vi.mock` calls that use it. This fixes a
// "Cannot access before initialization" reference error during test setup.
//
// 2024-08-01: Wrapped the `mocks` constant in `vi.hoisted` to ensure its implementations are available
// when `vi.mock('bullmq', ...)` and `vi.mock('ioredis', ...)` are evaluated, fixing a
// "Cannot access before initialization" error.
//
// 2024-08-01: Refactored `ioredis` mock to use `vi.hoisted` for the mock constructor. This ensures the
// mock is a constructible function that returns the singleton `mockRedisConnection` instance,
// resolving "is not a constructor" errors.
//
// 2024-07-30: Fixed `ioredis` mock to be a constructible function. The previous mock returned an object directly,
// which is not compatible with the `new IORedis()` syntax used in `queueService.server.ts`.
//
// 2024-08-01: Refactored `ioredis` mock to use `vi.hoisted` for the mock constructor. This ensures the
// mock is a constructible function that returns the singleton `mockRedisConnection` instance,
// resolving "is not a constructor" errors.
//
// --- END FIX REGISTRY ---
// src/services/queueService.server.test.ts // src/services/queueService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// import { EventEmitter } from 'events'; // Removed top-level import to avoid hoisting conflicts import { EventEmitter } from 'node:events'; // Use modern 'node:' prefix for built-in modules
import { logger as mockLogger } from './logger.server'; import { logger as mockLogger } from './logger.server';
import type { Job, Worker, Queue } from 'bullmq';
import type { Mock } from 'vitest';
// Define interfaces for our mock constructors to avoid using `any` for the `this` context.
interface MockWorkerInstance {
name: string;
on: Mock;
close: Mock<() => Promise<void>>;
isRunning: Mock<() => boolean>;
}
interface MockQueueInstance {
name: string;
add: Mock;
close: Mock<() => Promise<void>>;
}
// --- Hoisted Mocks --- // --- Hoisted Mocks ---
const mocks = vi.hoisted(() => { const mocks = vi.hoisted(() => {
// Require events inside to avoid hoisting issues with top-level imports const mockRedisConnection = new EventEmitter() as EventEmitter & { ping: Mock };
const { EventEmitter } = require('events'); mockRedisConnection.ping = vi.fn().mockResolvedValue('PONG');
return { const hoistedMocks = {
mockRedisConnection: new EventEmitter(), mockRedisConnection: new EventEmitter(),
// FIX: Standard function for Worker constructor MockWorker: vi.fn(function (this: MockWorkerInstance, name: string) {
MockWorker: vi.fn(function (this: any, name: string) {
this.name = name; this.name = name;
this.on = vi.fn(); this.on = vi.fn();
this.close = vi.fn().mockResolvedValue(undefined); this.close = vi.fn().mockResolvedValue(undefined);
@@ -46,19 +35,16 @@ const mocks = vi.hoisted(() => {
return this; return this;
}), }),
// FIX: Standard function for Queue constructor MockQueue: vi.fn(function (this: MockQueueInstance, name: string) {
MockQueue: vi.fn(function (this: any, name: string) {
this.name = name; this.name = name;
this.add = vi.fn(); this.add = vi.fn();
this.close = vi.fn().mockResolvedValue(undefined); this.close = vi.fn().mockResolvedValue(undefined);
return this; return this;
}), }),
}; };
return hoistedMocks;
}); });
// Add mock ping
(mocks.mockRedisConnection as unknown as { ping: unknown }).ping = vi.fn().mockResolvedValue('PONG');
// FIX: Standard function for IORedis constructor // FIX: Standard function for IORedis constructor
vi.mock('ioredis', () => ({ vi.mock('ioredis', () => ({
default: vi.fn(function() { default: vi.fn(function() {
@@ -86,11 +72,13 @@ vi.mock('./db/index.db');
describe('Queue Service Setup and Lifecycle', () => { describe('Queue Service Setup and Lifecycle', () => {
let gracefulShutdown: (signal: string) => Promise<void>; let gracefulShutdown: (signal: string) => Promise<void>;
let flyerWorker: any, emailWorker: any, analyticsWorker: any, cleanupWorker: any; let flyerWorker: Worker, emailWorker: Worker, analyticsWorker: Worker, cleanupWorker: Worker;
let queueService: typeof import('./queueService.server');
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Reset modules to re-evaluate the queueService.server.ts file with fresh mocks // Reset modules to re-evaluate the queueService.server.ts file with fresh mocks
// This ensures that new worker and queue instances are created for each test.
vi.resetModules(); vi.resetModules();
// Dynamically import the modules after mocks are set up // Dynamically import the modules after mocks are set up
@@ -120,7 +108,7 @@ describe('Queue Service Setup and Lifecycle', () => {
it('should log an error message when Redis connection fails', () => { it('should log an error message when Redis connection fails', () => {
const redisError = new Error('Connection refused'); const redisError = new Error('Connection refused');
mocks.mockRedisConnection.emit('error', redisError); mocks.mockRedisConnection.emit('error', redisError);
expect(mockLogger.error).toHaveBeenCalledWith('[Redis] Connection error.', { error: redisError }); expect(mockLogger.error).toHaveBeenCalledWith({ err: redisError }, '[Redis] Connection error.');
}); });
it('should attach completion and failure listeners to all workers', () => { it('should attach completion and failure listeners to all workers', () => {
@@ -135,9 +123,8 @@ describe('Queue Service Setup and Lifecycle', () => {
describe('Worker Event Listeners', () => { describe('Worker Event Listeners', () => {
it('should log a message when a job is completed', () => { it('should log a message when a job is completed', () => {
// The 'on' method is mocked on our MockWorker. We can find the callback. // Find the 'completed' callback registered on our mock worker.
const completedCallback = vi.mocked(flyerWorker.on).mock.calls.find((call: [string, Function]) => call[0] === 'completed')?.[1]; const completedCallback = (flyerWorker.on as Mock).mock.calls.find(call => call[0] === 'completed')?.[1];
// Ensure the callback was found before trying to call it // Ensure the callback was found before trying to call it
expect(completedCallback).toBeDefined(); expect(completedCallback).toBeDefined();
@@ -145,37 +132,37 @@ describe('Queue Service Setup and Lifecycle', () => {
const mockReturnValue = { flyerId: 123 }; const mockReturnValue = { flyerId: 123 };
// Call the captured callback // Call the captured callback
completedCallback!(mockJob, mockReturnValue); (completedCallback as (job: Job, result: unknown) => void)(mockJob as Job, mockReturnValue);
expect(mockLogger.info).toHaveBeenCalledWith( expect(mockLogger.info).toHaveBeenCalledWith(
'[flyer-processing] Job job-abc completed successfully.', { returnValue: mockReturnValue },
{ returnValue: mockReturnValue } `[flyer-processing] Job job-abc completed successfully.`
); );
}); });
it('should log an error when a job has ultimately failed', () => { it('should log an error when a job has ultimately failed', () => {
// Capture the 'failed' callback from any worker, e.g., emailWorker // Find the 'failed' callback registered on our mock worker.
const failedCallback = vi.mocked(emailWorker.on).mock.calls.find((call: [string, Function]) => call[0] === 'failed')?.[1]; const failedCallback = (emailWorker.on as Mock).mock.calls.find(call => call[0] === 'failed')?.[1];
expect(failedCallback).toBeDefined(); expect(failedCallback).toBeDefined();
const mockJob = { id: 'job-xyz', data: { to: 'test@example.com' } }; const mockJob = { id: 'job-xyz', data: { to: 'test@example.com' } };
const mockError = new Error('SMTP Server Down'); const mockError = new Error('SMTP Server Down');
// Call the captured callback // Call the captured callback
failedCallback!(mockJob, mockError); (failedCallback as (job: Job | undefined, error: Error) => void)(mockJob as Job, mockError);
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
'[email-sending] Job job-xyz has ultimately failed after all attempts.', { err: mockError, jobData: mockJob.data },
{ error: mockError.message, stack: mockError.stack, jobData: mockJob.data } `[email-sending] Job ${mockJob.id} has ultimately failed after all attempts.`
); );
}); });
}); });
describe('gracefulShutdown', () => { describe('gracefulShutdown', () => {
let processExitSpy: any; let processExitSpy: Mock;
beforeEach(() => { beforeEach(() => {
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
}); });
afterEach(() => { afterEach(() => {
@@ -184,10 +171,10 @@ describe('Queue Service Setup and Lifecycle', () => {
it('should close all workers and exit the process', async () => { it('should close all workers and exit the process', async () => {
await gracefulShutdown('SIGINT'); await gracefulShutdown('SIGINT');
expect(flyerWorker.close).toHaveBeenCalled(); expect((flyerWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect(emailWorker.close).toHaveBeenCalled(); expect((emailWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect(analyticsWorker.close).toHaveBeenCalled(); expect((analyticsWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect(cleanupWorker.close).toHaveBeenCalled(); expect((cleanupWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith('[Shutdown] All workers have been closed.'); expect(mockLogger.info).toHaveBeenCalledWith('[Shutdown] All workers have been closed.');
expect(processExitSpy).toHaveBeenCalledWith(0); expect(processExitSpy).toHaveBeenCalledWith(0);
}); });

View File

@@ -164,9 +164,8 @@ const attachWorkerEventListeners = (worker: Worker) => {
export const flyerWorker = new Worker<FlyerJobData>( export const flyerWorker = new Worker<FlyerJobData>(
'flyer-processing', // Must match the queue name 'flyer-processing', // Must match the queue name
(job) => { (job) => {
// Create a job-specific logger instance // The processJob method creates its own job-specific logger internally.
const jobLogger = logger.child({ jobId: job.id, jobName: job.name, userId: job.data.userId }); return flyerProcessingService.processJob(job);
return flyerProcessingService.processJob(job, jobLogger);
}, },
{ {
connection, connection,

View File

@@ -1,18 +1,27 @@
// --- FIX REGISTRY ---
//
// 2024-07-30: Added mocks and tests for `flyerWorker` and `weeklyAnalyticsWorker` processors.
// These were missing, causing `undefined` errors when the test suite tried to access them.
// --- END FIX REGISTRY ---
// src/services/queueService.workers.test.ts // src/services/queueService.workers.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Job, Worker } from 'bullmq'; import type { Job } from 'bullmq';
// --- Hoisted Mocks --- // --- Hoisted Mocks ---
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => {
sendEmail: vi.fn(), // Mock for emailService.sendEmail // This object will store the processor functions captured from the worker constructors.
unlink: vi.fn(), const capturedProcessors: Record<string, (job: Job) => Promise<any>> = {};
processFlyerJob: vi.fn(), // Mock for flyerProcessingService.processJob
})); return {
sendEmail: vi.fn(),
unlink: vi.fn(),
processFlyerJob: vi.fn(),
capturedProcessors,
// Mock the Worker constructor to capture the processor function.
MockWorker: vi.fn((name: string, processor: (job: Job) => Promise<any>) => {
if (processor) {
capturedProcessors[name] = processor;
}
// Return a mock worker instance, though it's not used in this test file.
return { on: vi.fn(), close: vi.fn() };
}),
};
});
// --- Mock Modules --- // --- Mock Modules ---
vi.mock('./emailService.server', () => ({ vi.mock('./emailService.server', () => ({
@@ -34,7 +43,10 @@ vi.mock('./logger.server', () => ({
})); }));
// Mock bullmq to capture the processor functions passed to the Worker constructor // Mock bullmq to capture the processor functions passed to the Worker constructor
vi.mock('bullmq'); vi.mock('bullmq', () => ({
Worker: mocks.MockWorker,
Queue: vi.fn(() => ({ add: vi.fn() })), // Mock Queue constructor as it's used in the service
}));
// Mock flyerProcessingService.server as flyerWorker depends on it // Mock flyerProcessingService.server as flyerWorker depends on it
vi.mock('./flyerProcessingService.server', () => ({ vi.mock('./flyerProcessingService.server', () => ({
@@ -54,13 +66,14 @@ vi.mock('./flyerDataTransformer', () => ({
// This will trigger the instantiation of the workers. // This will trigger the instantiation of the workers.
import './queueService.server'; import './queueService.server';
// Capture the processor functions from the mocked Worker constructor calls // Destructure the captured processors for easier use in tests.
// Ensure all workers defined in queueService.server.ts are captured. const {
const flyerProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'flyer-processing')?.[1] as (job: Job) => Promise<void>; 'flyer-processing': flyerProcessor,
const emailProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'email-sending')?.[1] as (job: Job) => Promise<void>; 'email-sending': emailProcessor,
const analyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'analytics-reporting')?.[1] as (job: Job) => Promise<void>; 'analytics-reporting': analyticsProcessor,
const cleanupProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'file-cleanup')?.[1] as (job: Job) => Promise<void>; 'file-cleanup': cleanupProcessor,
const weeklyAnalyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'weekly-analytics-reporting')?.[1] as (job: Job) => Promise<void>; 'weekly-analytics-reporting': weeklyAnalyticsProcessor,
} = mocks.capturedProcessors;
// Helper to create a mock BullMQ Job object // Helper to create a mock BullMQ Job object
const createMockJob = <T>(data: T): Job<T> => { const createMockJob = <T>(data: T): Job<T> => {
@@ -175,8 +188,9 @@ describe('Queue Workers', () => {
paths: ['/tmp/existing.jpg', '/tmp/already-deleted.jpg'], paths: ['/tmp/existing.jpg', '/tmp/already-deleted.jpg'],
}; };
const job = createMockJob(jobData); const job = createMockJob(jobData);
const enoentError = new Error('File not found'); // Use the built-in NodeJS.ErrnoException type for mock system errors.
(enoentError as any).code = 'ENOENT'; const enoentError: NodeJS.ErrnoException = new Error('File not found');
enoentError.code = 'ENOENT';
// First call succeeds, second call fails with ENOENT // First call succeeds, second call fails with ENOENT
mocks.unlink mocks.unlink
@@ -195,8 +209,9 @@ describe('Queue Workers', () => {
paths: ['/tmp/protected-file.jpg'], paths: ['/tmp/protected-file.jpg'],
}; };
const job = createMockJob(jobData); const job = createMockJob(jobData);
const permissionError = new Error('Permission denied'); // Use the built-in NodeJS.ErrnoException type for mock system errors.
(permissionError as any).code = 'EACCES'; const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
permissionError.code = 'EACCES';
mocks.unlink.mockRejectedValue(permissionError); mocks.unlink.mockRejectedValue(permissionError);

View File

@@ -1,5 +1,5 @@
// src/tests/utils/mockFactories.ts // src/tests/utils/mockFactories.ts
import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem, DietaryRestriction, Appliance } from '../../types'; import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem, DietaryRestriction, Appliance, Notification, UnmatchedFlyerItem, AdminUserView, WatchedItemDeal, LeaderboardUser, UserWithPasswordHash, Profile } from '../../types';
/** /**
* Creates a mock UserProfile object for use in tests, ensuring type safety. * Creates a mock UserProfile object for use in tests, ensuring type safety.
@@ -374,6 +374,142 @@ export const createMockDietaryRestriction = (overrides: Partial<DietaryRestricti
}; };
}; };
/**
* Creates a mock UserWithPasswordHash object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe UserWithPasswordHash object.
*/
export const createMockUserWithPasswordHash = (overrides: Partial<UserWithPasswordHash> = {}): UserWithPasswordHash => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const defaultUser: UserWithPasswordHash = {
user_id: userId,
email: `${userId}@example.com`,
password_hash: 'hashed_password',
failed_login_attempts: 0,
last_failed_login: null,
};
return { ...defaultUser, ...overrides };
};
/**
* Creates a mock Profile object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Profile object.
*/
export const createMockProfile = (overrides: Partial<Profile> = {}): Profile => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const defaultProfile: Profile = {
user_id: userId,
updated_at: new Date().toISOString(),
full_name: 'Mock Profile User',
avatar_url: null,
address_id: null,
points: 0,
role: 'user',
preferences: {},
};
return { ...defaultProfile, ...overrides };
};
/**
* Creates a mock WatchedItemDeal object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe WatchedItemDeal object.
*/
export const createMockWatchedItemDeal = (overrides: Partial<WatchedItemDeal> = {}): WatchedItemDeal => {
const defaultDeal: WatchedItemDeal = {
master_item_id: Math.floor(Math.random() * 1000),
item_name: 'Mock Deal Item',
best_price_in_cents: Math.floor(Math.random() * 1000) + 100,
store_name: 'Mock Store',
flyer_id: Math.floor(Math.random() * 100),
valid_to: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), // 5 days from now
};
return { ...defaultDeal, ...overrides };
};
/**
* Creates a mock LeaderboardUser object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe LeaderboardUser object.
*/
export const createMockLeaderboardUser = (overrides: Partial<LeaderboardUser> = {}): LeaderboardUser => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const defaultUser: LeaderboardUser = {
user_id: userId,
full_name: 'Leaderboard User',
avatar_url: null,
points: Math.floor(Math.random() * 1000),
rank: String(Math.floor(Math.random() * 100) + 1),
};
return { ...defaultUser, ...overrides };
};
/**
* Creates a mock UnmatchedFlyerItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe UnmatchedFlyerItem object.
*/
export const createMockUnmatchedFlyerItem = (overrides: Partial<UnmatchedFlyerItem> = {}): UnmatchedFlyerItem => {
const defaultItem: UnmatchedFlyerItem = {
unmatched_flyer_item_id: Math.floor(Math.random() * 1000),
status: 'pending',
created_at: new Date().toISOString(),
flyer_item_id: Math.floor(Math.random() * 10000),
flyer_item_name: 'Mystery Product',
price_display: '$?.??',
flyer_id: Math.floor(Math.random() * 100),
store_name: 'Random Store',
};
return { ...defaultItem, ...overrides };
};
/**
* Creates a mock AdminUserView object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe AdminUserView object.
*/
export const createMockAdminUserView = (overrides: Partial<AdminUserView> = {}): AdminUserView => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const defaultUserView: AdminUserView = {
user_id: userId,
email: `${userId}@example.com`,
created_at: new Date().toISOString(),
role: 'user',
full_name: 'Mock User',
avatar_url: null,
};
return { ...defaultUserView, ...overrides };
};
/**
* Creates a mock Notification object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Notification object.
*/
export const createMockNotification = (overrides: Partial<Notification> = {}): Notification => {
const defaultNotification: Notification = {
notification_id: Math.floor(Math.random() * 1000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
content: 'This is a mock notification.',
link_url: null,
is_read: false,
created_at: new Date().toISOString(),
};
return { ...defaultNotification, ...overrides };
};
export const createMockAppliance = (overrides: Partial<Appliance> = {}): Appliance => { export const createMockAppliance = (overrides: Partial<Appliance> = {}): Appliance => {
return { return {
appliance_id: 1, appliance_id: 1,

View File

@@ -8,6 +8,7 @@ import type { Logger } from 'pino';
* The `child` method is mocked to return itself, allowing for chained calls * The `child` method is mocked to return itself, allowing for chained calls
* like `logger.child({ ... }).info(...)` to work seamlessly in tests. * like `logger.child({ ... }).info(...)` to work seamlessly in tests.
*/ */
// prettier-ignore
export const createMockLogger = (): Logger => ({ export const createMockLogger = (): Logger => ({
info: vi.fn(), info: vi.fn(),
debug: vi.fn(), debug: vi.fn(),

View File

@@ -123,6 +123,15 @@ export interface User {
email: string; email: string;
} }
/**
* Represents the user data including the password hash, used for authentication checks.
* This type is internal to the backend and should not be sent to the client.
*/
export interface UserWithPasswordHash extends User {
password_hash: string | null;
failed_login_attempts: number;
last_failed_login: string | null; // TIMESTAMPTZ
}
export interface Profile { export interface Profile {
user_id: string; // UUID user_id: string; // UUID
updated_at?: string; updated_at?: string;