Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15747ac942 | ||
| e5fa89ef17 | |||
|
|
2c65da31e9 | ||
| eeec6af905 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.24",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.24",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
51
src/hooks/useUserProfileData.ts
Normal file
51
src/hooks/useUserProfileData.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// src/hooks/useUserProfileData.ts
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { UserProfile, Achievement, UserAchievement } from '../types';
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
export const useUserProfileData = () => {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [profileRes, achievementsRes] = await Promise.all([
|
||||
apiClient.getAuthenticatedUserProfile(),
|
||||
apiClient.getUserAchievements(),
|
||||
]);
|
||||
|
||||
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
|
||||
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
|
||||
|
||||
const profileData: UserProfile | null = await profileRes.json();
|
||||
const achievementsData: (UserAchievement & Achievement)[] | null =
|
||||
await achievementsRes.json();
|
||||
|
||||
logger.info(
|
||||
{ profileData, achievementsCount: achievementsData?.length },
|
||||
'useUserProfileData: Fetched data',
|
||||
);
|
||||
|
||||
if (profileData) {
|
||||
setProfile(profileData);
|
||||
}
|
||||
setAchievements(achievementsData || []);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||
setError(errorMessage);
|
||||
logger.error({ err }, 'Error in useUserProfileData:');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return { profile, setProfile, achievements, isLoading, error };
|
||||
};
|
||||
@@ -109,6 +109,33 @@ describe('ResetPasswordPage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should show an error message if API returns a non-JSON error response', async () => {
|
||||
// Simulate a server error returning HTML instead of JSON
|
||||
mockedApiClient.resetPassword.mockResolvedValue(
|
||||
new Response('<h1>Server Error</h1>', {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
);
|
||||
renderWithRouter('test-token');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('New Password'), {
|
||||
target: { value: 'newSecurePassword123' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), {
|
||||
target: { value: 'newSecurePassword123' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// The error from response.json() is implementation-dependent.
|
||||
// We check for a substring that is likely to be present.
|
||||
expect(screen.getByText(/not valid JSON/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(SyntaxError) }, 'Failed to reset password.');
|
||||
});
|
||||
|
||||
it('should show a loading spinner while submitting', async () => {
|
||||
let resolvePromise: (value: Response) => void;
|
||||
const mockPromise = new Promise<Response>((resolve) => {
|
||||
|
||||
@@ -123,6 +123,24 @@ describe('UserProfilePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null achievements data gracefully on fetch', async () => {
|
||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||
new Response(JSON.stringify(mockProfile)),
|
||||
);
|
||||
// Mock a successful response but with a null body for achievements
|
||||
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
|
||||
render(<UserProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
|
||||
// The mock achievements list should show 0 achievements because the component
|
||||
// should handle the null response and pass an empty array to the list.
|
||||
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
|
||||
'Achievements Count: 0',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the profile and achievements on successful fetch', async () => {
|
||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
|
||||
new Response(JSON.stringify(mockProfile)),
|
||||
@@ -294,6 +312,24 @@ describe('UserProfilePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-ok response with null body when saving name', async () => {
|
||||
// This tests the case where the server returns an error status but an empty/null body.
|
||||
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
render(<UserProfilePage />);
|
||||
await screen.findByText('Test User');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// The component should fall back to the default error message.
|
||||
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
|
||||
'Failed to update name.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown errors when saving name', async () => {
|
||||
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
|
||||
render(<UserProfilePage />);
|
||||
@@ -420,6 +456,22 @@ describe('UserProfilePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-ok response with null body when uploading avatar', async () => {
|
||||
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
|
||||
render(<UserProfilePage />);
|
||||
await screen.findByAltText('User Avatar');
|
||||
|
||||
const fileInput = screen.getByTestId('avatar-file-input');
|
||||
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
|
||||
'Failed to upload avatar.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown errors when uploading avatar', async () => {
|
||||
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
|
||||
render(<UserProfilePage />);
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { UserProfile, Achievement, UserAchievement } from '../types';
|
||||
import type { UserProfile } from '../types';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { notifySuccess, notifyError } from '../services/notificationService';
|
||||
import { AchievementsList } from '../components/AchievementsList';
|
||||
import { useUserProfileData } from '../hooks/useUserProfileData';
|
||||
|
||||
const UserProfilePage: React.FC = () => {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [achievements, setAchievements] = useState<(UserAchievement & Achievement)[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { profile, setProfile, achievements, isLoading, error } = useUserProfileData();
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -17,43 +15,10 @@ const UserProfilePage: React.FC = () => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Fetch profile and achievements data in parallel
|
||||
const [profileRes, achievementsRes] = await Promise.all([
|
||||
apiClient.getAuthenticatedUserProfile(),
|
||||
apiClient.getUserAchievements(),
|
||||
]);
|
||||
|
||||
if (!profileRes.ok) throw new Error('Failed to fetch user profile.');
|
||||
if (!achievementsRes.ok) throw new Error('Failed to fetch user achievements.');
|
||||
|
||||
const profileData: UserProfile = await profileRes.json();
|
||||
const achievementsData: (UserAchievement & Achievement)[] = await achievementsRes.json();
|
||||
|
||||
logger.info(
|
||||
{ profileData, achievementsCount: achievementsData?.length },
|
||||
'UserProfilePage: Fetched data',
|
||||
);
|
||||
|
||||
setProfile(profileData);
|
||||
|
||||
if (profileData) {
|
||||
setEditingName(profileData.full_name || '');
|
||||
}
|
||||
setAchievements(achievementsData);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||
setError(errorMessage);
|
||||
logger.error({ err }, 'Error fetching user profile data:');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []); // Empty dependency array means this runs once on component mount
|
||||
if (profile) {
|
||||
setEditingName(profile.full_name || '');
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!profile) return;
|
||||
@@ -61,8 +26,8 @@ const UserProfilePage: React.FC = () => {
|
||||
try {
|
||||
const response = await apiClient.updateUserProfile({ full_name: editingName });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update name.');
|
||||
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
|
||||
throw new Error(errorData?.message || 'Failed to update name.');
|
||||
}
|
||||
const updatedProfile = await response.json();
|
||||
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
||||
@@ -88,8 +53,8 @@ const UserProfilePage: React.FC = () => {
|
||||
try {
|
||||
const response = await apiClient.uploadAvatar(file);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to upload avatar.');
|
||||
const errorData = await response.json().catch(() => null); // Gracefully handle non-JSON responses
|
||||
throw new Error(errorData?.message || 'Failed to upload avatar.');
|
||||
}
|
||||
const updatedProfile = await response.json();
|
||||
setProfile((prevProfile) => (prevProfile ? { ...prevProfile, ...updatedProfile } : null));
|
||||
|
||||
@@ -264,6 +264,7 @@ describe('ProfileManager', () => {
|
||||
});
|
||||
|
||||
it('should show an error if trying to save profile when not logged in', async () => {
|
||||
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
||||
// This is an edge case, but good to test the safeguard
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
|
||||
@@ -271,6 +272,7 @@ describe('ProfileManager', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledWith('Cannot save profile, no user is logged in.');
|
||||
expect(loggerSpy).toHaveBeenCalledWith('[handleProfileSave] Aborted: No user is logged in.');
|
||||
});
|
||||
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -496,6 +498,23 @@ describe('ProfileManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error when trying to link a GitHub account', async () => {
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /link github account/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /link github account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledWith(
|
||||
'Account linking with github is not yet implemented.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch between all tabs correctly', async () => {
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||
|
||||
@@ -804,6 +823,63 @@ describe('ProfileManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow changing unit system when preferences are initially null', async () => {
|
||||
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
|
||||
const { rerender } = render(
|
||||
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||
|
||||
const imperialRadio = await screen.findByLabelText(/imperial/i);
|
||||
const metricRadio = screen.getByLabelText(/metric/i);
|
||||
|
||||
// With null preferences, neither should be checked.
|
||||
expect(imperialRadio).not.toBeChecked();
|
||||
expect(metricRadio).not.toBeChecked();
|
||||
|
||||
// Mock the API response for the update
|
||||
const updatedProfileWithPrefs = {
|
||||
...profileWithoutPrefs,
|
||||
preferences: { darkMode: false, unitSystem: 'metric' as const },
|
||||
};
|
||||
mockedApiClient.updateUserPreferences.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(updatedProfileWithPrefs),
|
||||
} as Response);
|
||||
|
||||
fireEvent.click(metricRadio);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith(
|
||||
{ unitSystem: 'metric' },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
|
||||
});
|
||||
|
||||
// Rerender with the new profile to check the UI update
|
||||
rerender(
|
||||
<ProfileManager {...defaultAuthenticatedProps} userProfile={updatedProfileWithPrefs} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||
expect(await screen.findByLabelText(/metric/i)).toBeChecked();
|
||||
expect(screen.getByLabelText(/imperial/i)).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should not call onProfileUpdate if updating unit system fails', async () => {
|
||||
mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed'));
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
|
||||
const metricRadio = await screen.findByLabelText(/metric/i);
|
||||
fireEvent.click(metricRadio);
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledWith('API failed');
|
||||
});
|
||||
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only call updateProfile when only profile data has changed', async () => {
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||
await waitFor(() =>
|
||||
@@ -1004,5 +1080,19 @@ describe('ProfileManager', () => {
|
||||
expect(notifyError).toHaveBeenCalledWith('Permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not trigger OAuth link if user profile is missing', async () => {
|
||||
// This is an edge case to test the guard clause in handleOAuthLink
|
||||
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /security/i }));
|
||||
|
||||
const linkButton = await screen.findByRole('button', { name: /link google account/i });
|
||||
fireEvent.click(linkButton);
|
||||
|
||||
// The function should just return, so nothing should happen.
|
||||
await waitFor(() => {
|
||||
expect(notifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,17 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Correction with ID 999 not found');
|
||||
});
|
||||
|
||||
it('PUT /corrections/:id should return 500 on a generic DB error', async () => {
|
||||
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(
|
||||
new Error('Generic DB Error'),
|
||||
);
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/corrections/101')
|
||||
.send({ suggested_value: 'new value' });
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Generic DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flyer Review Routes', () => {
|
||||
@@ -294,6 +305,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(response.body).toEqual(mockBrands);
|
||||
});
|
||||
|
||||
it('GET /brands should return 500 on DB error', async () => {
|
||||
vi.mocked(mockedDb.flyerRepo.getAllBrands).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
||||
const brandId = 55;
|
||||
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockResolvedValue(undefined);
|
||||
@@ -500,6 +518,16 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
expect(response.body.message).toBe('Flyer with ID 999 not found.');
|
||||
});
|
||||
|
||||
it('DELETE /flyers/:flyerId should return 500 on a generic DB error', async () => {
|
||||
const flyerId = 42;
|
||||
vi.mocked(mockedDb.flyerRepo.deleteFlyer).mockRejectedValue(
|
||||
new Error('Generic DB Error'),
|
||||
);
|
||||
const response = await supertest(app).delete(`/api/admin/flyers/${flyerId}`);
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Generic DB Error');
|
||||
});
|
||||
|
||||
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
|
||||
const response = await supertest(app).delete('/api/admin/flyers/abc');
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -54,6 +54,14 @@ vi.mock('../services/workers.server', () => ({
|
||||
weeklyAnalyticsWorker: { name: 'weekly-analytics-reporting', isRunning: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the monitoring service directly to test route error handling
|
||||
vi.mock('../services/monitoringService.server', () => ({
|
||||
monitoringService: {
|
||||
getWorkerStatuses: vi.fn(),
|
||||
getQueueStatuses: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
|
||||
vi.mock('../services/db/flyer.db');
|
||||
vi.mock('../services/db/recipe.db');
|
||||
@@ -78,11 +86,8 @@ vi.mock('@bull-board/express', () => ({
|
||||
import adminRouter from './admin.routes';
|
||||
|
||||
// Import the mocked modules to control them
|
||||
import * as queueService from '../services/queueService.server';
|
||||
import * as workerService from '../services/workers.server';
|
||||
import { monitoringService } from '../services/monitoringService.server';
|
||||
import { adminRepo } from '../services/db/index.db';
|
||||
const mockedQueueService = queueService as Mocked<typeof queueService>;
|
||||
const mockedWorkerService = workerService as Mocked<typeof workerService>;
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
@@ -146,16 +151,26 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
|
||||
});
|
||||
|
||||
it('should return 500 if fetching activity log fails', async () => {
|
||||
vi.mocked(adminRepo.getActivityLog).mockRejectedValue(new Error('DB Error'));
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workers/status', () => {
|
||||
it('should return the status of all registered workers', async () => {
|
||||
// Arrange: Set the mock status for each worker
|
||||
vi.mocked(mockedWorkerService.flyerWorker.isRunning).mockReturnValue(true);
|
||||
vi.mocked(mockedWorkerService.emailWorker.isRunning).mockReturnValue(true);
|
||||
vi.mocked(mockedWorkerService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
|
||||
vi.mocked(mockedWorkerService.cleanupWorker.isRunning).mockReturnValue(true);
|
||||
vi.mocked(mockedWorkerService.weeklyAnalyticsWorker.isRunning).mockReturnValue(true);
|
||||
const mockStatuses = [
|
||||
{ name: 'flyer-processing', isRunning: true },
|
||||
{ name: 'email-sending', isRunning: true },
|
||||
{ name: 'analytics-reporting', isRunning: false },
|
||||
{ name: 'file-cleanup', isRunning: true },
|
||||
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||
];
|
||||
vi.mocked(monitoringService.getWorkerStatuses).mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/workers/status');
|
||||
@@ -170,51 +185,41 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
{ name: 'weekly-analytics-reporting', isRunning: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return 500 if fetching worker statuses fails', async () => {
|
||||
vi.mocked(monitoringService.getWorkerStatuses).mockRejectedValue(new Error('Worker Error'));
|
||||
const response = await supertest(app).get('/api/admin/workers/status');
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Worker Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /queues/status', () => {
|
||||
it('should return job counts for all registered queues', async () => {
|
||||
// Arrange: Set the mock job counts for each queue
|
||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({
|
||||
waiting: 5,
|
||||
active: 1,
|
||||
completed: 100,
|
||||
failed: 2,
|
||||
delayed: 0,
|
||||
paused: 0,
|
||||
});
|
||||
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({
|
||||
waiting: 0,
|
||||
active: 0,
|
||||
completed: 50,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
paused: 0,
|
||||
});
|
||||
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({
|
||||
waiting: 0,
|
||||
active: 1,
|
||||
completed: 10,
|
||||
failed: 1,
|
||||
delayed: 0,
|
||||
paused: 0,
|
||||
});
|
||||
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({
|
||||
waiting: 2,
|
||||
active: 0,
|
||||
completed: 25,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
paused: 0,
|
||||
});
|
||||
vi.mocked(mockedQueueService.weeklyAnalyticsQueue.getJobCounts).mockResolvedValue({
|
||||
waiting: 1,
|
||||
active: 0,
|
||||
completed: 5,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
paused: 0,
|
||||
});
|
||||
const mockStatuses = [
|
||||
{
|
||||
name: 'flyer-processing',
|
||||
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||
},
|
||||
{
|
||||
name: 'email-sending',
|
||||
counts: { waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 },
|
||||
},
|
||||
{
|
||||
name: 'analytics-reporting',
|
||||
counts: { waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 },
|
||||
},
|
||||
{
|
||||
name: 'file-cleanup',
|
||||
counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 },
|
||||
},
|
||||
{
|
||||
name: 'weekly-analytics-reporting',
|
||||
counts: { waiting: 1, active: 0, completed: 5, failed: 0, delayed: 0, paused: 0 },
|
||||
},
|
||||
];
|
||||
vi.mocked(monitoringService.getQueueStatuses).mockResolvedValue(mockStatuses);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/queues/status');
|
||||
@@ -246,7 +251,7 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
});
|
||||
|
||||
it('should return 500 if fetching queue counts fails', async () => {
|
||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockRejectedValue(
|
||||
vi.mocked(monitoringService.getQueueStatuses).mockRejectedValue(
|
||||
new Error('Redis is down'),
|
||||
);
|
||||
|
||||
|
||||
@@ -318,6 +318,76 @@ describe('AI Routes (/api/ai)', () => {
|
||||
// because URL parameters cannot easily simulate empty strings for min(1) validation checks via supertest routing.
|
||||
});
|
||||
|
||||
describe('POST /upload-legacy', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
const mockUser = createMockUserProfile({
|
||||
user: { user_id: 'legacy-user-1', email: 'legacy-user@test.com' },
|
||||
});
|
||||
// This route requires authentication, so we create an app instance with a user.
|
||||
const authenticatedApp = createTestApp({
|
||||
router: aiRouter,
|
||||
basePath: '/api/ai',
|
||||
authenticatedUser: mockUser,
|
||||
});
|
||||
|
||||
it('should process a legacy flyer and return 200 on success', async () => {
|
||||
// Arrange
|
||||
const mockFlyer = createMockFlyer({ flyer_id: 10 });
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockResolvedValue(mockFlyer);
|
||||
|
||||
// Act
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.field('some_legacy_field', 'value') // simulate some body data
|
||||
.attach('flyerFile', imagePath);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyer);
|
||||
expect(aiService.aiService.processLegacyFlyerUpload).toHaveBeenCalledWith(
|
||||
expect.any(Object), // req.file
|
||||
expect.any(Object), // req.body
|
||||
mockUser,
|
||||
expect.any(Object), // req.log
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if no flyer file is uploaded', async () => {
|
||||
const response = await supertest(authenticatedApp)
|
||||
.post('/api/ai/upload-legacy')
|
||||
.field('some_legacy_field', 'value');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('No flyer file uploaded.');
|
||||
});
|
||||
|
||||
it('should return 409 and cleanup file if a duplicate flyer is detected', async () => {
|
||||
const duplicateError = new aiService.DuplicateFlyerError('Duplicate legacy flyer.', 101);
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(duplicateError);
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body.message).toBe('Duplicate legacy flyer.');
|
||||
expect(response.body.flyerId).toBe(101);
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return 500 and cleanup file on a generic service error', async () => {
|
||||
vi.mocked(aiService.aiService.processLegacyFlyerUpload).mockRejectedValue(new Error('Internal service failure'));
|
||||
const unlinkSpy = vi.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
|
||||
|
||||
const response = await supertest(authenticatedApp).post('/api/ai/upload-legacy').attach('flyerFile', imagePath);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('Internal service failure');
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /flyers/process (Legacy)', () => {
|
||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
||||
const mockDataPayload = {
|
||||
|
||||
@@ -550,12 +550,15 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
expect(setCookieHeader[0]).toContain('Max-Age=0');
|
||||
});
|
||||
|
||||
it('should still return 200 OK even if deleting the refresh token from DB fails', async () => {
|
||||
it('should still return 200 OK and log an error if deleting the refresh token from DB fails', async () => {
|
||||
// Arrange
|
||||
const dbError = new Error('DB connection lost');
|
||||
mockedAuthService.logout.mockRejectedValue(dbError);
|
||||
const { logger } = await import('../services/logger.server');
|
||||
|
||||
// Spy on logger.error to ensure it's called
|
||||
const errorSpy = vi.spyOn(logger, 'error');
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.post('/api/auth/logout')
|
||||
@@ -563,7 +566,12 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
|
||||
// Because authService.logout is fire-and-forget (not awaited), we need to
|
||||
// give the event loop a moment to process the rejected promise and trigger the .catch() block.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: dbError }),
|
||||
'Logout token invalidation failed in background.',
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('../services/db/index.db', () => ({
|
||||
getFlyerItems: vi.fn(),
|
||||
getFlyerItemsForFlyers: vi.fn(),
|
||||
countFlyerItemsForFlyers: vi.fn(),
|
||||
trackFlyerItemInteraction: vi.fn(),
|
||||
trackFlyerItemInteraction: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockFlyers);
|
||||
// Also assert that the default limit and offset were used.
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 0);
|
||||
});
|
||||
|
||||
it('should pass limit and offset query parameters to the db function', async () => {
|
||||
@@ -58,6 +60,18 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 15, 30);
|
||||
});
|
||||
|
||||
it('should use default for offset when only limit is provided', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/flyers?limit=5');
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 5, 0);
|
||||
});
|
||||
|
||||
it('should use default for limit when only offset is provided', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
|
||||
await supertest(app).get('/api/flyers?offset=10');
|
||||
expect(db.flyerRepo.getFlyers).toHaveBeenCalledWith(expectLogger, 20, 10);
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
vi.mocked(db.flyerRepo.getFlyers).mockRejectedValue(dbError);
|
||||
@@ -151,7 +165,7 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.message).toBe('DB Error');
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: dbError },
|
||||
{ error: dbError, flyerId: 123 },
|
||||
'Error fetching flyer items in /api/flyers/:id/items:',
|
||||
);
|
||||
});
|
||||
@@ -276,5 +290,24 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
.send({ type: 'invalid' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 202 and log an error if the tracking function fails', async () => {
|
||||
const trackingError = new Error('Tracking DB is down');
|
||||
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockRejectedValue(trackingError);
|
||||
|
||||
const response = await supertest(app)
|
||||
.post('/api/flyers/items/99/track')
|
||||
.send({ type: 'click' });
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
|
||||
// Allow the event loop to process the unhandled promise rejection from the fire-and-forget call
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ error: trackingError, itemId: 99 },
|
||||
'Flyer item interaction tracking failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,12 +48,12 @@ const trackItemSchema = z.object({
|
||||
/**
|
||||
* GET /api/flyers - Get a paginated list of all flyers.
|
||||
*/
|
||||
type GetFlyersRequest = z.infer<typeof getFlyersSchema>;
|
||||
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
|
||||
const { query } = req as unknown as GetFlyersRequest;
|
||||
try {
|
||||
const limit = query.limit ? Number(query.limit) : 20;
|
||||
const offset = query.offset ? Number(query.offset) : 0;
|
||||
// The `validateRequest` middleware ensures `req.query` is valid.
|
||||
// We parse it here to apply Zod's coercions (string to number) and defaults.
|
||||
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
|
||||
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
||||
res.json(flyers);
|
||||
} catch (error) {
|
||||
@@ -65,14 +65,14 @@ router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promis
|
||||
/**
|
||||
* GET /api/flyers/:id - Get a single flyer by its ID.
|
||||
*/
|
||||
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
||||
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
|
||||
const { params } = req as unknown as GetFlyerByIdRequest;
|
||||
try {
|
||||
const flyer = await db.flyerRepo.getFlyerById(params.id);
|
||||
// Explicitly parse to get the coerced number type for `id`.
|
||||
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||
const flyer = await db.flyerRepo.getFlyerById(id);
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
req.log.error({ error, flyerId: params.id }, 'Error fetching flyer by ID:');
|
||||
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -84,12 +84,14 @@ router.get(
|
||||
'/:id/items',
|
||||
validateRequest(flyerIdParamSchema),
|
||||
async (req, res, next): Promise<void> => {
|
||||
const { params } = req as unknown as GetFlyerByIdRequest;
|
||||
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
|
||||
try {
|
||||
const items = await db.flyerRepo.getFlyerItems(params.id, req.log);
|
||||
// Explicitly parse to get the coerced number type for `id`.
|
||||
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
|
||||
const items = await db.flyerRepo.getFlyerItems(id, req.log);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching flyer items in /api/flyers/:id/items:');
|
||||
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer items in /api/flyers/:id/items:');
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
@@ -105,6 +107,8 @@ router.post(
|
||||
async (req, res, next): Promise<void> => {
|
||||
const { body } = req as unknown as BatchFetchRequest;
|
||||
try {
|
||||
// No re-parsing needed here as `validateRequest` has already ensured the body shape,
|
||||
// and `express.json()` has parsed it. There's no type coercion to apply.
|
||||
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
@@ -124,8 +128,9 @@ router.post(
|
||||
async (req, res, next): Promise<void> => {
|
||||
const { body } = req as unknown as BatchCountRequest;
|
||||
try {
|
||||
// The DB function handles an empty array, so we can simplify.
|
||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
|
||||
// The schema ensures flyerIds is an array of numbers.
|
||||
// The `?? []` was redundant as `validateRequest` would have already caught a missing `flyerIds`.
|
||||
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error counting batch flyer items');
|
||||
@@ -137,11 +142,22 @@ router.post(
|
||||
/**
|
||||
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
|
||||
*/
|
||||
type TrackItemRequest = z.infer<typeof trackItemSchema>;
|
||||
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res): void => {
|
||||
const { params, body } = req as unknown as TrackItemRequest;
|
||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log);
|
||||
res.status(202).send();
|
||||
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res, next): void => {
|
||||
try {
|
||||
// Explicitly parse to get coerced types.
|
||||
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
||||
|
||||
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
|
||||
// We add a .catch to log any potential errors without crashing the server process.
|
||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
|
||||
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
|
||||
});
|
||||
|
||||
res.status(202).send();
|
||||
} catch (error) {
|
||||
// This will catch Zod parsing errors if they occur.
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -414,6 +414,29 @@ describe('Passport Configuration', () => {
|
||||
// Assert
|
||||
expect(done).toHaveBeenCalledWith(dbError, false);
|
||||
});
|
||||
|
||||
it('should call done(err, false) if jwt_payload is null', async () => {
|
||||
// Arrange
|
||||
const jwtPayload = null;
|
||||
const done = vi.fn();
|
||||
|
||||
// Act
|
||||
// We know the mock setup populates the callback.
|
||||
if (verifyCallbackWrapper.callback) {
|
||||
// The strategy would not even call the callback if the token is invalid/missing.
|
||||
// However, to test the robustness of our callback, we can invoke it directly with null.
|
||||
await verifyCallbackWrapper.callback(jwtPayload as any, done);
|
||||
}
|
||||
|
||||
// Assert
|
||||
// The code will throw a TypeError because it tries to access 'user_id' of null.
|
||||
// The catch block in the strategy will catch this and call done(err, false).
|
||||
expect(done).toHaveBeenCalledWith(expect.any(TypeError), false);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: expect.any(TypeError) },
|
||||
'Error during JWT authentication strategy:',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAdmin Middleware', () => {
|
||||
@@ -477,6 +500,54 @@ describe('Passport Configuration', () => {
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should return 403 Forbidden for various invalid user object shapes', () => {
|
||||
const mockNext = vi.fn();
|
||||
const mockRes: Partial<Response> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
};
|
||||
|
||||
// Case 1: user is not an object (e.g., a string)
|
||||
const req1 = { user: 'not-an-object' } as unknown as Request;
|
||||
isAdmin(req1, mockRes as Response, mockNext);
|
||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 2: user is null
|
||||
const req2 = { user: null } as unknown as Request;
|
||||
isAdmin(req2, mockRes as Response, mockNext);
|
||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 3: user object is missing 'user' property
|
||||
const req3 = { user: { role: 'admin' } } as unknown as Request;
|
||||
isAdmin(req3, mockRes as Response, mockNext);
|
||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 4: user.user is not an object
|
||||
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
|
||||
isAdmin(req4, mockRes as Response, mockNext);
|
||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 5: user.user is missing 'user_id'
|
||||
const req5 = {
|
||||
user: { role: 'admin', user: { email: 'test@test.com' } },
|
||||
} as unknown as Request;
|
||||
isAdmin(req5, mockRes as Response, mockNext);
|
||||
expect(mockRes.status).toHaveBeenLastCalledWith(403);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset the main mockNext for other tests in the suite
|
||||
mockNext.mockClear();
|
||||
});
|
||||
|
||||
it('should return 403 Forbidden if req.user is not a valid UserProfile object', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
@@ -611,13 +682,18 @@ describe('Passport Configuration', () => {
|
||||
optionalAuth(mockReq, mockRes as Response, mockNext);
|
||||
|
||||
// Assert
|
||||
// The new implementation logs a warning and proceeds.
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ error: authError },
|
||||
'Optional auth encountered an error, proceeding anonymously.',
|
||||
);
|
||||
expect(mockReq.user).toBeUndefined();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mockAuth Middleware', () => {
|
||||
const mockNext: NextFunction = vi.fn();
|
||||
const mockNext: NextFunction = vi.fn(); // This was a duplicate, fixed.
|
||||
const mockRes: Partial<Response> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
|
||||
@@ -323,12 +323,17 @@ export const optionalAuth = (req: Request, res: Response, next: NextFunction) =>
|
||||
'jwt',
|
||||
{ session: false },
|
||||
(err: Error | null, user: Express.User | false, info: { message: string } | Error) => {
|
||||
// If there's an authentication error (e.g., malformed token), log it but don't block the request.
|
||||
if (err) {
|
||||
// An actual error occurred during authentication (e.g., malformed token).
|
||||
// For optional auth, we log this but still proceed without a user.
|
||||
logger.warn({ error: err }, 'Optional auth encountered an error, proceeding anonymously.');
|
||||
return next();
|
||||
}
|
||||
if (info) {
|
||||
// The patch requested this specific error handling.
|
||||
logger.info({ info: info.message || info.toString() }, 'Optional auth info:');
|
||||
} // The patch requested this specific error handling.
|
||||
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds
|
||||
}
|
||||
if (user) (req as Express.Request).user = user; // Attach user if authentication succeeds.
|
||||
|
||||
next(); // Always proceed to the next middleware
|
||||
},
|
||||
|
||||
@@ -1140,6 +1140,19 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 404 if recipe not found', async () => {
|
||||
vi.mocked(db.recipeRepo.deleteRecipe).mockRejectedValue(new NotFoundError('Recipe not found'));
|
||||
const response = await supertest(app).delete('/api/users/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Recipe not found');
|
||||
});
|
||||
|
||||
it('DELETE /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||
const response = await supertest(app).delete('/api/users/recipes/abc');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
});
|
||||
|
||||
it("PUT /recipes/:recipeId should update a user's own recipe", async () => {
|
||||
const updates = { description: 'A new delicious description.' };
|
||||
const mockUpdatedRecipe = createMockRecipe({ recipe_id: 1, ...updates });
|
||||
@@ -1181,6 +1194,14 @@ describe('User Routes (/api/users)', () => {
|
||||
expect(response.body.errors[0].message).toBe('No fields provided to update.');
|
||||
});
|
||||
|
||||
it('PUT /recipes/:recipeId should return 400 for invalid recipe ID', async () => {
|
||||
const response = await supertest(app)
|
||||
.put('/api/users/recipes/abc')
|
||||
.send({ name: 'New Name' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.errors[0].message).toContain('received NaN');
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(
|
||||
new NotFoundError('Shopping list not found'),
|
||||
|
||||
@@ -215,7 +215,11 @@ export class AIService {
|
||||
this.logger.warn(
|
||||
'[AIService] Mock generateContent called. This should only happen in tests when no API key is available.',
|
||||
);
|
||||
return { text: '[]' } as unknown as GenerateContentResponse;
|
||||
// Return a minimal valid JSON object structure to prevent downstream parsing errors.
|
||||
const mockResponse = { store_name: 'Mock Store', items: [] };
|
||||
return {
|
||||
text: JSON.stringify(mockResponse),
|
||||
} as unknown as GenerateContentResponse;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -818,6 +822,7 @@ async enqueueFlyerProcessing(
|
||||
body: any,
|
||||
logger: Logger,
|
||||
): { parsed: FlyerProcessPayload; extractedData: Partial<ExtractedCoreData> | null | undefined } {
|
||||
logger.debug({ body, type: typeof body }, '[AIService] Starting _parseLegacyPayload');
|
||||
let parsed: FlyerProcessPayload = {};
|
||||
|
||||
try {
|
||||
@@ -826,6 +831,7 @@ async enqueueFlyerProcessing(
|
||||
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse top-level request body string.');
|
||||
return { parsed: {}, extractedData: {} };
|
||||
}
|
||||
logger.debug({ parsed }, '[AIService] Parsed top-level body');
|
||||
|
||||
// If the real payload is nested inside a 'data' property (which could be a string),
|
||||
// we parse it out but keep the original `parsed` object for top-level properties like checksum.
|
||||
@@ -841,13 +847,16 @@ async enqueueFlyerProcessing(
|
||||
potentialPayload = parsed.data;
|
||||
}
|
||||
}
|
||||
logger.debug({ potentialPayload }, '[AIService] Potential payload after checking "data" property');
|
||||
|
||||
// The extracted data is either in an `extractedData` key or is the payload itself.
|
||||
const extractedData = potentialPayload.extractedData ?? potentialPayload;
|
||||
logger.debug({ extractedData: !!extractedData }, '[AIService] Extracted data object');
|
||||
|
||||
// Merge for checksum lookup: properties in the outer `parsed` object (like a top-level checksum)
|
||||
// take precedence over any same-named properties inside `potentialPayload`.
|
||||
const finalParsed = { ...potentialPayload, ...parsed };
|
||||
logger.debug({ finalParsed }, '[AIService] Final parsed object for checksum lookup');
|
||||
|
||||
return { parsed: finalParsed, extractedData };
|
||||
}
|
||||
@@ -858,10 +867,12 @@ async enqueueFlyerProcessing(
|
||||
userProfile: UserProfile | undefined,
|
||||
logger: Logger,
|
||||
): Promise<Flyer> {
|
||||
logger.debug({ body, file }, '[AIService] Starting processLegacyFlyerUpload');
|
||||
const { parsed, extractedData: initialExtractedData } = this._parseLegacyPayload(body, logger);
|
||||
let extractedData = initialExtractedData;
|
||||
|
||||
const checksum = parsed.checksum ?? parsed?.data?.checksum ?? '';
|
||||
logger.debug({ checksum, parsed }, '[AIService] Extracted checksum from legacy payload');
|
||||
if (!checksum) {
|
||||
throw new ValidationError([], 'Checksum is required.');
|
||||
}
|
||||
@@ -900,8 +911,10 @@ async enqueueFlyerProcessing(
|
||||
const iconFileName = await generateFlyerIcon(file.path, iconsDir, logger);
|
||||
|
||||
const baseUrl = getBaseUrl(logger);
|
||||
logger.debug({ baseUrl, file }, 'Building legacy URLs');
|
||||
const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`;
|
||||
const imageUrl = `${baseUrl}/flyer-images/${file.filename}`;
|
||||
logger.debug({ imageUrl, iconUrl }, 'Constructed legacy URLs');
|
||||
|
||||
const flyerData: FlyerInsert = {
|
||||
file_name: originalFileName,
|
||||
|
||||
@@ -134,7 +134,6 @@ describe('AuthService', () => {
|
||||
'hashed-password',
|
||||
{ full_name: 'Test User', avatar_url: undefined },
|
||||
reqLog,
|
||||
{},
|
||||
);
|
||||
expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -40,7 +40,6 @@ class AuthService {
|
||||
hashedPassword,
|
||||
{ full_name: fullName, avatar_url: avatarUrl },
|
||||
reqLog,
|
||||
client, // Pass the transactional client
|
||||
);
|
||||
|
||||
logger.info(`Successfully created new user in DB: ${newUser.user.email} (ID: ${newUser.user.user_id})`);
|
||||
|
||||
@@ -282,6 +282,95 @@ describe('User DB Service', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('_createUser (private)', () => {
|
||||
it('should execute queries in order and return a full user profile', async () => {
|
||||
const mockUser = {
|
||||
user_id: 'private-user-id',
|
||||
email: 'private@example.com',
|
||||
};
|
||||
const mockDbProfile = {
|
||||
user_id: 'private-user-id',
|
||||
email: 'private@example.com',
|
||||
role: 'user',
|
||||
full_name: 'Private User',
|
||||
avatar_url: null,
|
||||
points: 0,
|
||||
preferences: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
user_created_at: new Date().toISOString(),
|
||||
user_updated_at: new Date().toISOString(),
|
||||
};
|
||||
const expectedProfile: UserProfile = {
|
||||
user: {
|
||||
user_id: mockDbProfile.user_id,
|
||||
email: mockDbProfile.email,
|
||||
created_at: mockDbProfile.user_created_at,
|
||||
updated_at: mockDbProfile.user_updated_at,
|
||||
},
|
||||
full_name: 'Private User',
|
||||
avatar_url: null,
|
||||
role: 'user',
|
||||
points: 0,
|
||||
preferences: null,
|
||||
created_at: mockDbProfile.created_at,
|
||||
updated_at: mockDbProfile.updated_at,
|
||||
};
|
||||
|
||||
// Mock the sequence of queries on the client
|
||||
(mockPoolInstance.query as Mock)
|
||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||
.mockResolvedValueOnce({ rows: [mockDbProfile] }); // SELECT profile
|
||||
|
||||
// Access private method for testing
|
||||
const result = await (userRepo as any)._createUser(
|
||||
mockPoolInstance, // Pass the mock client
|
||||
'private@example.com',
|
||||
'hashedpass',
|
||||
{ full_name: 'Private User' },
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
expect(result).toEqual(expectedProfile);
|
||||
expect(mockPoolInstance.query).toHaveBeenCalledTimes(3);
|
||||
expect(mockPoolInstance.query).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"SELECT set_config('my_app.user_metadata', $1, true)",
|
||||
[JSON.stringify({ full_name: 'Private User' })],
|
||||
);
|
||||
expect(mockPoolInstance.query).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||
['private@example.com', 'hashedpass'],
|
||||
);
|
||||
expect(mockPoolInstance.query).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining('FROM public.users u'),
|
||||
['private-user-id'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if profile is not found after user creation', async () => {
|
||||
const mockUser = { user_id: 'no-profile-user', email: 'no-profile@example.com' };
|
||||
|
||||
(mockPoolInstance.query as Mock)
|
||||
.mockResolvedValueOnce({ rows: [] }) // set_config
|
||||
.mockResolvedValueOnce({ rows: [mockUser] }) // INSERT user
|
||||
.mockResolvedValueOnce({ rows: [] }); // SELECT profile returns nothing
|
||||
|
||||
await expect(
|
||||
(userRepo as any)._createUser(
|
||||
mockPoolInstance,
|
||||
'no-profile@example.com',
|
||||
'pass',
|
||||
{},
|
||||
mockLogger,
|
||||
),
|
||||
).rejects.toThrow('Failed to create or retrieve user profile after registration.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserWithProfileByEmail', () => {
|
||||
it('should query for a user and their profile by email', async () => {
|
||||
const mockDbResult: any = {
|
||||
|
||||
@@ -61,6 +61,64 @@ export class UserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The internal logic for creating a user. This method assumes it is being run
|
||||
* within a database transaction and operates on a single PoolClient.
|
||||
*/
|
||||
private async _createUser(
|
||||
dbClient: PoolClient,
|
||||
email: string,
|
||||
passwordHash: string | null,
|
||||
profileData: { full_name?: string; avatar_url?: string },
|
||||
logger: Logger,
|
||||
): Promise<UserProfile> {
|
||||
logger.debug(`[DB _createUser] Starting user creation for email: ${email}`);
|
||||
|
||||
await dbClient.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
||||
JSON.stringify(profileData ?? {}),
|
||||
]);
|
||||
logger.debug(`[DB _createUser] Session metadata set for ${email}.`);
|
||||
|
||||
const userInsertRes = await dbClient.query<{ user_id: string; email: string }>(
|
||||
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||
[email, passwordHash],
|
||||
);
|
||||
const newUserId = userInsertRes.rows[0].user_id;
|
||||
logger.debug(`[DB _createUser] Inserted into users table. New user ID: ${newUserId}`);
|
||||
|
||||
const profileQuery = `
|
||||
SELECT u.user_id, u.email, u.created_at as user_created_at, u.updated_at as user_updated_at, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.created_at, p.updated_at
|
||||
FROM public.users u
|
||||
JOIN public.profiles p ON u.user_id = p.user_id
|
||||
WHERE u.user_id = $1;
|
||||
`;
|
||||
const finalProfileRes = await dbClient.query(profileQuery, [newUserId]);
|
||||
const flatProfile = finalProfileRes.rows[0];
|
||||
|
||||
if (!flatProfile) {
|
||||
throw new Error('Failed to create or retrieve user profile after registration.');
|
||||
}
|
||||
|
||||
const fullUserProfile: UserProfile = {
|
||||
user: {
|
||||
user_id: flatProfile.user_id,
|
||||
email: flatProfile.email,
|
||||
created_at: flatProfile.user_created_at,
|
||||
updated_at: flatProfile.user_updated_at,
|
||||
},
|
||||
full_name: flatProfile.full_name,
|
||||
avatar_url: flatProfile.avatar_url,
|
||||
role: flatProfile.role,
|
||||
points: flatProfile.points,
|
||||
preferences: flatProfile.preferences,
|
||||
created_at: flatProfile.created_at,
|
||||
updated_at: flatProfile.updated_at,
|
||||
};
|
||||
|
||||
logger.debug({ user: fullUserProfile }, `[DB _createUser] Fetched full profile for new user:`);
|
||||
return fullUserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user in the public.users table.
|
||||
* This method expects to be run within a transaction, so it requires a PoolClient.
|
||||
@@ -74,60 +132,18 @@ export class UserRepository {
|
||||
passwordHash: string | null,
|
||||
profileData: { full_name?: string; avatar_url?: string },
|
||||
logger: Logger,
|
||||
// Allow passing a transactional client
|
||||
client: Pool | PoolClient = this.db,
|
||||
): Promise<UserProfile> {
|
||||
// This method is now a wrapper that ensures the core logic runs within a transaction.
|
||||
try {
|
||||
logger.debug(`[DB createUser] Starting user creation for email: ${email}`);
|
||||
|
||||
// Use 'set_config' to safely pass parameters to a configuration variable.
|
||||
await client.query("SELECT set_config('my_app.user_metadata', $1, true)", [
|
||||
JSON.stringify(profileData),
|
||||
]);
|
||||
logger.debug(`[DB createUser] Session metadata set for ${email}.`);
|
||||
|
||||
// Insert the new user into the 'users' table. This will fire the trigger.
|
||||
const userInsertRes = await client.query<{ user_id: string }>(
|
||||
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING user_id, email',
|
||||
[email, passwordHash],
|
||||
);
|
||||
const newUserId = userInsertRes.rows[0].user_id;
|
||||
logger.debug(`[DB createUser] Inserted into users table. New user ID: ${newUserId}`);
|
||||
|
||||
// After the trigger has run, fetch the complete profile data.
|
||||
const profileQuery = `
|
||||
SELECT u.user_id, u.email, u.created_at as user_created_at, u.updated_at as user_updated_at, p.full_name, p.avatar_url, p.role, p.points, p.preferences, p.created_at, p.updated_at
|
||||
FROM public.users u
|
||||
JOIN public.profiles p ON u.user_id = p.user_id
|
||||
WHERE u.user_id = $1;
|
||||
`;
|
||||
const finalProfileRes = await client.query(profileQuery, [newUserId]);
|
||||
const flatProfile = finalProfileRes.rows[0];
|
||||
|
||||
if (!flatProfile) {
|
||||
throw new Error('Failed to create or retrieve user profile after registration.');
|
||||
// If this.db has a 'connect' method, it's a Pool. We must start a transaction.
|
||||
if ('connect' in this.db) {
|
||||
return await withTransaction(async (client) => {
|
||||
return this._createUser(client, email, passwordHash, profileData, logger);
|
||||
});
|
||||
} else {
|
||||
// If this.db is already a PoolClient, we're inside a transaction. Use it directly.
|
||||
return await this._createUser(this.db as PoolClient, email, passwordHash, profileData, logger);
|
||||
}
|
||||
|
||||
// Construct the nested UserProfile object to match the type definition.
|
||||
const fullUserProfile: UserProfile = {
|
||||
// user_id is now correctly part of the nested user object, not at the top level.
|
||||
user: {
|
||||
user_id: flatProfile.user_id,
|
||||
email: flatProfile.email,
|
||||
created_at: flatProfile.user_created_at,
|
||||
updated_at: flatProfile.user_updated_at,
|
||||
},
|
||||
full_name: flatProfile.full_name,
|
||||
avatar_url: flatProfile.avatar_url,
|
||||
role: flatProfile.role,
|
||||
points: flatProfile.points,
|
||||
preferences: flatProfile.preferences,
|
||||
created_at: flatProfile.created_at,
|
||||
updated_at: flatProfile.updated_at,
|
||||
};
|
||||
|
||||
logger.debug({ user: fullUserProfile }, `[DB createUser] Fetched full profile for new user:`);
|
||||
return fullUserProfile;
|
||||
} catch (error) {
|
||||
handleDbError(error, logger, 'Error during createUser', { email }, {
|
||||
uniqueMessage: 'A user with this email address already exists.',
|
||||
@@ -136,6 +152,7 @@ export class UserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds a user by their email and joins their profile data.
|
||||
* This is used by the LocalStrategy to get all necessary data for authentication and session creation in one query.
|
||||
|
||||
@@ -77,6 +77,7 @@ export class FlyerDataTransformer {
|
||||
baseUrl: string | undefined,
|
||||
logger: Logger,
|
||||
): { imageUrl: string; iconUrl: string } {
|
||||
logger.debug({ firstImage, iconFileName, baseUrl }, 'Building URLs');
|
||||
let finalBaseUrl = baseUrl;
|
||||
if (!finalBaseUrl) {
|
||||
const port = process.env.PORT || 3000;
|
||||
@@ -84,8 +85,10 @@ export class FlyerDataTransformer {
|
||||
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
|
||||
}
|
||||
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
|
||||
const imageUrl = `${finalBaseUrl}/flyer-images/${path.basename(firstImage)}`;
|
||||
const imageBasename = path.basename(firstImage);
|
||||
const imageUrl = `${finalBaseUrl}/flyer-images/${imageBasename}`;
|
||||
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
|
||||
logger.debug({ imageUrl, iconUrl, imageBasename }, 'Constructed URLs');
|
||||
return { imageUrl, iconUrl };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PdfConversionError,
|
||||
UnsupportedFileTypeError,
|
||||
TransformationError,
|
||||
DatabaseError,
|
||||
} from './processingErrors';
|
||||
import { NotFoundError } from './db/errors.db';
|
||||
import { FlyerFileHandler } from './flyerFileHandler.server';
|
||||
@@ -400,8 +401,8 @@ describe('FlyerProcessingService', () => {
|
||||
// This is more realistic than mocking the inner function `createFlyerAndItems`.
|
||||
vi.mocked(mockedDb.withTransaction).mockRejectedValue(dbError);
|
||||
|
||||
// The service wraps the generic DB error in a DatabaseError, but _reportErrorAndThrow re-throws the original.
|
||||
await expect(service.processJob(job)).rejects.toThrow(dbError);
|
||||
// The service wraps the generic DB error in a DatabaseError.
|
||||
await expect(service.processJob(job)).rejects.toThrow(DatabaseError);
|
||||
|
||||
// The final progress update should reflect the structured DatabaseError.
|
||||
expect(job.updateProgress).toHaveBeenLastCalledWith({
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// src/tests/e2e/admin-dashboard.e2e.test.ts
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
describe('E2E Admin Dashboard Flow', () => {
|
||||
// Use a unique email for every run to avoid collisions
|
||||
const uniqueId = Date.now();
|
||||
@@ -21,25 +19,18 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
// Safety cleanup: Ensure the user is deleted from the DB if the test fails mid-way.
|
||||
if (adminUserId) {
|
||||
try {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUserId]);
|
||||
} catch (err) {
|
||||
console.error('Error cleaning up E2E admin user:', err);
|
||||
}
|
||||
}
|
||||
await cleanupDb({
|
||||
userIds: [adminUserId],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow an admin to log in and access dashboard features', async () => {
|
||||
// 1. Register a new user (initially a regular user)
|
||||
const registerResponse = await request.post('/api/auth/register').send({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
full_name: 'E2E Admin User',
|
||||
});
|
||||
const registerResponse = await apiClient.registerUser(adminEmail, adminPassword, 'E2E Admin User');
|
||||
|
||||
expect(registerResponse.status).toBe(201);
|
||||
const registeredUser = registerResponse.body.userprofile.user;
|
||||
const registerData = await registerResponse.json();
|
||||
const registeredUser = registerData.userprofile.user;
|
||||
adminUserId = registeredUser.user_id;
|
||||
expect(adminUserId).toBeDefined();
|
||||
|
||||
@@ -50,46 +41,43 @@ describe('E2E Admin Dashboard Flow', () => {
|
||||
]);
|
||||
|
||||
// 3. Login to get the access token (now with admin privileges)
|
||||
const loginResponse = await request.post('/api/auth/login').send({
|
||||
email: adminEmail,
|
||||
password: adminPassword,
|
||||
});
|
||||
const loginResponse = await apiClient.loginUser(adminEmail, adminPassword, false);
|
||||
|
||||
expect(loginResponse.status).toBe(200);
|
||||
authToken = loginResponse.body.token;
|
||||
const loginData = await loginResponse.json();
|
||||
authToken = loginData.token;
|
||||
expect(authToken).toBeDefined();
|
||||
// Verify the role returned in the login response is now 'admin'
|
||||
expect(loginResponse.body.userprofile.role).toBe('admin');
|
||||
expect(loginData.userprofile.role).toBe('admin');
|
||||
|
||||
// 4. Fetch System Stats (Protected Admin Route)
|
||||
const statsResponse = await request
|
||||
.get('/api/admin/stats')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
const statsResponse = await apiClient.getApplicationStats(authToken);
|
||||
|
||||
expect(statsResponse.status).toBe(200);
|
||||
expect(statsResponse.body).toHaveProperty('userCount');
|
||||
expect(statsResponse.body).toHaveProperty('flyerCount');
|
||||
const statsData = await statsResponse.json();
|
||||
expect(statsData).toHaveProperty('userCount');
|
||||
expect(statsData).toHaveProperty('flyerCount');
|
||||
|
||||
// 5. Fetch User List (Protected Admin Route)
|
||||
const usersResponse = await request
|
||||
.get('/api/admin/users')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
const usersResponse = await apiClient.authedGet('/admin/users', { tokenOverride: authToken });
|
||||
|
||||
expect(usersResponse.status).toBe(200);
|
||||
expect(Array.isArray(usersResponse.body)).toBe(true);
|
||||
const usersData = await usersResponse.json();
|
||||
expect(Array.isArray(usersData)).toBe(true);
|
||||
// The list should contain the admin user we just created
|
||||
const self = usersResponse.body.find((u: any) => u.user_id === adminUserId);
|
||||
const self = usersData.find((u: any) => u.user_id === adminUserId);
|
||||
expect(self).toBeDefined();
|
||||
|
||||
// 6. Check Queue Status (Protected Admin Route)
|
||||
const queueResponse = await request
|
||||
.get('/api/admin/queues/status')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
const queueResponse = await apiClient.authedGet('/admin/queues/status', {
|
||||
tokenOverride: authToken,
|
||||
});
|
||||
|
||||
expect(queueResponse.status).toBe(200);
|
||||
expect(Array.isArray(queueResponse.body)).toBe(true);
|
||||
const queueData = await queueResponse.json();
|
||||
expect(Array.isArray(queueData)).toBe(true);
|
||||
// Verify that the 'flyer-processing' queue is present in the status report
|
||||
const flyerQueue = queueResponse.body.find((q: any) => q.name === 'flyer-processing');
|
||||
const flyerQueue = queueData.find((q: any) => q.name === 'flyer-processing');
|
||||
expect(flyerQueue).toBeDefined();
|
||||
expect(flyerQueue.counts).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { poll } from '../utils/poll';
|
||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||
import type { UserProfile } from '../../types';
|
||||
|
||||
@@ -178,34 +179,26 @@ describe('Authentication E2E Flow', () => {
|
||||
expect(registerResponse.status).toBe(201);
|
||||
createdUserIds.push(registerData.userprofile.user.user_id);
|
||||
|
||||
// Instead of a fixed delay, poll by attempting to log in. This is more robust
|
||||
// and confirms the user record is committed and readable by subsequent transactions.
|
||||
let loginSuccess = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Poll for up to 10 seconds
|
||||
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
||||
if (loginResponse.ok) {
|
||||
loginSuccess = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
expect(loginSuccess, 'User should be able to log in after registration. DB might be lagging.').toBe(true);
|
||||
// Poll until the user can log in, confirming the record has propagated.
|
||||
await poll(
|
||||
() => apiClient.loginUser(email, TEST_PASSWORD, false),
|
||||
(response) => response.ok,
|
||||
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
|
||||
);
|
||||
|
||||
// Act 1: Request a password reset
|
||||
const forgotResponse = await apiClient.requestPasswordReset(email);
|
||||
const forgotData = await forgotResponse.json();
|
||||
const resetToken = forgotData.token;
|
||||
|
||||
// --- DEBUG SECTION FOR FAILURE ---
|
||||
if (!resetToken) {
|
||||
console.error(' [DEBUG FAILURE] Token missing in response:', JSON.stringify(forgotData, null, 2));
|
||||
console.error(' [DEBUG FAILURE] This usually means the backend hit a DB error or is not in NODE_ENV=test mode.');
|
||||
}
|
||||
// ---------------------------------
|
||||
// Poll for the password reset token.
|
||||
const { response: forgotResponse, token: resetToken } = await poll(
|
||||
async () => {
|
||||
const response = await apiClient.requestPasswordReset(email);
|
||||
// Clone to read body without consuming the original response stream
|
||||
const data = response.ok ? await response.clone().json() : {};
|
||||
return { response, token: data.token };
|
||||
},
|
||||
(result) => !!result.token,
|
||||
{ timeout: 10000, interval: 1000, description: 'password reset token generation' },
|
||||
);
|
||||
|
||||
// Assert 1: Check that we received a token.
|
||||
expect(forgotResponse.status).toBe(200);
|
||||
expect(resetToken, 'Backend returned 200 but no token. Check backend logs for "Connection terminated" errors.').toBeDefined();
|
||||
expect(resetToken).toBeTypeOf('string');
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// src/tests/e2e/flyer-upload.e2e.test.ts
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import crypto from 'crypto';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { poll } from '../utils/poll';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
const uniqueId = Date.now();
|
||||
const userEmail = `e2e-uploader-${uniqueId}@example.com`;
|
||||
@@ -23,33 +21,24 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
let flyerId: number | null = null;
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup: Delete the flyer and user created during the test
|
||||
const pool = getPool();
|
||||
if (flyerId) {
|
||||
await pool.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]);
|
||||
}
|
||||
if (userId) {
|
||||
await pool.query('DELETE FROM public.users WHERE user_id = $1', [userId]);
|
||||
}
|
||||
// Use the centralized cleanup utility for robustness.
|
||||
await cleanupDb({
|
||||
userIds: [userId],
|
||||
flyerIds: [flyerId],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a user to upload a flyer and wait for processing to complete', async () => {
|
||||
// 1. Register a new user
|
||||
const registerResponse = await request.post('/api/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'E2E Flyer Uploader',
|
||||
});
|
||||
const registerResponse = await apiClient.registerUser(userEmail, userPassword, 'E2E Flyer Uploader');
|
||||
expect(registerResponse.status).toBe(201);
|
||||
|
||||
// 2. Login to get the access token
|
||||
const loginResponse = await request.post('/api/auth/login').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
});
|
||||
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
|
||||
expect(loginResponse.status).toBe(200);
|
||||
authToken = loginResponse.body.token;
|
||||
userId = loginResponse.body.userprofile.user.user_id;
|
||||
const loginData = await loginResponse.json();
|
||||
authToken = loginData.token;
|
||||
userId = loginData.userprofile.user.user_id;
|
||||
expect(authToken).toBeDefined();
|
||||
|
||||
// 3. Prepare the flyer file
|
||||
@@ -73,34 +62,37 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
|
||||
]);
|
||||
}
|
||||
|
||||
// Create a File object for the apiClient
|
||||
// FIX: The Node.js `Buffer` type can be incompatible with the web `File` API's
|
||||
// expected `BlobPart` type in some TypeScript configurations. Explicitly creating
|
||||
// a `Uint8Array` from the buffer ensures compatibility and resolves the type error.
|
||||
// `Uint8Array` is a valid `BufferSource`, which is a valid `BlobPart`.
|
||||
const flyerFile = new File([new Uint8Array(fileBuffer)], fileName, { type: 'image/jpeg' });
|
||||
|
||||
// Calculate checksum (required by the API)
|
||||
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// 4. Upload the flyer
|
||||
const uploadResponse = await request
|
||||
.post('/api/ai/upload-and-process')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.field('checksum', checksum)
|
||||
.attach('flyerFile', fileBuffer, fileName);
|
||||
const uploadResponse = await apiClient.uploadAndProcessFlyer(flyerFile, checksum, authToken);
|
||||
|
||||
expect(uploadResponse.status).toBe(202);
|
||||
const jobId = uploadResponse.body.jobId;
|
||||
const uploadData = await uploadResponse.json();
|
||||
const jobId = uploadData.jobId;
|
||||
expect(jobId).toBeDefined();
|
||||
|
||||
// 5. Poll for job completion
|
||||
let jobStatus;
|
||||
const maxRetries = 60; // Poll for up to 180 seconds
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3s
|
||||
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
// 5. Poll for job completion using the new utility
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await apiClient.getJobStatus(jobId, authToken);
|
||||
return statusResponse.json();
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 180000, interval: 3000, description: 'flyer processing job completion' },
|
||||
);
|
||||
|
||||
if (jobStatus.state === 'failed') {
|
||||
// Log the failure reason for easier debugging in CI/CD environments.
|
||||
console.error('E2E flyer processing job failed. Reason:', jobStatus.failedReason);
|
||||
}
|
||||
|
||||
expect(jobStatus.state).toBe('completed');
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
// src/tests/e2e/user-journey.e2e.test.ts
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const request = supertest(app);
|
||||
|
||||
describe('E2E User Journey', () => {
|
||||
// Use a unique email for every run to avoid collisions
|
||||
const uniqueId = Date.now();
|
||||
@@ -23,65 +20,54 @@ describe('E2E User Journey', () => {
|
||||
afterAll(async () => {
|
||||
// Safety cleanup: Ensure the user is deleted from the DB if the test fails mid-way.
|
||||
// If the test succeeds, the user deletes their own account, so this acts as a fallback.
|
||||
if (userId) {
|
||||
try {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [userId]);
|
||||
} catch (err) {
|
||||
console.error('Error cleaning up E2E test user:', err);
|
||||
}
|
||||
}
|
||||
await cleanupDb({
|
||||
userIds: [userId],
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete a full user lifecycle: Register -> Login -> Manage List -> Delete Account', async () => {
|
||||
// 1. Register a new user
|
||||
const registerResponse = await request.post('/api/auth/register').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
full_name: 'E2E Traveler',
|
||||
});
|
||||
const registerResponse = await apiClient.registerUser(userEmail, userPassword, 'E2E Traveler');
|
||||
|
||||
expect(registerResponse.status).toBe(201);
|
||||
expect(registerResponse.body.message).toBe('User registered successfully!');
|
||||
const registerData = await registerResponse.json();
|
||||
expect(registerData.message).toBe('User registered successfully!');
|
||||
|
||||
// 2. Login to get the access token
|
||||
const loginResponse = await request.post('/api/auth/login').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
});
|
||||
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
|
||||
|
||||
expect(loginResponse.status).toBe(200);
|
||||
authToken = loginResponse.body.token;
|
||||
userId = loginResponse.body.userprofile.user.user_id;
|
||||
const loginData = await loginResponse.json();
|
||||
authToken = loginData.token;
|
||||
userId = loginData.userprofile.user.user_id;
|
||||
|
||||
expect(authToken).toBeDefined();
|
||||
expect(userId).toBeDefined();
|
||||
|
||||
// 3. Create a Shopping List
|
||||
const createListResponse = await request
|
||||
.post('/api/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'E2E Party List' });
|
||||
const createListResponse = await apiClient.createShoppingList('E2E Party List', authToken);
|
||||
|
||||
expect(createListResponse.status).toBe(201);
|
||||
shoppingListId = createListResponse.body.shopping_list_id;
|
||||
const createListData = await createListResponse.json();
|
||||
shoppingListId = createListData.shopping_list_id;
|
||||
expect(shoppingListId).toBeDefined();
|
||||
|
||||
// 4. Add an item to the list
|
||||
const addItemResponse = await request
|
||||
.post(`/api/users/shopping-lists/${shoppingListId}/items`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ customItemName: 'Chips' });
|
||||
const addItemResponse = await apiClient.addShoppingListItem(
|
||||
shoppingListId,
|
||||
{ customItemName: 'Chips' },
|
||||
authToken,
|
||||
);
|
||||
|
||||
expect(addItemResponse.status).toBe(201);
|
||||
expect(addItemResponse.body.custom_item_name).toBe('Chips');
|
||||
const addItemData = await addItemResponse.json();
|
||||
expect(addItemData.custom_item_name).toBe('Chips');
|
||||
|
||||
// 5. Verify the list and item exist via GET
|
||||
const getListsResponse = await request
|
||||
.get('/api/users/shopping-lists')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
const getListsResponse = await apiClient.fetchShoppingLists(authToken);
|
||||
|
||||
expect(getListsResponse.status).toBe(200);
|
||||
const myLists = getListsResponse.body;
|
||||
const myLists = await getListsResponse.json();
|
||||
const targetList = myLists.find((l: any) => l.shopping_list_id === shoppingListId);
|
||||
|
||||
expect(targetList).toBeDefined();
|
||||
@@ -89,19 +75,16 @@ describe('E2E User Journey', () => {
|
||||
expect(targetList.items[0].custom_item_name).toBe('Chips');
|
||||
|
||||
// 6. Delete the User Account (Self-Service)
|
||||
const deleteAccountResponse = await request
|
||||
.delete('/api/users/account')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ password: userPassword });
|
||||
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
|
||||
tokenOverride: authToken,
|
||||
});
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
expect(deleteAccountResponse.body.message).toBe('Account deleted successfully.');
|
||||
const deleteData = await deleteAccountResponse.json();
|
||||
expect(deleteData.message).toBe('Account deleted successfully.');
|
||||
|
||||
// 7. Verify Login is no longer possible
|
||||
const failLoginResponse = await request.post('/api/auth/login').send({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
});
|
||||
const failLoginResponse = await apiClient.loginUser(userEmail, userPassword, false);
|
||||
|
||||
expect(failLoginResponse.status).toBe(401);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPool } from '../../services/db/connection.db';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import type { UserProfile } from '../../types';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { poll } from '../utils/poll';
|
||||
|
||||
describe('Database Service Integration Tests', () => {
|
||||
let testUser: UserProfile;
|
||||
@@ -26,6 +27,13 @@ describe('Database Service Integration Tests', () => {
|
||||
{ full_name: fullName },
|
||||
logger,
|
||||
);
|
||||
|
||||
// Poll to ensure the user record is findable before tests run.
|
||||
await poll(
|
||||
() => db.userRepo.findUserByEmail(testUserEmail, logger),
|
||||
(foundUser) => !!foundUser,
|
||||
{ timeout: 5000, interval: 500, description: `user ${testUserEmail} to be findable` },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/tests/integration/flyer-processing.integration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import app from '../../../server';
|
||||
import fs from 'node:fs/promises';
|
||||
@@ -11,6 +11,7 @@ import { logger } from '../../services/logger.server';
|
||||
import type { UserProfile, ExtractedFlyerItem } from '../../types';
|
||||
import { createAndLoginUser } from '../utils/testHelpers';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { poll } from '../utils/poll';
|
||||
import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
import piexif from 'piexifjs';
|
||||
import exifParser from 'exif-parser';
|
||||
@@ -55,7 +56,16 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const createdFilePaths: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
||||
// FIX: Stub FRONTEND_URL to ensure valid absolute URLs (http://...) are generated
|
||||
// for the database, satisfying the 'url_check' constraint.
|
||||
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
|
||||
});
|
||||
|
||||
// FIX: Reset mocks before each test to ensure isolation.
|
||||
// This prevents "happy path" mocks from leaking into error handling tests and vice versa.
|
||||
beforeEach(async () => {
|
||||
// 1. Reset AI Service Mock to default success state
|
||||
mockExtractCoreData.mockReset();
|
||||
mockExtractCoreData.mockResolvedValue({
|
||||
store_name: 'Mock Store',
|
||||
valid_from: null,
|
||||
@@ -71,9 +81,17 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 2. Restore DB Service Mock to real implementation
|
||||
// This ensures that unless a test specifically mocks a failure, the DB logic works as expected.
|
||||
const actual = await vi.importActual<typeof import('../../services/db/flyer.db')>('../../services/db/flyer.db');
|
||||
vi.mocked(createFlyerAndItems).mockReset();
|
||||
vi.mocked(createFlyerAndItems).mockImplementation(actual.createFlyerAndItems);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.unstubAllEnvs(); // Clean up env stubs
|
||||
|
||||
// Use the centralized cleanup utility.
|
||||
await cleanupDb({
|
||||
userIds: createdUserIds,
|
||||
@@ -96,7 +114,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// This prevents a 409 Conflict error when the second test runs.
|
||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
||||
const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup
|
||||
@@ -120,25 +138,19 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
// Assert 1: Check that a job ID was returned.
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it completes.
|
||||
let jobStatus;
|
||||
// Poll for up to 210 seconds (70 * 3s). This should be greater than the worker's
|
||||
// lockDuration (120s) to patiently wait for long-running jobs.
|
||||
const maxRetries = 70;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
console.log(`Polling attempt ${i + 1}...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
||||
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
if (token) {
|
||||
statusReq.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
const statusResponse = await statusReq;
|
||||
jobStatus = statusResponse.body;
|
||||
console.log(`Job status: ${JSON.stringify(jobStatus)}`);
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
if (token) {
|
||||
statusReq.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
const statusResponse = await statusReq;
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 210000, interval: 3000, description: 'flyer processing' },
|
||||
);
|
||||
|
||||
// Assert 2: Check that the job completed successfully.
|
||||
if (jobStatus?.state === 'failed') {
|
||||
@@ -220,7 +232,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const imageWithExifBuffer = Buffer.from(jpegWithExif, 'binary');
|
||||
|
||||
const uniqueFileName = `test-flyer-with-exif-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([imageWithExifBuffer], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(imageWithExifBuffer)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track original and derived files for cleanup
|
||||
@@ -239,19 +251,17 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Poll for job completion
|
||||
let jobStatus;
|
||||
const maxRetries = 60; // Poll for up to 180 seconds
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 180000, interval: 3000, description: 'EXIF stripping job' },
|
||||
);
|
||||
|
||||
// 3. Assert
|
||||
if (jobStatus?.state === 'failed') {
|
||||
@@ -306,7 +316,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
.toBuffer();
|
||||
|
||||
const uniqueFileName = `test-flyer-with-metadata-${Date.now()}.png`;
|
||||
const mockImageFile = new File([Buffer.from(imageWithMetadataBuffer)], uniqueFileName, { type: 'image/png' });
|
||||
const mockImageFile = new File([new Uint8Array(imageWithMetadataBuffer)], uniqueFileName, { type: 'image/png' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track files for cleanup
|
||||
@@ -325,19 +335,17 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Poll for job completion
|
||||
let jobStatus;
|
||||
const maxRetries = 60; // Poll for up to 180 seconds
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 180000, interval: 3000, description: 'PNG metadata stripping job' },
|
||||
);
|
||||
|
||||
// 3. Assert job completion
|
||||
if (jobStatus?.state === 'failed') {
|
||||
@@ -376,7 +384,7 @@ it(
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`fail-test-${Date.now()}`)]);
|
||||
const uniqueFileName = `ai-fail-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup
|
||||
@@ -392,17 +400,15 @@ it(
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it completes or fails.
|
||||
let jobStatus;
|
||||
const maxRetries = 60;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 180000, interval: 3000, description: 'AI failure test job' },
|
||||
);
|
||||
|
||||
// Assert 1: Check that the job failed.
|
||||
expect(jobStatus?.state).toBe('failed');
|
||||
@@ -427,7 +433,7 @@ it(
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(`db-fail-test-${Date.now()}`)]);
|
||||
const uniqueFileName = `db-fail-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup
|
||||
@@ -443,17 +449,15 @@ it(
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it completes or fails.
|
||||
let jobStatus;
|
||||
const maxRetries = 60;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 180000, interval: 3000, description: 'DB failure test job' },
|
||||
);
|
||||
|
||||
// Assert 1: Check that the job failed.
|
||||
expect(jobStatus?.state).toBe('failed');
|
||||
@@ -481,7 +485,7 @@ it(
|
||||
Buffer.from(`cleanup-fail-test-${Date.now()}`),
|
||||
]);
|
||||
const uniqueFileName = `cleanup-fail-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track the path of the file that will be created in the uploads directory.
|
||||
@@ -498,17 +502,15 @@ it(
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// Act 2: Poll for the job status until it fails.
|
||||
let jobStatus;
|
||||
const maxRetries = 60;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Act 2: Poll for job completion using the new utility.
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'failed', // We expect this one to fail
|
||||
{ timeout: 180000, interval: 3000, description: 'file cleanup failure test job' },
|
||||
);
|
||||
|
||||
// Assert 1: Check that the job actually failed.
|
||||
expect(jobStatus?.state).toBe('failed');
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as db from '../../services/db/index.db';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import * as imageProcessor from '../../utils/imageProcessor';
|
||||
import { poll } from '../utils/poll';
|
||||
import type {
|
||||
UserProfile,
|
||||
UserAchievement,
|
||||
@@ -66,6 +67,10 @@ describe('Gamification Flow Integration Test', () => {
|
||||
request,
|
||||
}));
|
||||
|
||||
// Stub environment variables for URL generation in the background worker.
|
||||
// This needs to be in beforeAll to ensure it's set before any code that might use it is imported.
|
||||
vi.stubEnv('FRONTEND_URL', 'http://localhost:3001');
|
||||
|
||||
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
|
||||
mockExtractCoreData.mockResolvedValue({
|
||||
store_name: 'Gamification Test Store',
|
||||
@@ -96,16 +101,12 @@ describe('Gamification Flow Integration Test', () => {
|
||||
it(
|
||||
'should award the "First Upload" achievement after a user successfully uploads and processes their first flyer',
|
||||
async () => {
|
||||
// --- Arrange: Stub environment variables for URL generation in the background worker ---
|
||||
const testBaseUrl = 'http://localhost:3001'; // Use a fixed port for predictability
|
||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
||||
|
||||
// --- Arrange: Prepare a unique flyer file for upload ---
|
||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]);
|
||||
const uniqueFileName = `gamification-test-flyer-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup
|
||||
@@ -124,20 +125,19 @@ describe('Gamification Flow Integration Test', () => {
|
||||
const { jobId } = uploadResponse.body;
|
||||
expect(jobId).toBeTypeOf('string');
|
||||
|
||||
// --- Act 2: Poll for job completion ---
|
||||
let jobStatus;
|
||||
const maxRetries = 60; // Poll for up to 180 seconds
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
jobStatus = statusResponse.body;
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!jobStatus) {
|
||||
// --- Act 2: Poll for job completion using the new utility ---
|
||||
const jobStatus = await poll(
|
||||
async () => {
|
||||
const statusResponse = await request
|
||||
.get(`/api/ai/jobs/${jobId}/status`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
return statusResponse.body;
|
||||
},
|
||||
(status) => status.state === 'completed' || status.state === 'failed',
|
||||
{ timeout: 180000, interval: 3000, description: 'gamification flyer processing' },
|
||||
);
|
||||
|
||||
if (!jobStatus) {
|
||||
console.error('[DEBUG] Gamification test job timed out: No job status received.');
|
||||
throw new Error('Gamification test job timed out: No job status received.');
|
||||
}
|
||||
@@ -187,8 +187,6 @@ describe('Gamification Flow Integration Test', () => {
|
||||
firstUploadAchievement!.points_value,
|
||||
);
|
||||
|
||||
// --- Cleanup ---
|
||||
vi.unstubAllEnvs();
|
||||
},
|
||||
240000, // Increase timeout to 240s to match other long-running processing tests
|
||||
);
|
||||
@@ -196,10 +194,6 @@ describe('Gamification Flow Integration Test', () => {
|
||||
describe('Legacy Flyer Upload', () => {
|
||||
it('should process a legacy upload and save fully qualified URLs to the database', async () => {
|
||||
// --- Arrange ---
|
||||
// 1. Stub environment variables to have a predictable base URL for the test.
|
||||
const testBaseUrl = 'https://cdn.example.com';
|
||||
vi.stubEnv('FRONTEND_URL', testBaseUrl);
|
||||
|
||||
// 2. Mock the icon generator to return a predictable filename.
|
||||
vi.mocked(imageProcessor.generateFlyerIcon).mockResolvedValue('legacy-icon.webp');
|
||||
|
||||
@@ -207,7 +201,7 @@ describe('Gamification Flow Integration Test', () => {
|
||||
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const uniqueFileName = `legacy-upload-test-${Date.now()}.jpg`;
|
||||
const mockImageFile = new File([imageBuffer], uniqueFileName, { type: 'image/jpeg' });
|
||||
const mockImageFile = new File([new Uint8Array(imageBuffer)], uniqueFileName, { type: 'image/jpeg' });
|
||||
const checksum = await generateFileChecksum(mockImageFile);
|
||||
|
||||
// Track created files for cleanup.
|
||||
@@ -257,11 +251,9 @@ describe('Gamification Flow Integration Test', () => {
|
||||
createdStoreIds.push(savedFlyer.store_id!); // Add for cleanup.
|
||||
|
||||
// 8. Assert that the URLs are fully qualified.
|
||||
expect(savedFlyer.image_url).to.equal(`${testBaseUrl}/flyer-images/${uniqueFileName}`);
|
||||
expect(savedFlyer.icon_url).to.equal(`${testBaseUrl}/flyer-images/icons/legacy-icon.webp`);
|
||||
|
||||
// --- Cleanup ---
|
||||
vi.unstubAllEnvs();
|
||||
expect(savedFlyer.image_url).to.equal(newFlyer.image_url);
|
||||
expect(savedFlyer.icon_url).to.equal(newFlyer.icon_url);
|
||||
expect(newFlyer.image_url).toContain('http://localhost:3001/flyer-images/');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '../../types';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { poll } from '../utils/poll';
|
||||
import { createAndLoginUser } from '../utils/testHelpers';
|
||||
|
||||
/**
|
||||
@@ -42,27 +43,12 @@ describe('Public API Routes Integration Tests', () => {
|
||||
});
|
||||
testUser = createdUser;
|
||||
|
||||
// DEBUG: Verify user existence in DB
|
||||
console.log(`[DEBUG] createAndLoginUser returned user ID: ${testUser.user.user_id}`);
|
||||
const userCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||
console.log(`[DEBUG] DB check for user found ${userCheck.rowCount ?? 0} rows.`);
|
||||
if (!userCheck.rowCount) {
|
||||
console.error(`[DEBUG] CRITICAL: User ${testUser.user.user_id} does not exist in public.users table! Attempting to wait...`);
|
||||
// Wait loop to ensure user persistence if there's a race condition
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const retryCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||
if (retryCheck.rowCount && retryCheck.rowCount > 0) {
|
||||
console.log(`[DEBUG] User found after retry ${i + 1}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final check before proceeding to avoid FK error
|
||||
const finalCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||
if (!finalCheck.rowCount) {
|
||||
throw new Error(`User ${testUser.user.user_id} failed to persist in DB. Cannot continue test.`);
|
||||
}
|
||||
// Poll to ensure the user record has propagated before creating dependent records.
|
||||
await poll(
|
||||
() => pool.query('SELECT 1 FROM public.users WHERE user_id = $1', [testUser.user.user_id]),
|
||||
(result) => (result.rowCount ?? 0) > 0,
|
||||
{ timeout: 5000, interval: 500, description: `user ${testUser.user.user_id} to persist` },
|
||||
);
|
||||
|
||||
// Create a recipe
|
||||
const recipeRes = await pool.query(
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as bcrypt from 'bcrypt';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import type { ShoppingList } from '../../types';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import { poll } from '../utils/poll';
|
||||
|
||||
describe('Shopping List DB Service Tests', () => {
|
||||
it('should create and retrieve a shopping list for a user', async ({ onTestFinished }) => {
|
||||
@@ -19,6 +20,12 @@ describe('Shopping List DB Service Tests', () => {
|
||||
);
|
||||
const testUserId = userprofile.user.user_id;
|
||||
|
||||
// Poll to ensure the user record has propagated before creating dependent records.
|
||||
await poll(
|
||||
() => getPool().query('SELECT 1 FROM public.users WHERE user_id = $1', [testUserId]),
|
||||
(result) => (result.rowCount ?? 0) > 0,
|
||||
{ timeout: 5000, interval: 500, description: `user ${testUserId} to persist` },
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
||||
});
|
||||
@@ -51,6 +58,13 @@ describe('Shopping List DB Service Tests', () => {
|
||||
);
|
||||
const testUserId = userprofile.user.user_id;
|
||||
|
||||
// Poll to ensure the user record has propagated before creating dependent records.
|
||||
await poll(
|
||||
() => getPool().query('SELECT 1 FROM public.users WHERE user_id = $1', [testUserId]),
|
||||
(result) => (result.rowCount ?? 0) > 0,
|
||||
{ timeout: 5000, interval: 500, description: `user ${testUserId} to persist` },
|
||||
);
|
||||
|
||||
onTestFinished(async () => {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUserId]);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// src/tests/integration/user.integration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
import app from '../../../server';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types';
|
||||
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { cleanupFiles } from '../utils/cleanupFiles';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
@@ -33,6 +36,25 @@ describe('User API Routes Integration Tests', () => {
|
||||
// This now cleans up ALL users created by this test suite to prevent pollution.
|
||||
afterAll(async () => {
|
||||
await cleanupDb({ userIds: createdUserIds });
|
||||
|
||||
// Safeguard to clean up any avatar files created during tests.
|
||||
const uploadDir = path.resolve(__dirname, '../../../uploads/avatars');
|
||||
try {
|
||||
const allFiles = await fs.readdir(uploadDir);
|
||||
// Filter for any file that contains any of the user IDs created in this test suite.
|
||||
const testFiles = allFiles
|
||||
.filter((f) => createdUserIds.some((userId) => userId && f.includes(userId)))
|
||||
.map((f) => path.join(uploadDir, f));
|
||||
|
||||
if (testFiles.length > 0) {
|
||||
await cleanupFiles(testFiles);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore if the directory doesn't exist, but log other errors.
|
||||
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Error during user integration test avatar file cleanup:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
||||
@@ -295,4 +317,64 @@ describe('User API Routes Integration Tests', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a user to upload an avatar image and update their profile', async () => {
|
||||
// Arrange: Path to a dummy image file
|
||||
const dummyImagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
|
||||
|
||||
// Act: Make the POST request to upload the avatar
|
||||
const response = await request
|
||||
.post('/api/users/profile/avatar')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('avatar', dummyImagePath);
|
||||
|
||||
// Assert: Check the response
|
||||
expect(response.status).toBe(200);
|
||||
const updatedProfile = response.body;
|
||||
expect(updatedProfile.avatar_url).toBeDefined();
|
||||
expect(updatedProfile.avatar_url).not.toBeNull();
|
||||
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/avatar-');
|
||||
|
||||
// Assert (Verification): Fetch the profile again to ensure the change was persisted
|
||||
const verifyResponse = await request
|
||||
.get('/api/users/profile')
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
const refetchedProfile = verifyResponse.body;
|
||||
expect(refetchedProfile.avatar_url).toBe(updatedProfile.avatar_url);
|
||||
});
|
||||
|
||||
it('should reject avatar upload for an invalid file type', async () => {
|
||||
// Arrange: Create a buffer representing a text file.
|
||||
const invalidFileBuffer = Buffer.from('This is not an image file.');
|
||||
const invalidFileName = 'test.txt';
|
||||
|
||||
// Act: Attempt to upload the text file to the avatar endpoint.
|
||||
const response = await request
|
||||
.post('/api/users/profile/avatar')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('avatar', invalidFileBuffer, invalidFileName);
|
||||
|
||||
// Assert: Check for a 400 Bad Request response.
|
||||
// This error comes from the multer fileFilter configuration in the route.
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Only image files are allowed!');
|
||||
});
|
||||
|
||||
it('should reject avatar upload for a file that is too large', async () => {
|
||||
// Arrange: Create a buffer larger than the configured limit (e.g., > 1MB).
|
||||
// The limit is set in the multer middleware in `user.routes.ts`.
|
||||
// We'll create a 2MB buffer to be safe.
|
||||
const largeFileBuffer = Buffer.alloc(2 * 1024 * 1024, 'a');
|
||||
const largeFileName = 'large-avatar.jpg';
|
||||
|
||||
// Act: Attempt to upload the large file.
|
||||
const response = await request
|
||||
.post('/api/users/profile/avatar')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.attach('avatar', largeFileBuffer, largeFileName);
|
||||
|
||||
// Assert: Check for a 400 Bad Request response from the multer error handler.
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('File too large');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,85 +1,57 @@
|
||||
// src/tests/utils/cleanup.ts
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import { logger } from '../../services/logger.server';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface TestResourceIds {
|
||||
userIds?: string[];
|
||||
flyerIds?: number[];
|
||||
storeIds?: number[];
|
||||
recipeIds?: number[];
|
||||
masterItemIds?: number[];
|
||||
budgetIds?: number[];
|
||||
interface CleanupOptions {
|
||||
userIds?: (string | null | undefined)[];
|
||||
flyerIds?: (number | null | undefined)[];
|
||||
storeIds?: (number | null | undefined)[];
|
||||
recipeIds?: (number | null | undefined)[];
|
||||
budgetIds?: (number | null | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A robust cleanup utility for integration tests.
|
||||
* It deletes entities in the correct order to avoid foreign key violations.
|
||||
* It's designed to be called in an `afterAll` hook.
|
||||
*
|
||||
* @param ids An object containing arrays of IDs for each resource type to clean up.
|
||||
* A centralized utility to clean up database records created during tests.
|
||||
* It deletes records in an order that respects foreign key constraints.
|
||||
* It performs operations on a single client connection but does not use a
|
||||
* transaction, ensuring that a failure to delete from one table does not
|
||||
* prevent cleanup attempts on others.
|
||||
*/
|
||||
export const cleanupDb = async (ids: TestResourceIds) => {
|
||||
export const cleanupDb = async (options: CleanupOptions) => {
|
||||
const pool = getPool();
|
||||
logger.info('[Test Cleanup] Starting database resource cleanup...');
|
||||
|
||||
const {
|
||||
userIds = [],
|
||||
flyerIds = [],
|
||||
storeIds = [],
|
||||
recipeIds = [],
|
||||
masterItemIds = [],
|
||||
budgetIds = [],
|
||||
} = ids;
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// --- Stage 1: Delete most dependent records ---
|
||||
// These records depend on users, recipes, flyers, etc.
|
||||
if (userIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.recipe_comments WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.suggested_corrections WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.shopping_lists WHERE user_id = ANY($1::uuid[])', [userIds]); // Assumes shopping_list_items cascades
|
||||
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.user_achievements WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.activity_log WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
// Order of deletion matters to avoid foreign key violations.
|
||||
// Children entities first, then parents.
|
||||
|
||||
if (options.budgetIds?.filter(Boolean).length) {
|
||||
await client.query('DELETE FROM public.budgets WHERE budget_id = ANY($1::int[])', [options.budgetIds]);
|
||||
logger.debug(`Cleaned up ${options.budgetIds.length} budget(s).`);
|
||||
}
|
||||
|
||||
// --- Stage 2: Delete parent records that other things depend on ---
|
||||
if (recipeIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.recipes WHERE recipe_id = ANY($1::int[])', [recipeIds]);
|
||||
if (options.recipeIds?.filter(Boolean).length) {
|
||||
await client.query('DELETE FROM public.recipes WHERE recipe_id = ANY($1::int[])', [options.recipeIds]);
|
||||
logger.debug(`Cleaned up ${options.recipeIds.length} recipe(s).`);
|
||||
}
|
||||
|
||||
// Flyers might be created by users, but we clean them up separately.
|
||||
// flyer_items should cascade from this.
|
||||
if (flyerIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.flyers WHERE flyer_id = ANY($1::bigint[])', [flyerIds]);
|
||||
if (options.flyerIds?.filter(Boolean).length) {
|
||||
await client.query('DELETE FROM public.flyers WHERE flyer_id = ANY($1::int[])', [options.flyerIds]);
|
||||
logger.debug(`Cleaned up ${options.flyerIds.length} flyer(s).`);
|
||||
}
|
||||
|
||||
// Stores are parents of flyers, so they come after.
|
||||
if (storeIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.stores WHERE store_id = ANY($1::int[])', [storeIds]);
|
||||
if (options.storeIds?.filter(Boolean).length) {
|
||||
await client.query('DELETE FROM public.stores WHERE store_id = ANY($1::int[])', [options.storeIds]);
|
||||
logger.debug(`Cleaned up ${options.storeIds.length} store(s).`);
|
||||
}
|
||||
|
||||
// Master items are parents of flyer_items and watched_items.
|
||||
if (masterItemIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.master_grocery_items WHERE master_grocery_item_id = ANY($1::int[])', [masterItemIds]);
|
||||
if (options.userIds?.filter(Boolean).length) {
|
||||
await client.query('DELETE FROM public.users WHERE user_id = ANY($1::uuid[])', [options.userIds]);
|
||||
logger.debug(`Cleaned up ${options.userIds.length} user(s).`);
|
||||
}
|
||||
|
||||
// Budgets are parents of nothing, but depend on users.
|
||||
if (budgetIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.budgets WHERE budget_id = ANY($1::int[])', [budgetIds]);
|
||||
}
|
||||
|
||||
// --- Stage 3: Delete the root user records ---
|
||||
if (userIds.length > 0) {
|
||||
const { rowCount } = await pool.query('DELETE FROM public.users WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
logger.info(`[Test Cleanup] Cleaned up ${rowCount} user(s).`);
|
||||
}
|
||||
|
||||
logger.info('[Test Cleanup] Finished database resource cleanup successfully.');
|
||||
} catch (error) {
|
||||
logger.error({ error }, '[Test Cleanup] CRITICAL: An error occurred during database cleanup.');
|
||||
throw error; // Re-throw to fail the test suite
|
||||
logger.error({ error, options }, 'A database cleanup operation failed.');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
@@ -1,48 +1,30 @@
|
||||
// src/tests/utils/cleanupFiles.ts
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'path';
|
||||
import { logger } from '../../services/logger.server';
|
||||
|
||||
/**
|
||||
* Safely cleans up files from the filesystem.
|
||||
* Designed to be used in `afterAll` or `afterEach` hooks in integration tests.
|
||||
*
|
||||
* @param filePaths An array of file paths to clean up.
|
||||
* A centralized utility to clean up files created during tests.
|
||||
* It iterates through a list of file paths and attempts to delete each one.
|
||||
* It gracefully handles errors for files that don't exist (e.g., already deleted
|
||||
* or never created due to a test failure).
|
||||
*/
|
||||
export const cleanupFiles = async (filePaths: string[]) => {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
logger.info('[Test Cleanup] No file paths provided for cleanup.');
|
||||
export const cleanupFiles = async (filePaths: (string | undefined | null)[]) => {
|
||||
const validPaths = filePaths.filter((p): p is string => !!p);
|
||||
if (validPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[Test Cleanup] Starting filesystem cleanup for ${filePaths.length} file(s)...`);
|
||||
logger.debug(`Cleaning up ${validPaths.length} test-created file(s)...`);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
filePaths.map(async (filePath) => {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
logger.debug(`[Test Cleanup] Successfully deleted file: ${filePath}`);
|
||||
} catch (err: any) {
|
||||
// Ignore "file not found" errors, but log other errors.
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.debug(`[Test Cleanup] File not found, skipping: ${filePath}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err, filePath },
|
||||
'[Test Cleanup] Failed to clean up file from filesystem.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
const cleanupPromises = validPaths.map(async (filePath) => {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error({ error, filePath }, 'Failed to delete test file during cleanup.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[Test Cleanup] Finished filesystem cleanup successfully.');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error },
|
||||
'[Test Cleanup] CRITICAL: An error occurred during filesystem cleanup.',
|
||||
);
|
||||
throw error; // Re-throw to fail the test suite if cleanup fails
|
||||
}
|
||||
await Promise.allSettled(cleanupPromises);
|
||||
};
|
||||
36
src/tests/utils/poll.ts
Normal file
36
src/tests/utils/poll.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// src/tests/utils/poll.ts
|
||||
|
||||
interface PollOptions {
|
||||
/** The maximum time to wait in milliseconds. Defaults to 10000 (10 seconds). */
|
||||
timeout?: number;
|
||||
/** The interval between attempts in milliseconds. Defaults to 500. */
|
||||
interval?: number;
|
||||
/** A description of the operation for better error messages. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic polling utility for asynchronous operations in tests.
|
||||
*
|
||||
* @param fn The async function to execute on each attempt.
|
||||
* @param validate A function that returns `true` if the result is satisfactory, ending the poll.
|
||||
* @param options Polling options like timeout and interval.
|
||||
* @returns A promise that resolves with the first valid result from `fn`.
|
||||
* @throws An error if the timeout is reached before `validate` returns `true`.
|
||||
*/
|
||||
export async function poll<T>(
|
||||
fn: () => Promise<T>,
|
||||
validate: (result: T) => boolean,
|
||||
options: PollOptions = {},
|
||||
): Promise<T> {
|
||||
const { timeout = 10000, interval = 500, description = 'operation' } = options;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await fn();
|
||||
if (validate(result)) return result;
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error(`Polling timed out for ${description} after ${timeout}ms.`);
|
||||
}
|
||||
Reference in New Issue
Block a user