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
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:
@@ -1,8 +1,7 @@
|
||||
// src/middleware/errorHandler.ts
|
||||
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 { logger } from '../services/logger.server';
|
||||
|
||||
interface HttpError extends Error {
|
||||
status?: number;
|
||||
@@ -20,12 +19,7 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
// --- 1. Determine Final Status Code and Message ---
|
||||
let statusCode = err.status ?? 500;
|
||||
const message = err.message;
|
||||
// Define a more specific type for validation errors from Zod.
|
||||
type ValidationIssue = {
|
||||
path: (string | number)[];
|
||||
message: string;
|
||||
};
|
||||
let errors: ValidationIssue[] | undefined;
|
||||
let validationIssues: ValidationIssue[] | undefined;
|
||||
let errorId: string | undefined;
|
||||
|
||||
// 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;
|
||||
} else if (err instanceof ValidationError) {
|
||||
statusCode = 400;
|
||||
errors = err.validationErrors;
|
||||
validationIssues = err.validationErrors;
|
||||
} else if (err instanceof DatabaseError) {
|
||||
// This is a generic fallback for other database errors that are not the specific subclasses above.
|
||||
statusCode = err.status;
|
||||
@@ -59,7 +53,7 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
log.warn(
|
||||
{
|
||||
err,
|
||||
validationErrors: errors, // Add validation issues to the log object
|
||||
validationErrors: validationIssues, // Add validation issues to the log object
|
||||
statusCode,
|
||||
},
|
||||
`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({
|
||||
message: responseMessage,
|
||||
...(errors && { errors }), // Conditionally add the 'errors' array if it exists
|
||||
...(validationIssues && { errors: validationIssues }), // Conditionally add the 'errors' array if it exists
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
// src/routes/admin.content.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes'; // This was a duplicate, fixed.
|
||||
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockUnmatchedFlyerItem } from '../tests/utils/mockFactories';
|
||||
import { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
@@ -235,15 +235,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
describe('Unmatched Items Route', () => {
|
||||
it('GET /unmatched-items should return a list of unmatched items', async () => {
|
||||
// Correctly create a mock for UnmatchedFlyerItem.
|
||||
// It's a combination of a flyer item and some context, including the 'flyer_item_name'.
|
||||
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
|
||||
}];
|
||||
const mockUnmatchedItems: UnmatchedFlyerItem[] = [createMockUnmatchedFlyerItem({ unmatched_flyer_item_id: 1, flyer_item_name: 'Mystery Item' })];
|
||||
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -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
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { Job } from 'bullmq';
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
// 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 supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
@@ -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) => {
|
||||
type ApproveCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
|
||||
const { params } = req as unknown as ApproveCorrectionRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
||||
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.' });
|
||||
} catch (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) => {
|
||||
type RejectCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
|
||||
const { params } = req as unknown as RejectCorrectionRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
||||
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.' });
|
||||
} catch (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) => {
|
||||
type UpdateCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
|
||||
const { params, body } = req as unknown as UpdateCorrectionRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as z.infer<typeof updateCorrectionSchema>;
|
||||
try {
|
||||
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(params.id, body.suggested_value, req.log);
|
||||
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) => {
|
||||
type UpdateRecipeStatusRequest = z.infer<typeof updateRecipeStatusSchema>;
|
||||
const { params, body } = req as unknown as UpdateRecipeStatusRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
|
||||
try {
|
||||
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);
|
||||
@@ -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) => {
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
||||
try {
|
||||
// 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.
|
||||
*/
|
||||
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 { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
||||
const { params } = req as unknown as z.infer<typeof schema>;
|
||||
try {
|
||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||
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.
|
||||
*/
|
||||
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 {
|
||||
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
||||
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) => {
|
||||
type UpdateCommentStatusRequest = z.infer<typeof updateCommentStatusSchema>;
|
||||
const { params, body } = req as unknown as UpdateCommentStatusRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as z.infer<typeof updateCommentStatusSchema>;
|
||||
try {
|
||||
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);
|
||||
@@ -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) => {
|
||||
type ActivityLogRequest = z.infer<typeof activityLogSchema>;
|
||||
const { query } = req as unknown as ActivityLogRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { query } = req as unknown as z.infer<typeof activityLogSchema>;
|
||||
try {
|
||||
const logs = await db.adminRepo.getActivityLog(query.limit, query.offset, req.log);
|
||||
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) => {
|
||||
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 {
|
||||
const user = await db.userRepo.findUserProfileById(params.id, req.log);
|
||||
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) => {
|
||||
type UpdateUserRoleRequest = z.infer<typeof updateUserRoleSchema>;
|
||||
const { params, body } = req as unknown as UpdateUserRoleRequest;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
|
||||
try {
|
||||
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
|
||||
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) => {
|
||||
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 {
|
||||
if (adminUser.user.user_id === params.id) {
|
||||
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.
|
||||
*/
|
||||
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 { 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}`);
|
||||
|
||||
// Enqueue the cleanup job. The worker will handle the file deletion.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/admin.stats.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/admin.system.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// src/routes/admin.users.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { User, UserProfile } from '../types';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes'; // This was a duplicate, fixed.
|
||||
import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
|
||||
import { User, UserProfile, Profile } from '../types';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
@@ -85,19 +85,10 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should return a list of all users on success', async () => {
|
||||
// Define a specific type for the user list returned by the admin endpoint
|
||||
// to avoid using `any` and improve test type safety.
|
||||
type UserSummary = {
|
||||
user_id: string;
|
||||
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 },
|
||||
// Use the mock factory to create consistent test data.
|
||||
const mockUsers = [
|
||||
createMockAdminUserView({ user_id: '1', email: 'user1@test.com', role: 'user' }),
|
||||
createMockAdminUserView({ user_id: '2', email: 'user2@test.com', role: 'admin' }),
|
||||
];
|
||||
vi.mocked(mockedDb.adminRepo.getAllUsers).mockResolvedValue(mockUsers);
|
||||
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', () => {
|
||||
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);
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/users/user-to-update')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/ai.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
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 type { Job } from 'bullmq';
|
||||
import aiRouter from './ai.routes';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// src/routes/auth.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
import { UserProfile } from '../types';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
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 () => {
|
||||
// Arrange:
|
||||
const mockNewUser: UserProfile = {
|
||||
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: {}
|
||||
};
|
||||
const mockNewUser = createMockUserProfile({ user_id: 'new-user-id', user: { user_id: 'new-user-id', email: newUserEmail }, full_name: 'Test User' });
|
||||
|
||||
// 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
|
||||
@@ -325,13 +316,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
describe('POST /forgot-password', () => {
|
||||
it('should send a reset link if the user exists', async () => {
|
||||
// Arrange
|
||||
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({
|
||||
user_id: 'user-123',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'some_hash',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null,
|
||||
});
|
||||
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
|
||||
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
@@ -367,13 +352,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
it('should still return 200 OK if the email service fails', async () => {
|
||||
// Arrange
|
||||
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({
|
||||
user_id: 'user-123',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'some_hash',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null,
|
||||
});
|
||||
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
|
||||
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
|
||||
// Mock the email service to fail
|
||||
const { sendPasswordResetEmail } = await import('../services/emailService.server');
|
||||
@@ -447,13 +426,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
describe('POST /refresh-token', () => {
|
||||
it('should issue a new access token with a valid refresh token cookie', async () => {
|
||||
const mockUser = {
|
||||
user_id: 'user-123',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'some_hash',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null,
|
||||
};
|
||||
const mockUser = createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' });
|
||||
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser);
|
||||
|
||||
const response = await supertest(app)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/budget.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import budgetRouter from './budget.routes';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 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 express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import dealsRouter from './deals.routes';
|
||||
import { dealsRepo } from '../services/db/deals.db';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import type { UserProfile, WatchedItemDeal } from '../types';
|
||||
import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||
import type { WatchedItemDeal } from '../types';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
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 () => {
|
||||
const mockDeals: WatchedItemDeal[] = [{
|
||||
master_item_id: 123,
|
||||
item_name: 'Apples',
|
||||
best_price_in_cents: 199,
|
||||
store_name: 'Mock Store',
|
||||
flyer_id: 101,
|
||||
valid_to: new Date().toISOString(),
|
||||
}];
|
||||
const mockDeals: WatchedItemDeal[] = [createMockWatchedItemDeal({ item_name: 'Apples' })];
|
||||
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals);
|
||||
|
||||
const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/flyer.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import flyerRouter from './flyer.routes';
|
||||
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// 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 { z } from 'zod';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// src/routes/gamification.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import gamificationRouter from './gamification.routes';
|
||||
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 { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
@@ -207,8 +207,8 @@ describe('Gamification Routes (/api/achievements)', () => {
|
||||
|
||||
describe('GET /leaderboard', () => {
|
||||
it('should return a list of top users (public endpoint)', async () => {
|
||||
const mockLeaderboard = [{ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' }];
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard as any);
|
||||
const mockLeaderboard = [createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' })];
|
||||
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
|
||||
|
||||
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard?limit=5');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/gamification.routes.ts
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import express, { NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import passport, { isAdmin } from './passport.routes';
|
||||
import { gamificationRepo } from '../services/db/index.db';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/health.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import healthRouter from './health.routes';
|
||||
import * as dbConnection from '../services/db/connection.db';
|
||||
import { connection as redisConnection } from '../services/queueService.server';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/personalization.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import personalizationRouter from './personalization.routes';
|
||||
import { createMockMasterGroceryItem, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/price.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import priceRouter from './price.routes';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/price.routes.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/recipe.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import recipeRouter from './recipe.routes';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/recipe.routes.ts
|
||||
import { Router, type Request, type Response, type NextFunction } from 'express';
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/stats.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import statsRouter from './stats.routes';
|
||||
import { mockLogger } from '../tests/utils/mockLogger';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// src/routes/system.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import systemRouter from './system.routes';
|
||||
import { exec, type ExecException } from 'child_process';
|
||||
import { geocodingService } from '../services/geocodingService.server';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// src/routes/user.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
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.
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import userRouter from './user.routes';
|
||||
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe } from '../tests/utils/mockFactories';
|
||||
import { Appliance, Notification } from '../types';
|
||||
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe, createMockNotification, createMockDietaryRestriction, createMockAppliance, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
|
||||
import { Appliance, Notification, DietaryRestriction } from '../types';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
@@ -351,12 +351,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
describe('DELETE /account', () => {
|
||||
it('should delete the account with the correct password', async () => {
|
||||
const userWithHash = {
|
||||
...mockUserProfile.user,
|
||||
password_hash: 'hashed-password',
|
||||
failed_login_attempts: 0, // Add missing properties
|
||||
last_failed_login: null
|
||||
};
|
||||
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
|
||||
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
|
||||
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
|
||||
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 () => {
|
||||
const userWithHash = {
|
||||
...mockUserProfile.user,
|
||||
password_hash: 'hashed-password',
|
||||
failed_login_attempts: 0,
|
||||
last_failed_login: null
|
||||
};
|
||||
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
|
||||
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
|
||||
const response = await supertest(app)
|
||||
@@ -422,8 +412,8 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
describe('GET and PUT /users/me/dietary-restrictions', () => {
|
||||
it('GET should return a list of restriction IDs', async () => {
|
||||
const mockRestrictions = [{ dietary_restriction_id: 1, name: 'Gluten-Free', type: 'diet' as const }];
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
|
||||
const mockRestrictions: DietaryRestriction[] = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
|
||||
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
|
||||
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockRestrictions);
|
||||
@@ -455,8 +445,8 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
describe('GET and PUT /users/me/appliances', () => {
|
||||
it('GET should return a list of appliance IDs', async () => {
|
||||
const mockAppliances: Appliance[] = [{ appliance_id: 2, name: 'Air Fryer' }];
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
|
||||
const mockAppliances: Appliance[] = [createMockAppliance({ name: 'Air Fryer' })];
|
||||
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
|
||||
const response = await supertest(app).get('/api/users/me/appliances');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockAppliances);
|
||||
@@ -482,7 +472,7 @@ describe('User Routes (/api/users)', () => {
|
||||
|
||||
describe('Notification Routes', () => {
|
||||
it('GET /notifications should return notifications for the user', async () => {
|
||||
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '', link_url: null }];
|
||||
const mockNotifications: Notification[] = [createMockNotification({ user_id: 'user-123', content: 'Test' })];
|
||||
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
|
||||
|
||||
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 () => {
|
||||
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');
|
||||
expect(response.status).toBe(204);
|
||||
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
|
||||
});
|
||||
|
||||
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.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 addressData = { address_line_1: '123 New St' };
|
||||
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)
|
||||
.put('/api/users/profile/address')
|
||||
|
||||
@@ -17,6 +17,23 @@ import { validateRequest } from '../middleware/validation.middleware';
|
||||
|
||||
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) ---
|
||||
|
||||
const numericIdParam = (key: string) => z.object({
|
||||
@@ -24,15 +41,16 @@ const numericIdParam = (key: string) => z.object({
|
||||
});
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
body: z.object({
|
||||
full_name: z.string().optional(),
|
||||
avatar_url: z.string().url().optional(),
|
||||
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.' }),
|
||||
body: z.object({ full_name: z.string().optional(), 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({
|
||||
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',
|
||||
avatarUpload.single('avatar'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
||||
const user = req.user as User;
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`; // This was a duplicate, fixed.
|
||||
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl }, req.log);
|
||||
res.json(updatedProfile);
|
||||
try { // The try-catch block was already correct here.
|
||||
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
||||
const user = req.user as UserProfile;
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
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',
|
||||
validateRequest(notificationQuerySchema),
|
||||
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
|
||||
const { query } = req as unknown as GetNotificationsRequest;
|
||||
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log);
|
||||
res.json(notifications);
|
||||
try {
|
||||
const { query } = req as unknown as GetNotificationsRequest;
|
||||
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',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as User;
|
||||
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log);
|
||||
res.status(204).send(); // No Content
|
||||
try { // The try-catch block was already correct here.
|
||||
// Fix: Cast to UserProfile and access the nested user property.
|
||||
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(
|
||||
'/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as User;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as MarkNotificationReadRequest;
|
||||
|
||||
await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log);
|
||||
res.status(204).send(); // Success, no content to return
|
||||
try { // The try-catch block was already correct here.
|
||||
// Fix: Cast to UserProfile and access the nested user property.
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as MarkNotificationReadRequest;
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
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 {
|
||||
const saltRounds = 10; // This was a duplicate, fixed.
|
||||
const saltRounds = 10;
|
||||
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
|
||||
await db.userRepo.updateUserPassword(user.user_id, hashedPassword, req.log);
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as DeleteAccountRequest;
|
||||
|
||||
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.' });
|
||||
}
|
||||
|
||||
// Per ADR-001, findUserWithPasswordHashById will throw if user or hash is missing.
|
||||
const isMatch = await bcrypt.compare(body.password, userWithHash.password_hash);
|
||||
if (!isMatch) {
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as AddWatchedItemRequest;
|
||||
try {
|
||||
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) {
|
||||
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({
|
||||
errorMessage,
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteWatchedItemRequest;
|
||||
try {
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as CreateShoppingListRequest;
|
||||
try {
|
||||
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) {
|
||||
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({
|
||||
errorMessage,
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as GetShoppingListRequest;
|
||||
try {
|
||||
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>;
|
||||
router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItemSchema), async (req, res, next: NextFunction) => {
|
||||
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;
|
||||
try {
|
||||
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) {
|
||||
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({
|
||||
errorMessage,
|
||||
params: req.params, body: req.body
|
||||
@@ -417,6 +452,7 @@ const updateShoppingListItemSchema = numericIdParam('itemId').extend({
|
||||
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
|
||||
router.put('/shopping-lists/items/:itemId', validateRequest(updateShoppingListItemSchema), async (req, res, next: NextFunction) => {
|
||||
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;
|
||||
try {
|
||||
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>;
|
||||
router.delete('/shopping-lists/items/:itemId', validateRequest(shoppingListItemIdSchema), async (req, res, next: NextFunction) => {
|
||||
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;
|
||||
try {
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as UpdatePreferencesRequest;
|
||||
try {
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as SetUserRestrictionsRequest;
|
||||
try {
|
||||
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) {
|
||||
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({
|
||||
errorMessage,
|
||||
body: req.body,
|
||||
@@ -519,6 +558,7 @@ type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
|
||||
router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body } = req as unknown as SetUserAppliancesRequest;
|
||||
try {
|
||||
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) {
|
||||
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({
|
||||
errorMessage,
|
||||
body: req.body,
|
||||
@@ -544,15 +584,19 @@ const addressIdSchema = numericIdParam('addressId');
|
||||
type GetAddressRequest = z.infer<typeof addressIdSchema>;
|
||||
router.get('/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as GetAddressRequest;
|
||||
const addressId = params.addressId;
|
||||
// Security check: Ensure the requested addressId matches the one on the user's profile.
|
||||
if (user.address_id !== addressId) {
|
||||
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
|
||||
try {
|
||||
const addressId = params.addressId;
|
||||
// Security check: Ensure the requested addressId matches the one on the user's profile.
|
||||
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>;
|
||||
router.put('/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => {
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { body: addressData } = req as unknown as UpdateUserAddressRequest;
|
||||
|
||||
try {
|
||||
// Per ADR-002, complex operations involving multiple database writes should be
|
||||
// encapsulated in a single service method that manages the transaction.
|
||||
// This ensures both the address upsert and the user profile update are atomic. // This was a duplicate, fixed.
|
||||
const addressId = await userService.upsertUserAddress(user, addressData, req.log);
|
||||
// This ensures both the address upsert and the user profile update are atomic.
|
||||
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 });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -592,6 +637,7 @@ type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
|
||||
router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => {
|
||||
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params } = req as unknown as DeleteRecipeRequest;
|
||||
try {
|
||||
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) => {
|
||||
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
|
||||
const user = req.user as UserProfile;
|
||||
// Apply ADR-003 pattern for type safety
|
||||
const { params, body } = req as unknown as UpdateRecipeRequest;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/services/aiService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { logger as mockLoggerInstance } from './logger.server';
|
||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||
import type { MasterGroceryItem } from '../types';
|
||||
// Import the class, not the singleton instance, so we can instantiate it with mocks.
|
||||
import { AIService } from './aiService.server';
|
||||
@@ -17,6 +18,7 @@ vi.mock('sharp', () => ({
|
||||
default: mockSharp,
|
||||
}));
|
||||
|
||||
|
||||
describe('AI Service (Server)', () => {
|
||||
// Create mock dependencies that will be injected into the service
|
||||
const mockAiClient = { generateContent: vi.fn() };
|
||||
@@ -24,6 +26,9 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
// Instantiate the service with our mock dependencies
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -195,7 +200,7 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
@@ -205,7 +210,7 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
|
||||
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;
|
||||
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');
|
||||
@@ -214,8 +219,8 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
describe('_normalizeExtractedItems (private method)', () => {
|
||||
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 normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems, mockLoggerInstance);
|
||||
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);
|
||||
expect(normalized.price_display).toBe('');
|
||||
expect(normalized.quantity).toBe('');
|
||||
expect(normalized.category_name).toBe('Other/Miscellaneous');
|
||||
@@ -274,7 +279,7 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
|
||||
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' };
|
||||
|
||||
it('should throw a "feature disabled" error', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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 type { Logger } from 'pino';
|
||||
import { pRateLimit } from 'p-ratelimit';
|
||||
@@ -24,7 +24,10 @@ interface IFileSystem {
|
||||
* making the AIService testable without making real API calls to Google.
|
||||
*/
|
||||
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) {
|
||||
// Allow initialization without key in test/build environments if strictly needed
|
||||
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.
|
||||
// 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;
|
||||
@@ -169,7 +188,7 @@ export class AIService {
|
||||
imagePath: string,
|
||||
imageMimeType: string,
|
||||
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 = `
|
||||
Analyze the provided receipt image. Extract all purchased line items.
|
||||
For each item, identify its description and total price.
|
||||
@@ -190,12 +209,12 @@ export class AIService {
|
||||
|
||||
try {
|
||||
// Wrap the AI call with the rate limiter.
|
||||
const response = await this.rateLimiter(() =>
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
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);
|
||||
|
||||
if (!parsedJson) {
|
||||
@@ -212,8 +231,9 @@ export class AIService {
|
||||
imagePaths: { path: string; mimetype: string }[],
|
||||
masterItems: MasterGroceryItem[],
|
||||
submitterIp?: string,
|
||||
userProfileAddress?: string
|
||||
, logger: Logger = this.logger): Promise<{
|
||||
userProfileAddress?: string,
|
||||
logger: Logger = this.logger
|
||||
): Promise<{
|
||||
store_name: string;
|
||||
valid_from: string | null;
|
||||
valid_to: string | null;
|
||||
@@ -234,17 +254,16 @@ export class AIService {
|
||||
const geminiCallStartTime = process.hrtime.bigint();
|
||||
|
||||
// Wrap the AI call with the rate limiter.
|
||||
const response = await this.rateLimiter(() =>
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: [{ parts: [{ text: prompt }, ...imageParts] }]
|
||||
}));
|
||||
|
||||
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.`);
|
||||
|
||||
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)}`);
|
||||
|
||||
@@ -293,8 +312,8 @@ export class AIService {
|
||||
imagePath: string,
|
||||
imageMimeType: string,
|
||||
cropArea: { x: number; y: number; width: number; height: number },
|
||||
extractionType: 'store_name' | 'dates' | 'item_details'
|
||||
, logger: Logger = this.logger): Promise<{ text: string }> {
|
||||
extractionType: 'store_name' | 'dates' | 'item_details',
|
||||
logger: Logger = this.logger): Promise<{ text: string | undefined }> {
|
||||
// 1. Define prompts based on the extraction type
|
||||
const prompts = {
|
||||
store_name: 'What is the store name in this image? Respond with only the name.',
|
||||
@@ -327,13 +346,12 @@ export class AIService {
|
||||
try {
|
||||
logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`);
|
||||
// Wrap the AI call with the rate limiter.
|
||||
const response = await this.rateLimiter(() =>
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
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}"`);
|
||||
return { text };
|
||||
} catch (apiError) {
|
||||
@@ -356,20 +374,23 @@ export class AIService {
|
||||
|
||||
try {
|
||||
// Wrap the AI call with the rate limiter.
|
||||
const response = await this.rateLimiter(() => this.aiClient.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
const result = await this.rateLimiter(() => this.aiClient.generateContent({
|
||||
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.
|
||||
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.
|
||||
const sources = (response.candidates?.[0]?.groundingMetadata?.groundingAttributions || []).map((att: any) => ({
|
||||
uri: att.web?.uri || '',
|
||||
title: att.web?.title || 'Untitled'
|
||||
// The new SDK provides the search queries used, not a direct list of web attributions.
|
||||
// We will transform these queries into searchable links to fulfill the contract of the function.
|
||||
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) {
|
||||
logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps");
|
||||
throw apiError;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.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
|
||||
vi.unmock('./admin.db');
|
||||
|
||||
@@ -45,9 +45,7 @@ describe('Admin DB Service', () => {
|
||||
|
||||
describe('getSuggestedCorrections', () => {
|
||||
it('should execute the correct query and return corrections', async () => {
|
||||
const mockCorrections: SuggestedCorrection[] = [
|
||||
{ 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() },
|
||||
];
|
||||
const mockCorrections: SuggestedCorrection[] = [createMockSuggestedCorrection({ suggested_correction_id: 1 })];
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: mockCorrections });
|
||||
|
||||
const result = await adminRepo.getSuggestedCorrections(mockLogger);
|
||||
@@ -106,7 +104,7 @@ describe('Admin DB Service', () => {
|
||||
|
||||
describe('updateSuggestedCorrection', () => {
|
||||
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 });
|
||||
|
||||
const result = await adminRepo.updateSuggestedCorrection(1, '300', mockLogger);
|
||||
@@ -281,18 +279,19 @@ describe('Admin DB Service', () => {
|
||||
|
||||
describe('resolveUnmatchedFlyerItem', () => {
|
||||
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) => {
|
||||
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);
|
||||
});
|
||||
|
||||
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('UPDATE public.flyer_items'), [101, 55]);
|
||||
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', () => {
|
||||
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 });
|
||||
const result = await adminRepo.getAllUsers(mockLogger);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('FROM public.users u JOIN public.profiles p'));
|
||||
@@ -460,11 +459,11 @@ describe('Admin DB Service', () => {
|
||||
|
||||
describe('updateUserRole', () => {
|
||||
it('should update the user role and return the updated user', async () => {
|
||||
const mockUser: User = { user_id: '1', email: 'test@test.com' };
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockUser], rowCount: 1 });
|
||||
const mockProfile: Profile = createMockProfile({ user_id: '1', role: 'admin' });
|
||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile], rowCount: 1 });
|
||||
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(result).toEqual(mockUser);
|
||||
expect(result).toEqual(mockProfile);
|
||||
});
|
||||
|
||||
it('should throw an error if the user is not found (rowCount is 0)', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool, withTransaction } from './connection.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
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 {
|
||||
private db: Pool | PoolClient;
|
||||
@@ -395,7 +395,7 @@ export class AdminRepository {
|
||||
action: string;
|
||||
displayText: string;
|
||||
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> {
|
||||
const { userId, action, displayText, icon, details } = logData;
|
||||
try {
|
||||
@@ -515,9 +515,9 @@ export class AdminRepository {
|
||||
* @param role The new role to assign ('user' or 'admin').
|
||||
* @returns A promise that resolves to the updated Profile object.
|
||||
*/
|
||||
async updateUserRole(userId: string, role: 'user' | 'admin', logger: Logger): Promise<User> {
|
||||
async updateUserRole(userId: string, role: 'user' | 'admin', logger: Logger): Promise<Profile> {
|
||||
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 *',
|
||||
[role, userId]
|
||||
);
|
||||
|
||||
@@ -54,7 +54,9 @@ export class BudgetRepository {
|
||||
});
|
||||
} catch (error) {
|
||||
// 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.');
|
||||
}
|
||||
logger.error({ err: error, budgetData, userId }, 'Database error in createBudget');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||
import { DealsRepository } from './deals.db';
|
||||
import type { WatchedItemDeal } from '../../types';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
// Un-mock the module we are testing to ensure we use the real implementation.
|
||||
vi.unmock('./deals.db');
|
||||
@@ -19,12 +20,13 @@ vi.mock('../logger.server', () => ({
|
||||
import { logger as mockLogger } from '../logger.server';
|
||||
|
||||
describe('Deals DB Service', () => {
|
||||
// Import the Pool type to use for casting the mock instance.
|
||||
let dealsRepo: DealsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 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', () => {
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
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
|
||||
this.name = 'ValidationError';
|
||||
this.validationErrors = errors;
|
||||
|
||||
@@ -217,8 +217,8 @@ describe('Flyer DB Service', () => {
|
||||
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // findOrCreateStore
|
||||
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
|
||||
.mockResolvedValueOnce({ rows: mockItems }); // insertFlyerItems
|
||||
return callback(mockClient as any);
|
||||
}); // Cast to any is acceptable here as we are mocking the implementation
|
||||
return callback(mockClient as unknown as PoolClient);
|
||||
});
|
||||
|
||||
const result = await createFlyerAndItems(flyerData, itemsData, mockLogger);
|
||||
|
||||
|
||||
@@ -1,64 +1,50 @@
|
||||
// --- 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
|
||||
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 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 ---
|
||||
const mocks = vi.hoisted(() => {
|
||||
// Require events inside to avoid hoisting issues with top-level imports
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
return {
|
||||
const mockRedisConnection = new EventEmitter() as EventEmitter & { ping: Mock };
|
||||
mockRedisConnection.ping = vi.fn().mockResolvedValue('PONG');
|
||||
|
||||
const hoistedMocks = {
|
||||
mockRedisConnection: new EventEmitter(),
|
||||
|
||||
// FIX: Standard function for Worker constructor
|
||||
MockWorker: vi.fn(function (this: any, name: string) {
|
||||
|
||||
MockWorker: vi.fn(function (this: MockWorkerInstance, name: string) {
|
||||
this.name = name;
|
||||
this.on = vi.fn();
|
||||
this.close = vi.fn().mockResolvedValue(undefined);
|
||||
this.isRunning = vi.fn().mockReturnValue(true);
|
||||
return this;
|
||||
}),
|
||||
|
||||
// FIX: Standard function for Queue constructor
|
||||
MockQueue: vi.fn(function (this: any, name: string) {
|
||||
|
||||
MockQueue: vi.fn(function (this: MockQueueInstance, name: string) {
|
||||
this.name = name;
|
||||
this.add = vi.fn();
|
||||
this.close = vi.fn().mockResolvedValue(undefined);
|
||||
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
|
||||
vi.mock('ioredis', () => ({
|
||||
default: vi.fn(function() {
|
||||
@@ -86,11 +72,13 @@ vi.mock('./db/index.db');
|
||||
|
||||
describe('Queue Service Setup and Lifecycle', () => {
|
||||
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 () => {
|
||||
vi.clearAllMocks();
|
||||
// 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();
|
||||
|
||||
// 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', () => {
|
||||
const redisError = new Error('Connection refused');
|
||||
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', () => {
|
||||
@@ -135,9 +123,8 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
|
||||
describe('Worker Event Listeners', () => {
|
||||
it('should log a message when a job is completed', () => {
|
||||
// The 'on' method is mocked on our MockWorker. We can find the callback.
|
||||
const completedCallback = vi.mocked(flyerWorker.on).mock.calls.find((call: [string, Function]) => call[0] === 'completed')?.[1];
|
||||
|
||||
// Find the 'completed' callback registered on our mock worker.
|
||||
const completedCallback = (flyerWorker.on as Mock).mock.calls.find(call => call[0] === 'completed')?.[1];
|
||||
// Ensure the callback was found before trying to call it
|
||||
expect(completedCallback).toBeDefined();
|
||||
|
||||
@@ -145,37 +132,37 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
const mockReturnValue = { flyerId: 123 };
|
||||
|
||||
// Call the captured callback
|
||||
completedCallback!(mockJob, mockReturnValue);
|
||||
(completedCallback as (job: Job, result: unknown) => void)(mockJob as Job, mockReturnValue);
|
||||
|
||||
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', () => {
|
||||
// Capture the 'failed' callback from any worker, e.g., emailWorker
|
||||
const failedCallback = vi.mocked(emailWorker.on).mock.calls.find((call: [string, Function]) => call[0] === 'failed')?.[1];
|
||||
// Find the 'failed' callback registered on our mock worker.
|
||||
const failedCallback = (emailWorker.on as Mock).mock.calls.find(call => call[0] === 'failed')?.[1];
|
||||
expect(failedCallback).toBeDefined();
|
||||
|
||||
const mockJob = { id: 'job-xyz', data: { to: 'test@example.com' } };
|
||||
const mockError = new Error('SMTP Server Down');
|
||||
|
||||
// Call the captured callback
|
||||
failedCallback!(mockJob, mockError);
|
||||
(failedCallback as (job: Job | undefined, error: Error) => void)(mockJob as Job, mockError);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'[email-sending] Job job-xyz has ultimately failed after all attempts.',
|
||||
{ error: mockError.message, stack: mockError.stack, jobData: mockJob.data }
|
||||
{ err: mockError, jobData: mockJob.data },
|
||||
`[email-sending] Job ${mockJob.id} has ultimately failed after all attempts.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gracefulShutdown', () => {
|
||||
let processExitSpy: any;
|
||||
let processExitSpy: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -184,10 +171,10 @@ describe('Queue Service Setup and Lifecycle', () => {
|
||||
|
||||
it('should close all workers and exit the process', async () => {
|
||||
await gracefulShutdown('SIGINT');
|
||||
expect(flyerWorker.close).toHaveBeenCalled();
|
||||
expect(emailWorker.close).toHaveBeenCalled();
|
||||
expect(analyticsWorker.close).toHaveBeenCalled();
|
||||
expect(cleanupWorker.close).toHaveBeenCalled();
|
||||
expect((flyerWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((emailWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((analyticsWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect((cleanupWorker as unknown as MockQueueInstance).close).toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('[Shutdown] All workers have been closed.');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
@@ -164,9 +164,8 @@ const attachWorkerEventListeners = (worker: Worker) => {
|
||||
export const flyerWorker = new Worker<FlyerJobData>(
|
||||
'flyer-processing', // Must match the queue name
|
||||
(job) => {
|
||||
// Create a job-specific logger instance
|
||||
const jobLogger = logger.child({ jobId: job.id, jobName: job.name, userId: job.data.userId });
|
||||
return flyerProcessingService.processJob(job, jobLogger);
|
||||
// The processJob method creates its own job-specific logger internally.
|
||||
return flyerProcessingService.processJob(job);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
|
||||
@@ -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
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Job, Worker } from 'bullmq';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Job } from 'bullmq';
|
||||
|
||||
// --- Hoisted Mocks ---
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendEmail: vi.fn(), // Mock for emailService.sendEmail
|
||||
unlink: vi.fn(),
|
||||
processFlyerJob: vi.fn(), // Mock for flyerProcessingService.processJob
|
||||
}));
|
||||
const mocks = vi.hoisted(() => {
|
||||
// This object will store the processor functions captured from the worker constructors.
|
||||
const capturedProcessors: Record<string, (job: Job) => Promise<any>> = {};
|
||||
|
||||
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 ---
|
||||
vi.mock('./emailService.server', () => ({
|
||||
@@ -34,7 +43,10 @@ vi.mock('./logger.server', () => ({
|
||||
}));
|
||||
|
||||
// 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
|
||||
vi.mock('./flyerProcessingService.server', () => ({
|
||||
@@ -54,13 +66,14 @@ vi.mock('./flyerDataTransformer', () => ({
|
||||
// This will trigger the instantiation of the workers.
|
||||
import './queueService.server';
|
||||
|
||||
// Capture the processor functions from the mocked Worker constructor calls
|
||||
// Ensure all workers defined in queueService.server.ts are captured.
|
||||
const flyerProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'flyer-processing')?.[1] as (job: Job) => Promise<void>;
|
||||
const emailProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'email-sending')?.[1] as (job: Job) => Promise<void>;
|
||||
const analyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'analytics-reporting')?.[1] as (job: Job) => Promise<void>;
|
||||
const cleanupProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'file-cleanup')?.[1] as (job: Job) => Promise<void>;
|
||||
const weeklyAnalyticsProcessor = vi.mocked(Worker).mock.calls.find(call => call[0] === 'weekly-analytics-reporting')?.[1] as (job: Job) => Promise<void>;
|
||||
// Destructure the captured processors for easier use in tests.
|
||||
const {
|
||||
'flyer-processing': flyerProcessor,
|
||||
'email-sending': emailProcessor,
|
||||
'analytics-reporting': analyticsProcessor,
|
||||
'file-cleanup': cleanupProcessor,
|
||||
'weekly-analytics-reporting': weeklyAnalyticsProcessor,
|
||||
} = mocks.capturedProcessors;
|
||||
|
||||
// Helper to create a mock BullMQ Job object
|
||||
const createMockJob = <T>(data: T): Job<T> => {
|
||||
@@ -175,8 +188,9 @@ describe('Queue Workers', () => {
|
||||
paths: ['/tmp/existing.jpg', '/tmp/already-deleted.jpg'],
|
||||
};
|
||||
const job = createMockJob(jobData);
|
||||
const enoentError = new Error('File not found');
|
||||
(enoentError as any).code = 'ENOENT';
|
||||
// Use the built-in NodeJS.ErrnoException type for mock system errors.
|
||||
const enoentError: NodeJS.ErrnoException = new Error('File not found');
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// First call succeeds, second call fails with ENOENT
|
||||
mocks.unlink
|
||||
@@ -195,8 +209,9 @@ describe('Queue Workers', () => {
|
||||
paths: ['/tmp/protected-file.jpg'],
|
||||
};
|
||||
const job = createMockJob(jobData);
|
||||
const permissionError = new Error('Permission denied');
|
||||
(permissionError as any).code = 'EACCES';
|
||||
// Use the built-in NodeJS.ErrnoException type for mock system errors.
|
||||
const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
|
||||
permissionError.code = 'EACCES';
|
||||
|
||||
mocks.unlink.mockRejectedValue(permissionError);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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.
|
||||
@@ -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 => {
|
||||
return {
|
||||
appliance_id: 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Logger } from 'pino';
|
||||
* The `child` method is mocked to return itself, allowing for chained calls
|
||||
* like `logger.child({ ... }).info(...)` to work seamlessly in tests.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export const createMockLogger = (): Logger => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -123,6 +123,15 @@ export interface User {
|
||||
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 {
|
||||
user_id: string; // UUID
|
||||
updated_at?: string;
|
||||
|
||||
Reference in New Issue
Block a user