acheivments + flyercorrection
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 2m18s

This commit is contained in:
2025-11-29 14:54:49 -08:00
parent 753c675d9f
commit bb2ad6ce1a
14 changed files with 374 additions and 225 deletions

View File

@@ -1,7 +1,6 @@
// src/components/MyDealsPage.tsx
import React, { useState, useEffect } from 'react';
import { WatchedItemDeal } from '../types';
import { getBestSalePricesForUser } from '../services/db'; // We need a client-side API function for this
import { apiFetch } from '../services/apiClient';
import { logger } from '../services/logger';
import { AlertCircle, Tag, Store, Calendar } from 'lucide-react';

View File

@@ -1,7 +1,7 @@
// src/pages/VoiceLabPage.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { VoiceLabPage } from './VoiceLabPage';
import * as aiApiClient from '../services/aiApiClient';
import { notifyError } from '../services/notificationService';
@@ -11,7 +11,6 @@ const mockedAiApiClient = aiApiClient as Mocked<typeof aiApiClient>;
// Define mock at module level
const mockAudioPlay = vi.fn(() => {
// eslint-disable-next-line no-console
console.log('[TEST MOCK] mockAudioPlay called');
return Promise.resolve();
});

View File

@@ -31,7 +31,7 @@ vi.mock('./passport', () => ({
// Mock the default export (the passport instance)
default: {
// The 'authenticate' method returns a middleware function. We mock that.
authenticate: vi.fn((_strategy: string, _options: object) => (_req: Request, _res: Response, next: NextFunction) => {
authenticate: vi.fn(() => (_req: Request, _res: Response, next: NextFunction) => {
// This mock middleware will be controlled by the `isAdmin` mock below.
// In a real scenario, you might attach a user to `req.user` here if needed.
return next();
@@ -102,6 +102,8 @@ describe('Admin Routes (/api/admin)', () => {
req.user = {
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
role: 'admin',
// Add missing properties to align with the UserProfile type
points: 0,
} as UserProfile;
next(); // Grant access
});
@@ -109,8 +111,8 @@ describe('Admin Routes (/api/admin)', () => {
it('GET /corrections should return corrections data', async () => {
// Arrange
const mockCorrections = [{ correction_id: 1, suggested_value: 'New Price' }];
(mockedDb.getSuggestedCorrections as Mock).mockResolvedValue(mockCorrections);
const mockCorrections: Awaited<ReturnType<typeof db.getSuggestedCorrections>> = [{ suggested_correction_id: 1, flyer_item_id: 1, user_id: '1', correction_type: 'price', suggested_value: 'New Price', status: 'pending', created_at: new Date().toISOString() }];
vi.mocked(mockedDb.getSuggestedCorrections).mockResolvedValue(mockCorrections);
// Act
const response = await supertest(app).get('/api/admin/corrections');
@@ -124,11 +126,11 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /brands', () => {
it('should return a list of all brands on success', async () => {
// Arrange
const mockBrands = [
const mockBrands: Awaited<ReturnType<typeof db.getAllBrands>> = [
{ brand_id: 1, name: 'Brand A', logo_url: '/path/a.png' },
{ brand_id: 2, name: 'Brand B', logo_url: '/path/b.png' },
];
(mockedDb.getAllBrands as Mock).mockResolvedValue(mockBrands);
vi.mocked(mockedDb.getAllBrands).mockResolvedValue(mockBrands);
// Act
const response = await supertest(app).get('/api/admin/brands');
@@ -180,8 +182,8 @@ describe('Admin Routes (/api/admin)', () => {
const mockDailyStats = [
{ date: '2024-01-01', new_users: 5, new_flyers: 10 },
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
];
(mockedDb.getDailyStatsForLast30Days as Mock).mockResolvedValue(mockDailyStats);
] as Awaited<ReturnType<typeof db.getDailyStatsForLast30Days>>;
vi.mocked(mockedDb.getDailyStatsForLast30Days).mockResolvedValue(mockDailyStats);
// Act
const response = await supertest(app).get('/api/admin/stats/daily');
@@ -202,11 +204,11 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /unmatched-items', () => {
it('should return a list of unmatched items on success', async () => {
// Arrange
const mockUnmatchedItems = [
{ flyer_item_id: 1, raw_item_description: 'Ketchup Chips', price_display: '$3.00' },
{ flyer_item_id: 2, raw_item_description: 'Mystery Soda', price_display: '2 for $4.00' },
const mockUnmatchedItems: Awaited<ReturnType<typeof db.getUnmatchedFlyerItems>> = [
{ unmatched_flyer_item_id: 1, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 101, flyer_item_name: 'Ketchup Chips', price_display: '$3.00', flyer_id: 1, store_name: 'Test Store' },
{ unmatched_flyer_item_id: 2, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 102, flyer_item_name: 'Mystery Soda', price_display: '2 for $4.00', flyer_id: 1, store_name: 'Test Store' },
];
(mockedDb.getUnmatchedFlyerItems as Mock).mockResolvedValue(mockUnmatchedItems);
vi.mocked(mockedDb.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
// Act
const response = await supertest(app).get('/api/admin/unmatched-items');
@@ -296,9 +298,9 @@ describe('Admin Routes (/api/admin)', () => {
it('should update a correction and return the updated data', async () => {
// Arrange
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
const mockUpdatedCorrection = { correction_id: correctionId, ...requestBody };
(mockedDb.updateSuggestedCorrection as Mock).mockResolvedValue(mockUpdatedCorrection);
const requestBody = { suggested_value: 'A new corrected value' };
const mockUpdatedCorrection: Awaited<ReturnType<typeof db.updateSuggestedCorrection>> = { suggested_correction_id: correctionId, flyer_item_id: 1, user_id: '1', correction_type: 'price', status: 'pending', created_at: new Date().toISOString(), ...requestBody };
vi.mocked(mockedDb.updateSuggestedCorrection).mockResolvedValue(mockUpdatedCorrection);
// Act: Use .send() to include a request body
const response = await supertest(app)
@@ -393,9 +395,9 @@ describe('Admin Routes (/api/admin)', () => {
it('should update a recipe status and return the updated recipe', async () => {
// Arrange
const recipeId = 201;
const requestBody = { status: 'public' };
const mockUpdatedRecipe = { recipe_id: recipeId, status: 'public', name: 'Test Recipe' };
(mockedDb.updateRecipeStatus as Mock).mockResolvedValue(mockUpdatedRecipe);
const requestBody = { status: 'public' as const };
const mockUpdatedRecipe: Awaited<ReturnType<typeof db.updateRecipeStatus>> = { recipe_id: recipeId, status: 'public', name: 'Test Recipe', avg_rating: 0, rating_count: 0, fork_count: 0, created_at: new Date().toISOString() };
vi.mocked(mockedDb.updateRecipeStatus).mockResolvedValue(mockUpdatedRecipe);
// Act
const response = await supertest(app)
@@ -430,9 +432,9 @@ describe('Admin Routes (/api/admin)', () => {
it('should update a comment status and return the updated comment', async () => {
// Arrange
const commentId = 301;
const requestBody = { status: 'hidden' };
const mockUpdatedComment = { comment_id: commentId, status: 'hidden', content: 'Test Comment' };
(mockedDb.updateRecipeCommentStatus as Mock).mockResolvedValue(mockUpdatedComment);
const requestBody = { status: 'hidden' as const };
const mockUpdatedComment: Awaited<ReturnType<typeof db.updateRecipeCommentStatus>> = { recipe_comment_id: commentId, recipe_id: 1, user_id: '1', status: 'hidden', content: 'Test Comment', created_at: new Date().toISOString() };
vi.mocked(mockedDb.updateRecipeCommentStatus).mockResolvedValue(mockUpdatedComment);
// Act
const response = await supertest(app)
@@ -457,11 +459,11 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /users', () => {
it('should return a list of all users on success', async () => {
// Arrange
const mockUsers = [
{ user_id: '1', email: 'user1@test.com', role: 'user' },
{ user_id: '2', email: 'user2@test.com', role: 'admin' },
const mockUsers: Awaited<ReturnType<typeof db.getAllUsers>> = [
{ user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
];
(mockedDb.getAllUsers as Mock).mockResolvedValue(mockUsers);
vi.mocked(mockedDb.getAllUsers).mockResolvedValue(mockUsers);
// Act
const response = await supertest(app).get('/api/admin/users');
@@ -473,7 +475,7 @@ describe('Admin Routes (/api/admin)', () => {
});
it('should return a 500 error if the database call fails', async () => {
(mockedDb.getAllUsers as Mock).mockRejectedValue(new Error('Failed to fetch users'));
vi.mocked(mockedDb.getAllUsers).mockRejectedValue(new Error('Failed to fetch users'));
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(500);
});
@@ -482,8 +484,8 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /activity-log', () => {
it('should return a list of activity logs with default pagination', async () => {
// Arrange
const mockLogs = [{ log_id: 1, action: 'user_login', user_id: '1' }];
(mockedDb.getActivityLog as Mock).mockResolvedValue(mockLogs);
const mockLogs: Awaited<ReturnType<typeof db.getActivityLog>> = [{ activity_log_id: 1, action: 'user_registered', display_text: 'test', created_at: new Date().toISOString(), user_id: '1', activity_type: null, entity_id: null, details: { full_name: 'test', user_avatar_url: 'test', user_full_name: 'test' } }];
vi.mocked(mockedDb.getActivityLog).mockResolvedValue(mockLogs);
// Act
const response = await supertest(app).get('/api/admin/activity-log');
@@ -530,8 +532,8 @@ describe('Admin Routes (/api/admin)', () => {
describe('GET /users/:id', () => {
it('should fetch a single user successfully', async () => {
// Arrange
const mockUser = { user_id: 'user-123', email: 'single@test.com', role: 'user' };
(mockedDb.findUserProfileById as Mock).mockResolvedValue(mockUser);
const mockUser: Awaited<ReturnType<typeof db.findUserProfileById>> = { user_id: 'user-123', role: 'user', points: 0 };
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(mockUser);
// Act
const response = await supertest(app).get('/api/admin/users/user-123');
@@ -544,7 +546,7 @@ describe('Admin Routes (/api/admin)', () => {
it('should return 404 for a non-existent user', async () => {
// Arrange
(mockedDb.findUserProfileById as Mock).mockResolvedValue(undefined);
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(undefined);
// Act
const response = await supertest(app).get('/api/admin/users/non-existent-id');
@@ -558,8 +560,8 @@ describe('Admin Routes (/api/admin)', () => {
describe('PUT /users/:id', () => {
it('should update a user role successfully', async () => {
// Arrange
const updatedUser = { user_id: 'user-to-update', role: 'admin' };
(mockedDb.updateUserRole as Mock).mockResolvedValue(updatedUser);
const updatedUser: Awaited<ReturnType<typeof db.updateUserRole>> = { user_id: 'user-to-update', email: 'test@test.com' };
vi.mocked(mockedDb.updateUserRole).mockResolvedValue(updatedUser);
// Act
const response = await supertest(app)
@@ -573,7 +575,7 @@ describe('Admin Routes (/api/admin)', () => {
});
it('should return 404 for a non-existent user', async () => {
(mockedDb.updateUserRole as Mock).mockRejectedValue(new Error('User with ID non-existent not found.'));
vi.mocked(mockedDb.updateUserRole).mockRejectedValue(new Error('User with ID non-existent not found.'));
const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' });
expect(response.status).toBe(404);
});
@@ -587,7 +589,7 @@ describe('Admin Routes (/api/admin)', () => {
describe('DELETE /users/:id', () => {
it('should successfully delete a user', async () => {
(mockedDb.deleteUserById as Mock).mockResolvedValue(undefined);
vi.mocked(mockedDb.deleteUserById).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/admin/users/user-to-delete');
expect(response.status).toBe(204);
expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');

View File

@@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import express, { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path';
import fs from 'node:fs/promises';
import aiRouter from './ai';
import * as aiService from '../services/aiService.server';
import * as db from '../services/db';
@@ -41,7 +40,7 @@ vi.mock('./passport', () => ({
// We need to import the mocked passport object to control its behavior in tests.
import passport from './passport';
const mockedAuthenticate = passport.authenticate as Mocked<any>;
const mockedAuthenticate = vi.mocked(passport.authenticate);
// Create a minimal Express app to host our router
const app = express();
@@ -54,7 +53,7 @@ describe('AI Routes (/api/ai)', () => {
// Default mock for passport.authenticate to simulate an unauthenticated request.
// This will be overridden in tests that require an authenticated user.
mockedAuthenticate.mockImplementation(
(strategy: string, options: object) => (req: Request, res: Response, next: NextFunction) => {
() => (req: Request, res: Response, next: NextFunction) => {
res.status(401).json({ message: 'Unauthorized' });
});
});
@@ -66,8 +65,8 @@ describe('AI Routes (/api/ai)', () => {
const mockExtractedData = {
store_name: 'Test Store',
items: [{ item: 'Test Item', price_display: '$1.99' }],
};
mockedAiService.extractCoreDataFromFlyerImage.mockResolvedValue(mockExtractedData as any);
} as Awaited<ReturnType<typeof aiService.extractCoreDataFromFlyerImage>>;
mockedAiService.extractCoreDataFromFlyerImage.mockResolvedValue(mockExtractedData);
// 2. Define the other form fields that are sent along with the file.
const mockMasterItems = [{ master_item_id: 1, item_name: 'Milk' }];
@@ -112,7 +111,13 @@ describe('AI Routes (/api/ai)', () => {
it('should save a flyer and return 201 on success', async () => {
// Arrange
mockedDb.findFlyerByChecksum.mockResolvedValue(undefined); // No duplicate
mockedDb.createFlyerAndItems.mockResolvedValue({ flyer_id: 1, ...mockDataPayload.extractedData } as any);
mockedDb.createFlyerAndItems.mockResolvedValue({
flyer_id: 1,
created_at: new Date().toISOString(),
file_name: mockDataPayload.originalFileName,
image_url: '/assets/some-image.jpg',
...mockDataPayload.extractedData
});
mockedDb.logActivity.mockResolvedValue();
// Act
@@ -129,7 +134,7 @@ describe('AI Routes (/api/ai)', () => {
it('should return 409 Conflict if flyer checksum already exists', async () => {
// Arrange
mockedDb.findFlyerByChecksum.mockResolvedValue({ flyer_id: 99 } as any); // Duplicate found
mockedDb.findFlyerByChecksum.mockResolvedValue({ flyer_id: 99 } as Awaited<ReturnType<typeof db.findFlyerByChecksum>>); // Duplicate found
// Act
const response = await supertest(app)
@@ -158,12 +163,14 @@ describe('AI Routes (/api/ai)', () => {
user_id: 'user-123',
user: { user_id: 'user-123', email: 'test@test.com' },
role: 'user',
// Add missing properties to align with the UserProfile type
points: 0,
} as UserProfile;
beforeEach(() => {
// For this block, simulate a logged-in user by having the middleware call next().
mockedAuthenticate.mockImplementation(
(strategy: string, options: object) => (req: Request, res: Response, next: NextFunction) => {
() => (req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
}

View File

@@ -45,7 +45,7 @@ vi.mock('./passport', () => ({
}));
// Get a reference to the mocked authenticate function to control it in tests.
const mockedAuthenticate = passport.authenticate as Mocked<any>;
const mockedAuthenticate = vi.mocked(passport.authenticate);
// Create a minimal Express app to host our router
const app = express();
@@ -66,8 +66,8 @@ describe('Auth Routes (/api/auth)', () => {
// 1. User does not exist
mockedDb.findUserByEmail.mockResolvedValue(undefined);
// 2. Mock the user creation and token saving
const mockNewUser = { user_id: 'new-user-id', email: newUserEmail };
mockedDb.createUser.mockResolvedValue(mockNewUser as any);
const mockNewUser: Awaited<ReturnType<typeof db.createUser>> = { user_id: 'new-user-id', email: newUserEmail };
mockedDb.createUser.mockResolvedValue(mockNewUser);
mockedDb.saveRefreshToken.mockResolvedValue();
mockedDb.logActivity.mockResolvedValue();
@@ -116,8 +116,14 @@ describe('Auth Routes (/api/auth)', () => {
it('should reject registration if the email already exists', async () => {
// Arrange: Mock that the user already exists in the database
const existingUser = { user_id: 'existing-id', email: newUserEmail, password_hash: 'somehash' };
mockedDb.findUserByEmail.mockResolvedValue(existingUser as any);
const existingUser: Awaited<ReturnType<typeof db.findUserByEmail>> = {
user_id: 'existing-id',
email: newUserEmail,
password_hash: 'somehash',
failed_login_attempts: 0,
last_failed_login: null,
};
mockedDb.findUserByEmail.mockResolvedValue(existingUser);
// Act
const response = await supertest(app)
@@ -150,12 +156,11 @@ describe('Auth Routes (/api/auth)', () => {
// 1. Simulate passport successfully finding a user.
const mockUser = { user_id: 'user-123', email: 'test@test.com' };
mockedAuthenticate.mockImplementation(
(strategy: string, options: object, callback: (err: Error | null, user: object | false, info: object | null) => void) =>
(req: Request, res: Response, next: NextFunction) => {
// Call the route's custom callback with the mock user.
callback(null, mockUser, null);
}
);
(strategy, options, callback) => (req: Request, res: Response, next: NextFunction) => {
// Call the route's custom callback with the mock user.
// The callback is the third argument passed to passport.authenticate in the route handler.
if (callback) callback(null, mockUser, {});
});
// 2. Mock the database calls that happen after successful authentication.
mockedDb.saveRefreshToken.mockResolvedValue();
@@ -176,12 +181,10 @@ describe('Auth Routes (/api/auth)', () => {
it('should reject login with incorrect credentials', async () => {
// Arrange: Simulate passport failing to find a user.
mockedAuthenticate.mockImplementation(
(strategy: string, options: object, callback: (err: Error | null, user: object | false, info: object | null) => void) =>
(req: Request, res: Response, next: NextFunction) => {
// Call the callback with `false` for the user and an info message.
callback(null, false, { message: 'Incorrect email or password.' });
}
);
(strategy, options, callback) => (req: Request, res: Response, next: NextFunction) => {
// Call the callback with `false` for the user and an info message.
if (callback) callback(null, false, { message: 'Incorrect email or password.' });
});
// Act
const response = await supertest(app)
@@ -196,11 +199,9 @@ describe('Auth Routes (/api/auth)', () => {
it('should reject login for a locked account', async () => {
// Arrange: Simulate passport finding a locked account.
mockedAuthenticate.mockImplementation(
(strategy: string, options: object, callback: (err: Error | null, user: object | false, info: object | null) => void) =>
(req: Request, res: Response, next: NextFunction) => {
callback(null, false, { message: 'Account is temporarily locked.' });
}
);
(strategy, options, callback) => (req: Request, res: Response, next: NextFunction) => {
if (callback) callback(null, false, { message: 'Account is temporarily locked.' });
});
const response = await supertest(app).post('/api/auth/login').send(loginCredentials);

View File

@@ -27,13 +27,14 @@ describe('Passport Configuration', () => {
// This block tests the callback logic in isolation, which is fast but gives poor coverage.
// We will add a new block to test the strategy through passport itself.
it('should successfully authenticate a user with correct credentials', async () => {
const mockUser = {
const mockUser: Awaited<ReturnType<typeof db.findUserByEmail>> = {
user_id: 'user-1',
email: 'test@test.com',
password_hash: await require('bcrypt').hash('password123', 10),
failed_login_attempts: 0,
last_failed_login: null,
};
mockedDb.findUserByEmail.mockResolvedValue(mockUser as any);
mockedDb.findUserByEmail.mockResolvedValue(mockUser);
// This test is valid but doesn't contribute to coverage of passport.ts
// We will re-test this scenario in the new describe block.
expect(true).toBe(true);
@@ -133,12 +134,23 @@ describe('Passport Configuration', () => {
});
});
// Define a type for the internal structure of a Passport strategy for testing purposes.
interface TestableStrategy extends JwtStrategy {
success: (user: unknown, info?: unknown) => void;
fail: (challenge: unknown, status?: number) => void;
error: (err: Error) => void;
_verify: (payload: unknown, done: (err: Error | null, user?: unknown, info?: unknown) => void) => void;
}
// Define a type for the passport instance that exposes its internal strategies map.
type TestablePassportInstance = passport.Authenticator & { _strategies: Record<string, TestableStrategy> };
describe('JwtStrategy (via Passport Execution)', () => {
it('should call done(null, userProfile) on successful authentication', async () => {
// Arrange
const jwtPayload = { user_id: 'user-123' };
const mockProfile = { user_id: 'user-123', role: 'user' };
(mockedDb.findUserProfileById as Mock).mockResolvedValue(mockProfile as any);
const mockProfile: Awaited<ReturnType<typeof db.findUserProfileById>> = { user_id: 'user-123', role: 'user', points: 0 };
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(mockProfile);
const { default: passportInstance } = await import('./passport');
// Act: Simulate passport authenticating a request
@@ -146,7 +158,7 @@ describe('Passport Configuration', () => {
const req = { headers: { authorization: 'Bearer my-token' } } as Request;
// We need to manually trigger the strategy's logic by accessing internal properties.
// This is a common pattern for testing passport strategies.
const strategy = (passportInstance as any)._strategies.jwt;
const strategy = (passportInstance as unknown as TestablePassportInstance)._strategies.jwt;
await new Promise(resolve => strategy.success(mockProfile, resolve));
// Assert
@@ -156,14 +168,14 @@ describe('Passport Configuration', () => {
it('should call done(null, false) when user is not found', async () => {
// Arrange
const jwtPayload = { user_id: 'non-existent-user' };
(mockedDb.findUserProfileById as Mock).mockResolvedValue(undefined);
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(undefined);
const { default: passportInstance } = await import('./passport');
const strategy = (passportInstance as any)._strategies.jwt;
const strategy = (passportInstance as unknown as TestablePassportInstance)._strategies.jwt;
const fail = vi.fn();
strategy.fail = fail;
// Act
await new Promise(resolve => strategy._verify(jwtPayload, (err: any, user: any) => resolve(user)));
await new Promise(resolve => strategy._verify(jwtPayload, (err, user) => resolve(user)));
// Assert
expect(fail).toHaveBeenCalled();
@@ -173,14 +185,14 @@ describe('Passport Configuration', () => {
// Arrange
const jwtPayload = { user_id: 'user-123' };
const dbError = new Error('DB connection failed');
(mockedDb.findUserProfileById as Mock).mockRejectedValue(dbError);
vi.mocked(mockedDb.findUserProfileById).mockRejectedValue(dbError);
const { default: passportInstance } = await import('./passport');
const strategy = (passportInstance as any)._strategies.jwt;
const strategy = (passportInstance as unknown as TestablePassportInstance)._strategies.jwt;
const error = vi.fn();
strategy.error = error;
// Act
await new Promise(resolve => strategy._verify(jwtPayload, (err: any, user: any) => resolve(user)));
await new Promise(resolve => strategy._verify(jwtPayload, (err, user) => resolve(user)));
// Assert
expect(error).toHaveBeenCalledWith(dbError);

View File

@@ -91,10 +91,10 @@ describe('Public Routes (/api)', () => {
it('should return a list of flyers on success', async () => {
// Arrange: Mock the database response
const mockFlyers = [
{ flyer_id: 1, store_name: 'Store A' },
{ flyer_id: 2, store_name: 'Store B' },
];
(mockedDb.getFlyers as Mock).mockResolvedValue(mockFlyers);
{ flyer_id: 1, file_name: 'flyer_a.jpg', image_url: '/a.jpg', created_at: new Date().toISOString() },
{ flyer_id: 2, file_name: 'flyer_b.jpg', image_url: '/b.jpg', created_at: new Date().toISOString() },
] as Awaited<ReturnType<typeof db.getFlyers>>;
vi.mocked(mockedDb.getFlyers).mockResolvedValue(mockFlyers);
// Act: Make the request
const response = await supertest(app).get('/api/flyers');
@@ -119,8 +119,8 @@ describe('Public Routes (/api)', () => {
describe('GET /master-items', () => {
it('should return a list of master items', async () => {
const mockItems = [{ master_grocery_item_id: 1, name: 'Milk' }];
(mockedDb.getAllMasterItems as Mock).mockResolvedValue(mockItems);
const mockItems: Awaited<ReturnType<typeof db.getAllMasterItems>> = [{ master_grocery_item_id: 1, name: 'Milk', created_at: new Date().toISOString() }];
vi.mocked(mockedDb.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/master-items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
@@ -129,8 +129,8 @@ describe('Public Routes (/api)', () => {
describe('GET /flyers/:id/items', () => {
it('should return items for a specific flyer', async () => {
const mockFlyerItems = [{ flyer_item_id: 1, item: 'Cheese' }];
(mockedDb.getFlyerItems as Mock).mockResolvedValue(mockFlyerItems);
const mockFlyerItems: Awaited<ReturnType<typeof db.getFlyerItems>> = [{ flyer_item_id: 1, flyer_id: 123, item: 'Cheese', price_display: '$5', price_in_cents: 500, created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString(), quantity: '500g' }];
vi.mocked(mockedDb.getFlyerItems).mockResolvedValue(mockFlyerItems);
const response = await supertest(app).get('/api/flyers/123/items');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockFlyerItems);
@@ -140,8 +140,8 @@ describe('Public Routes (/api)', () => {
describe('POST /flyer-items/batch-fetch', () => {
it('should return items for multiple flyers', async () => {
const mockFlyerItems = [{ flyer_item_id: 1, item: 'Bread' }];
(mockedDb.getFlyerItemsForFlyers as Mock).mockResolvedValue(mockFlyerItems);
const mockFlyerItems: Awaited<ReturnType<typeof db.getFlyerItemsForFlyers>> = [{ flyer_item_id: 1, flyer_id: 1, item: 'Bread', price_display: '$2', price_in_cents: 200, created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString(), quantity: '1 loaf' }];
vi.mocked(mockedDb.getFlyerItemsForFlyers).mockResolvedValue(mockFlyerItems);
const response = await supertest(app)
.post('/api/flyer-items/batch-fetch')
.send({ flyerIds: [1, 2] });
@@ -160,8 +160,8 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-sale-percentage', () => {
it('should return recipes based on sale percentage', async () => {
const mockRecipes = [{ recipe_id: 1, name: 'Pasta' }];
(mockedDb.getRecipesBySalePercentage as Mock).mockResolvedValue(mockRecipes);
const mockRecipes: Awaited<ReturnType<typeof db.getRecipesBySalePercentage>> = [{ recipe_id: 1, name: 'Pasta', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: new Date().toISOString() }];
vi.mocked(mockedDb.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecipes);
@@ -215,8 +215,8 @@ describe('Public Routes (/api)', () => {
describe('GET /recipes/by-ingredient-and-tag', () => {
it('should return recipes for a given ingredient and tag', async () => {
const mockRecipes = [{ recipe_id: 2, name: 'Chicken Tacos' }];
(mockedDb.findRecipesByIngredientAndTag as Mock).mockResolvedValue(mockRecipes);
const mockRecipes: Awaited<ReturnType<typeof db.findRecipesByIngredientAndTag>> = [{ recipe_id: 2, name: 'Chicken Tacos', avg_rating: 0, rating_count: 0, fork_count: 0, status: 'public', created_at: new Date().toISOString() }];
vi.mocked(mockedDb.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRecipes);

View File

@@ -1,10 +1,9 @@
// src/routes/system.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import type { ExecException } from 'child_process';
import systemRouter from './system';
import { exec } from 'child_process';
// Define a mock function at the module level. This will be our controlled version of `exec`.
const mockedExec = vi.fn();

View File

@@ -30,7 +30,7 @@ vi.mock('../services/logger.server', () => ({
vi.mock('./passport', () => ({
default: {
authenticate: vi.fn((strategy, options) => (req: Request, res: Response, next: NextFunction) => {
// The default behavior of this mock is to simulate an unauthenticated request.
// The default behavior of this mock is to simulate an unauthenticated request by default.
// We will override this implementation in tests that require an authenticated user.
res.status(401).json({ message: 'Unauthorized' });
}),
@@ -51,7 +51,7 @@ describe('User Routes (/api/users)', () => {
vi.clearAllMocks();
// Reset the authenticate mock to its default "unauthorized" state before each test
mockedAuthenticate.mockImplementation(
(_strategy: string | string[] | Strategy, _options: object) => (req: Request, res: Response, next: NextFunction) => {
() => (req: Request, res: Response, next: NextFunction) => {
res.status(401).json({ message: 'Unauthorized' });
});
});
@@ -80,6 +80,7 @@ describe('User Routes (/api/users)', () => {
role: 'user',
full_name: 'Test User',
avatar_url: null,
points: 0, // Add missing 'points' property
preferences: {},
};
@@ -87,7 +88,7 @@ describe('User Routes (/api/users)', () => {
// Arrange: For all tests in this block, simulate a logged-in user.
// We override the mock implementation to attach a user to the request and call next().
mockedAuthenticate.mockImplementation(
(_strategy: string | string[] | Strategy, _options: object) => (req: Request, res: Response, next: NextFunction) => {
() => (req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
}
@@ -97,8 +98,8 @@ describe('User Routes (/api/users)', () => {
describe('GET /profile', () => {
it('should return the full user profile', async () => {
// Arrange
(mockedDb.findUserProfileById as Mocked<any>).mockResolvedValue(mockUserProfile);
(mockedDb.findUserById as Mocked<any>).mockResolvedValue(mockUserProfile.user);
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(mockUserProfile);
vi.mocked(mockedDb.findUserById).mockResolvedValue(mockUserProfile.user);
// Act
const response = await supertest(app).get('/api/users/profile');
@@ -111,7 +112,7 @@ describe('User Routes (/api/users)', () => {
it('should return 404 if profile is not found in DB', async () => {
// Arrange
(mockedDb.findUserProfileById as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.findUserProfileById).mockResolvedValue(undefined);
// Act
const response = await supertest(app).get('/api/users/profile');
@@ -125,8 +126,8 @@ describe('User Routes (/api/users)', () => {
describe('GET /watched-items', () => {
it('should return a list of watched items', async () => {
// Arrange
const mockItems = [{ master_grocery_item_id: 1, name: 'Milk' }];
mockedDb.getWatchedItems.mockResolvedValue(mockItems as any);
const mockItems: Awaited<ReturnType<typeof db.getWatchedItems>> = [{ master_grocery_item_id: 1, name: 'Milk', created_at: new Date().toISOString() }];
mockedDb.getWatchedItems.mockResolvedValue(mockItems);
// Act
const response = await supertest(app).get('/api/users/watched-items');
@@ -142,8 +143,8 @@ describe('User Routes (/api/users)', () => {
it('should add an item to the watchlist and return the new item', async () => {
// Arrange
const newItem = { itemName: 'Organic Bananas', category: 'Produce' };
const mockAddedItem = { master_grocery_item_id: 99, name: 'Organic Bananas' };
(mockedDb.addWatchedItem as Mocked<any>).mockResolvedValue(mockAddedItem);
const mockAddedItem: Awaited<ReturnType<typeof db.addWatchedItem>> = { master_grocery_item_id: 99, name: 'Organic Bananas', created_at: new Date().toISOString() };
vi.mocked(mockedDb.addWatchedItem).mockResolvedValue(mockAddedItem);
// Act
const response = await supertest(app)
@@ -161,7 +162,7 @@ describe('User Routes (/api/users)', () => {
it('should remove an item from the watchlist', async () => {
// Arrange
const masterItemId = 99;
(mockedDb.removeWatchedItem as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.removeWatchedItem).mockResolvedValue(undefined);
// Act
const response = await supertest(app).delete(`/api/users/watched-items/${masterItemId}`);
@@ -175,8 +176,8 @@ describe('User Routes (/api/users)', () => {
describe('Shopping List Routes', () => {
it('GET /shopping-lists should return all shopping lists for the user', async () => {
// Arrange
const mockLists = [{ shopping_list_id: 1, name: 'Weekly Groceries', items: [] }];
(mockedDb.getShoppingLists as Mocked<any>).mockResolvedValue(mockLists);
const mockLists: Awaited<ReturnType<typeof db.getShoppingLists>> = [{ shopping_list_id: 1, user_id: mockUserProfile.user_id, name: 'Weekly Groceries', created_at: new Date().toISOString(), items: [] }];
vi.mocked(mockedDb.getShoppingLists).mockResolvedValue(mockLists);
// Act
const response = await supertest(app).get('/api/users/shopping-lists');
@@ -188,8 +189,8 @@ describe('User Routes (/api/users)', () => {
});
it('POST /shopping-lists should create a new list', async () => {
const mockNewList = { shopping_list_id: 2, name: 'Party Supplies', items: [] };
(mockedDb.createShoppingList as Mocked<any>).mockResolvedValue(mockNewList);
const mockNewList: Awaited<ReturnType<typeof db.createShoppingList>> = { shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies', created_at: new Date().toISOString(), items: [] };
vi.mocked(mockedDb.createShoppingList).mockResolvedValue(mockNewList);
const response = await supertest(app)
.post('/api/users/shopping-lists')
@@ -201,7 +202,7 @@ describe('User Routes (/api/users)', () => {
});
it('DELETE /shopping-lists/:listId should delete a list', async () => {
(mockedDb.deleteShoppingList as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.deleteShoppingList).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/1');
expect(response.status).toBe(204);
expect(mockedDb.deleteShoppingList).toHaveBeenCalledWith(1, 'user-123');
@@ -213,8 +214,8 @@ describe('User Routes (/api/users)', () => {
// Arrange
const listId = 1;
const itemData = { customItemName: 'Paper Towels' };
const mockAddedItem = { shopping_list_item_id: 101, shopping_list_id: listId, ...itemData };
(mockedDb.addShoppingListItem as Mocked<any>).mockResolvedValue(mockAddedItem);
const mockAddedItem: Awaited<ReturnType<typeof db.addShoppingListItem>> = { shopping_list_item_id: 101, shopping_list_id: listId, quantity: 1, is_purchased: false, added_at: new Date().toISOString(), ...itemData };
vi.mocked(mockedDb.addShoppingListItem).mockResolvedValue(mockAddedItem);
// Act
const response = await supertest(app)
@@ -231,8 +232,8 @@ describe('User Routes (/api/users)', () => {
// Arrange
const itemId = 101;
const updates = { is_purchased: true, quantity: 2 };
const mockUpdatedItem = { shopping_list_item_id: itemId, ...updates };
(mockedDb.updateShoppingListItem as Mocked<any>).mockResolvedValue(mockUpdatedItem);
const mockUpdatedItem: Awaited<ReturnType<typeof db.updateShoppingListItem>> = { shopping_list_item_id: itemId, shopping_list_id: 1, added_at: new Date().toISOString(), ...updates };
vi.mocked(mockedDb.updateShoppingListItem).mockResolvedValue(mockUpdatedItem);
// Act
const response = await supertest(app)
@@ -246,7 +247,7 @@ describe('User Routes (/api/users)', () => {
});
it('DELETE /shopping-lists/items/:itemId should delete an item', async () => {
(mockedDb.removeShoppingListItem as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.removeShoppingListItem).mockResolvedValue(undefined);
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
expect(response.status).toBe(204);
expect(mockedDb.removeShoppingListItem).toHaveBeenCalledWith(101);
@@ -258,7 +259,7 @@ describe('User Routes (/api/users)', () => {
// Arrange
const profileUpdates = { full_name: 'New Name' };
const updatedProfile = { ...mockUserProfile, ...profileUpdates };
(mockedDb.updateUserProfile as Mocked<any>).mockResolvedValue(updatedProfile);
vi.mocked(mockedDb.updateUserProfile).mockResolvedValue(updatedProfile);
// Act
const response = await supertest(app)
@@ -287,7 +288,7 @@ describe('User Routes (/api/users)', () => {
describe('PUT /profile/password', () => {
it('should update the password successfully with a strong password', async () => {
// Arrange
(mockedDb.updateUserPassword as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.updateUserPassword).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -317,8 +318,8 @@ describe('User Routes (/api/users)', () => {
it('should delete the account with the correct password', async () => {
// Arrange
const userWithHash = { ...mockUserProfile.user, password_hash: await bcrypt.hash('correct-password', 10) };
(mockedDb.findUserWithPasswordHashById as Mocked<any>).mockResolvedValue(userWithHash);
(mockedDb.deleteUserById as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(mockedDb.deleteUserById).mockResolvedValue(undefined);
// Act
const response = await supertest(app)
@@ -333,7 +334,7 @@ describe('User Routes (/api/users)', () => {
it('should return 403 for an incorrect password', async () => {
const userWithHash = { ...mockUserProfile.user, password_hash: await bcrypt.hash('correct-password', 10) };
(mockedDb.findUserWithPasswordHashById as Mocked<any>).mockResolvedValue(userWithHash);
vi.mocked(mockedDb.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
const response = await supertest(app)
.delete('/api/users/account')
@@ -349,11 +350,10 @@ describe('User Routes (/api/users)', () => {
describe('PUT /profile/preferences', () => {
it('should update user preferences successfully', async () => {
// Arrange
const preferencesUpdate = { theme: 'dark', unit_system: 'metric' };
// Mock the DB functions that are called after the update to return a full profile
(mockedDb.updateUserPreferences as Mocked<any>).mockResolvedValue(undefined);
(mockedDb.findUserProfileById as Mocked<any>).mockResolvedValue({ ...mockUserProfile, preferences: preferencesUpdate });
(mockedDb.findUserById as Mocked<any>).mockResolvedValue(mockUserProfile.user);
const preferencesUpdate = { darkMode: true, unitSystem: 'metric' as const };
// Mock the DB function that is called to update preferences
const updatedProfile = { ...mockUserProfile, preferences: preferencesUpdate };
vi.mocked(mockedDb.updateUserPreferences).mockResolvedValue(updatedProfile);
// Act
const response = await supertest(app)
@@ -362,7 +362,7 @@ describe('User Routes (/api/users)', () => {
// Assert
expect(response.status).toBe(200);
expect(response.body.preferences).toEqual(preferencesUpdate);
expect(response.body).toEqual(updatedProfile);
expect(mockedDb.updateUserPreferences).toHaveBeenCalledWith('user-123', preferencesUpdate);
});
@@ -381,8 +381,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 }, { dietary_restriction_id: 3 }];
(mockedDb.getUserDietaryRestrictions as Mocked<any>).mockResolvedValue(mockRestrictions);
const mockRestrictions: Awaited<ReturnType<typeof db.getUserDietaryRestrictions>> = [{ dietary_restriction_id: 1, name: 'Gluten-Free', type: 'diet' }];
vi.mocked(mockedDb.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
@@ -392,7 +392,7 @@ describe('User Routes (/api/users)', () => {
});
it('PUT should successfully set the restrictions', async () => {
(mockedDb.setUserDietaryRestrictions as Mocked<any>).mockResolvedValue(undefined);
vi.mocked(mockedDb.setUserDietaryRestrictions).mockResolvedValue(undefined);
const restrictionIds = [1, 3, 5];
const response = await supertest(app)
@@ -406,97 +406,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_id: 2 }, { appliance_id: 4 }];
(mockedDb.getUserAppliances as Mocked<any>).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
expect(mockedDb.getUserAppliances).toHaveBeenCalledWith('user-123');
});
it('PUT should successfully set the appliances', async () => {
(mockedDb.setUserAppliances as Mocked<any>).mockResolvedValue(undefined);
const applianceIds = [2, 4, 6];
const response = await supertest(app).put('/api/users/me/appliances').send({ applianceIds });
expect(response.status).toBe(204);
expect(mockedDb.setUserAppliances).toHaveBeenCalledWith('user-123', applianceIds);
});
});
describe('User Preferences and Personalization', () => {
describe('PUT /profile/preferences', () => {
it('should update user preferences successfully', async () => {
// Arrange
const preferencesUpdate = { theme: 'dark', unit_system: 'metric' };
// Mock the DB functions that are called after the update to return a full profile
(mockedDb.updateUserPreferences as Mocked<any>).mockResolvedValue(undefined);
(mockedDb.findUserProfileById as Mocked<any>).mockResolvedValue({ ...mockUserProfile, preferences: preferencesUpdate });
(mockedDb.findUserById as Mocked<any>).mockResolvedValue(mockUserProfile.user);
// Act
const response = await supertest(app)
.put('/api/users/profile/preferences')
.send(preferencesUpdate);
// Assert
expect(response.status).toBe(200);
expect(response.body.preferences).toEqual(preferencesUpdate);
expect(mockedDb.updateUserPreferences).toHaveBeenCalledWith('user-123', preferencesUpdate);
});
it('should return 400 if the request body is not a valid object', async () => {
// Act: Send a non-object payload with the correct content-type to test the route's validation
const response = await supertest(app)
.put('/api/users/profile/preferences')
.set('Content-Type', 'application/json')
.send('"not-an-object"');
// Assert
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid preferences format. Body must be a JSON object.');
});
});
describe('GET and PUT /users/me/dietary-restrictions', () => {
it('GET should return a list of restriction IDs', async () => {
const mockRestrictions = [{ dietary_restriction_id: 1 }, { dietary_restriction_id: 3 }];
(mockedDb.getUserDietaryRestrictions as Mocked<any>).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
expect(mockedDb.getUserDietaryRestrictions).toHaveBeenCalledWith('user-123');
});
it('PUT should successfully set the restrictions', async () => {
(mockedDb.setUserDietaryRestrictions as Mocked<any>).mockResolvedValue(undefined);
const restrictionIds = [1, 3, 5];
const response = await supertest(app)
.put('/api/users/me/dietary-restrictions')
.send({ restrictionIds });
expect(response.status).toBe(204);
expect(mockedDb.setUserDietaryRestrictions).toHaveBeenCalledWith('user-123', restrictionIds);
});
it('PUT should return 400 if restrictionIds is not an array', async () => {
const response = await supertest(app)
.put('/api/users/me/dietary-restrictions')
.send({ restrictionIds: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('restrictionIds must be an array.');
});
});
describe('GET and PUT /users/me/appliances', () => {
it('GET should return a list of appliance IDs', async () => {
const mockAppliances = [{ appliance_id: 2 }, { appliance_id: 4 }];
(mockedDb.getUserAppliances as Mocked<any>).mockResolvedValue(mockAppliances);
const mockAppliances: Awaited<ReturnType<typeof db.getUserAppliances>> = [{ appliance_id: 2, name: 'Air Fryer' }];
vi.mocked(mockedDb.getUserAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances');
@@ -513,7 +424,7 @@ describe('User Routes (/api/users)', () => {
expect(mockedDb.setUserAppliances).toHaveBeenCalledWith('user-123', applianceIds);
});
});
});
});
});
});

View File

@@ -6,7 +6,7 @@ import path from 'path';
import fs from 'fs/promises';
import * as db from '../services/db';
import { logger } from '../services/logger';
import { User, UserProfile } from '../types';
import { User } from '../types';
const router = express.Router();
@@ -109,4 +109,28 @@ router.post(
}
);
/**
* POST /api/users/notifications/:notificationId/mark-read - Mark a single notification as read.
*/
router.post(
'/notifications/:notificationId/mark-read',
passport.authenticate('jwt', { session: false }),
async (req: Request, res: Response) => {
const user = req.user as User;
const notificationId = parseInt(req.params.notificationId, 10);
if (isNaN(notificationId)) {
return res.status(400).json({ message: 'Invalid notification ID.' });
}
try {
await db.markNotificationAsRead(notificationId, user.user_id);
res.status(204).send(); // Success, no content to return
} catch (error) {
logger.error('Error marking single notification as read:', { error, userId: user.user_id, notificationId });
res.status(500).json({ message: 'Failed to mark notification as read.' });
}
}
);
export default router;

View File

@@ -829,6 +829,15 @@ export const markAllNotificationsAsRead = async (tokenOverride?: string): Promis
return apiFetch(`/users/notifications/mark-all-read`, { method: 'POST' }, tokenOverride);
};
/**
* Marks a single notification as read.
* @param notificationId The ID of the notification to mark as read.
* @returns A promise that resolves to the API response.
*/
export const markNotificationAsRead = async (notificationId: number, tokenOverride?: string): Promise<Response> => {
return apiFetch(`/users/notifications/${notificationId}/mark-read`, { method: 'POST' }, tokenOverride);
};
// --- Budgeting and Spending Analysis API Functions ---
/**

View File

@@ -0,0 +1,164 @@
// src/services/db/notification.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getNotificationsForUser,
createNotification,
createBulkNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
} from './notification';
import type { Notification } from '../../types';
// Mock the getPool function to return a mocked pool object.
const mockQuery = vi.fn();
const mockRelease = vi.fn();
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
connect: () => ({
query: mockQuery,
release: mockRelease,
}),
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('Notification DB Service', () => {
beforeEach(() => {
// Clear mock history before each test
vi.clearAllMocks();
});
describe('getNotificationsForUser', () => {
it('should execute the correct query with limit and offset and return notifications', async () => {
// Arrange
const mockNotifications: Notification[] = [
{ notification_id: 1, user_id: 'user-123', content: 'Test notification 1', is_read: false, created_at: new Date().toISOString() },
{ notification_id: 2, user_id: 'user-123', content: 'Test notification 2', is_read: true, created_at: new Date().toISOString() },
];
mockQuery.mockResolvedValue({ rows: mockNotifications });
// Act
const result = await getNotificationsForUser('user-123', 10, 5);
// Assert
expect(getPool().query).toHaveBeenCalledTimes(1);
expect(getPool().query).toHaveBeenCalledWith(
expect.stringContaining('SELECT * FROM public.notifications'),
['user-123', 10, 5]
);
expect(result).toEqual(mockNotifications);
});
});
describe('createNotification', () => {
it('should insert a new notification and return it', async () => {
// Arrange
const mockNotification: Notification = {
notification_id: 1,
user_id: 'user-123',
content: 'Test notification',
link_url: '/test',
is_read: false,
created_at: new Date().toISOString(),
};
mockQuery.mockResolvedValue({ rows: [mockNotification] });
// Act
const result = await createNotification('user-123', 'Test notification', '/test');
// Assert
expect(getPool().query).toHaveBeenCalledTimes(1);
expect(getPool().query).toHaveBeenCalledWith(
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
['user-123', 'Test notification', '/test']
);
expect(result).toEqual(mockNotification);
});
});
describe('createBulkNotifications', () => {
it('should build a correct bulk insert query and release the client', async () => {
// Arrange
mockQuery.mockResolvedValue({ rows: [] }); // The query doesn't return rows
const notificationsToCreate = [
{ user_id: 'user-1', content: "First message" },
{ user_id: 'user-2', content: "It's a message with a 'quote'", link_url: '/link' },
];
// Act
await createBulkNotifications(notificationsToCreate);
// Assert
const expectedValues = `('user-1', 'First message', NULL),('user-2', 'It''s a message with a ''quote''', '/link')`;
const expectedQuery = `INSERT INTO public.notifications (user_id, content, link_url) VALUES ${expectedValues}`;
expect(getPool().connect).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledWith(expectedQuery);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it('should not query the database if the notifications array is empty', async () => {
await createBulkNotifications([]);
expect(getPool().connect).not.toHaveBeenCalled();
expect(mockQuery).not.toHaveBeenCalled();
});
});
describe('markNotificationAsRead', () => {
it('should update a single notification and return the updated record', async () => {
// Arrange
const mockNotification: Notification = {
notification_id: 123,
user_id: 'user-abc',
content: 'Your item is on sale!',
is_read: true, // The returned value should be true
created_at: new Date().toISOString(),
};
mockQuery.mockResolvedValue({ rows: [mockNotification], rowCount: 1 });
// Act
const result = await markNotificationAsRead(123, 'user-abc');
// Assert
expect(getPool().query).toHaveBeenCalledTimes(1);
expect(getPool().query).toHaveBeenCalledWith(
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
[123, 'user-abc']
);
expect(result).toEqual(mockNotification);
});
it('should throw an error if the notification is not found or does not belong to the user', async () => {
// Arrange: Simulate the DB returning no rows
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
// Act & Assert: Expect the function to throw the specific error.
await expect(markNotificationAsRead(999, 'user-abc')).rejects.toThrow('Notification not found or user does not have permission.');
});
});
describe('markAllNotificationsAsRead', () => {
it('should execute an UPDATE query to mark all notifications as read for a user', async () => {
// Arrange
mockQuery.mockResolvedValue({ rowCount: 3 }); // Simulate updating 3 notifications
// Act
await markAllNotificationsAsRead('user-xyz');
// Assert
expect(getPool().query).toHaveBeenCalledTimes(1);
expect(getPool().query).toHaveBeenCalledWith(`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`, ['user-xyz']);
});
});
});

View File

@@ -83,4 +83,28 @@ export async function markAllNotificationsAsRead(userId: string): Promise<void>
logger.error('Database error in markAllNotificationsAsRead:', { error, userId });
throw new Error('Failed to mark notifications as read.');
}
}
/**
* Marks a single notification as read for a specific user.
* Ensures that a user can only mark their own notifications.
* @param notificationId The ID of the notification to mark as read.
* @param userId The ID of the user who owns the notification.
* @returns A promise that resolves to the updated Notification object.
* @throws An error if the notification is not found or does not belong to the user.
*/
export async function markNotificationAsRead(notificationId: number, userId: string): Promise<Notification> {
try {
const res = await getPool().query<Notification>(
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
[notificationId, userId]
);
if (res.rowCount === 0) {
throw new Error('Notification not found or user does not have permission.');
}
return res.rows[0];
} catch (error) {
logger.error('Database error in markNotificationAsRead:', { error, notificationId, userId });
throw new Error('Failed to mark notification as read.');
}
}

View File

@@ -34,12 +34,10 @@ vi.mock('./logger.server', () => ({
describe('Email Service (Server)', () => {
beforeEach(async () => {
// eslint-disable-next-line no-console
console.log('[TEST SETUP] Setting up Email Service mocks');
vi.clearAllMocks();
// Reset to default successful implementation
mockSendMail.mockImplementation((mailOptions) => {
// eslint-disable-next-line no-console
console.log('[TEST DEBUG] mockSendMail (default) called with:', mailOptions?.to);
return Promise.resolve({
messageId: 'default-mock-id',