more db unit tests - best o luck !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 4m24s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 4m24s
This commit is contained in:
@@ -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
|
||||
|
||||
76
src/middleware/errorHandler.test.ts
Normal file
76
src/middleware/errorHandler.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user