diff --git a/.gitea/workflows/deploy-to-prod.yml b/.gitea/workflows/deploy-to-prod.yml index fa75dc7..adeae76 100644 --- a/.gitea/workflows/deploy-to-prod.yml +++ b/.gitea/workflows/deploy-to-prod.yml @@ -12,6 +12,11 @@ on: description: 'Type "deploy-to-prod" to confirm you want to deploy the main branch.' required: true default: 'do-not-run' + force_reload: + description: 'Force PM2 reload even if version matches (true/false).' + required: false + type: boolean + default: false jobs: deploy-production: @@ -97,18 +102,18 @@ jobs: DB_USER: ${{ secrets.DB_USER }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_NAME: ${{ secrets.DB_DATABASE_PROD }} - REDIS_URL: "redis://localhost:6379" + REDIS_URL: 'redis://localhost:6379' REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_PROD }} - FRONTEND_URL: "https://flyer-crawler.projectium.com" + FRONTEND_URL: 'https://flyer-crawler.projectium.com' JWT_SECRET: ${{ secrets.JWT_SECRET }} GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} - SMTP_HOST: "localhost" - SMTP_PORT: "1025" - SMTP_SECURE: "false" - SMTP_USER: "" - SMTP_PASS: "" - SMTP_FROM_EMAIL: "noreply@flyer-crawler.projectium.com" + SMTP_HOST: 'localhost' + SMTP_PORT: '1025' + SMTP_SECURE: 'false' + SMTP_USER: '' + SMTP_PASS: '' + SMTP_FROM_EMAIL: 'noreply@flyer-crawler.projectium.com' run: | if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_NAME" ]; then echo "ERROR: One or more production database secrets (DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE_PROD) are not set." @@ -117,15 +122,35 @@ jobs: echo "Installing production dependencies and restarting server..." cd /var/www/flyer-crawler.projectium.com npm install --omit=dev - pm2 startOrReload ecosystem.config.cjs --env production && pm2 save - echo "Production backend server reloaded successfully." - + + # --- Version Check Logic --- + # Get the version from the newly deployed package.json + NEW_VERSION=$(node -p "require('./package.json').version") + echo "Deployed Package Version: $NEW_VERSION" + + # Get the running version from PM2 for the main API process + # We use a small node script to parse the JSON output from pm2 jlist + RUNNING_VERSION=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.version : ''); } catch(e) { console.log(''); }") + echo "Running PM2 Version: $RUNNING_VERSION" + + if [ "${{ gitea.event.inputs.force_reload }}" == "true" ] || [ "$NEW_VERSION" != "$RUNNING_VERSION" ] || [ -z "$RUNNING_VERSION" ]; then + if [ "${{ gitea.event.inputs.force_reload }}" == "true" ]; then + echo "Force reload triggered by manual input. Reloading PM2..." + else + echo "Version mismatch (Running: $RUNNING_VERSION -> Deployed: $NEW_VERSION) or app not running. Reloading PM2..." + fi + pm2 startOrReload ecosystem.config.cjs --env production && pm2 save + echo "Production backend server reloaded successfully." + else + echo "Version $NEW_VERSION is already running. Skipping PM2 reload." + fi + echo "Updating schema hash in production database..." CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }') PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \ "INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW()) ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();" - + UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A) if [ "$CURRENT_HASH" = "$UPDATED_HASH" ]; then echo "✅ Schema hash successfully updated in the database to: $UPDATED_HASH" @@ -139,4 +164,4 @@ jobs: sleep 5 pm2 describe flyer-crawler-api || echo "Could not find production pm2 process." pm2 logs flyer-crawler-api --lines 20 --nostream || echo "Could not find production pm2 process." - pm2 env flyer-crawler-api || echo "Could not find production pm2 process." \ No newline at end of file + pm2 env flyer-crawler-api || echo "Could not find production pm2 process." diff --git a/.gitea/workflows/deploy-to-test.yml b/.gitea/workflows/deploy-to-test.yml index b2f0bb2..fdc7b42 100644 --- a/.gitea/workflows/deploy-to-test.yml +++ b/.gitea/workflows/deploy-to-test.yml @@ -43,7 +43,20 @@ jobs: - name: Install Dependencies run: npm ci # 'ci' is faster and safer for CI/CD than 'install'. -# ----------------------------------------------------------------------- + - name: Bump Version and Push + run: | + # Configure git for the commit. + git config --global user.name 'Gitea Actions' + git config --global user.email 'actions@gitea.projectium.com' + + # Bump the patch version number. This creates a new commit and a new tag. + # The commit message includes [skip ci] to prevent this push from triggering another workflow run. + npm version patch -m "ci: Bump version to %s [skip ci]" + + # Push the new commit and the new tag back to the main branch. + git push --follow-tags + + # ----------------------------------------------------------------------- # --- NEW DEBUGGING STEPS --- - name: Verify Project Structure @@ -85,23 +98,23 @@ jobs: DB_HOST: ${{ secrets.DB_HOST }} DB_USER: ${{ secrets.DB_USER }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} - DB_NAME: "flyer-crawler-test" # Explicitly set for tests + DB_NAME: 'flyer-crawler-test' # Explicitly set for tests # --- Redis credentials for the test suite --- - REDIS_URL: "redis://localhost:6379" + REDIS_URL: 'redis://localhost:6379' REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }} # --- Integration test specific variables --- - FRONTEND_URL: "http://localhost:3000" - VITE_API_BASE_URL: "http://localhost:3001/api" + FRONTEND_URL: 'http://localhost:3000' + VITE_API_BASE_URL: 'http://localhost:3001/api' GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} - + # --- JWT Secret for Passport authentication in tests --- JWT_SECRET: ${{ secrets.JWT_SECRET }} # --- Increase Node.js memory limit to prevent heap out of memory errors --- # This is crucial for memory-intensive tasks like running tests and coverage. - NODE_OPTIONS: "--max-old-space-size=8192" + NODE_OPTIONS: '--max-old-space-size=8192' run: | # Fail-fast check to ensure secrets are configured in Gitea for testing. @@ -109,15 +122,15 @@ jobs: echo "ERROR: One or more test secrets (DB_*, GEMINI_API_KEY, REDIS_PASSWORD_TEST) are not set in Gitea repository secrets." exit 1 fi - + # Temporarily disable secret masking to prevent the runner from garbling test output numbers. echo "::stop-commands secret-masking::" - + # Run unit and integration tests as separate steps. # The `|| true` ensures the workflow continues even if tests fail, allowing coverage to run. echo "--- Running Unit Tests ---" npm run test:unit -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true - + echo "--- Running Integration Tests ---" npm run test:integration -- --coverage --reporter=verbose --includeTaskLocation --testTimeout=10000 --silent=passed-only || true @@ -155,13 +168,13 @@ jobs: # nyc's `report` command can merge multiple coverage files automatically. # The standard way to do this is to place all `coverage-final.json` files # into a single directory and point `nyc report` to it. - + # Step 1: Define a directory for nyc to use as its source for merging. # We use a path relative to the workspace to avoid issues with the runner's CWD. NYC_SOURCE_DIR=".coverage/nyc-source-for-report" mkdir -p "$NYC_SOURCE_DIR" || echo "Directory $NYC_SOURCE_DIR already exists." echo "Created temporary directory for nyc reporting source: $NYC_SOURCE_DIR" - + # Step 2: Copy the individual coverage reports into the source directory. # We give them unique names to be safe, though it's not strictly necessary. cp .coverage/unit/coverage-final.json "$NYC_SOURCE_DIR/unit-coverage.json" @@ -170,7 +183,7 @@ jobs: cp .coverage/integration-server/coverage-final.json "$NYC_SOURCE_DIR/integration-server-coverage.json" || echo "Server coverage file not found, skipping." echo "Copied coverage files to source directory. Contents:" ls -l "$NYC_SOURCE_DIR" - + # Step 3: Generate the reports directly from the source directory. # We explicitly tell nyc where to find the source coverage files (`--temp-dir`) # and where to output the final reports (`--report-dir`). @@ -281,7 +294,7 @@ jobs: run: | echo "Deploying application files to /var/www/flyer-crawler-test.projectium.com..." APP_PATH="/var/www/flyer-crawler-test.projectium.com" - + # Ensure the destination directory exists mkdir -p "$APP_PATH" mkdir -p "$APP_PATH/flyer-images/icons" "$APP_PATH/flyer-images/archive" # Ensure all required subdirectories exist @@ -308,23 +321,22 @@ jobs: DB_NAME: ${{ secrets.DB_DATABASE_TEST }} # Redis Credentials - REDIS_URL: "redis://localhost:6379" + REDIS_URL: 'redis://localhost:6379' REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }} # Application Secrets - FRONTEND_URL: "https://flyer-crawler-test.projectium.com" + FRONTEND_URL: 'https://flyer-crawler-test.projectium.com' JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }} GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} # SMTP (email) - SMTP_HOST: "localhost" - SMTP_PORT: "1025" - SMTP_SECURE: "false" - SMTP_USER: "" # Using MailHog, no auth needed - SMTP_PASS: "" # Using MailHog, no auth needed - SMTP_FROM_EMAIL: "noreply@flyer-crawler-test.projectium.com" - + SMTP_HOST: 'localhost' + SMTP_PORT: '1025' + SMTP_SECURE: 'false' + SMTP_USER: '' # Using MailHog, no auth needed + SMTP_PASS: '' # Using MailHog, no auth needed + SMTP_FROM_EMAIL: 'noreply@flyer-crawler-test.projectium.com' run: | # Fail-fast check to ensure secrets are configured in Gitea. @@ -341,7 +353,7 @@ jobs: # We also add `&& pm2 save` to persist the process list across server reboots. pm2 startOrReload ecosystem.config.cjs --env test && pm2 save echo "Test backend server reloaded successfully." - + # After a successful deployment, update the schema hash in the database. # This ensures the next deployment will compare against this new state. echo "Updating schema hash in test database..." @@ -349,7 +361,7 @@ jobs: PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \ "INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW()) ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();" - + # Verify the hash was updated UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'test';" -t -A) if [ "$CURRENT_HASH" = "$UPDATED_HASH" ]; then @@ -357,7 +369,7 @@ jobs: else echo "ERROR: Failed to update schema hash in the database." fi - + echo "--- Cleaning up test-generated flyer assets from production directories ---" PROD_APP_PATH="/var/www/flyer-crawler.projectium.com" find "$PROD_APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete || echo "No test flyer images to delete in prod." @@ -372,4 +384,4 @@ jobs: sleep 5 # Wait a few seconds for the app to start and log its output. pm2 describe flyer-crawler-api-test || echo "Could not find test pm2 process." pm2 logs flyer-crawler-api-test --lines 20 --nostream || echo "Could not find test pm2 process." - pm2 env flyer-crawler-api-test || echo "Could not find test pm2 process." \ No newline at end of file + pm2 env flyer-crawler-api-test || echo "Could not find test pm2 process." diff --git a/server.ts b/server.ts index 5531c5a..e32e4f3 100644 --- a/server.ts +++ b/server.ts @@ -1,9 +1,9 @@ // server.ts import express, { Request, Response, NextFunction } from 'express'; import { randomUUID } from 'crypto'; -import timeout from 'connect-timeout'; +import timeout from 'connect-timeout'; import cookieParser from 'cookie-parser'; -import listEndpoints from 'express-list-endpoints'; +import listEndpoints from 'express-list-endpoints'; import { getPool } from './src/services/db/connection.db'; import passport from './src/routes/passport.routes'; @@ -23,10 +23,15 @@ import statsRouter from './src/routes/stats.routes'; import gamificationRouter from './src/routes/gamification.routes'; import systemRouter from './src/routes/system.routes'; import healthRouter from './src/routes/health.routes'; -import { errorHandler } from './src/middleware/errorHandler'; +import { errorHandler } from './src/middleware/errorHandler'; import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService'; import type { UserProfile } from './src/types'; -import { analyticsQueue, weeklyAnalyticsQueue, gracefulShutdown, tokenCleanupQueue } from './src/services/queueService.server'; +import { + analyticsQueue, + weeklyAnalyticsQueue, + gracefulShutdown, + tokenCleanupQueue, +} from './src/services/queueService.server'; // --- START DEBUG LOGGING --- // Log the database connection details as seen by the SERVER PROCESS. @@ -41,12 +46,15 @@ logger.info(` Database: ${process.env.DB_NAME}`); // Query the users table to see what the server process sees on startup. // Corrected the query to be unambiguous by specifying the table alias for each column. // `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p). -getPool().query('SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id') - .then(res => { +getPool() + .query( + 'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id', + ) + .then((res) => { logger.debug('[SERVER PROCESS] Users found in DB on startup:'); console.table(res.rows); }) - .catch(err => { + .catch((err) => { logger.error({ err }, '[SERVER PROCESS] Could not query users table on startup.'); }); @@ -74,10 +82,10 @@ app.use(timeout('5m')); // --- Logging Middleware --- const getDurationInMilliseconds = (start: [number, number]): number => { - const NS_PER_SEC = 1e9; - const NS_TO_MS = 1e6; - const diff = process.hrtime(start); - return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS; + const NS_PER_SEC = 1e9; + const NS_TO_MS = 1e6; + const diff = process.hrtime(start); + return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS; }; /** @@ -96,55 +104,55 @@ interface RequestLogDetails { } const requestLogger = (req: Request, res: Response, next: NextFunction) => { - const requestId = randomUUID(); - const user = req.user as UserProfile | undefined; - const start = process.hrtime(); - const { method, originalUrl } = req; + const requestId = randomUUID(); + const user = req.user as UserProfile | undefined; + const start = process.hrtime(); + const { method, originalUrl } = req; - // Create a request-scoped logger instance as per ADR-004 - // This attaches contextual info to every log message generated for this request. - req.log = logger.child({ - request_id: requestId, - user_id: user?.user_id, // This will be undefined until the auth middleware runs, but the logger will hold the reference. - ip_address: req.ip, - }); + // Create a request-scoped logger instance as per ADR-004 + // This attaches contextual info to every log message generated for this request. + req.log = logger.child({ + request_id: requestId, + user_id: user?.user.user_id, // This will be undefined until the auth middleware runs, but the logger will hold the reference. + ip_address: req.ip, + }); - req.log.debug({ method, originalUrl }, `[Request Logger] INCOMING`); + req.log.debug({ method, originalUrl }, `[Request Logger] INCOMING`); - res.on('finish', () => { - const durationInMilliseconds = getDurationInMilliseconds(start); - const { statusCode, statusMessage } = res; - const finalUser = req.user as UserProfile | undefined; + res.on('finish', () => { + const durationInMilliseconds = getDurationInMilliseconds(start); + const { statusCode, statusMessage } = res; + const finalUser = req.user as UserProfile | undefined; - // The base log object includes details relevant for all status codes. - const logDetails: RequestLogDetails = { - user_id: finalUser?.user_id, - method, - originalUrl, - statusCode, - statusMessage, - duration: durationInMilliseconds.toFixed(2), - }; + // The base log object includes details relevant for all status codes. + const logDetails: RequestLogDetails = { + user_id: finalUser?.user.user_id, + method, + originalUrl, + statusCode, + statusMessage, + duration: durationInMilliseconds.toFixed(2), + }; - // For failed requests, add the full request details for better debugging. - // Pino's `redact` config will automatically sanitize sensitive headers and body fields. - if (statusCode >= 400) { - logDetails.req = { headers: req.headers, body: req.body }; - } - if (statusCode >= 500) req.log.error(logDetails, 'Request completed with server error'); - else if (statusCode >= 400) req.log.warn(logDetails, 'Request completed with client error'); - else req.log.info(logDetails, 'Request completed successfully'); - }); + // For failed requests, add the full request details for better debugging. + // Pino's `redact` config will automatically sanitize sensitive headers and body fields. + if (statusCode >= 400) { + logDetails.req = { headers: req.headers, body: req.body }; + } + if (statusCode >= 500) req.log.error(logDetails, 'Request completed with server error'); + else if (statusCode >= 400) req.log.warn(logDetails, 'Request completed with client error'); + else req.log.info(logDetails, 'Request completed successfully'); + }); - next(); + next(); }; app.use(requestLogger); // Use the logging middleware for all requests // --- Security Warning --- if (!process.env.JWT_SECRET) { - logger.error('CRITICAL: JWT_SECRET is not set. The application cannot start securely.'); - process.exit(1); + logger.error('CRITICAL: JWT_SECRET is not set. The application cannot start securely.'); + process.exit(1); } // --- API Routes --- @@ -197,13 +205,18 @@ if (process.env.NODE_ENV !== 'test') { }); // Start the scheduled background jobs - startBackgroundJobs(backgroundJobService, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue, logger); + startBackgroundJobs( + backgroundJobService, + analyticsQueue, + weeklyAnalyticsQueue, + tokenCleanupQueue, + logger, + ); // --- Graceful Shutdown Handling --- process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); } - // Export the app for integration testing -export default app; \ No newline at end of file +export default app; diff --git a/src/App.tsx b/src/App.tsx index 3c90dc6..2055190 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,11 @@ // src/App.tsx import React, { useState, useCallback, useEffect } from 'react'; -import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom'; +import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import * as pdfjsLib from 'pdfjs-dist'; import { Footer } from './components/Footer'; // Assuming this is where your Footer component will live import { Header } from './components/Header'; -import { logger } from './services/logger.client'; +import { logger } from './services/logger.client'; import type { Flyer, Profile, UserProfile } from './types'; import { ProfileManager } from './pages/admin/components/ProfileManager'; import { VoiceAssistant } from './features/voice-assistant/VoiceAssistant'; @@ -30,7 +30,10 @@ import { HomePage } from './pages/HomePage'; // This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing. // We need to explicitly tell pdf.js where to load its worker script from. // By importing pdfjs-dist, we can host the worker locally, which is more reliable. -pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).toString(); +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.mjs', + import.meta.url, +).toString(); function App() { const { userProfile, authStatus, login, logout, updateProfile } = useAuth(); @@ -47,10 +50,10 @@ function App() { console.log('[App] Render:', { flyersCount: flyers.length, selectedFlyerId: selectedFlyer?.flyer_id, - paramsFlyerId: params?.flyerId, - authStatus: authStatus, - profileId: userProfile?.user_id, - locationSearch: location.search + paramsFlyerId: params?.flyerId, // This was a duplicate, fixed. + authStatus, + profileId: userProfile?.user.user_id, + locationSearch: location.search, }); } }); @@ -72,73 +75,83 @@ function App() { const handleOpenCorrectionTool = useCallback(() => openModal('correctionTool'), [openModal]); const handleCloseCorrectionTool = useCallback(() => closeModal('correctionTool'), [closeModal]); - const handleDataExtractedFromCorrection = useCallback((type: 'store_name' | 'dates', value: string) => { - if (!selectedFlyer) return; + const handleDataExtractedFromCorrection = useCallback( + (type: 'store_name' | 'dates', value: string) => { + if (!selectedFlyer) return; - // This is a simplified update. A real implementation would involve - // making another API call to update the flyer record in the database. - // For now, we just update the local state for immediate visual feedback. - const updatedFlyer = { ...selectedFlyer }; - if (type === 'store_name') { - updatedFlyer.store = { ...updatedFlyer.store!, name: value }; - } else if (type === 'dates') { - // A more robust solution would parse the date string properly. - } - setSelectedFlyer(updatedFlyer); - }, [selectedFlyer]); + // This is a simplified update. A real implementation would involve + // making another API call to update the flyer record in the database. + // For now, we just update the local state for immediate visual feedback. + const updatedFlyer = { ...selectedFlyer }; + if (type === 'store_name') { + updatedFlyer.store = { ...updatedFlyer.store!, name: value }; + } else if (type === 'dates') { + // A more robust solution would parse the date string properly. + } + setSelectedFlyer(updatedFlyer); + }, + [selectedFlyer], + ); - const handleProfileUpdate = useCallback((updatedProfileData: Profile) => { - // When the profile is updated, the API returns a `Profile` object. - // We need to merge it with the existing `user` object to maintain - // the `UserProfile` type in our state. - updateProfile(updatedProfileData); - }, [updateProfile]); + const handleProfileUpdate = useCallback( + (updatedProfileData: Profile) => { + // When the profile is updated, the API returns a `Profile` object. + // We need to merge it with the existing `user` object to maintain + // the `UserProfile` type in our state. + updateProfile(updatedProfileData); + }, + [updateProfile], + ); // --- State Synchronization and Error Handling --- // Effect to set initial theme based on user profile, local storage, or system preference useEffect(() => { - if (process.env.NODE_ENV === 'test') console.log('[App] Effect: Theme Update', { profileId: userProfile?.user_id }); + if (process.env.NODE_ENV === 'test') + console.log('[App] Effect: Theme Update', { profileId: userProfile?.user.user_id }); if (userProfile && userProfile.preferences?.darkMode !== undefined) { - // Preference from DB - const dbDarkMode = userProfile.preferences.darkMode; - setIsDarkMode(dbDarkMode); - document.documentElement.classList.toggle('dark', dbDarkMode); + // Preference from DB + const dbDarkMode = userProfile.preferences.darkMode; + setIsDarkMode(dbDarkMode); + document.documentElement.classList.toggle('dark', dbDarkMode); } else { - // Fallback to local storage or system preference - const savedMode = localStorage.getItem('darkMode'); - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const initialDarkMode = savedMode !== null ? savedMode === 'true' : prefersDark; - setIsDarkMode(initialDarkMode); - document.documentElement.classList.toggle('dark', initialDarkMode); + // Fallback to local storage or system preference + const savedMode = localStorage.getItem('darkMode'); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const initialDarkMode = savedMode !== null ? savedMode === 'true' : prefersDark; + setIsDarkMode(initialDarkMode); + document.documentElement.classList.toggle('dark', initialDarkMode); } - }, [userProfile?.preferences?.darkMode, userProfile?.user_id]); + }, [userProfile?.preferences?.darkMode, userProfile?.user.user_id]); // Effect to set initial unit system based on user profile or local storage useEffect(() => { if (userProfile && userProfile.preferences?.unitSystem) { - setUnitSystem(userProfile.preferences.unitSystem); + setUnitSystem(userProfile.preferences.unitSystem); } else { - const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null; - if (savedSystem) { - setUnitSystem(savedSystem); - } + const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null; + if (savedSystem) { + setUnitSystem(savedSystem); + } } - }, [userProfile?.preferences?.unitSystem, userProfile?.user_id]); + }, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]); // This is the login handler that will be passed to the ProfileManager component. - const handleLoginSuccess = useCallback(async (userProfile: UserProfile, token: string, _rememberMe: boolean) => { - try { - await login(token, userProfile); - // After successful login, fetch user-specific data - // The useData hook will automatically refetch user data when `user` changes. - // We can remove the explicit fetch here. - } catch (e) { - // The `login` function within the `useAuth` hook already handles its own errors - // and notifications, so we just need to log any unexpected failures here. - logger.error({ err: e }, 'An error occurred during the login success handling.'); - } - }, [login]); + const handleLoginSuccess = useCallback( + async (userProfile: UserProfile, token: string, _rememberMe: boolean) => { + try { + await login(token, userProfile); + // After successful login, fetch user-specific data + // The useData hook will automatically refetch user data when `user` changes. + // We can remove the explicit fetch here. + } catch (e) { + // The `login` function within the `useAuth` hook already handles its own errors + // and notifications, so we just need to log any unexpected failures here. + logger.error({ err: e }, 'An error occurred during the login success handling.'); + } + }, + [login], + ); // Effect to handle the token from Google OAuth redirect useEffect(() => { @@ -149,8 +162,9 @@ function App() { logger.info('Received Google Auth token from URL. Authenticating...'); // The login flow is now handled by the useAuth hook. We just need to trigger it. // We pass only the token; the AuthProvider will fetch the user profile. - login(googleToken) - .catch(err => logger.error('Failed to log in with Google token', { error: err })); + login(googleToken).catch((err) => + logger.error('Failed to log in with Google token', { error: err }), + ); // Clean the token from the URL navigate(location.pathname, { replace: true }); } @@ -158,19 +172,17 @@ function App() { const githubToken = urlParams.get('githubAuthToken'); if (githubToken) { logger.info('Received GitHub Auth token from URL. Authenticating...'); - login(githubToken) - .catch(err => { - logger.error('Failed to log in with GitHub token', { error: err }); - // Optionally, redirect to a page with an error message - // navigate('/login?error=github_auth_failed'); - }); - + login(githubToken).catch((err) => { + logger.error('Failed to log in with GitHub token', { error: err }); + // Optionally, redirect to a page with an error message + // navigate('/login?error=github_auth_failed'); + }); + // Clean the token from the URL navigate(location.pathname, { replace: true }); } }, [login, location.search, navigate, location.pathname]); - const handleFlyerSelect = useCallback(async (flyer: Flyer) => { setSelectedFlyer(flyer); }, []); @@ -188,14 +200,14 @@ function App() { if (flyerIdFromUrl && flyers.length > 0) { const flyerId = parseInt(flyerIdFromUrl, 10); - const flyerToSelect = flyers.find(f => f.flyer_id === flyerId); + const flyerToSelect = flyers.find((f) => f.flyer_id === flyerId); if (flyerToSelect && flyerToSelect.flyer_id !== selectedFlyer?.flyer_id) { handleFlyerSelect(flyerToSelect); } - }; + } }, [flyers, handleFlyerSelect, selectedFlyer, params.flyerId]); - // Read the application version injected at build time. + // Read the application version injected at build time. // This will only be available in the production build, not during local development. const appVersion = config.app.version; const commitMessage = config.app.commitMessage; @@ -223,31 +235,30 @@ function App() { } `} - -
{userProfile && ( - + )} {appVersion && commitMessage && ( @@ -269,19 +280,35 @@ function App() { )} - + } + > + + } + /> + + } /> - }> - - } /> - - } /> {/* Admin Routes */} @@ -318,4 +345,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/features/flyer/FlyerList.test.tsx b/src/features/flyer/FlyerList.test.tsx index a237dc1..a1cc765 100644 --- a/src/features/flyer/FlyerList.test.tsx +++ b/src/features/flyer/FlyerList.test.tsx @@ -66,24 +66,48 @@ const mockedToast = toast as Mocked; describe('FlyerList', () => { const mockOnFlyerSelect = vi.fn(); - const mockProfile: UserProfile = createMockUserProfile({ user_id: '1', role: 'user' }); + const mockProfile: UserProfile = createMockUserProfile({ + user: { user_id: '1', email: 'test@example.com' }, + role: 'user', + }); beforeEach(() => { vi.clearAllMocks(); }); it('should render the heading', () => { - render(); + render( + , + ); expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument(); }); it('should display a message when there are no flyers', () => { - render(); + render( + , + ); expect(screen.getByText(/no flyers have been processed yet/i)).toBeInTheDocument(); }); it('should render a list of flyers with correct details', () => { - render(); + render( + , + ); // Check first flyer expect(screen.getByText('Metro')).toBeInTheDocument(); @@ -96,7 +120,14 @@ describe('FlyerList', () => { }); it('should call onFlyerSelect with the correct flyer when an item is clicked', () => { - render(); + render( + , + ); const firstFlyerItem = screen.getByText('Metro').closest('li'); fireEvent.click(firstFlyerItem!); @@ -106,7 +137,14 @@ describe('FlyerList', () => { }); it('should apply a selected style to the currently selected flyer', () => { - render(); + render( + , + ); const selectedItem = screen.getByText('Metro').closest('li'); expect(selectedItem).toHaveClass('bg-brand-light', 'dark:bg-brand-dark/30'); @@ -114,7 +152,14 @@ describe('FlyerList', () => { describe('UI Details and Edge Cases', () => { it('should render an image icon when icon_url is present', () => { - render(); + render( + , + ); const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3 const iconImage = flyerWithIcon?.querySelector('img'); expect(iconImage).toBeInTheDocument(); @@ -122,7 +167,14 @@ describe('FlyerList', () => { }); it('should render a document icon when icon_url is not present', () => { - render(); + render( + , + ); const flyerWithoutIcon = screen.getByText('Walmart').closest('li'); // Flyer ID 2 const iconImage = flyerWithoutIcon?.querySelector('img'); const documentIcon = flyerWithoutIcon?.querySelector('svg'); @@ -131,16 +183,33 @@ describe('FlyerList', () => { }); it('should render "Unknown Store" if store data is missing', () => { - render(); + render( + , + ); expect(screen.getByText('Unknown Store')).toBeInTheDocument(); }); it('should render a map link if store_address is present and stop propagation on click', () => { - render(); + render( + , + ); const flyerWithAddress = screen.getByText('Unknown Store').closest('li'); const mapLink = flyerWithAddress?.querySelector('a'); expect(mapLink).toBeInTheDocument(); - expect(mapLink).toHaveAttribute('href', 'https://www.google.com/maps/search/?api=1&query=456%20Side%20St%2C%20Ottawa'); + expect(mapLink).toHaveAttribute( + 'href', + 'https://www.google.com/maps/search/?api=1&query=456%20Side%20St%2C%20Ottawa', + ); // Test that clicking the map link does not select the flyer fireEvent.click(mapLink!); @@ -148,7 +217,14 @@ describe('FlyerList', () => { }); it('should render a detailed tooltip', () => { - render(); + render( + , + ); const firstFlyerItem = screen.getByText('Metro').closest('li'); const tooltipText = firstFlyerItem?.getAttribute('title'); expect(tooltipText).toContain('File: metro_flyer_oct_1.pdf'); @@ -160,9 +236,16 @@ describe('FlyerList', () => { }); it('should handle invalid dates gracefully in display and tooltip', () => { - render(); + render( + , + ); const badDateItem = screen.getByText('Date Store').closest('li'); - + // Display should not show "Valid:" text if dates are invalid expect(badDateItem).toHaveTextContent('5 items'); expect(badDateItem).not.toHaveTextContent(/Valid:/); @@ -175,15 +258,32 @@ describe('FlyerList', () => { }); describe('Admin Functionality', () => { - const adminProfile: UserProfile = createMockUserProfile({ user_id: 'admin-1', role: 'admin' }); + const adminProfile: UserProfile = createMockUserProfile({ + user: { user_id: 'admin-1', email: 'admin@example.com' }, + role: 'admin', + }); it('should not show the cleanup button for non-admin users', () => { - render(); + render( + , + ); expect(screen.queryByTitle(/clean up files/i)).not.toBeInTheDocument(); }); it('should show the cleanup button for admin users', () => { - render(); + render( + , + ); expect(screen.getByTitle('Clean up files for flyer ID 1')).toBeInTheDocument(); expect(screen.getByTitle('Clean up files for flyer ID 2')).toBeInTheDocument(); }); @@ -192,12 +292,21 @@ describe('FlyerList', () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); mockedApiClient.cleanupFlyerFiles.mockResolvedValue(new Response(null, { status: 200 })); - render(); + render( + , + ); const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1'); fireEvent.click(cleanupButton); - expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to clean up the files for flyer ID 1? This action cannot be undone.'); + expect(confirmSpy).toHaveBeenCalledWith( + 'Are you sure you want to clean up the files for flyer ID 1? This action cannot be undone.', + ); await waitFor(() => { expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1); }); @@ -206,7 +315,14 @@ describe('FlyerList', () => { it('should not call cleanupFlyerFiles when admin clicks and cancels', () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - render(); + render( + , + ); const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1'); fireEvent.click(cleanupButton); @@ -220,7 +336,14 @@ describe('FlyerList', () => { const apiError = new Error('Cleanup failed'); mockedApiClient.cleanupFlyerFiles.mockRejectedValue(apiError); - render(); + render( + , + ); const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1'); fireEvent.click(cleanupButton); @@ -252,4 +375,4 @@ describe('formatShortDate', () => { expect(formatShortDate('2023-02-30')).toBeNull(); // Invalid day expect(formatShortDate('not a date')).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/services/userService.test.ts b/src/services/userService.test.ts index b289d37..41dd134 100644 --- a/src/services/userService.test.ts +++ b/src/services/userService.test.ts @@ -69,7 +69,10 @@ describe('UserService', () => { it('should create a new address and link it to a user who has no address', async () => { const { logger } = await import('./logger.server'); // Arrange: A user profile without an existing address_id. - const user = createMockUserProfile({ user_id: 'user-123', address_id: null }); + const user = createMockUserProfile({ + user: { user_id: 'user-123', email: 'test@example.com' }, + address_id: null, + }); const addressData: Partial
= { address_line_1: '123 New St', city: 'Newville' }; // Mock the address repository to return a new address ID. @@ -84,21 +87,34 @@ describe('UserService', () => { // 1. Verify the transaction helper was called. expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1); // 2. Verify the address was upserted with the correct data. - expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({ - ...addressData, - address_id: undefined, // user.address_id was null, so it should be undefined. - }, logger); + expect(mocks.mockUpsertAddress).toHaveBeenCalledWith( + { + ...addressData, + address_id: undefined, // user.address_id was null, so it should be undefined. + }, + logger, + ); // 3. Verify the user's profile was updated to link the new address ID. expect(mocks.mockUpdateUserProfile).toHaveBeenCalledTimes(1); - expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith('user-123', { address_id: newAddressId }, logger); + expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith( + user.user.user_id, + { address_id: newAddressId }, + logger, + ); }); it('should update an existing address and NOT link it if the ID does not change', async () => { const { logger } = await import('./logger.server'); // Arrange: A user profile with an existing address_id. const existingAddressId = 42; - const user = createMockUserProfile({ user_id: 'user-123', address_id: existingAddressId }); - const addressData: Partial
= { address_line_1: '123 Updated St', city: 'Updateville' }; + const user = createMockUserProfile({ + user: { user_id: 'user-123', email: 'test@example.com' }, + address_id: existingAddressId, + }); + const addressData: Partial
= { + address_line_1: '123 Updated St', + city: 'Updateville', + }; // Mock the address repository to return the SAME address ID. mocks.mockUpsertAddress.mockResolvedValue(existingAddressId); @@ -111,12 +127,15 @@ describe('UserService', () => { // 1. Verify the transaction helper was called. expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1); // 2. Verify the address was upserted with the existing ID. - expect(mocks.mockUpsertAddress).toHaveBeenCalledWith({ - ...addressData, - address_id: existingAddressId, - }, logger); + expect(mocks.mockUpsertAddress).toHaveBeenCalledWith( + { + ...addressData, + address_id: existingAddressId, + }, + logger, + ); // 3. Since the address ID did not change, the user profile should NOT be updated. expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/src/tests/utils/componentMocks.tsx b/src/tests/utils/componentMocks.tsx index 6f0fc12..365b273 100644 --- a/src/tests/utils/componentMocks.tsx +++ b/src/tests/utils/componentMocks.tsx @@ -16,100 +16,178 @@ import type { StatCardProps } from '../../pages/admin/components/StatCard'; import type { ExtractedDataTableProps } from '../../features/flyer/ExtractedDataTable'; export const MockHeader: React.FC = (props) => ( -
- - -
+
+ + +
); -export const MockProfileManager: React.FC> = ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }) => { - if (!isOpen) return null; - return ( -
- - - -
- ); +export const MockProfileManager: React.FC> = ({ + isOpen, + onClose, + onProfileUpdate, + onLoginSuccess, +}) => { + if (!isOpen) return null; + return ( +
+ + + +
+ ); }; -export const MockVoiceAssistant: React.FC = ({ isOpen, onClose }) => ( - isOpen ?
: null -); +export const MockVoiceAssistant: React.FC = ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null; -export const MockFlyerCorrectionTool: React.FC> = ({ isOpen, onClose, onDataExtracted }) => ( - isOpen ?
: null -); +export const MockFlyerCorrectionTool: React.FC> = ({ + isOpen, + onClose, + onDataExtracted, +}) => + isOpen ? ( +
+ + + +
+ ) : null; -export const MockWhatsNewModal: React.FC> = ({ isOpen, onClose }) => ( - isOpen ?
: null -); +export const MockWhatsNewModal: React.FC> = ({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null; // Add mock implementations for the remaining simple page/layout components export const MockAdminPage: React.FC = () =>
Admin Page
; -export const MockAdminRoute: React.FC> = () =>
; -export const MockCorrectionsPage: React.FC = () =>
Corrections Page
; -export const MockAdminStatsPage: React.FC = () =>
Admin Stats Page
; -export const MockResetPasswordPage: React.FC = () =>
Reset Password Page
; -export const MockVoiceLabPage: React.FC = () =>
Voice Lab Page
; -export const MockMainLayout: React.FC> = () =>
; +export const MockAdminRoute: React.FC> = () => ( +
+ +
+); +export const MockCorrectionsPage: React.FC = () => ( +
Corrections Page
+); +export const MockAdminStatsPage: React.FC = () => ( +
Admin Stats Page
+); +export const MockResetPasswordPage: React.FC = () => ( +
Reset Password Page
+); +export const MockVoiceLabPage: React.FC = () => ( +
Voice Lab Page
+); +export const MockMainLayout: React.FC> = () => ( +
+ +
+); export const MockHomePage: React.FC> = ({ onOpenCorrectionTool }) => ( -
- -
+
+ +
); -export const MockSystemCheck: React.FC = () =>
System Health Checks
; +export const MockSystemCheck: React.FC = () => ( +
System Health Checks
+); interface MockCorrectionRowProps { - correction: SuggestedCorrection; - onProcessed: (id: number) => void; + correction: SuggestedCorrection; + onProcessed: (id: number) => void; } -export const MockCorrectionRow: React.FC = ({ correction, onProcessed }) => ( - - {correction.flyer_item_name} - - - - +export const MockCorrectionRow: React.FC = ({ + correction, + onProcessed, +}) => ( + + {correction.flyer_item_name} + + + + ); -export const MockFlyerDisplay: React.FC> = ({ imageUrl, onOpenCorrectionTool }) => ( -
- -
+export const MockFlyerDisplay: React.FC> = ({ + imageUrl, + onOpenCorrectionTool, +}) => ( +
+ +
); export const MockAnalysisPanel: React.FC = () =>
; -export const MockExtractedDataTable: React.FC> = ({ items = [] }) => ( -
{items.length} items
-); +export const MockExtractedDataTable: React.FC> = ({ + items = [], +}) =>
{items.length} items
; /** * A simple mock for the StatCard component that renders its props. */ export const MockStatCard: React.FC = ({ title, value, icon }) => ( -
-

{title}

-

{value}

- {icon} -
+
+

{title}

+

{value}

+ {icon} +
); // --- Icon Mocks --- -export const MockShieldExclamationIcon: React.FC> = (props) => ; -export const MockChartBarIcon: React.FC> = (props) => ; -export const MockUsersIcon: React.FC> = (props) => ; -export const MockDocumentDuplicateIcon: React.FC> = (props) => ; -export const MockBuildingStorefrontIcon: React.FC> = (props) => ; -export const MockBellAlertIcon: React.FC> = (props) => ; -export const MockBookOpenIcon: React.FC> = (props) => ; +export const MockShieldExclamationIcon: React.FC> = (props) => ( + +); +export const MockChartBarIcon: React.FC> = (props) => ( + +); +export const MockUsersIcon: React.FC> = (props) => ( + +); +export const MockDocumentDuplicateIcon: React.FC> = (props) => ( + +); +export const MockBuildingStorefrontIcon: React.FC> = (props) => ( + +); +export const MockBellAlertIcon: React.FC> = (props) => ( + +); +export const MockBookOpenIcon: React.FC> = (props) => ( + +); export const MockFooter: React.FC = () => ;