From f43db7f82eab6de8497eccbe0c60e15c2c3b4d35 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Sat, 22 Nov 2025 13:36:04 -0800 Subject: [PATCH] frontend work ! --- .gitea/workflows/deploy.yml | 3 + src/routes/user.integration.test.ts | 87 +++++++++++++++++++++++++++++ src/services/apiClient.ts | 38 ++++++------- 3 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 src/routes/user.integration.test.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fe4351b6..cb2b4de4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -119,6 +119,9 @@ jobs: GITHUB_CLIENT_SECRET: "dummy_github_secret" FRONTEND_URL: "http://localhost:3000" + # ✅ NEW: Provide the full backend URL for the Node.js test environment + VITE_API_BASE_URL: "http://localhost:3001/api" + # ✅ NEW: Backend requires this to start GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} diff --git a/src/routes/user.integration.test.ts b/src/routes/user.integration.test.ts new file mode 100644 index 00000000..dae6237f --- /dev/null +++ b/src/routes/user.integration.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as apiClient from '../services/apiClient'; +import type { User, Profile } from '../types'; + +/** + * @vitest-environment node + */ + +/** + * A helper function to create a new user and log them in, returning the user object and auth token. + * This provides an authenticated context for testing protected API endpoints. + */ +const createAndLoginUser = async (email: string, password = 'password123') => { + // Register the new user. + await apiClient.registerUser(email, password, 'Test User'); + + // Log in to get the auth token. + const { user, token } = await apiClient.loginUser(email, password, false); + return { user, token }; +}; + +describe('User API Routes Integration Tests', () => { + let testUser: User; + let authToken: string; + + // 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 () => { + const email = `user-test-${Date.now()}@example.com`; + const { user, token } = await createAndLoginUser(email); + testUser = user; + authToken = token; + }); + + // After all tests, clean up by deleting the created user. + afterAll(async () => { + if (testUser) { + // This requires an authenticated call to delete the account. + await apiClient.deleteUserAccount('password123', authToken); + } + }); + + it('should fetch the authenticated user profile via GET /api/users/profile', async () => { + // Act: Call the API endpoint using the authenticated token. + const profile = await apiClient.getAuthenticatedUserProfile(authToken); + + // Assert: Verify the profile data matches the created user. + expect(profile).toBeDefined(); + expect(profile.id).toBe(testUser.id); + expect(profile.user.email).toBe(testUser.email); + 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 updatedProfile = await apiClient.updateUserProfile(profileUpdates, authToken); + + // Assert: Check that the returned profile reflects the changes. + expect(updatedProfile).toBeDefined(); + expect(updatedProfile.full_name).toBe('Updated Test User'); + + // Also, fetch the profile again to ensure the change was persisted. + const refetchedProfile = await apiClient.getAuthenticatedUserProfile(authToken); + expect(refetchedProfile.full_name).toBe('Updated Test User'); + }); + + 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 updatedProfile = await apiClient.updateUserPreferences(preferenceUpdates, authToken); + + // Assert: Check that the preferences object in the returned profile is updated. + expect(updatedProfile).toBeDefined(); + expect(updatedProfile.preferences).toBeDefined(); + expect(updatedProfile.preferences?.darkMode).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 9e7f8fe7..61ae754c 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -57,10 +57,12 @@ const refreshToken = async (): Promise => { * @param options The fetch options. * @returns A promise that resolves to the fetch Response. */ -export const apiFetch = async (url: string, options: RequestInit = {}): Promise => { +export const apiFetch = async (url: string, options: RequestInit = {}, tokenOverride?: string): Promise => { // Create a new headers object to avoid mutating the original options. const headers = new Headers(options.headers || {}); - const token = localStorage.getItem('authToken'); + // Use the token override if provided (for testing), otherwise get it from localStorage. + // The `typeof window` check prevents errors in the Node.js test environment. + const token = tokenOverride ?? (typeof window !== 'undefined' ? localStorage.getItem('authToken') : null); if (token) { headers.set('Authorization', `Bearer ${token}`); } @@ -450,16 +452,12 @@ export const removeShoppingListItem = async (itemId: number): Promise => { * @returns A promise that resolves to the user's combined UserProfile object. * @throws An error if the request fails or if the user is not authenticated. */ -export const getAuthenticatedUserProfile = async (): Promise => { - const token = localStorage.getItem('authToken'); - - if (!token) { - throw new Error('Authentication token not found.'); - } - +export const getAuthenticatedUserProfile = async (tokenOverride?: string): Promise => { + // The token is now passed to apiFetch, which handles the Authorization header. + // If no token is provided (in browser context), apiFetch will get it from localStorage. const response = await apiFetch(`${API_BASE_URL}/users/profile`, { method: 'GET', - }); + }, tokenOverride); if (!response.ok) { if (response.status === 401) throw new Error('Session expired. Please log in again.'); @@ -1087,12 +1085,12 @@ export async function resetPassword(token: string, newPassword: string): Promise * @param preferences A partial object of the user's preferences to update. * @returns A promise that resolves to the user's full, updated profile object. */ -export async function updateUserPreferences(preferences: Partial): Promise { +export async function updateUserPreferences(preferences: Partial, tokenOverride?: string): Promise { const response = await apiFetch(`${API_BASE_URL}/users/profile/preferences`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preferences), - }); + }, tokenOverride); const data = await response.json(); @@ -1107,12 +1105,12 @@ export async function updateUserPreferences(preferences: Partial { +export async function updateUserProfile(profileData: { full_name?: string; avatar_url?: string }, tokenOverride?: string): Promise { const response = await apiFetch(`${API_BASE_URL}/users/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileData), - }); + }, tokenOverride); const data = await response.json(); @@ -1126,10 +1124,10 @@ export async function updateUserProfile(profileData: { full_name?: string; avata * Fetches a complete export of the user's data from the backend. * @returns A promise that resolves to a JSON object of the user's data. */ -export async function exportUserData(): Promise { +export async function exportUserData(tokenOverride?: string): Promise { const response = await apiFetch(`${API_BASE_URL}/users/data-export`, { method: 'GET', - }); + }, tokenOverride); if (!response.ok) { const errorData = await response.json().catch(() => ({ message: 'Failed to export user data.' })); @@ -1174,12 +1172,12 @@ export const setUserAppliances = async (applianceIds: number[]): Promise = * @param newPassword The user's new password. * @returns A promise that resolves on success. */ -export async function updateUserPassword(newPassword: string): Promise<{ message: string }> { +export async function updateUserPassword(newPassword: string, tokenOverride?: string): Promise<{ message: string }> { const response = await apiFetch(`${API_BASE_URL}/users/profile/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newPassword }), - }); + }, tokenOverride); const data = await response.json(); if (!response.ok) { @@ -1193,12 +1191,12 @@ export async function updateUserPassword(newPassword: string): Promise<{ message * @param password The user's current password for verification. * @returns A promise that resolves on success. */ -export async function deleteUserAccount(password: string): Promise<{ message: string }> { +export async function deleteUserAccount(password: string, tokenOverride?: string): Promise<{ message: string }> { const response = await apiFetch(`${API_BASE_URL}/users/account`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), - }); + }, tokenOverride); const data = await response.json(); if (!response.ok) {