more db unit tests - best o luck !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 4m24s

This commit is contained in:
2025-12-06 22:58:37 -08:00
parent 6f74de3f88
commit cea3586984
9 changed files with 600 additions and 16 deletions

View File

@@ -199,12 +199,24 @@ jobs:
APP_PATH="/var/www/flyer-crawler.projectium.com"
echo "--- Cleaning up test-generated flyer assets ---"
# Use find to delete files within the directories, but not the directories themselves.
# Target only the specific test files by name pattern.
find "$APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete
find "$APP_PATH/flyer-images/icons" -type f -name '*-test-flyer-image.*' -delete
find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory not found, skipping."
# Target only the specific test files by name pattern. We now explicitly exclude the test report directory.
find "$APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete || echo "No test flyer images to delete."
find "$APP_PATH/flyer-images/icons" -type f -name '*-test-flyer-image.*' -delete || echo "No test flyer icons to delete."
find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory is empty or not found, skipping."
echo "✅ Test artifacts cleared from asset directories."
- name: Deploy Coverage Report to Public URL
if: always()
run: |
TARGET_DIR="/var/www/flyer.torbonium.com/test"
echo "Deploying HTML coverage report to $TARGET_DIR..."
# Ensure the target directory exists and clear its contents before deploying.
mkdir -p "$TARGET_DIR"
rm -rf "$TARGET_DIR"/*
# Copy the entire contents of the generated coverage report directory.
cp -r .coverage/* "$TARGET_DIR/"
echo "✅ Coverage report deployed to https://flyer-crawler.projectium.com/test"
- name: Archive Code Coverage Report
# This action saves the generated HTML coverage report as a downloadable artifact.
uses: actions/upload-artifact@v3

View File

@@ -0,0 +1,76 @@
// src/middleware/errorHandler.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Request, Response, NextFunction } from 'express';
import { errorHandler } from './errorHandler';
import { logger } from '../services/logger.server';
// Mock the logger to prevent console output during tests and to spy on its methods.
vi.mock('../services/logger.server', () => ({
logger: {
error: vi.fn(),
},
}));
describe('Error Handler Middleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: NextFunction;
beforeEach(() => {
vi.clearAllMocks();
// Create fresh mocks for each test to ensure isolation.
mockRequest = {
path: '/test-path',
method: 'GET',
};
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
headersSent: false, // Default to headers not being sent
};
mockNext = vi.fn();
});
it('should return a generic 500 error for a standard Error object', () => {
const error = new Error('Something unexpected happened');
errorHandler(error, mockRequest as Request, mockResponse as Response, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
message: 'An unexpected server error occurred.',
});
expect(logger.error).toHaveBeenCalledWith('Unhandled API Error:', expect.any(Object));
expect(mockNext).not.toHaveBeenCalled();
});
it('should use the status code and message from a custom HttpError', () => {
const httpError = new Error('Resource not found') as any;
httpError.status = 404;
errorHandler(httpError, mockRequest as Request, mockResponse as Response, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(404);
expect(mockResponse.json).toHaveBeenCalledWith({
message: 'Resource not found',
});
// Should not log a 4xx error as a server error
expect(logger.error).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('should call next(err) if headers have already been sent', () => {
const error = new Error('Late error');
// Simulate a scenario where the response has already started being sent
mockResponse.headersSent = true;
errorHandler(error, mockRequest as Request, mockResponse as Response, mockNext);
// The middleware should delegate to Express's default error handler
expect(mockNext).toHaveBeenCalledWith(error);
// It should not try to set the status or send a JSON response itself
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
});

View File

@@ -682,6 +682,76 @@ describe('Admin Routes (/api/admin)', () => {
});
});
describe('GET /users', () => {
it('should return a list of all users on success', async () => {
// Arrange
const mockUsers: (User & { role: 'user' | 'admin', created_at: string, full_name: string | null, avatar_url: string | null })[] = [
{ user_id: '1', email: 'user1@test.com', role: 'user' as const, created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
];
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
// Act
const response = await supertest(app).get('/api/admin/users');
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUsers);
expect(mockedDb.getAllUsers).toHaveBeenCalledTimes(1);
});
it('should return a 500 error if the database call fails', async () => {
mockedDb.getAllUsers.mockRejectedValue(new Error('Failed to fetch users'));
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(500);
});
});
describe('GET /users/:id', () => {
it('should fetch a single user successfully', async () => {
// Arrange
const mockUser = createMockUserProfile({ user_id: 'user-123' });
mockedDb.findUserProfileById.mockResolvedValue(mockUser);
// Act
const response = await supertest(app).get('/api/admin/users/user-123');
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUser);
expect(mockedDb.findUserProfileById).toHaveBeenCalledWith('user-123');
});
it('should return 404 for a non-existent user', async () => {
// Arrange
mockedDb.findUserProfileById.mockResolvedValue(undefined);
// Act
const response = await supertest(app).get('/api/admin/users/non-existent-id');
// Assert
expect(response.status).toBe(404); // Corrected from 404 to 500 based on error handling in admin.ts
expect(response.body.message).toBe('User not found.');
});
});
describe('DELETE /users/:id', () => {
it('should successfully delete a user', async () => {
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');
});
it('should prevent an admin from deleting their own account', async () => {
// The admin user ID is 'admin-user-id' from the beforeEach hook
const response = await supertest(app).delete('/api/admin/users/admin-user-id');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Admins cannot delete their own account.');
expect(mockedDb.deleteUserById).not.toHaveBeenCalled();
});
});
describe('POST /trigger/daily-deal-check', () => {
it('should trigger the daily deal check job and return 202 Accepted', async () => {
// Arrange

View File

@@ -1,14 +1,16 @@
// 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 express, { type Request, type Response, type NextFunction, Router } from 'express';
import path from 'node:path';
import aiRouter from './ai.routes';
import { createMockUserProfile, createMockFlyer } from '../tests/utils/mockFactories';
import * as flyerDb from '../services/db/flyer.db';
import * as aiService from '../services/aiService.server';
import * as adminDb from '../services/db/admin.db';
// Mock the AI service to avoid making real AI calls
// We mock the singleton instance directly.
vi.mock('../services/aiService.server');
// Mock the specific DB modules used by the AI router.
@@ -21,6 +23,15 @@ vi.mock('../services/db/admin.db', () => ({
logActivity: vi.fn(),
}));
// Mock the queue service
vi.mock('../services/queueService.server', () => ({
flyerQueue: {
add: vi.fn(),
getJob: vi.fn(), // Also mock getJob for the status endpoint
},
}));
import { flyerQueue } from '../services/queueService.server';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: {
@@ -48,12 +59,122 @@ const app = express();
app.use(express.json({ strict: false }));
app.use('/api/ai', aiRouter);
// Add a generic error handler to catch errors passed via next()
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ message: err.message || 'Internal Server Error' });
});
describe('AI Routes (/api/ai)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('POST /flyers/process', () => {
describe('POST /upload-and-process', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
it('should enqueue a job and return 202 on success', async () => {
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-123' } as any);
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.field('checksum', 'new-checksum')
.attach('flyerFile', imagePath);
expect(response.status).toBe(202);
expect(response.body.message).toBe('Flyer accepted for processing.');
expect(response.body.jobId).toBe('job-123');
expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', expect.any(Object));
});
it('should return 400 if no file is provided', async () => {
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.field('checksum', 'some-checksum');
expect(response.status).toBe(400);
expect(response.body.message).toBe('A flyer file (PDF or image) is required.');
});
it('should return 400 if checksum is missing', async () => {
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.attach('flyerFile', imagePath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('File checksum is required.');
});
it('should return 409 if flyer checksum already exists', async () => {
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 99 }));
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.field('checksum', 'duplicate-checksum')
.attach('flyerFile', imagePath);
expect(response.status).toBe(409);
expect(response.body.message).toBe('This flyer has already been processed.');
});
it('should return 500 if enqueuing the job fails', async () => {
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockRejectedValue(new Error('Redis connection failed'));
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.field('checksum', 'new-checksum')
.attach('flyerFile', imagePath);
expect(response.status).toBe(500);
});
it('should pass user ID to the job when authenticated', async () => {
// Arrange: Create a mock authenticated user and inject it into the request
const mockUser = createMockUserProfile({ user_id: 'auth-user-1' });
app.use((req, res, next) => {
req.user = mockUser;
next();
});
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job-456' } as any);
// Act
await supertest(app)
.post('/api/ai/upload-and-process')
.field('checksum', 'auth-checksum')
.attach('flyerFile', imagePath);
// Assert
expect(flyerQueue.add).toHaveBeenCalled();
expect(vi.mocked(flyerQueue.add).mock.calls[0][1].userId).toBe('auth-user-1');
});
});
describe('GET /jobs/:jobId/status', () => {
it('should return 404 if job is not found', async () => {
// Mock the queue to return null for the job
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
const response = await supertest(app).get('/api/ai/jobs/non-existent-job/status');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Job not found.');
});
it('should return job status if job is found', async () => {
const mockJob = { id: 'job-123', getState: async () => 'completed', progress: 100, returnvalue: { flyerId: 1 } };
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as any);
const response = await supertest(app).get('/api/ai/jobs/job-123/status');
expect(response.status).toBe(200);
expect(response.body.state).toBe('completed');
});
});
describe('POST /flyers/process (Legacy)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockDataPayload = {
checksum: 'test-checksum',
@@ -157,6 +278,114 @@ describe('AI Routes (/api/ai)', () => {
});
});
describe('POST /rescan-area', () => {
it('should return 400 if image file is missing', async () => {
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('cropArea', JSON.stringify({ x: 0, y: 0, width: 10, height: 10 }))
.field('extractionType', 'store_name');
expect(response.status).toBe(400);
});
});
describe('POST /rescan-area (authenticated)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockUser = createMockUserProfile({ user_id: 'user-123' });
beforeEach(() => {
// Inject an authenticated user for this test block
app.use((req, res, next) => {
req.user = mockUser;
next();
});
});
it('should call the AI service and return the result on success', async () => {
const mockResult = { text: 'Rescanned Text' };
vi.mocked(aiService.aiService.extractTextFromImageArea).mockResolvedValue(mockResult);
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
.field('extractionType', 'item_details')
.attach('image', imagePath);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockResult);
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
});
it('should return 500 if the AI service throws an error', async () => {
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(new Error('AI API is down'));
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
.field('extractionType', 'item_details')
.attach('image', imagePath);
expect(response.status).toBe(500);
expect(response.body.message).toContain('AI API is down');
});
it('should return 400 if cropArea is missing', async () => {
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('extractionType', 'store_name')
.attach('image', imagePath);
expect(response.status).toBe(400);
});
});
describe('POST /rescan-area (authenticated)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockUser = createMockUserProfile({ user_id: 'user-123' });
beforeEach(() => {
// Inject an authenticated user for this test block
app.use((req, res, next) => {
req.user = mockUser;
next();
});
});
it('should call the AI service and return the result on success', async () => {
const mockResult = { text: 'Rescanned Text' };
vi.mocked(aiService.aiService.extractTextFromImageArea).mockResolvedValue(mockResult);
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
.field('extractionType', 'item_details')
.attach('image', imagePath);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockResult);
expect(aiService.aiService.extractTextFromImageArea).toHaveBeenCalled();
});
it('should return 500 if the AI service throws an error', async () => {
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(new Error('AI API is down'));
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('cropArea', JSON.stringify({ x: 10, y: 10, width: 50, height: 50 }))
.field('extractionType', 'item_details')
.attach('image', imagePath);
expect(response.status).toBe(500);
expect(response.body.message).toContain('AI API is down');
});
it('should return 400 if cropArea is missing', async () => {
const response = await supertest(app)
.post('/api/ai/rescan-area')
.field('extractionType', 'store_name')
.attach('image', imagePath);
expect(response.status).toBe(400);
});
});
describe('when user is authenticated', () => {
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
@@ -185,5 +414,35 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(501);
expect(response.body.message).toBe('Image generation is not yet implemented.');
});
it('POST /plan-trip should return 500 if the AI service fails', async () => {
vi.mocked(aiService.aiService.planTripWithMaps).mockRejectedValue(new Error('Maps API key invalid'));
const response = await supertest(app)
.post('/api/ai/plan-trip')
.send({
items: [],
store: { name: 'Test Store' },
userLocation: { latitude: 0, longitude: 0 },
});
expect(response.status).toBe(500);
expect(response.body.message).toContain('Maps API key invalid');
});
it('POST /plan-trip should return 500 if the AI service fails', async () => {
vi.mocked(aiService.aiService.planTripWithMaps).mockRejectedValue(new Error('Maps API key invalid'));
const response = await supertest(app)
.post('/api/ai/plan-trip')
.send({
items: [],
store: { name: 'Test Store' },
userLocation: { latitude: 0, longitude: 0 },
});
expect(response.status).toBe(500);
expect(response.body.message).toContain('Maps API key invalid');
});
});
});

View File

@@ -1,15 +1,17 @@
// src/routes/user.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import express, { Request, Response, NextFunction } from 'express';
// Use * as bcrypt to match the implementation's import style and ensure mocks align.
import * as bcrypt from 'bcrypt';
import userRouter from './user.routes';
import * as userDb from '../services/db/user.db';
import * as personalizationDb from '../services/db/personalization.db';
import * as notificationDb from '../services/db/notification.db';
import * as addressDb from '../services/db/address.db';
import * as shoppingDb from '../services/db/shopping.db';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem } from '../tests/utils/mockFactories';
import { Appliance } from '../types';
import { Appliance, Notification } from '../types';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/user.db', () => ({
@@ -19,8 +21,6 @@ vi.mock('../services/db/user.db', () => ({
findUserWithPasswordHashById: vi.fn(),
deleteUserById: vi.fn(),
updateUserPreferences: vi.fn(),
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
findUserById: vi.fn(),
findUserByEmail: vi.fn(),
createUser: vi.fn(),
@@ -41,10 +41,16 @@ vi.mock('../services/db/shopping.db', () => ({
addShoppingListItem: vi.fn(),
updateShoppingListItem: vi.fn(),
removeShoppingListItem: vi.fn(),
}));
vi.mock('../services/db/notification.db', () => ({
getNotificationsForUser: vi.fn(),
markAllNotificationsAsRead: vi.fn(),
markNotificationAsRead: vi.fn(),
}));
vi.mock('../services/db/address.db', () => ({
getAddressById: vi.fn(),
upsertAddress: vi.fn(),
}));
// 2. Mock bcrypt.
// We return an object that satisfies both default and named imports to be safe.
@@ -98,6 +104,12 @@ const createApp = (authenticatedUser?: any) => {
}
app.use('/api/users', userRouter);
// Add a generic error handler to catch errors passed via next()
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ message: err.message || 'Internal Server Error' });
});
return app;
};
@@ -136,6 +148,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(404);
expect(response.body.message).toBe('Profile not found for this user.');
});
it('should return 500 if the database call fails', async () => {
vi.mocked(userDb.findUserProfileById).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/users/profile');
expect(response.status).toBe(500);
});
});
describe('GET /watched-items', () => {
@@ -146,6 +164,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(personalizationDb.getWatchedItems).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/users/watched-items');
expect(response.status).toBe(500);
});
});
describe('POST /watched-items', () => {
@@ -159,6 +183,14 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(201);
expect(response.body).toEqual(mockAddedItem);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(personalizationDb.addWatchedItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app)
.post('/api/users/watched-items')
.send({ itemName: 'Failing Item', category: 'Errors' });
expect(response.status).toBe(500);
});
});
describe('DELETE /watched-items/:masterItemId', () => {
@@ -168,6 +200,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(204);
expect(personalizationDb.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user_id, 99);
});
it('should return 500 if the database call fails', async () => {
vi.mocked(personalizationDb.removeWatchedItem).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).delete('/api/users/watched-items/99');
expect(response.status).toBe(500);
});
});
describe('Shopping List Routes', () => {
@@ -195,6 +233,12 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).delete('/api/users/shopping-lists/1');
expect(response.status).toBe(204);
});
it('should return 400 for an invalid listId on DELETE', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid list ID.');
});
});
describe('Shopping List Item Routes', () => {
@@ -229,6 +273,12 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).delete('/api/users/shopping-lists/items/101');
expect(response.status).toBe(204);
});
it('should return 400 for an invalid itemId on DELETE', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/items/abc');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid item ID.');
});
});
describe('PUT /profile', () => {
@@ -373,5 +423,98 @@ describe('User Routes (/api/users)', () => {
});
});
});
describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => {
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '' }];
vi.mocked(notificationDb.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockNotifications);
expect(notificationDb.getNotificationsForUser).toHaveBeenCalledWith('user-123', 10, 0);
});
it('POST /notifications/mark-all-read should return 204', async () => {
vi.mocked(notificationDb.markAllNotificationsAsRead).mockResolvedValue(undefined);
const response = await supertest(app).post('/api/users/notifications/mark-all-read');
expect(response.status).toBe(204);
expect(notificationDb.markAllNotificationsAsRead).toHaveBeenCalledWith('user-123');
});
it('POST /notifications/:notificationId/mark-read should return 204', async () => {
vi.mocked(notificationDb.markNotificationAsRead).mockResolvedValue({} as any);
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
expect(response.status).toBe(204);
expect(notificationDb.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
});
it('should return 400 for an invalid notificationId', async () => {
const response = await supertest(app).post('/api/users/notifications/abc/mark-read');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid notification ID.');
});
});
describe('Address Routes', () => {
it('GET /addresses/:addressId should return 403 if address does not belong to user', async () => {
const appWithDifferentUser = createApp({ ...mockUserProfile, address_id: 999 });
const response = await supertest(appWithDifferentUser).get('/api/users/addresses/1');
expect(response.status).toBe(403);
});
it('GET /addresses/:addressId should return 404 if address not found', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: 1 });
vi.mocked(addressDb.getAddressById).mockResolvedValue(undefined);
const response = await supertest(appWithUser).get('/api/users/addresses/1');
expect(response.status).toBe(404);
});
it('PUT /profile/address should call upsertAddress and updateUserProfile if needed', async () => {
const appWithUser = createApp({ ...mockUserProfile, address_id: null }); // User has no address yet
const addressData = { address_line_1: '123 New St' };
vi.mocked(addressDb.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(userDb.updateUserProfile).mockResolvedValue({} as any);
const response = await supertest(appWithUser)
.put('/api/users/profile/address')
.send(addressData);
expect(response.status).toBe(200);
expect(addressDb.upsertAddress).toHaveBeenCalledWith({ ...addressData, address_id: undefined });
// Verify that the user's profile was updated to link the new address
expect(userDb.updateUserProfile).toHaveBeenCalledWith('user-123', { address_id: 5 });
});
});
describe('POST /profile/avatar', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = { ...mockUserProfile, avatar_url: '/uploads/avatars/new-avatar.png' };
vi.mocked(userDb.updateUserProfile).mockResolvedValue(mockUpdatedProfile);
// Create a dummy file path for supertest to attach
const dummyImagePath = 'test-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('/uploads/avatars/');
expect(userDb.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user_id, { avatar_url: expect.any(String) });
});
it('should return 400 if a non-image file is uploaded', async () => {
const dummyTextPath = 'document.txt';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', Buffer.from('this is not an image'), dummyTextPath);
expect(response.status).toBe(400);
expect(response.body.message).toBe('Only image files are allowed!');
});
});
});
});

View File

@@ -142,7 +142,7 @@ export async function getApplicationStats(): Promise<{
};
} catch (error) {
logger.error('Database error in getApplicationStats:', { error });
throw new Error('Failed to retrieve application statistics.');
throw error; // Re-throw the original error to be handled by the caller
}
}

View File

@@ -91,6 +91,10 @@ describe('Flyer DB Service', () => {
await createFlyerAndItems(mockFlyerData, []);
// Also test the case where store_address is null
const mockFlyerDataNoAddress = { ...mockFlyerData, store_address: null };
await createFlyerAndItems(mockFlyerDataNoAddress, []);
// Verify it doesn't try to insert items but still commits the flyer
expect(mockPoolInstance.query).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.flyer_items'));
expect(mockPoolInstance.query).toHaveBeenCalledWith('COMMIT');

View File

@@ -90,9 +90,13 @@ export async function createShoppingList(userId: string, name: string): Promise<
*/
export async function deleteShoppingList(listId: number, userId: string): Promise<void> {
try {
// The user_id check ensures a user can only delete their own list.
await getPool().query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
const res = await getPool().query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
if (res.rowCount === 0) {
throw new Error('Shopping list not found or user does not have permission to delete.');
}
} catch (error) {
// If it's our custom error, re-throw it. Otherwise, wrap it.
if (error instanceof Error && error.message.startsWith('Shopping list not found')) throw error;
logger.error('Database error in deleteShoppingList:', { error });
throw new Error('Failed to delete shopping list.');
}
@@ -105,10 +109,14 @@ export async function deleteShoppingList(listId: number, userId: string): Promis
* @returns A promise that resolves to the newly created ShoppingListItem object.
*/
export async function addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
if (!item.masterItemId && !item.customItemName) {
throw new Error('Either masterItemId or customItemName must be provided.');
}
try {
const res = await getPool().query<ShoppingListItem>(
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
[listId, item.masterItemId, item.customItemName]
[listId, item.masterItemId ?? null, item.customItemName ?? null]
);
return res.rows[0];
} catch (error) {
@@ -125,12 +133,16 @@ export async function addShoppingListItem(listId: number, item: { masterItemId?:
* @param itemId The ID of the shopping list item to remove.
*/
export async function removeShoppingListItem(itemId: number): Promise<void> {
let res;
try {
await getPool().query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
res = await getPool().query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
} catch (error) {
logger.error('Database error in removeShoppingListItem:', { error });
throw new Error('Failed to remove item from shopping list.');
}
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
}
/**
@@ -237,9 +249,14 @@ export async function updateShoppingListItem(itemId: number, updates: Partial<Sh
const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE shopping_list_item_id = $${valueIndex} RETURNING *`;
const res = await getPool().query<ShoppingListItem>(query, values);
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
return res.rows[0];
} catch (error) {
logger.error('Database error in updateShoppingListItem:', { error });
// If it's our custom error, re-throw it. Otherwise, wrap it.
if (error instanceof Error && error.message.startsWith('Shopping list item not found')) throw error;
throw new Error('Failed to update shopping list item.');
}
}

View File

@@ -52,7 +52,10 @@ export default defineConfig({
coverage: {
provider: 'v8',
// We remove 'text' here. The final text report will be generated by `nyc` after merging.
reporter: ['text', 'html', 'json'],
reporter: [
// Add maxCols to suggest a wider output for the text summary.
['text', { maxCols: 200 }],
'html', 'json'],
// hanging-process reporter helps identify tests that do not exit properly - comes at a high cost tho
//reporter: ['verbose', 'html', 'json', 'hanging-process'],
reportsDirectory: './.coverage/unit',