From 79c57fd8b0f99ed6ce1bf160aae17ef07309dac8 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Fri, 28 Nov 2025 20:04:51 -0800 Subject: [PATCH] testing routes --- package.json | 2 +- src/routes/ai.test.ts | 107 +++++++++++++++- src/routes/auth.test.ts | 190 ++++++++++++++++++++++++++++ src/routes/public.routes.test.ts | 83 ++++++++++++ src/routes/system.test.ts | 19 +++ src/routes/user.test.ts | 208 +++++++++++++++++++++++++++++++ vite.config.ts | 2 +- 7 files changed, 603 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index dcafbf55..ab21f8c1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "vite build", "preview": "vite preview", "test": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run", - "test:coverage": "npm run clean; npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=20000; npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=20000", + "test:coverage": "npm run clean; npm run test:unit -- --coverage --reporter=verbose --reporter=tree --includeTaskLocation --testTimeout=20000; npm run test:integration -- --coverage --reporter=verbose --reporter=tree --includeTaskLocation --testTimeout=20000", "test:unit": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run -c vite.config.ts", "test:integration": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run -c vitest.config.integration.ts", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", diff --git a/src/routes/ai.test.ts b/src/routes/ai.test.ts index 3a205876..b338d2d0 100644 --- a/src/routes/ai.test.ts +++ b/src/routes/ai.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import aiRouter from './ai'; import * as aiService from '../services/aiService.server'; +import { UserProfile } from '../types'; // Mock the AI service to avoid making real AI calls vi.mock('../services/aiService.server'); @@ -21,20 +22,20 @@ vi.mock('../services/logger.server', () => ({ }, })); -// Mock passport's optionalAuth to simplify testing. -// We just need it to call next() so the route handler can run. +// Mock the passport module to control authentication for different tests. vi.mock('./passport', () => ({ // Mock the default export for passport.authenticate default: { - authenticate: vi.fn((strategy, options) => (req: Request, res: Response, next: NextFunction) => { - // This mock allows passport.authenticate('jwt', ...) to pass through - next(); - }), + authenticate: vi.fn(), }, // Mock the named export for optionalAuth optionalAuth: vi.fn((req, res, next) => next()), })); +// We need to import the mocked passport object to control its behavior in tests. +import passport from './passport'; +const mockedAuthenticate = passport.authenticate as Mocked; + // Create a minimal Express app to host our router const app = express(); app.use(express.json()); @@ -43,6 +44,12 @@ app.use('/api/ai', aiRouter); describe('AI Routes (/api/ai)', () => { beforeEach(() => { vi.clearAllMocks(); + // 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) => { + res.status(401).json({ message: 'Unauthorized' }); + }); }); describe('POST /process-flyer', () => { @@ -86,4 +93,92 @@ describe('AI Routes (/api/ai)', () => { expect(response.body.data).toEqual(mockExtractedData); }); }); + + describe('POST /flyers/process', () => { + const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg'); + const mockDataPayload = { + checksum: 'test-checksum', + originalFileName: 'flyer.jpg', + extractedData: { store_name: 'Test Store', items: [] }, + }; + + 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.logActivity.mockResolvedValue(); + + // Act + const response = await supertest(app) + .post('/api/ai/flyers/process') + .field('data', JSON.stringify(mockDataPayload)) + .attach('flyerImage', imagePath); + + // Assert + expect(response.status).toBe(201); + expect(response.body.message).toBe('Flyer processed and saved successfully.'); + expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); + }); + + it('should return 409 Conflict if flyer checksum already exists', async () => { + // Arrange + mockedDb.findFlyerByChecksum.mockResolvedValue({ flyer_id: 99 } as any); // Duplicate found + + // Act + const response = await supertest(app) + .post('/api/ai/flyers/process') + .field('data', JSON.stringify(mockDataPayload)) + .attach('flyerImage', imagePath); + + // Assert + expect(response.status).toBe(409); + expect(response.body.message).toBe('This flyer has already been processed.'); + expect(mockedDb.createFlyerAndItems).not.toHaveBeenCalled(); + }); + + it('should return 400 if no image file is provided', async () => { + const response = await supertest(app) + .post('/api/ai/flyers/process') + .field('data', JSON.stringify(mockDataPayload)); // No .attach() + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Flyer image file is required.'); + }); + }); + + describe('when user is authenticated', () => { + const mockUserProfile: UserProfile = { + user_id: 'user-123', + user: { user_id: 'user-123', email: 'test@test.com' }, + role: 'user', + } 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.user = mockUserProfile; + next(); + } + ); + }); + + it('POST /quick-insights should return the stubbed response', async () => { + const response = await supertest(app) + .post('/api/ai/quick-insights') + .send({ items: [] }); + + expect(response.status).toBe(200); + expect(response.body.text).toContain('server-generated quick insight'); + }); + + it('POST /generate-image should return 501 Not Implemented', async () => { + const response = await supertest(app) + .post('/api/ai/generate-image') + .send({ prompt: 'test' }); + + expect(response.status).toBe(501); + expect(response.body.message).toBe('Image generation is not yet implemented.'); + }); + }); }); \ No newline at end of file diff --git a/src/routes/auth.test.ts b/src/routes/auth.test.ts index 77eaf87a..f28b2caf 100644 --- a/src/routes/auth.test.ts +++ b/src/routes/auth.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import supertest from 'supertest'; import express from 'express'; import authRouter from './auth'; +import * as bcrypt from 'bcrypt'; +import passport from './passport'; import * as db from '../services/db'; // Mock the entire db service @@ -19,6 +21,32 @@ vi.mock('../services/logger.server', () => ({ }, })); +// Mock the email service to prevent actual emails from being sent during tests. +vi.mock('../services/emailService.server', () => ({ + sendPasswordResetEmail: vi.fn(), +})); + +// Mock the bcrypt library to control password comparison results. +vi.mock('bcrypt', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, compare: vi.fn() }; +}); + +/** + * Mock the passport module. The login route uses a custom callback with + * passport.authenticate, so we need to mock it in a way that lets us + * control what gets passed to that callback (err, user, info). + */ +vi.mock('./passport', () => ({ + default: { + // The authenticate method returns a middleware function. We mock that. + authenticate: vi.fn(), + }, +})); + +// Get a reference to the mocked authenticate function to control it in tests. +const mockedAuthenticate = passport.authenticate as Mocked; + // Create a minimal Express app to host our router const app = express(); app.use(express.json()); @@ -113,4 +141,166 @@ describe('Auth Routes (/api/auth)', () => { expect(response.body.message).toBe('Email and password are required.'); }); }); + + describe('POST /login', () => { + const loginCredentials = { email: 'test@test.com', password: 'password123' }; + + it('should successfully log in a user and return a token and cookie', async () => { + // Arrange: + // 1. Simulate passport successfully finding a user. + const mockUser = { user_id: 'user-123', email: 'test@test.com' }; + mockedAuthenticate.mockImplementation( + (strategy, options, callback) => (req, res, next) => { + // Call the route's custom callback with the mock user. + callback(null, mockUser, null); + } + ); + // 2. Mock the database calls that happen after successful authentication. + mockedDb.saveRefreshToken.mockResolvedValue(); + + // Act + const response = await supertest(app) + .post('/api/auth/login') + .send(loginCredentials); + + // Assert + expect(response.status).toBe(200); + expect(response.body.user).toEqual(mockUser); + expect(response.body.token).toBeTypeOf('string'); + // Check that the 'Set-Cookie' header was sent for the refresh token. + expect(response.headers['set-cookie']).toBeDefined(); + expect(response.headers['set-cookie'][0]).toContain('refreshToken='); + }); + + it('should reject login with incorrect credentials', async () => { + // Arrange: Simulate passport failing to find a user. + mockedAuthenticate.mockImplementation( + (strategy, options, callback) => (req, res, next) => { + // Call the callback with `false` for the user and an info message. + callback(null, false, { message: 'Incorrect email or password.' }); + } + ); + + // Act + const response = await supertest(app) + .post('/api/auth/login') + .send(loginCredentials); + + // Assert + expect(response.status).toBe(401); + expect(response.body.message).toBe('Incorrect email or password.'); + }); + + it('should reject login for a locked account', async () => { + // Arrange: Simulate passport finding a locked account. + mockedAuthenticate.mockImplementation( + (strategy, options, callback) => (req, res, next) => { + callback(null, false, { message: 'Account is temporarily locked.' }); + } + ); + + const response = await supertest(app).post('/api/auth/login').send(loginCredentials); + + expect(response.status).toBe(401); + expect(response.body.message).toBe('Account is temporarily locked.'); + }); + }); + + describe('POST /forgot-password', () => { + it('should send a reset link if the user exists', async () => { + // Arrange + const mockUser = { user_id: 'user-123', email: 'test@test.com' }; + mockedDb.findUserByEmail.mockResolvedValue(mockUser as any); + mockedDb.createPasswordResetToken.mockResolvedValue(); + + // Act + const response = await supertest(app) + .post('/api/auth/forgot-password') + .send({ email: 'test@test.com' }); + + // Assert + expect(response.status).toBe(200); + expect(response.body.message).toContain('a password reset link has been sent'); + // Since NODE_ENV is 'test', the token should be returned in the response. + expect(response.body.token).toBeTypeOf('string'); + expect(mockedDb.createPasswordResetToken).toHaveBeenCalledTimes(1); + }); + + it('should return a generic success message even if the user does not exist', async () => { + // Arrange + mockedDb.findUserByEmail.mockResolvedValue(undefined); + + // Act + const response = await supertest(app) + .post('/api/auth/forgot-password') + .send({ email: 'nouser@test.com' }); + + // Assert + expect(response.status).toBe(200); + expect(response.body.message).toContain('a password reset link has been sent'); + // Ensure no token was created or email sent for a non-existent user. + expect(mockedDb.createPasswordResetToken).not.toHaveBeenCalled(); + }); + }); + + describe('POST /reset-password', () => { + it('should reset the password with a valid token and strong password', async () => { + // Arrange + const tokenRecord = { user_id: 'user-123', token_hash: 'hashed-token' }; + mockedDb.getValidResetTokens.mockResolvedValue([tokenRecord] as any); + (bcrypt.compare as Mocked).mockResolvedValue(true); // Mock that the token matches the hash + mockedDb.updateUserPassword.mockResolvedValue(); + mockedDb.deleteResetToken.mockResolvedValue(); + + // Act + const response = await supertest(app) + .post('/api/auth/reset-password') + .send({ token: 'valid-token', newPassword: 'a-Very-Strong-Password-789!' }); + + // Assert + expect(response.status).toBe(200); + expect(response.body.message).toBe('Password has been reset successfully.'); + expect(mockedDb.updateUserPassword).toHaveBeenCalledWith('user-123', expect.any(String)); + expect(mockedDb.deleteResetToken).toHaveBeenCalledWith('hashed-token'); + }); + + it('should reject with an invalid or expired token', async () => { + // Arrange: Mock that the token does not match any valid hashes. + mockedDb.getValidResetTokens.mockResolvedValue([{ user_id: 'user-123', token_hash: 'hashed-token' }] as any); + (bcrypt.compare as Mocked).mockResolvedValue(false); + + // Act + const response = await supertest(app) + .post('/api/auth/reset-password') + .send({ token: 'invalid-token', newPassword: 'password123' }); + + // Assert + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid or expired password reset token.'); + }); + }); + + describe('POST /refresh-token', () => { + it('should issue a new access token with a valid refresh token cookie', async () => { + // Arrange + const mockUser = { user_id: 'user-123', email: 'test@test.com' }; + mockedDb.findUserByRefreshToken.mockResolvedValue(mockUser as any); + + // Act + const response = await supertest(app) + .post('/api/auth/refresh-token') + .set('Cookie', 'refreshToken=valid-refresh-token'); + + // Assert + expect(response.status).toBe(200); + expect(response.body.token).toBeTypeOf('string'); + expect(mockedDb.findUserByRefreshToken).toHaveBeenCalledWith('valid-refresh-token'); + }); + + it('should return 401 if no refresh token cookie is provided', async () => { + const response = await supertest(app).post('/api/auth/refresh-token'); + expect(response.status).toBe(401); + expect(response.body.message).toBe('Refresh token not found.'); + }); + }); }); \ No newline at end of file diff --git a/src/routes/public.routes.test.ts b/src/routes/public.routes.test.ts index 1f695b04..fc05db95 100644 --- a/src/routes/public.routes.test.ts +++ b/src/routes/public.routes.test.ts @@ -168,4 +168,87 @@ describe('Public Routes (/api)', () => { expect(mockedDb.getRecipesBySalePercentage).toHaveBeenCalledWith(75); }); }); + + describe('POST /flyer-items/batch-count', () => { + it('should return the count of items for multiple flyers', async () => { + // Arrange + mockedDb.countFlyerItemsForFlyers.mockResolvedValue(42); + + // Act + const response = await supertest(app) + .post('/api/flyer-items/batch-count') + .send({ flyerIds: [1, 2, 3] }); + + // Assert + expect(response.status).toBe(200); + expect(response.body).toEqual({ count: 42 }); + expect(mockedDb.countFlyerItemsForFlyers).toHaveBeenCalledWith([1, 2, 3]); + }); + + it('should return 400 if flyerIds is not an array', async () => { + // Act + const response = await supertest(app) + .post('/api/flyer-items/batch-count') + .send({ flyerIds: 'invalid-data' }); + + // Assert + expect(response.status).toBe(400); + expect(response.body.message).toBe('flyerIds must be an array.'); + }); + }); + + describe('GET /recipes/by-sale-ingredients', () => { + it('should return recipes with default minIngredients', async () => { + mockedDb.getRecipesByMinSaleIngredients.mockResolvedValue([]); + const response = await supertest(app).get('/api/recipes/by-sale-ingredients'); + expect(response.status).toBe(200); + // Check that the default value of 3 was used + expect(mockedDb.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(3); + }); + + it('should return 400 for an invalid minIngredients parameter', async () => { + const response = await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=abc'); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Query parameter "minIngredients" must be a positive integer.'); + }); + }); + + 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.mockResolvedValue(mockRecipes as any); + 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); + expect(mockedDb.findRecipesByIngredientAndTag).toHaveBeenCalledWith('chicken', 'quick'); + }); + + it('should return 400 if a query parameter is missing', async () => { + const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken'); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Both "ingredient" and "tag" query parameters are required.'); + }); + }); + + describe('GET /stats/most-frequent-sales', () => { + it('should return most frequent sale items with default parameters', async () => { + mockedDb.getMostFrequentSaleItems.mockResolvedValue([]); + const response = await supertest(app).get('/api/stats/most-frequent-sales'); + expect(response.status).toBe(200); + // Check that default values (days=30, limit=10) were used + expect(mockedDb.getMostFrequentSaleItems).toHaveBeenCalledWith(30, 10); + }); + + it('should use provided query parameters', async () => { + mockedDb.getMostFrequentSaleItems.mockResolvedValue([]); + await supertest(app).get('/api/stats/most-frequent-sales?days=90&limit=5'); + expect(mockedDb.getMostFrequentSaleItems).toHaveBeenCalledWith(90, 5); + }); + + it('should return 400 for an invalid "days" parameter', async () => { + const response = await supertest(app).get('/api/stats/most-frequent-sales?days=400'); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Query parameter "days" must be an integer between 1 and 365.'); + }); + }); }); diff --git a/src/routes/system.test.ts b/src/routes/system.test.ts index 85084262..3ffca14f 100644 --- a/src/routes/system.test.ts +++ b/src/routes/system.test.ts @@ -116,5 +116,24 @@ describe('System Routes (/api/system)', () => { expect(response.status).toBe(500); expect(response.body.message).toBe('Failed to query PM2 status.'); }); + + it('should return 500 if exec produces stderr without an error object', async () => { + // Arrange: Simulate a scenario where the command writes to stderr but doesn't + // produce a formal error object for the callback's first argument. + const stderrMessage = 'A non-fatal warning or configuration issue.'; + (mockedExec as Mock).mockImplementation(( + command: string, + callback: (error: ExecException | null, stdout: string, stderr: string) => void + ) => { + callback(null, '', stderrMessage); + return {}; + }); + + // Act + const response = await supertest(app).get('/api/system/pm2-status'); + + // Assert + expect(response.status).toBe(500); + }); }); }); \ No newline at end of file diff --git a/src/routes/user.test.ts b/src/routes/user.test.ts index e1624b34..e7232cf2 100644 --- a/src/routes/user.test.ts +++ b/src/routes/user.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import supertest from 'supertest'; import express, { Request, Response, NextFunction } from 'express'; +import * as bcrypt from 'bcrypt'; import userRouter from './user'; import * as db from '../services/db'; import { UserProfile } from '../types'; @@ -135,5 +136,212 @@ describe('User Routes (/api/users)', () => { expect(mockedDb.getWatchedItems).toHaveBeenCalledWith('user-123'); }); }); + + describe('POST /watched-items', () => { + 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).mockResolvedValue(mockAddedItem); + + // Act + const response = await supertest(app) + .post('/api/users/watched-items') + .send(newItem); + + // Assert + expect(response.status).toBe(201); + expect(response.body).toEqual(mockAddedItem); + expect(mockedDb.addWatchedItem).toHaveBeenCalledWith('user-123', newItem.itemName, newItem.category); + }); + }); + + describe('DELETE /watched-items/:masterItemId', () => { + it('should remove an item from the watchlist', async () => { + // Arrange + const masterItemId = 99; + (mockedDb.removeWatchedItem as Mocked).mockResolvedValue(undefined); + + // Act + const response = await supertest(app).delete(`/api/users/watched-items/${masterItemId}`); + + // Assert + expect(response.status).toBe(204); + expect(mockedDb.removeWatchedItem).toHaveBeenCalledWith('user-123', masterItemId); + }); + }); + + 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).mockResolvedValue(mockLists); + + // Act + const response = await supertest(app).get('/api/users/shopping-lists'); + + // Assert + expect(response.status).toBe(200); + expect(response.body).toEqual(mockLists); + expect(mockedDb.getShoppingLists).toHaveBeenCalledWith('user-123'); + }); + + it('POST /shopping-lists should create a new list', async () => { + const mockNewList = { shopping_list_id: 2, name: 'Party Supplies', items: [] }; + (mockedDb.createShoppingList as Mocked).mockResolvedValue(mockNewList); + + const response = await supertest(app) + .post('/api/users/shopping-lists') + .send({ name: 'Party Supplies' }); + + expect(response.status).toBe(201); + expect(response.body).toEqual(mockNewList); + expect(mockedDb.createShoppingList).toHaveBeenCalledWith('user-123', 'Party Supplies'); + }); + + it('DELETE /shopping-lists/:listId should delete a list', async () => { + (mockedDb.deleteShoppingList as Mocked).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'); + }); + }); + + describe('Shopping List Item Routes', () => { + it('POST /shopping-lists/:listId/items should add an item to a list', async () => { + // 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).mockResolvedValue(mockAddedItem); + + // Act + const response = await supertest(app) + .post(`/api/users/shopping-lists/${listId}/items`) + .send(itemData); + + // Assert + expect(response.status).toBe(201); + expect(response.body).toEqual(mockAddedItem); + expect(mockedDb.addShoppingListItem).toHaveBeenCalledWith(listId, itemData); + }); + + it('PUT /shopping-lists/items/:itemId should update an item', async () => { + // Arrange + const itemId = 101; + const updates = { is_purchased: true, quantity: 2 }; + const mockUpdatedItem = { shopping_list_item_id: itemId, ...updates }; + (mockedDb.updateShoppingListItem as Mocked).mockResolvedValue(mockUpdatedItem); + + // Act + const response = await supertest(app) + .put(`/api/users/shopping-lists/items/${itemId}`) + .send(updates); + + // Assert + expect(response.status).toBe(200); + expect(response.body).toEqual(mockUpdatedItem); + expect(mockedDb.updateShoppingListItem).toHaveBeenCalledWith(itemId, updates); + }); + + it('DELETE /shopping-lists/items/:itemId should delete an item', async () => { + (mockedDb.removeShoppingListItem as Mocked).mockResolvedValue(undefined); + const response = await supertest(app).delete('/api/users/shopping-lists/items/101'); + expect(response.status).toBe(204); + expect(mockedDb.removeShoppingListItem).toHaveBeenCalledWith(101); + }); + }); + + describe('PUT /profile', () => { + it('should update the user profile successfully', async () => { + // Arrange + const profileUpdates = { full_name: 'New Name' }; + const updatedProfile = { ...mockUserProfile, ...profileUpdates }; + (mockedDb.updateUserProfile as Mocked).mockResolvedValue(updatedProfile); + + // Act + const response = await supertest(app) + .put('/api/users/profile') + .send(profileUpdates); + + // Assert + expect(response.status).toBe(200); + expect(response.body).toEqual(updatedProfile); + expect(mockedDb.updateUserProfile).toHaveBeenCalledWith('user-123', expect.objectContaining(profileUpdates)); + }); + + it('should return 400 if no update fields are provided', async () => { + // Act + const response = await supertest(app) + .put('/api/users/profile') + .send({}); // Empty body + + // Assert + expect(response.status).toBe(400); + expect(response.body.message).toBe('At least one field to update must be provided.'); + expect(mockedDb.updateUserProfile).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /profile/password', () => { + it('should update the password successfully with a strong password', async () => { + // Arrange + (mockedDb.updateUserPassword as Mocked).mockResolvedValue(undefined); + + // Act + const response = await supertest(app) + .put('/api/users/profile/password') + .send({ newPassword: 'a-Very-Strong-Password-456!' }); + + // Assert + expect(response.status).toBe(200); + expect(response.body.message).toBe('Password updated successfully.'); + expect(mockedDb.updateUserPassword).toHaveBeenCalledWith('user-123', expect.any(String)); + }); + + it('should return 400 for a weak password', async () => { + // Act + const response = await supertest(app) + .put('/api/users/profile/password') + .send({ newPassword: 'weak' }); + + // Assert + expect(response.status).toBe(400); + expect(response.body.message).toContain('New password is too weak.'); + expect(mockedDb.updateUserPassword).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /account', () => { + 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).mockResolvedValue(userWithHash); + (mockedDb.deleteUserById as Mocked).mockResolvedValue(undefined); + + // Act + const response = await supertest(app) + .delete('/api/users/account') + .send({ password: 'correct-password' }); + + // Assert + expect(response.status).toBe(200); + expect(response.body.message).toBe('Account deleted successfully.'); + expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-123'); + }); + + 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).mockResolvedValue(userWithHash); + + const response = await supertest(app) + .delete('/api/users/account') + .send({ password: 'wrong-password' }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('Incorrect password.'); + expect(mockedDb.deleteUserById).not.toHaveBeenCalled(); + }); + }); }); }); \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 61ffb758..d4343afa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,7 +51,7 @@ 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', 'tree'], + reporter: ['text', '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',