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,8 +122,28 @@ jobs:
echo "Installing production dependencies and restarting server..."
cd /var/www/flyer-crawler.projectium.com
npm install --omit=dev
# --- 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 }')

View File

@@ -43,6 +43,19 @@ 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 ---
@@ -85,15 +98,15 @@ 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 ---
@@ -101,7 +114,7 @@ jobs:
# --- 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.
@@ -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.

View File

@@ -26,7 +26,12 @@ import healthRouter from './src/routes/health.routes';
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.');
});
@@ -105,7 +113,7 @@ const requestLogger = (req: Request, res: Response, next: NextFunction) => {
// 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.
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,
});
@@ -118,7 +126,7 @@ const requestLogger = (req: Request, res: Response, next: NextFunction) => {
// The base log object includes details relevant for all status codes.
const logDetails: RequestLogDetails = {
user_id: finalUser?.user_id,
user_id: finalUser?.user.user_id,
method,
originalUrl,
statusCode,
@@ -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;

View File

@@ -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,7 +75,8 @@ function App() {
const handleOpenCorrectionTool = useCallback(() => openModal('correctionTool'), [openModal]);
const handleCloseCorrectionTool = useCallback(() => closeModal('correctionTool'), [closeModal]);
const handleDataExtractedFromCorrection = useCallback((type: 'store_name' | 'dates', value: string) => {
const handleDataExtractedFromCorrection = useCallback(
(type: 'store_name' | 'dates', value: string) => {
if (!selectedFlyer) return;
// This is a simplified update. A real implementation would involve
@@ -85,20 +89,26 @@ function App() {
// A more robust solution would parse the date string properly.
}
setSelectedFlyer(updatedFlyer);
}, [selectedFlyer]);
},
[selectedFlyer],
);
const handleProfileUpdate = useCallback((updatedProfileData: Profile) => {
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]);
},
[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;
@@ -112,7 +122,7 @@ function App() {
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(() => {
@@ -124,10 +134,11 @@ function App() {
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) => {
const handleLoginSuccess = useCallback(
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
try {
await login(token, userProfile);
// After successful login, fetch user-specific data
@@ -138,7 +149,9 @@ function App() {
// 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]);
},
[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,8 +172,7 @@ function App() {
const githubToken = urlParams.get('githubAuthToken');
if (githubToken) {
logger.info('Received GitHub Auth token from URL. Authenticating...');
login(githubToken)
.catch(err => {
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');
@@ -170,7 +183,6 @@ function App() {
}
}, [login, location.search, navigate, location.pathname]);
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
setSelectedFlyer(flyer);
}, []);
@@ -188,11 +200,11 @@ 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.
@@ -223,7 +235,6 @@ function App() {
}
`}</style>
<Header
isDarkMode={isDarkMode}
unitSystem={unitSystem}
@@ -269,19 +280,35 @@ function App() {
)}
<Routes>
<Route element={
<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 */}

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,7 +236,14 @@ 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
@@ -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);

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({
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
{
...addressData,
address_id: undefined, // user.address_id was null, so it should be undefined.
}, logger);
},
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,10 +127,13 @@ 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({
expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
{
...addressData,
address_id: existingAddressId,
}, logger);
},
logger,
);
// 3. Since the address ID did not change, the user profile should NOT be updated.
expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
});

View File

@@ -22,51 +22,110 @@ export const MockHeader: React.FC<HeaderProps> = (props) => (
</header>
);
export const MockProfileManager: React.FC<Partial<ProfileManagerProps>> = ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }) => {
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>
<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>
);
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;
}
export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({ correction, onProcessed }) => (
export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({
correction,
onProcessed,
}) => (
<tr data-testid={`correction-row-${correction.suggested_correction_id}`}>
<td>{correction.flyer_item_name}</td>
<td>
@@ -80,17 +139,22 @@ export const MockCorrectionRow: React.FC<MockCorrectionRowProps> = ({ correction
</tr>
);
export const MockFlyerDisplay: React.FC<Partial<FlyerDisplayProps>> = ({ imageUrl, onOpenCorrectionTool }) => (
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>
<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.
@@ -104,12 +168,26 @@ export const MockStatCard: React.FC<StatCardProps> = ({ title, value, icon }) =>
);
// --- 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>;