final ts cleanup?
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 11m31s

This commit is contained in:
2025-12-21 23:08:35 -08:00
parent 91dd6add60
commit b14225f69f
7 changed files with 584 additions and 287 deletions

View File

@@ -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."
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."

View File

@@ -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."
pm2 env flyer-crawler-api-test || echo "Could not find test pm2 process."

115
server.ts
View File

@@ -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;
export default app;

View File

@@ -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() {
}
`}</style>
<Header
<Header
isDarkMode={isDarkMode}
unitSystem={unitSystem}
userProfile={userProfile}
authStatus={authStatus}
onOpenProfile={handleOpenProfile}
onOpenVoiceAssistant={handleOpenVoiceAssistant}
onOpenVoiceAssistant={handleOpenVoiceAssistant}
onSignOut={logout}
/>
<ProfileManager
isOpen={isModalOpen('profile')}
onClose={handleCloseProfile}
authStatus={authStatus}
userProfile={userProfile}
onProfileUpdate={handleProfileUpdate}
onLoginSuccess={handleLoginSuccess}
onSignOut={logout}
isOpen={isModalOpen('profile')}
onClose={handleCloseProfile}
authStatus={authStatus}
userProfile={userProfile}
onProfileUpdate={handleProfileUpdate}
onLoginSuccess={handleLoginSuccess}
onSignOut={logout}
/>
{userProfile && (
<VoiceAssistant
isOpen={isModalOpen('voiceAssistant')}
onClose={handleCloseVoiceAssistant}
/>
<VoiceAssistant
isOpen={isModalOpen('voiceAssistant')}
onClose={handleCloseVoiceAssistant}
/>
)}
{appVersion && commitMessage && (
@@ -269,19 +280,35 @@ function App() {
)}
<Routes>
<Route element={
<MainLayout
onFlyerSelect={handleFlyerSelect}
selectedFlyerId={selectedFlyer?.flyer_id || null}
onOpenProfile={handleOpenProfile}
<Route
element={
<MainLayout
onFlyerSelect={handleFlyerSelect}
selectedFlyerId={selectedFlyer?.flyer_id || null}
onOpenProfile={handleOpenProfile}
/>
}
>
<Route
index
element={
<HomePage
selectedFlyer={selectedFlyer}
flyerItems={flyerItems}
onOpenCorrectionTool={handleOpenCorrectionTool}
/>
}
/>
<Route
path="/flyers/:flyerId"
element={
<HomePage
selectedFlyer={selectedFlyer}
flyerItems={flyerItems}
onOpenCorrectionTool={handleOpenCorrectionTool}
/>
}
/>
}>
<Route index element={
<HomePage selectedFlyer={selectedFlyer} flyerItems={flyerItems} onOpenCorrectionTool={handleOpenCorrectionTool} />
} />
<Route path="/flyers/:flyerId" element={
<HomePage selectedFlyer={selectedFlyer} flyerItems={flyerItems} onOpenCorrectionTool={handleOpenCorrectionTool} />
} />
</Route>
{/* Admin Routes */}
@@ -318,4 +345,4 @@ function App() {
);
}
export default App;
export default App;

View File

@@ -66,24 +66,48 @@ const mockedToast = toast as Mocked<typeof toast>;
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(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={null} />);
render(
<FlyerList
flyers={[]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={null}
/>,
);
expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument();
});
it('should display a message when there are no flyers', () => {
render(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={null} />);
render(
<FlyerList
flyers={[]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={null}
/>,
);
expect(screen.getByText(/no flyers have been processed yet/i)).toBeInTheDocument();
});
it('should render a list of flyers with correct details', () => {
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
// 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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={1} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={1}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('Unknown Store')).toBeInTheDocument();
});
it('should render a map link if store_address is present and stop propagation on click', () => {
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.queryByTitle(/clean up files/i)).not.toBeInTheDocument();
});
it('should show the cleanup button for admin users', () => {
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
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(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
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();
});
});
});

View File

@@ -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> = { 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> = { 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> = {
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();
});
});
});
});

View File

@@ -16,100 +16,178 @@ import type { StatCardProps } from '../../pages/admin/components/StatCard';
import type { ExtractedDataTableProps } from '../../features/flyer/ExtractedDataTable';
export const MockHeader: React.FC<HeaderProps> = (props) => (
<header data-testid="header-mock">
<button onClick={props.onOpenProfile}>Open Profile</button>
<button onClick={props.onOpenVoiceAssistant}>Open Voice Assistant</button>
</header>
<header data-testid="header-mock">
<button onClick={props.onOpenProfile}>Open Profile</button>
<button onClick={props.onOpenVoiceAssistant}>Open Voice Assistant</button>
</header>
);
export const MockProfileManager: React.FC<Partial<ProfileManagerProps>> = ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }) => {
if (!isOpen) return null;
return (
<div data-testid="profile-manager-mock">
<button onClick={onClose}>Close Profile</button>
<button onClick={() => onProfileUpdate?.(createMockProfile({ user_id: '1', role: 'user', points: 0, full_name: 'Updated' }))}>Update Profile</button>
<button onClick={() => onLoginSuccess?.(createMockUserProfile({ user_id: '1', user: { user_id: '1', email: 'a@b.com' } }), 'token', false)}>Login</button>
</div>
);
export const MockProfileManager: React.FC<Partial<ProfileManagerProps>> = ({
isOpen,
onClose,
onProfileUpdate,
onLoginSuccess,
}) => {
if (!isOpen) return null;
return (
<div data-testid="profile-manager-mock">
<button onClick={onClose}>Close Profile</button>
<button
onClick={() =>
onProfileUpdate?.(createMockProfile({ role: 'user', points: 0, full_name: 'Updated' }))
}
>
Update Profile
</button>
<button
onClick={() =>
onLoginSuccess?.(
createMockUserProfile({ user: { user_id: '1', email: 'a@b.com' } }),
'token',
false,
)
}
>
Login
</button>
</div>
);
};
export const MockVoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) => (
isOpen ? <div data-testid="voice-assistant-mock"><button onClick={onClose}>Close Voice Assistant</button></div> : null
);
export const MockVoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) =>
isOpen ? (
<div data-testid="voice-assistant-mock">
<button onClick={onClose}>Close Voice Assistant</button>
</div>
) : null;
export const MockFlyerCorrectionTool: React.FC<Partial<FlyerCorrectionToolProps>> = ({ isOpen, onClose, onDataExtracted }) => (
isOpen ? <div data-testid="flyer-correction-tool-mock"><button onClick={onClose}>Close Correction</button><button onClick={() => onDataExtracted?.('store_name', 'New Store Name')}>Extract Store</button><button onClick={() => onDataExtracted?.('dates', '2023-01-01')}>Extract Dates</button></div> : null
);
export const MockFlyerCorrectionTool: React.FC<Partial<FlyerCorrectionToolProps>> = ({
isOpen,
onClose,
onDataExtracted,
}) =>
isOpen ? (
<div data-testid="flyer-correction-tool-mock">
<button onClick={onClose}>Close Correction</button>
<button onClick={() => onDataExtracted?.('store_name', 'New Store Name')}>
Extract Store
</button>
<button onClick={() => onDataExtracted?.('dates', '2023-01-01')}>Extract Dates</button>
</div>
) : null;
export const MockWhatsNewModal: React.FC<Partial<WhatsNewModalProps>> = ({ isOpen, onClose }) => (
isOpen ? <div data-testid="whats-new-modal-mock"><button onClick={onClose}>Close What's New</button></div> : null
);
export const MockWhatsNewModal: React.FC<Partial<WhatsNewModalProps>> = ({ isOpen, onClose }) =>
isOpen ? (
<div data-testid="whats-new-modal-mock">
<button onClick={onClose}>Close What's New</button>
</div>
) : null;
// Add mock implementations for the remaining simple page/layout components
export const MockAdminPage: React.FC = () => <div data-testid="admin-page-mock">Admin Page</div>;
export const MockAdminRoute: React.FC<Partial<AdminRouteProps>> = () => <div data-testid="admin-route-mock"><Outlet /></div>;
export const MockCorrectionsPage: React.FC = () => <div data-testid="corrections-page-mock">Corrections Page</div>;
export const MockAdminStatsPage: React.FC = () => <div data-testid="admin-stats-page-mock">Admin Stats Page</div>;
export const MockResetPasswordPage: React.FC = () => <div data-testid="reset-password-page-mock">Reset Password Page</div>;
export const MockVoiceLabPage: React.FC = () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div>;
export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => <div data-testid="main-layout-mock"><Outlet /></div>;
export const MockAdminRoute: React.FC<Partial<AdminRouteProps>> = () => (
<div data-testid="admin-route-mock">
<Outlet />
</div>
);
export const MockCorrectionsPage: React.FC = () => (
<div data-testid="corrections-page-mock">Corrections Page</div>
);
export const MockAdminStatsPage: React.FC = () => (
<div data-testid="admin-stats-page-mock">Admin Stats Page</div>
);
export const MockResetPasswordPage: React.FC = () => (
<div data-testid="reset-password-page-mock">Reset Password Page</div>
);
export const MockVoiceLabPage: React.FC = () => (
<div data-testid="voice-lab-page-mock">Voice Lab Page</div>
);
export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => (
<div data-testid="main-layout-mock">
<Outlet />
</div>
);
export const MockHomePage: React.FC<Partial<HomePageProps>> = ({ onOpenCorrectionTool }) => (
<div data-testid="home-page-mock">
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
</div>
<div data-testid="home-page-mock">
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
</div>
);
export const MockSystemCheck: React.FC = () => <div data-testid="system-check-mock">System Health Checks</div>;
export const MockSystemCheck: React.FC = () => (
<div data-testid="system-check-mock">System Health Checks</div>
);
interface MockCorrectionRowProps {
correction: SuggestedCorrection;
onProcessed: (id: number) => void;
correction: SuggestedCorrection;
onProcessed: (id: number) => void;
}
export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({ correction, onProcessed }) => (
<tr data-testid={`correction-row-${correction.suggested_correction_id}`}>
<td>{correction.flyer_item_name}</td>
<td>
<button
data-testid={`process-btn-${correction.suggested_correction_id}`}
onClick={() => onProcessed(correction.suggested_correction_id)}
>
Process
</button>
</td>
</tr>
export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({
correction,
onProcessed,
}) => (
<tr data-testid={`correction-row-${correction.suggested_correction_id}`}>
<td>{correction.flyer_item_name}</td>
<td>
<button
data-testid={`process-btn-${correction.suggested_correction_id}`}
onClick={() => onProcessed(correction.suggested_correction_id)}
>
Process
</button>
</td>
</tr>
);
export const MockFlyerDisplay: React.FC<Partial<FlyerDisplayProps>> = ({ imageUrl, onOpenCorrectionTool }) => (
<div data-testid="flyer-display" data-image-url={imageUrl}>
<button data-testid="mock-open-correction-tool" onClick={onOpenCorrectionTool}>Open Correction Tool</button>
</div>
export const MockFlyerDisplay: React.FC<Partial<FlyerDisplayProps>> = ({
imageUrl,
onOpenCorrectionTool,
}) => (
<div data-testid="flyer-display" data-image-url={imageUrl}>
<button data-testid="mock-open-correction-tool" onClick={onOpenCorrectionTool}>
Open Correction Tool
</button>
</div>
);
export const MockAnalysisPanel: React.FC = () => <div data-testid="analysis-panel" />;
export const MockExtractedDataTable: React.FC<Partial<ExtractedDataTableProps>> = ({ items = [] }) => (
<div data-testid="extracted-data-table">{items.length} items</div>
);
export const MockExtractedDataTable: React.FC<Partial<ExtractedDataTableProps>> = ({
items = [],
}) => <div data-testid="extracted-data-table">{items.length} items</div>;
/**
* A simple mock for the StatCard component that renders its props.
*/
export const MockStatCard: React.FC<StatCardProps> = ({ title, value, icon }) => (
<div data-testid="stat-card-mock">
<h3>{title}</h3>
<p>{value}</p>
{icon}
</div>
<div data-testid="stat-card-mock">
<h3>{title}</h3>
<p>{value}</p>
{icon}
</div>
);
// --- Icon Mocks ---
export const MockShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="shield-icon" {...props} />;
export const MockChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="chart-icon" {...props} />;
export const MockUsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="users-icon" {...props} />;
export const MockDocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="document-duplicate-icon" {...props} />;
export const MockBuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="building-storefront-icon" {...props} />;
export const MockBellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="bell-alert-icon" {...props} />;
export const MockBookOpenIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => <svg data-testid="book-open-icon" {...props} />;
export const MockShieldExclamationIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="shield-icon" {...props} />
);
export const MockChartBarIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="chart-icon" {...props} />
);
export const MockUsersIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="users-icon" {...props} />
);
export const MockDocumentDuplicateIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="document-duplicate-icon" {...props} />
);
export const MockBuildingStorefrontIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="building-storefront-icon" {...props} />
);
export const MockBellAlertIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="bell-alert-icon" {...props} />
);
export const MockBookOpenIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg data-testid="book-open-icon" {...props} />
);
export const MockFooter: React.FC = () => <footer data-testid="footer-mock">Mock Footer</footer>;