// src/tests/integration/user.integration.test.ts import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import supertest from 'supertest'; import path from 'path'; import fs from 'node:fs/promises'; import { logger } from '../../services/logger.server'; import { getPool } from '../../services/db/connection.db'; import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types'; import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers'; import { cleanupDb } from '../utils/cleanup'; import { cleanupFiles } from '../utils/cleanupFiles'; /** * @vitest-environment node */ describe('User API Routes Integration Tests', () => { let request: ReturnType; let testUser: UserProfile; let authToken: string; const createdUserIds: string[] = []; const createdMasterItemIds: number[] = []; // Before any tests run, create a new user and log them in. // The token will be used for all subsequent API calls in this test suite. beforeAll(async () => { vi.stubEnv('FRONTEND_URL', 'https://example.com'); const app = (await import('../../../server')).default; request = supertest(app); const email = `user-test-${Date.now()}@example.com`; const { user, token } = await createAndLoginUser({ email, fullName: 'Test User', request }); testUser = user; authToken = token; createdUserIds.push(user.user.user_id); }); // After all tests, clean up by deleting the created user. // This now cleans up ALL users created by this test suite to prevent pollution. afterAll(async () => { vi.unstubAllEnvs(); await cleanupDb({ userIds: createdUserIds, masterItemIds: createdMasterItemIds }); // Safeguard to clean up any avatar files created during tests. const uploadDir = path.resolve(__dirname, '../../../uploads/avatars'); try { const allFiles = await fs.readdir(uploadDir); // Filter for any file that contains any of the user IDs created in this test suite. const testFiles = allFiles .filter((f) => createdUserIds.some((userId) => userId && f.includes(userId))) .map((f) => path.join(uploadDir, f)); if (testFiles.length > 0) { await cleanupFiles(testFiles); } } catch (error) { // Ignore if the directory doesn't exist, but log other errors. if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Error during user integration test avatar file cleanup:', error); } } }); it('should fetch the authenticated user profile via GET /api/users/profile', async () => { // Act: Call the API endpoint using the authenticated token. const response = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); const profile = response.body; // Assert: Verify the profile data matches the created user. expect(response.status).toBe(200); expect(profile.user.user_id).toBe(testUser.user.user_id); expect(profile.user.email).toBe(testUser.user.email); // This was already correct expect(profile.full_name).toBe('Test User'); expect(profile.role).toBe('user'); }); it('should update the user profile via PUT /api/users/profile', async () => { // Arrange: Define the profile updates. const profileUpdates = { full_name: 'Updated Test User', }; // Act: Call the update endpoint with the new data and the auth token. const response = await request .put('/api/users/profile') .set('Authorization', `Bearer ${authToken}`) .send(profileUpdates); const updatedProfile = response.body; // Assert: Check that the returned profile reflects the changes. expect(response.status).toBe(200); expect(updatedProfile.full_name).toBe('Updated Test User'); // Also, fetch the profile again to ensure the change was persisted. const refetchResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); const refetchedProfile = refetchResponse.body; expect(refetchedProfile.full_name).toBe('Updated Test User'); }); it('should allow updating the profile with an empty string for avatar_url', async () => { // Arrange: Define the profile updates. const profileUpdates = { full_name: 'Empty Avatar User', avatar_url: '', }; // Act: Call the update endpoint with the new data and the auth token. const response = await request .put('/api/users/profile') .set('Authorization', `Bearer ${authToken}`) .send(profileUpdates); const updatedProfile = response.body; // Assert: Check that the returned profile reflects the changes. expect(response.status).toBe(200); expect(updatedProfile.full_name).toBe('Empty Avatar User'); expect(updatedProfile.avatar_url).toBeNull(); // Also, fetch the profile again to ensure the change was persisted in the database as NULL. const refetchResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); expect(refetchResponse.body.avatar_url).toBeNull(); }); it('should update user preferences via PUT /api/users/profile/preferences', async () => { // Arrange: Define the preference updates. const preferenceUpdates = { darkMode: true, }; // Act: Call the update endpoint. const response = await request .put('/api/users/profile/preferences') .set('Authorization', `Bearer ${authToken}`) .send(preferenceUpdates); const updatedProfile = response.body; // Assert: Check that the preferences object in the returned profile is updated. expect(response.status).toBe(200); expect(updatedProfile.preferences?.darkMode).toBe(true); }); it('should reject registration with a weak password', async () => { // Arrange: Define a new user with a known weak password. const email = `weak-password-user-${Date.now()}@example.com`; const weakPassword = 'password'; // Act & Assert: Attempt to register and expect the promise to reject // with an error message indicating the password is too weak. const response = await request.post('/api/auth/register').send({ email, password: weakPassword, full_name: 'Weak Password User', }); expect(response.status).toBe(400); const errorData = response.body as { message: string; errors: { message: string }[] }; // For validation errors, the detailed messages are in the `errors` array. // We join them to check for the specific feedback from the password strength checker. const detailedErrorMessage = errorData.errors?.map((e) => e.message).join(' '); expect(detailedErrorMessage).toMatch(/Password is too weak/); }); it('should allow a user to delete their own account and then fail to log in', async () => { // Arrange: Create a new, separate user just for this deletion test. const deletionEmail = `delete-me-${Date.now()}@example.com`; const { user: deletionUser, token: deletionToken } = await createAndLoginUser({ email: deletionEmail, request }); createdUserIds.push(deletionUser.user.user_id); // Act: Call the delete endpoint with the correct password and token. const response = await request .delete('/api/users/account') .set('Authorization', `Bearer ${deletionToken}`) .send({ password: TEST_PASSWORD }); const deleteResponse = response.body; // Assert: Check for a successful deletion message. expect(response.status).toBe(200); expect(deleteResponse.message).toBe('Account deleted successfully.'); // Assert (Verification): Attempting to log in again with the same credentials should now fail. const loginResponse = await request .post('/api/auth/login') .send({ email: deletionEmail, password: TEST_PASSWORD }); expect(loginResponse.status).toBe(401); const errorData = loginResponse.body; expect(errorData.message).toBe('Incorrect email or password.'); }); it('should allow a user to reset their password and log in with the new one', async () => { // Arrange: Create a new user for the password reset flow. const resetEmail = `reset-me-${Date.now()}@example.com`; const { user: resetUser } = await createAndLoginUser({ email: resetEmail, request }); createdUserIds.push(resetUser.user.user_id); // Act 1: Request a password reset. In our test environment, the token is returned in the response. const resetRequestRawResponse = await request .post('/api/auth/forgot-password') .send({ email: resetEmail }); if (resetRequestRawResponse.status !== 200) { const errorData = resetRequestRawResponse.body; throw new Error(errorData.message || 'Password reset request failed'); } const resetRequestResponse = resetRequestRawResponse.body; const resetToken = resetRequestResponse.token; // Assert 1: Check that we received a token. expect(resetToken).toBeDefined(); expect(resetToken).toBeTypeOf('string'); // Act 2: Use the token to set a new password. const newPassword = 'my-new-secure-password-!@#$'; const resetRawResponse = await request .post('/api/auth/reset-password') .send({ token: resetToken!, newPassword }); if (resetRawResponse.status !== 200) { const errorData = resetRawResponse.body; throw new Error(errorData.message || 'Password reset failed'); } const resetResponse = resetRawResponse.body; // Assert 2: Check for a successful password reset message. expect(resetResponse.message).toBe('Password has been reset successfully.'); // Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change. const loginResponse = await request .post('/api/auth/login') .send({ email: resetEmail, password: newPassword }); const loginData = loginResponse.body; expect(loginData.userprofile).toBeDefined(); expect(loginData.userprofile.user.user_id).toBe(resetUser.user.user_id); }); describe('User Data Routes (Watched Items & Shopping Lists)', () => { it('should allow a user to add and remove a watched item', async () => { // Act 1: Add a new watched item. The API returns the created master item. const addResponse = await request .post('/api/users/watched-items') .set('Authorization', `Bearer ${authToken}`) .send({ itemName: 'Integration Test Item', category: 'Other/Miscellaneous' }); const newItem = addResponse.body; if (newItem?.master_grocery_item_id) createdMasterItemIds.push(newItem.master_grocery_item_id); // Assert 1: Check that the item was created correctly. expect(addResponse.status).toBe(201); expect(newItem.name).toBe('Integration Test Item'); // Act 2: Fetch all watched items for the user. const watchedItemsResponse = await request .get('/api/users/watched-items') .set('Authorization', `Bearer ${authToken}`); const watchedItems = watchedItemsResponse.body; // Assert 2: Verify the new item is in the user's watched list. expect( watchedItems.some( (item: MasterGroceryItem) => item.master_grocery_item_id === newItem.master_grocery_item_id, ), ).toBe(true); // Act 3: Remove the watched item. const removeResponse = await request .delete(`/api/users/watched-items/${newItem.master_grocery_item_id}`) .set('Authorization', `Bearer ${authToken}`); expect(removeResponse.status).toBe(204); // Assert 3: Fetch again and verify the item is gone. const finalWatchedItemsResponse = await request .get('/api/users/watched-items') .set('Authorization', `Bearer ${authToken}`); const finalWatchedItems = finalWatchedItemsResponse.body; expect( finalWatchedItems.some( (item: MasterGroceryItem) => item.master_grocery_item_id === newItem.master_grocery_item_id, ), ).toBe(false); }); it('should allow a user to manage a shopping list', async () => { // Act 1: Create a new shopping list. const createListResponse = await request .post('/api/users/shopping-lists') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'My Integration Test List' }); const newList = createListResponse.body; // Assert 1: Check that the list was created. expect(createListResponse.status).toBe(201); expect(newList.name).toBe('My Integration Test List'); // Act 2: Add an item to the new list. const addItemResponse = await request .post(`/api/users/shopping-lists/${newList.shopping_list_id}/items`) .set('Authorization', `Bearer ${authToken}`) .send({ customItemName: 'Custom Test Item' }); const addedItem = addItemResponse.body; // Assert 2: Check that the item was added. expect(addItemResponse.status).toBe(201); expect(addedItem.custom_item_name).toBe('Custom Test Item'); // Assert 3: Fetch all lists and verify the new item is present in the correct list. const fetchResponse = await request .get('/api/users/shopping-lists') .set('Authorization', `Bearer ${authToken}`); const lists = fetchResponse.body; expect(fetchResponse.status).toBe(200); const updatedList = lists.find( (l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id, ); // The `add` endpoint returns the new ID as a string, while the `fetch` endpoint returns it as a number. // To make the test robust, we convert both to strings before comparing. expect(String(updatedList?.items[0].shopping_list_item_id)).toEqual( String(addedItem.shopping_list_item_id), ); }); }); it('should allow a user to upload an avatar image and update their profile', async () => { // Arrange: Path to a dummy image file const dummyImagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); // Act: Make the POST request to upload the avatar const response = await request .post('/api/users/profile/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', dummyImagePath); // Assert: Check the response expect(response.status).toBe(200); const updatedProfile = response.body; expect(updatedProfile.avatar_url).toBeDefined(); expect(updatedProfile.avatar_url).not.toBeNull(); expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar'); // Assert (Verification): Fetch the profile again to ensure the change was persisted const verifyResponse = await request .get('/api/users/profile') .set('Authorization', `Bearer ${authToken}`); const refetchedProfile = verifyResponse.body; expect(refetchedProfile.avatar_url).toBe(updatedProfile.avatar_url); }); it('should reject avatar upload for an invalid file type', async () => { // Arrange: Create a buffer representing a text file. const invalidFileBuffer = Buffer.from('This is not an image file.'); const invalidFileName = 'test.txt'; // Act: Attempt to upload the text file to the avatar endpoint. const response = await request .post('/api/users/profile/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', invalidFileBuffer, invalidFileName); // Assert: Check for a 400 Bad Request response. // This error comes from the multer fileFilter configuration in the route. expect(response.status).toBe(400); expect(response.body.message).toBe('Only image files are allowed!'); }); it('should reject avatar upload for a file that is too large', async () => { // Arrange: Create a buffer larger than the configured limit (e.g., > 1MB). // The limit is set in the multer middleware in `user.routes.ts`. // We'll create a 2MB buffer to be safe. const largeFileBuffer = Buffer.alloc(2 * 1024 * 1024, 'a'); const largeFileName = 'large-avatar.jpg'; // Act: Attempt to upload the large file. const response = await request .post('/api/users/profile/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', largeFileBuffer, largeFileName); // Assert: Check for a 400 Bad Request response from the multer error handler. expect(response.status).toBe(400); expect(response.body.message).toBe('File upload error: File too large'); }); });