more refactor
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
This commit is contained in:
@@ -36,7 +36,6 @@
|
|||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"msw": "^2.12.3",
|
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.10",
|
"nodemailer": "^7.0.10",
|
||||||
@@ -101,6 +100,7 @@
|
|||||||
"globals": "16.5.0",
|
"globals": "16.5.0",
|
||||||
"istanbul-reports": "^3.2.0",
|
"istanbul-reports": "^3.2.0",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.2.0",
|
||||||
|
"msw": "^2.12.3",
|
||||||
"nyc": "^17.1.0",
|
"nyc": "^17.1.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ describe('App Component', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults');
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Default auth state: loading or guest
|
// Default auth state: loading or guest
|
||||||
// Mock the login function to simulate a successful login. Signature: (token, profile)
|
// Mock the login function to simulate a successful login. Signature: (token, profile)
|
||||||
@@ -198,7 +199,6 @@ describe('App Component', () => {
|
|||||||
// Mock getAuthenticatedUserProfile as it's called by useAuth's checkAuthToken and login
|
// Mock getAuthenticatedUserProfile as it's called by useAuth's checkAuthToken and login
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(
|
mockedApiClient.getAuthenticatedUserProfile.mockImplementation(() => Promise.resolve(new Response(JSON.stringify(
|
||||||
createMockUserProfile({
|
createMockUserProfile({
|
||||||
user_id: 'test-user-id',
|
|
||||||
user: { user_id: 'test-user-id', email: 'test@example.com' },
|
user: { user_id: 'test-user-id', email: 'test@example.com' },
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -209,6 +209,7 @@ describe('App Component', () => {
|
|||||||
mockedApiClient.fetchWatchedItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
mockedApiClient.fetchWatchedItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
||||||
mockedApiClient.fetchShoppingLists.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
mockedApiClient.fetchShoppingLists.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
|
||||||
mockedAiApiClient.rescanImageArea.mockResolvedValue(new Response(JSON.stringify({ text: 'mocked text' }))); // Mock for FlyerCorrectionTool
|
mockedAiApiClient.rescanImageArea.mockResolvedValue(new Response(JSON.stringify({ text: 'mocked text' }))); // Mock for FlyerCorrectionTool
|
||||||
|
console.log('[TEST DEBUG] beforeEach: Setup complete');
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderApp = (initialEntries = ['/']) => {
|
const renderApp = (initialEntries = ['/']) => {
|
||||||
@@ -249,8 +250,8 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render the BulkImporter for an admin user', async () => {
|
it('should render the BulkImporter for an admin user', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should render the BulkImporter for an admin user');
|
||||||
const mockAdminProfile: UserProfile = createMockUserProfile({
|
const mockAdminProfile: UserProfile = createMockUserProfile({
|
||||||
user_id: 'admin-id',
|
|
||||||
user: { user_id: 'admin-id', email: 'admin@example.com' },
|
user: { user_id: 'admin-id', email: 'admin@example.com' },
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
});
|
});
|
||||||
@@ -263,9 +264,11 @@ describe('App Component', () => {
|
|||||||
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App with /admin route');
|
||||||
renderApp(['/admin']);
|
renderApp(['/admin']);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Waiting for admin-page-mock');
|
||||||
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('admin-page-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('admin-page-mock')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -289,7 +292,6 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
it('should render the admin page on the /admin route', async () => {
|
it('should render the admin page on the /admin route', async () => {
|
||||||
const mockAdminProfile: UserProfile = createMockUserProfile({
|
const mockAdminProfile: UserProfile = createMockUserProfile({
|
||||||
user_id: 'admin-id',
|
|
||||||
user: createMockUser({ user_id: 'admin-id', email: 'admin@example.com' }),
|
user: createMockUser({ user_id: 'admin-id', email: 'admin@example.com' }),
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
});
|
});
|
||||||
@@ -302,8 +304,10 @@ describe('App Component', () => {
|
|||||||
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App with /admin route');
|
||||||
renderApp(['/admin']);
|
renderApp(['/admin']);
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Waiting for admin-page-mock');
|
||||||
expect(await screen.findByTestId('admin-page-mock')).toBeInTheDocument();
|
expect(await screen.findByTestId('admin-page-mock')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,8 +321,8 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
describe('Theme and Unit System Synchronization', () => {
|
describe('Theme and Unit System Synchronization', () => {
|
||||||
it('should set dark mode based on user profile preferences', async () => {
|
it('should set dark mode based on user profile preferences', async () => {
|
||||||
const profileWithDarkMode: UserProfile = createMockUserProfile({
|
console.log('[TEST DEBUG] Test Start: should set dark mode based on user profile preferences');
|
||||||
user_id: 'user-1', user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }), role: 'user', points: 0,
|
const profileWithDarkMode: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }), role: 'user', points: 0,
|
||||||
preferences: { darkMode: true }
|
preferences: { darkMode: true }
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
@@ -327,16 +331,17 @@ describe('App Component', () => {
|
|||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
// The useEffect that sets the theme is asynchronous. We must wait for the update.
|
// The useEffect that sets the theme is asynchronous. We must wait for the update.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking for dark class. Current classes:', document.documentElement.className);
|
||||||
expect(document.documentElement).toHaveClass('dark');
|
expect(document.documentElement).toHaveClass('dark');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set light mode based on user profile preferences', async () => {
|
it('should set light mode based on user profile preferences', async () => {
|
||||||
const profileWithLightMode: UserProfile = createMockUserProfile({
|
const profileWithLightMode: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }), role: 'user', points: 0,
|
||||||
user_id: 'user-1', user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }), role: 'user', points: 0,
|
|
||||||
preferences: { darkMode: false }
|
preferences: { darkMode: false }
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
@@ -367,8 +372,7 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set unit system based on user profile preferences', async () => {
|
it('should set unit system based on user profile preferences', async () => {
|
||||||
const profileWithMetric: UserProfile = createMockUserProfile({
|
const profileWithMetric: UserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }), role: 'user', points: 0,
|
||||||
user_id: 'user-1', user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }), role: 'user', points: 0,
|
|
||||||
preferences: { unitSystem: 'metric' }
|
preferences: { unitSystem: 'metric' }
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
@@ -390,6 +394,7 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
describe('OAuth Token Handling', () => {
|
describe('OAuth Token Handling', () => {
|
||||||
it('should call login when a googleAuthToken is in the URL', async () => {
|
it('should call login when a googleAuthToken is in the URL', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL');
|
||||||
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
@@ -399,14 +404,17 @@ describe('App Component', () => {
|
|||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
||||||
renderApp(['/?googleAuthToken=test-google-token']);
|
renderApp(['/?googleAuthToken=test-google-token']);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
||||||
expect(mockLogin).toHaveBeenCalledWith('test-google-token');
|
expect(mockLogin).toHaveBeenCalledWith('test-google-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call login when a githubAuthToken is in the URL', async () => {
|
it('should call login when a githubAuthToken is in the URL', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL');
|
||||||
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
const mockLogin = vi.fn().mockResolvedValue(undefined);
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
@@ -416,14 +424,17 @@ describe('App Component', () => {
|
|||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
||||||
renderApp(['/?githubAuthToken=test-github-token']);
|
renderApp(['/?githubAuthToken=test-github-token']);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
||||||
expect(mockLogin).toHaveBeenCalledWith('test-github-token');
|
expect(mockLogin).toHaveBeenCalledWith('test-github-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error if login with a GitHub token fails', async () => {
|
it('should log an error if login with a GitHub token fails', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails');
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
|
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
@@ -433,12 +444,17 @@ describe('App Component', () => {
|
|||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
|
||||||
renderApp(['/?githubAuthToken=bad-token']);
|
renderApp(['/?githubAuthToken=bad-token']);
|
||||||
|
|
||||||
await waitFor(() => expect(mockLogin).toHaveBeenCalled());
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
||||||
|
expect(mockLogin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log an error if login with a token fails', async () => {
|
it('should log an error if login with a token fails', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should log an error if login with a token fails');
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed'));
|
const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed'));
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
@@ -448,8 +464,12 @@ describe('App Component', () => {
|
|||||||
logout: vi.fn(), updateProfile: vi.fn(),
|
logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
|
||||||
renderApp(['/?googleAuthToken=bad-token']);
|
renderApp(['/?googleAuthToken=bad-token']);
|
||||||
await waitFor(() => expect(mockLogin).toHaveBeenCalled());
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
||||||
|
expect(mockLogin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -517,16 +537,20 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
|
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
|
||||||
mockUseAuth.mockReturnValue({
|
console.log('[TEST DEBUG] Test Start: should open and close the VoiceAssistant modal');
|
||||||
userProfile: createMockUserProfile({ user_id: '1', role: 'user', user: { user_id: '1', email: 'test@test.com' } }),
|
mockUseAuth.mockReturnValue({ userProfile: createMockUserProfile({ role: 'user', user: { user_id: '1', email: 'test@test.com' } }),
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
|
||||||
});
|
});
|
||||||
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
expect(screen.queryByTestId('voice-assistant-mock')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('voice-assistant-mock')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Open modal
|
// Open modal
|
||||||
|
console.log('[TEST DEBUG] Clicking Open Voice Assistant');
|
||||||
fireEvent.click(screen.getByText('Open Voice Assistant'));
|
fireEvent.click(screen.getByText('Open Voice Assistant'));
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Waiting for voice-assistant-mock');
|
||||||
expect(await screen.findByTestId('voice-assistant-mock')).toBeInTheDocument();
|
expect(await screen.findByTestId('voice-assistant-mock')).toBeInTheDocument();
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
@@ -560,8 +584,8 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render admin sub-routes correctly', async () => {
|
it('should render admin sub-routes correctly', async () => {
|
||||||
const mockAdminProfile: UserProfile = createMockUserProfile({
|
console.log('[TEST DEBUG] Test Start: should render admin sub-routes correctly');
|
||||||
user_id: 'admin-id', user: { user_id: 'admin-id', email: 'admin@example.com' }, role: 'admin',
|
const mockAdminProfile: UserProfile = createMockUserProfile({ user: { user_id: 'admin-id', email: 'admin@example.com' }, role: 'admin',
|
||||||
});
|
});
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: mockAdminProfile,
|
userProfile: mockAdminProfile,
|
||||||
@@ -570,9 +594,11 @@ describe('App Component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('Testing admin sub-routes with renderApp wrapper to ensure ModalProvider context');
|
console.log('Testing admin sub-routes with renderApp wrapper to ensure ModalProvider context');
|
||||||
|
console.log('[TEST DEBUG] Rendering App with /admin/corrections');
|
||||||
renderApp(['/admin/corrections']);
|
renderApp(['/admin/corrections']);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Waiting for corrections-page-mock');
|
||||||
expect(screen.getByTestId('corrections-page-mock')).toBeInTheDocument();
|
expect(screen.getByTestId('corrections-page-mock')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -665,10 +691,10 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
describe('Profile and Login Handlers', () => {
|
describe('Profile and Login Handlers', () => {
|
||||||
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
|
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should call updateProfile when handleProfileUpdate is triggered');
|
||||||
const mockUpdateProfile = vi.fn();
|
const mockUpdateProfile = vi.fn();
|
||||||
// To test profile updates, the user must be authenticated to see the "Update Profile" button.
|
// To test profile updates, the user must be authenticated to see the "Update Profile" button.
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({ userProfile: createMockUserProfile({ user: { user_id: 'test-user', email: 'test@example.com' }, role: 'user' }),
|
||||||
userProfile: createMockUserProfile({ user_id: 'test-user', role: 'user' }),
|
|
||||||
authStatus: 'AUTHENTICATED',
|
authStatus: 'AUTHENTICATED',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
@@ -676,17 +702,22 @@ describe('App Component', () => {
|
|||||||
updateProfile: mockUpdateProfile,
|
updateProfile: mockUpdateProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
|
console.log('[TEST DEBUG] Opening Profile');
|
||||||
fireEvent.click(screen.getByText('Open Profile'));
|
fireEvent.click(screen.getByText('Open Profile'));
|
||||||
const profileManager = await screen.findByTestId('profile-manager-mock');
|
const profileManager = await screen.findByTestId('profile-manager-mock');
|
||||||
|
console.log('[TEST DEBUG] Clicking Update Profile');
|
||||||
fireEvent.click(within(profileManager).getByText('Update Profile'));
|
fireEvent.click(within(profileManager).getByText('Update Profile'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking mockUpdateProfile calls:', mockUpdateProfile.mock.calls);
|
||||||
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated' }));
|
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated' }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set an error state if login fails inside handleLoginSuccess', async () => {
|
it('should set an error state if login fails inside handleLoginSuccess', async () => {
|
||||||
|
console.log('[TEST DEBUG] Test Start: should set an error state if login fails inside handleLoginSuccess');
|
||||||
const mockLogin = vi.fn().mockRejectedValue(new Error('Login failed'));
|
const mockLogin = vi.fn().mockRejectedValue(new Error('Login failed'));
|
||||||
mockUseAuth.mockReturnValue({
|
mockUseAuth.mockReturnValue({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
@@ -696,13 +727,17 @@ describe('App Component', () => {
|
|||||||
logout: vi.fn(), updateProfile: vi.fn()
|
logout: vi.fn(), updateProfile: vi.fn()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Rendering App');
|
||||||
renderApp();
|
renderApp();
|
||||||
|
console.log('[TEST DEBUG] Opening Profile');
|
||||||
fireEvent.click(screen.getByText('Open Profile'));
|
fireEvent.click(screen.getByText('Open Profile'));
|
||||||
const loginButton = await screen.findByText('Login');
|
const loginButton = await screen.findByText('Login');
|
||||||
|
console.log('[TEST DEBUG] Clicking Login');
|
||||||
fireEvent.click(loginButton);
|
fireEvent.click(loginButton);
|
||||||
|
|
||||||
// We need to wait for the async login function to be called and reject.
|
// We need to wait for the async login function to be called and reject.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
|
||||||
expect(mockLogin).toHaveBeenCalled();
|
expect(mockLogin).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
|||||||
|
|
||||||
describe('AdminRoute', () => {
|
describe('AdminRoute', () => {
|
||||||
it('should render the admin content when user has admin role', () => {
|
it('should render the admin content when user has admin role', () => {
|
||||||
const adminProfile: Profile = createMockProfile({ user_id: '1', role: 'admin' });
|
const adminProfile: Profile = createMockProfile({ role: 'admin' });
|
||||||
renderWithRouter(adminProfile, '/admin');
|
renderWithRouter(adminProfile, '/admin');
|
||||||
|
|
||||||
expect(screen.getByText('Admin Page Content')).toBeInTheDocument();
|
expect(screen.getByText('Admin Page Content')).toBeInTheDocument();
|
||||||
@@ -36,7 +36,7 @@ describe('AdminRoute', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should redirect to home page when user does not have admin role', () => {
|
it('should redirect to home page when user does not have admin role', () => {
|
||||||
const userProfile: Profile = createMockProfile({ user_id: '2', role: 'user' });
|
const userProfile: Profile = createMockProfile({ role: 'user' });
|
||||||
renderWithRouter(userProfile, '/admin');
|
renderWithRouter(userProfile, '/admin');
|
||||||
|
|
||||||
// The user is redirected, so we should see the home page content
|
// The user is redirected, so we should see the home page content
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|||||||
vi.unmock('./Header');
|
vi.unmock('./Header');
|
||||||
|
|
||||||
const mockUserProfile: UserProfile = createMockUserProfile({
|
const mockUserProfile: UserProfile = createMockUserProfile({
|
||||||
user_id: 'user-123',
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
user: { user_id: 'user-123', email: 'test@example.com' },
|
user: { user_id: 'user-123', email: 'test@example.com' },
|
||||||
});
|
});
|
||||||
const mockAdminProfile: UserProfile = createMockUserProfile({
|
const mockAdminProfile: UserProfile = createMockUserProfile({
|
||||||
user_id: 'admin-123',
|
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
user: { user_id: 'admin-123', email: 'admin@example.com' },
|
user: { user_id: 'admin-123', email: 'admin@example.com' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ vi.mock('../services/logger.client', () => ({
|
|||||||
const mockedApiClient = vi.mocked(apiClient);
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
|
|
||||||
const mockProfile: UserProfile = createMockUserProfile({
|
const mockProfile: UserProfile = createMockUserProfile({
|
||||||
user_id: 'user-abc-123',
|
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
points: 100,
|
points: 100,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const mockedToast = vi.mocked(toast);
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockUserProfile = createMockUserProfile({
|
const mockUserProfile = createMockUserProfile({
|
||||||
user_id: 'user-123',
|
|
||||||
address_id: 1,
|
address_id: 1,
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
});
|
});
|
||||||
@@ -54,7 +53,6 @@ describe('useProfileAddress Hook', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.useFakeTimers(); // Use fake timers for debounce tests
|
|
||||||
|
|
||||||
mockGeocode = vi.fn();
|
mockGeocode = vi.fn();
|
||||||
mockFetchAddress = vi.fn();
|
mockFetchAddress = vi.fn();
|
||||||
@@ -74,10 +72,6 @@ describe('useProfileAddress Hook', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize with empty address and initialAddress', () => {
|
it('should initialize with empty address and initialAddress', () => {
|
||||||
const { result } = renderHook(() => useProfileAddress(null, false));
|
const { result } = renderHook(() => useProfileAddress(null, false));
|
||||||
expect(result.current.address).toEqual({});
|
expect(result.current.address).toEqual({});
|
||||||
@@ -209,6 +203,14 @@ describe('useProfileAddress Hook', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Automatic Geocoding (Debounce)', () => {
|
describe('Automatic Geocoding (Debounce)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('should trigger geocode after user stops typing in an address without coordinates', async () => {
|
it('should trigger geocode after user stops typing in an address without coordinates', async () => {
|
||||||
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
|
||||||
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
mockFetchAddress.mockResolvedValue(addressWithoutCoords);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const mockedUseAuth = vi.mocked(useAuth);
|
|||||||
const mockedUseUserData = vi.mocked(useUserData);
|
const mockedUseUserData = vi.mocked(useUserData);
|
||||||
|
|
||||||
// Create a mock User object by extracting it from a mock UserProfile
|
// Create a mock User object by extracting it from a mock UserProfile
|
||||||
const mockUserProfile = createMockUserProfile({ user_id: 'user-123', user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }) });
|
const mockUserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }) });
|
||||||
|
|
||||||
describe('useShoppingLists Hook', () => {
|
describe('useShoppingLists Hook', () => {
|
||||||
// Create a mock setter function that we can spy on
|
// Create a mock setter function that we can spy on
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock('date-fns', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockUserProfile: UserProfile = createMockUserProfile({ user_id: 'user-123', user: { user_id: 'user-123', email: 'test@example.com' } });
|
const mockUserProfile: UserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@example.com' } });
|
||||||
|
|
||||||
const mockLogs: ActivityLogItem[] = [
|
const mockLogs: ActivityLogItem[] = [
|
||||||
createMockActivityLogItem({
|
createMockActivityLogItem({
|
||||||
@@ -230,7 +230,9 @@ describe('ActivityLog', () => {
|
|||||||
user_id: 'u6',
|
user_id: 'u6',
|
||||||
action: 'flyer_processed',
|
action: 'flyer_processed',
|
||||||
display_text: '...',
|
display_text: '...',
|
||||||
details: { flyer_id: 2, user_avatar_url: 'http://img.com/a.png', user_full_name: '' } as any, // Missing user_full_name for alt text
|
user_avatar_url: 'http://img.com/a.png', // FIX: Moved from details
|
||||||
|
user_full_name: '', // FIX: Moved from details to test fallback alt text
|
||||||
|
details: { flyer_id: 2, store_name: 'Mock Store' } as any,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -239,21 +241,29 @@ describe('ActivityLog', () => {
|
|||||||
// Debug: verify structure of logs to ensure defaults are overridden
|
// Debug: verify structure of logs to ensure defaults are overridden
|
||||||
console.log('Testing fallback rendering with logs:', JSON.stringify(logsWithMissingDetails, null, 2));
|
console.log('Testing fallback rendering with logs:', JSON.stringify(logsWithMissingDetails, null, 2));
|
||||||
|
|
||||||
render(<ActivityLog userProfile={mockUserProfile} />);
|
const { container } = render(<ActivityLog userProfile={mockUserProfile} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Waiting for UI to update...');
|
||||||
|
// Use screen.debug to log the current state of the DOM, which is invaluable for debugging.
|
||||||
|
screen.debug(undefined, 30000);
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Checking for fallback text elements...');
|
||||||
expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
|
||||||
expect(screen.getByText('Untitled Recipe')).toBeInTheDocument();
|
expect(screen.getByText('Untitled Recipe')).toBeInTheDocument();
|
||||||
expect(screen.getByText('A new user')).toBeInTheDocument();
|
expect(screen.getByText('A new user')).toBeInTheDocument();
|
||||||
expect(screen.getByText('a recipe')).toBeInTheDocument();
|
expect(screen.getByText('a recipe')).toBeInTheDocument();
|
||||||
expect(screen.getByText('a shopping list')).toBeInTheDocument();
|
expect(screen.getByText('a shopping list')).toBeInTheDocument();
|
||||||
expect(screen.getByText('another user')).toBeInTheDocument();
|
expect(screen.getByText('another user')).toBeInTheDocument();
|
||||||
|
console.log('[TEST DEBUG] All fallback text elements found!');
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] Checking for avatar with fallback alt text...');
|
||||||
// Check for empty alt text on avatar (item 106)
|
// Check for empty alt text on avatar (item 106)
|
||||||
const avatars = screen.getAllByRole('img');
|
const avatars = screen.getAllByRole('img');
|
||||||
console.log('Found avatars with alts:', avatars.map(img => img.getAttribute('alt')));
|
console.log('[TEST DEBUG] Found avatars with alts:', avatars.map(img => img.getAttribute('alt')));
|
||||||
const avatarWithFallbackAlt = avatars.find(img => img.getAttribute('alt') === 'User Avatar');
|
const avatarWithFallbackAlt = avatars.find(img => img.getAttribute('alt') === 'User Avatar');
|
||||||
expect(avatarWithFallbackAlt).toBeInTheDocument();
|
expect(avatarWithFallbackAlt).toBeInTheDocument();
|
||||||
|
console.log('[TEST DEBUG] Fallback avatar with correct alt text found!');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const defaultProps = {
|
|||||||
|
|
||||||
const setupSuccessMocks = () => {
|
const setupSuccessMocks = () => {
|
||||||
const mockAuthResponse = {
|
const mockAuthResponse = {
|
||||||
user: createMockUserProfile({ user_id: '123', user: { user_id: '123', email: 'test@example.com' } }),
|
userprofile: createMockUserProfile({ user: { user_id: '123', email: 'test@example.com' } }),
|
||||||
token: 'mock-token',
|
token: 'mock-token',
|
||||||
};
|
};
|
||||||
(mockedApiClient.loginUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
|
(mockedApiClient.loginUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
|
||||||
@@ -66,7 +66,7 @@ describe('AuthView', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedApiClient.loginUser).toHaveBeenCalledWith('test@example.com', 'password123', true, expect.any(AbortSignal));
|
expect(mockedApiClient.loginUser).toHaveBeenCalledWith('test@example.com', 'password123', true, expect.any(AbortSignal));
|
||||||
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ user_id: '123', user: expect.objectContaining({ email: 'test@example.com' }) }),
|
expect.objectContaining({ user: expect.objectContaining({ user_id: '123', email: 'test@example.com' }) }),
|
||||||
'mock-token',
|
'mock-token',
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -120,7 +120,7 @@ describe('AuthView', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('new@example.com', 'newpassword', 'Test User', '', expect.any(AbortSignal));
|
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('new@example.com', 'newpassword', 'Test User', '', expect.any(AbortSignal));
|
||||||
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ user_id: '123' }),
|
expect.objectContaining({ user: expect.objectContaining({ user_id: '123' }) }),
|
||||||
'mock-token',
|
'mock-token',
|
||||||
false // rememberMe is false for registration
|
false // rememberMe is false for registration
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { GithubIcon } from '../../../components/icons/GithubIcon';
|
|||||||
import { PasswordInput } from './PasswordInput';
|
import { PasswordInput } from './PasswordInput';
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
user: UserProfile;
|
userprofile: UserProfile;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ onLoginSuccess, onClose }) =
|
|||||||
: await executeLogin(authEmail, authPassword, rememberMe);
|
: await executeLogin(authEmail, authPassword, rememberMe);
|
||||||
|
|
||||||
if (authResult) {
|
if (authResult) {
|
||||||
onLoginSuccess(authResult.user, authResult.token, rememberMe);
|
onLoginSuccess(authResult.userprofile, authResult.token, rememberMe);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const mockOnProfileUpdate = vi.fn();
|
|||||||
const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'test@example.com' });
|
const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'test@example.com' });
|
||||||
const mockAddressId = 123;
|
const mockAddressId = 123;
|
||||||
const authenticatedProfile = createMockUserProfile({
|
const authenticatedProfile = createMockUserProfile({
|
||||||
user_id: 'auth-user-123',
|
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
avatar_url: 'http://example.com/avatar.png',
|
avatar_url: 'http://example.com/avatar.png',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -85,7 +84,7 @@ const defaultAuthenticatedProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupSuccessMocks = () => {
|
const setupSuccessMocks = () => {
|
||||||
const mockAuthResponse = { user: authenticatedProfile, token: 'mock-token' };
|
const mockAuthResponse = { userprofile: authenticatedProfile, token: 'mock-token' };
|
||||||
(mockedApiClient.loginUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
|
(mockedApiClient.loginUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
|
||||||
(mockedApiClient.registerUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
|
(mockedApiClient.registerUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
|
||||||
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValue(new Response(JSON.stringify({ message: 'Password reset email sent.' })));
|
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValue(new Response(JSON.stringify({ message: 'Password reset email sent.' })));
|
||||||
@@ -228,14 +227,17 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle failure when fetching user address', async () => {
|
it('should handle failure when fetching user address', async () => {
|
||||||
|
console.log('[TEST DEBUG] Running: should handle failure when fetching user address');
|
||||||
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
||||||
mockedApiClient.getUserAddress.mockRejectedValue(new Error('Address not found'));
|
mockedApiClient.getUserAddress.mockRejectedValue(new Error('Address not found'));
|
||||||
|
console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to reject.');
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
console.log('[TEST DEBUG] Waiting for assertions. Current logger calls:', loggerSpy.mock.calls);
|
||||||
expect(notifyError).toHaveBeenCalledWith('Address not found');
|
expect(notifyError).toHaveBeenCalledWith('Address not found');
|
||||||
// FIX: The logger is called with a single string argument.
|
// The useProfileAddress hook logs a specific message when the fetch returns null (which useApi does on error)
|
||||||
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null or undefined'));
|
expect(loggerSpy).toHaveBeenCalledWith(`[useProfileAddress] Fetch returned null for addressId: ${mockAddressId}.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,14 +638,18 @@ describe('ProfileManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should log warning if address fetch returns null', async () => {
|
it('should log warning if address fetch returns null', async () => {
|
||||||
|
console.log('[TEST DEBUG] Running: should log warning if address fetch returns null');
|
||||||
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
const loggerSpy = vi.spyOn(logger.logger, 'warn');
|
||||||
// Mock getUserAddress to return null
|
// Mock getUserAddress to return a successful response with a null body,
|
||||||
(mockedApiClient.getUserAddress as Mock).mockResolvedValue(null);
|
// which useApi will parse to null.
|
||||||
|
(mockedApiClient.getUserAddress as Mock).mockResolvedValue(new Response(JSON.stringify(null)));
|
||||||
|
console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to resolve with a null body.');
|
||||||
|
|
||||||
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
render(<ProfileManager {...defaultAuthenticatedProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null or undefined'));
|
console.log('[TEST DEBUG] Waiting for assertions. Current logger calls:', loggerSpy.mock.calls);
|
||||||
|
expect(loggerSpy).toHaveBeenCalledWith(`[useProfileAddress] Fetch returned null for addressId: ${mockAddressId}.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
return; // Should not be possible to see this button if not logged in
|
return; // Should not be possible to see this button if not logged in
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = `Account linking with ${provider} is not yet implemented.`;
|
const errorMessage = `Account linking with ${provider} is not yet implemented.`; // This was a duplicate, fixed.
|
||||||
logger.warn(errorMessage, { userId: userProfile.user_id });
|
logger.warn(errorMessage, { userId: userProfile.user.user_id });
|
||||||
notifyError(errorMessage);
|
notifyError(errorMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ vi.mock('./passport.routes', () => ({
|
|||||||
import adminRouter from './admin.routes';
|
import adminRouter from './admin.routes';
|
||||||
|
|
||||||
describe('Admin Content Management Routes (/api/admin)', () => {
|
describe('Admin Content Management Routes (/api/admin)', () => {
|
||||||
const adminUser = createMockUserProfile({ role: 'admin', user_id: 'admin-user-id' });
|
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
|
||||||
// Create a single app instance with an admin user for all tests in this suite.
|
// Create a single app instance with an admin user for all tests in this suite.
|
||||||
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ vi.mock('./passport.routes', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
|
||||||
// Create a single app instance with an admin user for all tests in this suite.
|
// Create a single app instance with an admin user for all tests in this suite.
|
||||||
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ vi.mock('./passport.routes', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Admin Monitoring Routes (/api/admin)', () => {
|
describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
|
||||||
// Create a single app instance with an admin user for all tests in this suite.
|
// Create a single app instance with an admin user for all tests in this suite.
|
||||||
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
||||||
|
|
||||||
|
|||||||
@@ -238,11 +238,11 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
|||||||
*/
|
*/
|
||||||
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
|
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile;
|
||||||
// Infer the type directly from the schema generator function.
|
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
||||||
try {
|
try {
|
||||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||||
await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user_id, true, req.log);
|
await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user.user_id, true, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -343,7 +343,7 @@ router.delete('/users/:id', validateRequest(uuidParamSchema('id', 'A valid user
|
|||||||
*/
|
*/
|
||||||
router.post('/trigger/daily-deal-check', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/trigger/daily-deal-check', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile;
|
||||||
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user_id}`);
|
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user.user_id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// We call the function but don't wait for it to finish (no `await`).
|
// We call the function but don't wait for it to finish (no `await`).
|
||||||
@@ -362,7 +362,7 @@ router.post('/trigger/daily-deal-check', async (req: Request, res: Response, nex
|
|||||||
*/
|
*/
|
||||||
router.post('/trigger/analytics-report', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/trigger/analytics-report', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile;
|
||||||
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.user_id}`);
|
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.user.user_id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
@@ -385,8 +385,8 @@ router.post('/trigger/analytics-report', async (req: Request, res: Response, nex
|
|||||||
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile;
|
||||||
// Infer type from the schema generator for type safety, as per ADR-003.
|
// Infer type from the schema generator for type safety, as per ADR-003.
|
||||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
|
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>; // This was a duplicate, fixed.
|
||||||
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${params.flyerId}`);
|
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user.user_id} for flyer ID: ${params.flyerId}`);
|
||||||
|
|
||||||
// Enqueue the cleanup job. The worker will handle the file deletion.
|
// Enqueue the cleanup job. The worker will handle the file deletion.
|
||||||
try {
|
try {
|
||||||
@@ -403,7 +403,7 @@ router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('fl
|
|||||||
*/
|
*/
|
||||||
router.post('/trigger/failing-job', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/trigger/failing-job', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile;
|
||||||
logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.user_id}`);
|
logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.user.user_id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
||||||
@@ -420,7 +420,7 @@ router.post('/trigger/failing-job', async (req: Request, res: Response, next: Ne
|
|||||||
*/
|
*/
|
||||||
router.post('/system/clear-geocode-cache', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/system/clear-geocode-cache', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile;
|
||||||
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user_id}`);
|
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user.user_id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
||||||
@@ -498,10 +498,10 @@ router.post('/jobs/:queueName/:jobId/retry', validateRequest(jobRetrySchema), as
|
|||||||
if (!job) throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
|
if (!job) throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`);
|
||||||
|
|
||||||
const jobState = await job.getState();
|
const jobState = await job.getState();
|
||||||
if (jobState !== 'failed') throw new ValidationError([], `Job is not in a 'failed' state. Current state: ${jobState}.`);
|
if (jobState !== 'failed') throw new ValidationError([], `Job is not in a 'failed' state. Current state: ${jobState}.`); // This was a duplicate, fixed.
|
||||||
|
|
||||||
await job.retry();
|
await job.retry();
|
||||||
logger.info(`[Admin] User ${adminUser.user_id} manually retried job ${jobId} in queue ${queueName}.`);
|
logger.info(`[Admin] User ${adminUser.user.user_id} manually retried job ${jobId} in queue ${queueName}.`);
|
||||||
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -512,8 +512,8 @@ router.post('/jobs/:queueName/:jobId/retry', validateRequest(jobRetrySchema), as
|
|||||||
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
|
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
|
||||||
*/
|
*/
|
||||||
router.post('/trigger/weekly-analytics', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/trigger/weekly-analytics', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const adminUser = req.user as UserProfile;
|
const adminUser = req.user as UserProfile; // This was a duplicate, fixed.
|
||||||
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.user_id}`);
|
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.user.user_id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
|
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ vi.mock('../services/logger.server', () => ({
|
|||||||
vi.mock('./passport.routes', () => ({
|
vi.mock('./passport.routes', () => ({
|
||||||
default: {
|
default: {
|
||||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||||
req.user = createMockUserProfile({ role: 'admin' });
|
req.user = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
|
||||||
next();
|
next();
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -64,7 +64,7 @@ vi.mock('./passport.routes', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Admin System Routes (/api/admin/system)', () => {
|
describe('Admin System Routes (/api/admin/system)', () => {
|
||||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-user-id', email: 'admin@test.com' } });
|
||||||
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { createMockUserProfile, createMockAdminUserView, createMockProfile } from '../tests/utils/mockFactories';
|
import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
|
||||||
import type { UserProfile } from '../types';
|
import type { UserProfile, Profile } from '../types';
|
||||||
import { NotFoundError } from '../services/db/errors.db';
|
import { NotFoundError } from '../services/db/errors.db';
|
||||||
import { createTestApp } from '../tests/utils/createTestApp';
|
import { createTestApp } from '../tests/utils/createTestApp';
|
||||||
import { mockLogger } from '../tests/utils/mockLogger';
|
import { mockLogger } from '../tests/utils/mockLogger';
|
||||||
@@ -70,7 +70,7 @@ vi.mock('./passport.routes', () => ({
|
|||||||
describe('Admin User Management Routes (/api/admin/users)', () => {
|
describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||||
const adminId = '123e4567-e89b-12d3-a456-426614174000';
|
const adminId = '123e4567-e89b-12d3-a456-426614174000';
|
||||||
const userId = '123e4567-e89b-12d3-a456-426614174001';
|
const userId = '123e4567-e89b-12d3-a456-426614174001';
|
||||||
const adminUser = createMockUserProfile({ role: 'admin', user_id: adminId });
|
const adminUser = createMockUserProfile({ role: 'admin', user: { user_id: adminId, email: 'admin@test.com' } });
|
||||||
// Create a single app instance with an admin user for all tests in this suite.
|
// Create a single app instance with an admin user for all tests in this suite.
|
||||||
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
const app = createTestApp({ router: adminRouter, basePath: '/api/admin', authenticatedUser: adminUser });
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
|||||||
|
|
||||||
describe('GET /users/:id', () => {
|
describe('GET /users/:id', () => {
|
||||||
it('should fetch a single user successfully', async () => {
|
it('should fetch a single user successfully', async () => {
|
||||||
const mockUser = createMockUserProfile({ user_id: userId });
|
const mockUser = createMockUserProfile({ user: { user_id: userId, email: 'user@test.com' } });
|
||||||
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUser);
|
vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUser);
|
||||||
const response = await supertest(app).get(`/api/admin/users/${userId}`);
|
const response = await supertest(app).get(`/api/admin/users/${userId}`);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -134,10 +134,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
|||||||
|
|
||||||
describe('PUT /users/:id', () => {
|
describe('PUT /users/:id', () => {
|
||||||
it('should update a user role successfully', async () => {
|
it('should update a user role successfully', async () => {
|
||||||
const updatedUser = createMockProfile({
|
// The updateUserRole function returns a Profile, which does not have a user_id.
|
||||||
user_id: userId,
|
// The createMockProfile factory is incorrect as it tries to add one.
|
||||||
|
// We create the mock object manually to match the Profile type.
|
||||||
|
const updatedUser: Profile = {
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
});
|
points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
vi.mocked(adminRepo.updateUserRole).mockResolvedValue(updatedUser);
|
vi.mocked(adminRepo.updateUserRole).mockResolvedValue(updatedUser);
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.put(`/api/admin/users/${userId}`)
|
.put(`/api/admin/users/${userId}`)
|
||||||
|
|||||||
@@ -76,12 +76,8 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
const mkdirError = new Error('EACCES: permission denied');
|
const mkdirError = new Error('EACCES: permission denied');
|
||||||
vi.resetModules(); // Reset modules to re-run top-level code
|
vi.resetModules(); // Reset modules to re-run top-level code
|
||||||
vi.doMock('node:fs', () => ({
|
vi.doMock('node:fs', () => ({
|
||||||
default: {
|
...fs,
|
||||||
...fs, // Keep other fs functions
|
mkdirSync: vi.fn().mockImplementation(() => { throw mkdirError; }),
|
||||||
mkdirSync: vi.fn().mockImplementation(() => {
|
|
||||||
throw mkdirError;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
const { logger } = await import('../services/logger.server');
|
const { logger } = await import('../services/logger.server');
|
||||||
|
|
||||||
@@ -89,7 +85,8 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
await import('./ai.routes');
|
await import('./ai.routes');
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, `Failed to create storage path (/var/www/flyer-crawler.projectium.com/flyer-images). File uploads may fail.`);
|
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||||
|
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, `Failed to create storage path (${storagePath}). File uploads may fail.`);
|
||||||
vi.doUnmock('node:fs'); // Cleanup
|
vi.doUnmock('node:fs'); // Cleanup
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -165,7 +162,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
it('should pass user ID to the job when authenticated', async () => {
|
it('should pass user ID to the job when authenticated', async () => {
|
||||||
// Arrange: Create a new app instance specifically for this test
|
// Arrange: Create a new app instance specifically for this test
|
||||||
// with the authenticated user middleware already applied.
|
// with the authenticated user middleware already applied.
|
||||||
const mockUser = createMockUserProfile({ user_id: 'auth-user-1' });
|
const mockUser = createMockUserProfile({ user: { user_id: 'auth-user-1', email: 'auth-user-1@test.com' } });
|
||||||
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
|
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUser });
|
||||||
|
|
||||||
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
vi.mocked(mockedDb.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||||
@@ -193,7 +190,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
country: 'CA',
|
country: 'CA',
|
||||||
});
|
});
|
||||||
const mockUserWithAddress = createMockUserProfile({
|
const mockUserWithAddress = createMockUserProfile({
|
||||||
user_id: 'auth-user-2',
|
user: { user_id: 'auth-user-2', email: 'auth-user-2@test.com' },
|
||||||
address: mockAddress,
|
address: mockAddress,
|
||||||
});
|
});
|
||||||
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserWithAddress });
|
const authenticatedApp = createTestApp({ router: aiRouter, basePath: '/api/ai', authenticatedUser: mockUserWithAddress });
|
||||||
@@ -392,7 +389,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
.field('items', JSON.stringify([]))
|
.field('items', JSON.stringify([]))
|
||||||
.attach('flyerImage', imagePath);
|
.attach('flyerImage', imagePath);
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201); // This test was failing with 500, the fix is in ai.routes.ts
|
||||||
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
|
||||||
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
|
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
|
||||||
expect(flyerDataArg.store_name).toBe('Root Store');
|
expect(flyerDataArg.store_name).toBe('Root Store');
|
||||||
@@ -414,9 +411,10 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
|
|
||||||
it('should return 500 on a generic error', async () => {
|
it('should return 500 on a generic error', async () => {
|
||||||
// To trigger the catch block, we can cause the middleware to fail.
|
// To trigger the catch block, we can cause the middleware to fail.
|
||||||
// A simple way is to mock the service to throw an error.
|
// Mock logger.info to throw, which is inside the try block.
|
||||||
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(new Error('Generic Error')); // Not used by route, but triggers catch
|
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
|
||||||
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', Buffer.from('')); // Empty buffer might cause issues
|
// Attach a valid file to get past the `if (!req.file)` check.
|
||||||
|
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', imagePath);
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -464,7 +462,9 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
|
|
||||||
it('should return 500 on a generic error', async () => {
|
it('should return 500 on a generic error', async () => {
|
||||||
// An empty buffer can sometimes cause underlying libraries to throw an error
|
// An empty buffer can sometimes cause underlying libraries to throw an error
|
||||||
const response = await supertest(app).post('/api/ai/extract-address').attach('image', Buffer.from(''));
|
// To reliably trigger the catch block, mock the logger to throw.
|
||||||
|
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
|
||||||
|
const response = await supertest(app).post('/api/ai/extract-address').attach('image', imagePath);
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -484,14 +484,16 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
|
|
||||||
it('should return 500 on a generic error', async () => {
|
it('should return 500 on a generic error', async () => {
|
||||||
// An empty buffer can sometimes cause underlying libraries to throw an error
|
// An empty buffer can sometimes cause underlying libraries to throw an error
|
||||||
const response = await supertest(app).post('/api/ai/extract-logo').attach('images', Buffer.from(''));
|
// To reliably trigger the catch block, mock the logger to throw.
|
||||||
|
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
|
||||||
|
const response = await supertest(app).post('/api/ai/extract-logo').attach('images', imagePath);
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /rescan-area (authenticated)', () => { // This was a duplicate, fixed.
|
describe('POST /rescan-area (authenticated)', () => { // This was a duplicate, fixed.
|
||||||
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
|
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg'); // This was a duplicate, fixed.
|
||||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'user-123@test.com' } });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Inject an authenticated user for this test block
|
// Inject an authenticated user for this test block
|
||||||
@@ -532,7 +534,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when user is authenticated', () => {
|
describe('when user is authenticated', () => {
|
||||||
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
|
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'user-123@test.com' } });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// For this block, simulate an authenticated request by attaching the user.
|
// For this block, simulate an authenticated request by attaching the user.
|
||||||
@@ -545,7 +547,7 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
it('POST /quick-insights should return the stubbed response', async () => {
|
it('POST /quick-insights should return the stubbed response', async () => {
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/ai/quick-insights')
|
.post('/api/ai/quick-insights')
|
||||||
.send({ items: [] });
|
.send({ items: [{ name: 'test' }] });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.text).toContain('server-generated quick insight');
|
expect(response.body.text).toContain('server-generated quick insight');
|
||||||
@@ -556,14 +558,14 @@ describe('AI Routes (/api/ai)', () => {
|
|||||||
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
|
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/ai/quick-insights')
|
.post('/api/ai/quick-insights')
|
||||||
.send({ items: [] });
|
.send({ items: [{ name: 'test' }] });
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /deep-dive should return the stubbed response', async () => {
|
it('POST /deep-dive should return the stubbed response', async () => {
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/ai/deep-dive')
|
.post('/api/ai/deep-dive')
|
||||||
.send({ items: [] });
|
.send({ items: [{ name: 'test' }] });
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.text).toContain('server-generated deep dive');
|
expect(response.body.text).toContain('server-generated deep dive');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import * as aiService from '../services/aiService.server'; // Correctly import s
|
|||||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||||
import { sanitizeFilename } from '../utils/stringUtils';
|
import { sanitizeFilename } from '../utils/stringUtils';
|
||||||
import { logger } from '../services/logger.server';
|
import { logger } from '../services/logger.server';
|
||||||
import { UserProfile, ExtractedCoreData } from '../types';
|
import { UserProfile, ExtractedCoreData, ExtractedFlyerItem } from '../types';
|
||||||
import { flyerQueue } from '../services/queueService.server';
|
import { flyerQueue } from '../services/queueService.server';
|
||||||
import { validateRequest } from '../middleware/validation.middleware';
|
import { validateRequest } from '../middleware/validation.middleware';
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
|
|||||||
filePath: req.file.path,
|
filePath: req.file.path,
|
||||||
originalFileName: req.file.originalname,
|
originalFileName: req.file.originalname,
|
||||||
checksum: checksum,
|
checksum: checksum,
|
||||||
userId: userProfile?.user_id,
|
userId: userProfile?.user.user_id,
|
||||||
submitterIp: req.ip, // Capture the submitter's IP address
|
submitterIp: req.ip, // Capture the submitter's IP address
|
||||||
userProfileAddress: userProfileAddress, // Pass the user's profile address
|
userProfileAddress: userProfileAddress, // Pass the user's profile address
|
||||||
});
|
});
|
||||||
@@ -314,7 +314,9 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
|||||||
// Transform the extracted items into the format required for database insertion.
|
// Transform the extracted items into the format required for database insertion.
|
||||||
// This adds default values for fields like `view_count` and `click_count`
|
// This adds default values for fields like `view_count` and `click_count`
|
||||||
// and makes this legacy endpoint consistent with the newer FlyerDataTransformer service.
|
// and makes this legacy endpoint consistent with the newer FlyerDataTransformer service.
|
||||||
const itemsForDb = (extractedData.items ?? []).map(item => ({
|
const rawItems = extractedData.items ?? [];
|
||||||
|
const itemsArray = Array.isArray(rawItems) ? rawItems : (typeof rawItems === 'string' ? JSON.parse(rawItems) : []);
|
||||||
|
const itemsForDb = itemsArray.map((item: Partial<ExtractedFlyerItem>) => ({
|
||||||
...item,
|
...item,
|
||||||
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
|
master_item_id: item.master_item_id === null ? undefined : item.master_item_id,
|
||||||
view_count: 0,
|
view_count: 0,
|
||||||
@@ -354,7 +356,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
|||||||
valid_to: extractedData.valid_to ?? null,
|
valid_to: extractedData.valid_to ?? null,
|
||||||
store_address: extractedData.store_address ?? null,
|
store_address: extractedData.store_address ?? null,
|
||||||
item_count: 0, // Set default to 0; the trigger will update it.
|
item_count: 0, // Set default to 0; the trigger will update it.
|
||||||
uploaded_by: userProfile?.user_id, // Associate with user if logged in
|
uploaded_by: userProfile?.user.user_id, // Associate with user if logged in
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Create flyer and its items in a transaction
|
// 3. Create flyer and its items in a transaction
|
||||||
@@ -364,7 +366,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'),
|
|||||||
|
|
||||||
// Log this significant event
|
// Log this significant event
|
||||||
await db.adminRepo.logActivity({
|
await db.adminRepo.logActivity({
|
||||||
userId: userProfile?.user_id,
|
userId: userProfile?.user.user_id,
|
||||||
action: 'flyer_processed',
|
action: 'flyer_processed',
|
||||||
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
displayText: `Processed a new flyer for ${flyerData.store_name}.`,
|
||||||
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name }
|
details: { flyerId: newFlyer.flyer_id, storeName: flyerData.store_name }
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const passportMocks = vi.hoisted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default success case
|
// Default success case
|
||||||
const user = createMockUserProfile({ user_id: 'user-123', user: { user_id: 'user-123', email: req.body.email } });
|
const user = createMockUserProfile({ user: { user_id: 'user-123', email: req.body.email } });
|
||||||
|
|
||||||
// If a callback is provided (custom callback signature), call it
|
// If a callback is provided (custom callback signature), call it
|
||||||
if (callback) {
|
if (callback) {
|
||||||
@@ -160,7 +160,7 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
|
|
||||||
it('should successfully register a new user with a strong password', async () => {
|
it('should successfully register a new user with a strong password', async () => {
|
||||||
// Arrange:
|
// Arrange:
|
||||||
const mockNewUser = createMockUserProfile({ user_id: 'new-user-id', user: { user_id: 'new-user-id', email: newUserEmail }, full_name: 'Test User' });
|
const mockNewUser = createMockUserProfile({ user: { user_id: 'new-user-id', email: newUserEmail }, full_name: 'Test User' });
|
||||||
|
|
||||||
// FIX: Mock the method on the imported singleton instance `userRepo` directly,
|
// FIX: Mock the method on the imported singleton instance `userRepo` directly,
|
||||||
// as this is what the route handler uses. Spying on the prototype does not
|
// as this is what the route handler uses. Spying on the prototype does not
|
||||||
@@ -187,7 +187,7 @@ describe('Auth Routes (/api/auth)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set a refresh token cookie on successful registration', async () => {
|
it('should set a refresh token cookie on successful registration', async () => {
|
||||||
const mockNewUser = createMockUserProfile({ user_id: 'new-user-id', user: { user_id: 'new-user-id', email: 'cookie@test.com' } });
|
const mockNewUser = createMockUserProfile({ user: { user_id: 'new-user-id', email: 'cookie@test.com' } });
|
||||||
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser);
|
vi.mocked(db.userRepo.createUser).mockResolvedValue(mockNewUser);
|
||||||
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
|
vi.mocked(db.userRepo.saveRefreshToken).mockResolvedValue(undefined);
|
||||||
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
|
vi.mocked(db.adminRepo.logActivity).mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -105,30 +105,30 @@ router.post('/register', validateRequest(registerSchema), async (req: Request, r
|
|||||||
// The createUser method in UserRepository now handles its own transaction.
|
// The createUser method in UserRepository now handles its own transaction.
|
||||||
const newUser = await userRepo.createUser(email, hashedPassword, { full_name, avatar_url }, req.log);
|
const newUser = await userRepo.createUser(email, hashedPassword, { full_name, avatar_url }, req.log);
|
||||||
|
|
||||||
const userEmail = newUser.user.email || 'unknown';
|
const userEmail = newUser.user.email;
|
||||||
const userId = newUser.user_id || 'unknown';
|
const userId = newUser.user.user_id;
|
||||||
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
|
logger.info(`Successfully created new user in DB: ${userEmail} (ID: ${userId})`);
|
||||||
|
|
||||||
// Use the new standardized logging function
|
// Use the new standardized logging function
|
||||||
await adminRepo.logActivity({
|
await adminRepo.logActivity({
|
||||||
userId: newUser.user_id,
|
userId: newUser.user.user_id,
|
||||||
action: 'user_registered',
|
action: 'user_registered',
|
||||||
displayText: `${userEmail} has registered.`,
|
displayText: `${userEmail} has registered.`,
|
||||||
icon: 'user-plus',
|
icon: 'user-plus',
|
||||||
}, req.log);
|
}, req.log);
|
||||||
|
|
||||||
const payload = { user_id: newUser.user_id, email: userEmail };
|
const payload = { user_id: newUser.user.user_id, email: userEmail };
|
||||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
|
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
|
||||||
|
|
||||||
const refreshToken = crypto.randomBytes(64).toString('hex');
|
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||||
await userRepo.saveRefreshToken(newUser.user_id, refreshToken, req.log);
|
await userRepo.saveRefreshToken(newUser.user.user_id, refreshToken, req.log);
|
||||||
|
|
||||||
res.cookie('refreshToken', refreshToken, {
|
res.cookie('refreshToken', refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
});
|
});
|
||||||
return res.status(201).json({ message: 'User registered successfully!', user: payload, token });
|
return res.status(201).json({ message: 'User registered successfully!', userprofile: newUser, token });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof UniqueConstraintError) {
|
if (error instanceof UniqueConstraintError) {
|
||||||
// If the email is a duplicate, return a 409 Conflict status.
|
// If the email is a duplicate, return a 409 Conflict status.
|
||||||
@@ -168,12 +168,12 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userProfile = user as UserProfile;
|
const userProfile = user as UserProfile;
|
||||||
const payload = { user_id: userProfile.user_id, email: userProfile.user.email, role: userProfile.role };
|
const payload = { user_id: userProfile.user.user_id, email: userProfile.user.email, role: userProfile.role };
|
||||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
|
const refreshToken = crypto.randomBytes(64).toString('hex'); // This was a duplicate, fixed.
|
||||||
await userRepo.saveRefreshToken(userProfile.user_id, refreshToken, req.log);
|
await userRepo.saveRefreshToken(userProfile.user.user_id, refreshToken, req.log);
|
||||||
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
|
||||||
|
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
@@ -184,7 +184,7 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
|
|||||||
|
|
||||||
res.cookie('refreshToken', refreshToken, cookieOptions);
|
res.cookie('refreshToken', refreshToken, cookieOptions);
|
||||||
// Return the full user profile object on login to avoid a second fetch on the client.
|
// Return the full user profile object on login to avoid a second fetch on the client.
|
||||||
return res.json({ user: userProfile, token: accessToken });
|
return res.json({ userprofile: userProfile, token: accessToken });
|
||||||
} catch (tokenErr) {
|
} catch (tokenErr) {
|
||||||
req.log.error({ error: tokenErr }, `Failed to save refresh token during login for user: ${userProfile.user.email}`);
|
req.log.error({ error: tokenErr }, `Failed to save refresh token during login for user: ${userProfile.user.email}`);
|
||||||
return next(tokenErr);
|
return next(tokenErr);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import budgetRouter from './budget.routes';
|
|||||||
import * as db from '../services/db/index.db';
|
import * as db from '../services/db/index.db';
|
||||||
|
|
||||||
const mockUser = createMockUserProfile({
|
const mockUser = createMockUserProfile({
|
||||||
user_id: 'user-123',
|
|
||||||
user: { user_id: 'user-123', email: 'test@test.com' }
|
user: { user_id: 'user-123', email: 'test@test.com' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ const expectLogger = expect.objectContaining({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Budget Routes (/api/budgets)', () => {
|
describe('Budget Routes (/api/budgets)', () => {
|
||||||
const mockUserProfile = createMockUserProfile({ user_id: 'user-123', points: 100 });
|
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' }, points: 100 });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -81,7 +80,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockBudgets);
|
expect(response.body).toEqual(mockBudgets);
|
||||||
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(mockUserProfile.user_id, expectLogger);
|
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(mockUserProfile.user.user_id, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if the database call fails', async () => {
|
it('should return 500 if the database call fails', async () => {
|
||||||
@@ -192,7 +191,7 @@ describe('Budget Routes (/api/budgets)', () => {
|
|||||||
const response = await supertest(app).delete('/api/budgets/1');
|
const response = await supertest(app).delete('/api/budgets/1');
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(1, mockUserProfile.user_id, expectLogger);
|
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if the budget is not found', async () => {
|
it('should return 404 if the budget is not found', async () => {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const expectLogger = expect.objectContaining({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Deals Routes (/api/users/deals)', () => {
|
describe('Deals Routes (/api/users/deals)', () => {
|
||||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
const mockUser = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' } });
|
||||||
const basePath = '/api/users/deals';
|
const basePath = '/api/users/deals';
|
||||||
const authenticatedApp = createTestApp({ router: dealsRouter, basePath, authenticatedUser: mockUser });
|
const authenticatedApp = createTestApp({ router: dealsRouter, basePath, authenticatedUser: mockUser });
|
||||||
const unauthenticatedApp = createTestApp({ router: dealsRouter, basePath });
|
const unauthenticatedApp = createTestApp({ router: dealsRouter, basePath });
|
||||||
@@ -74,7 +74,7 @@ describe('Deals Routes (/api/users/deals)', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockDeals);
|
expect(response.body).toEqual(mockDeals);
|
||||||
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(mockUser.user_id, expectLogger);
|
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(mockUser.user.user_id, expectLogger);
|
||||||
expect(mockLogger.info).toHaveBeenCalledWith({ dealCount: 1 }, 'Successfully fetched best watched item deals.');
|
expect(mockLogger.info).toHaveBeenCalledWith({ dealCount: 1 }, 'Successfully fetched best watched item deals.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ const expectLogger = expect.objectContaining({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Gamification Routes (/api/achievements)', () => {
|
describe('Gamification Routes (/api/achievements)', () => {
|
||||||
const mockUserProfile = createMockUserProfile({ user_id: 'user-123', points: 100 });
|
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'user@test.com' }, points: 100 });
|
||||||
const mockAdminProfile = createMockUserProfile({ user_id: 'admin-456', role: 'admin', points: 999 });
|
const mockAdminProfile = createMockUserProfile({ user: { user_id: 'admin-456', email: 'admin@test.com' }, role: 'admin', points: 999 });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ describe('Passport Configuration', () => {
|
|||||||
// Arrange
|
// Arrange
|
||||||
const mockAuthableProfile = {
|
const mockAuthableProfile = {
|
||||||
...createMockUserProfile({
|
...createMockUserProfile({
|
||||||
user_id: 'user-123',
|
|
||||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||||
points: 0,
|
points: 0,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -130,9 +129,9 @@ describe('Passport Configuration', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith('test@test.com', logger);
|
expect(mockedDb.userRepo.findUserWithProfileByEmail).toHaveBeenCalledWith('test@test.com', logger);
|
||||||
expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password');
|
expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password');
|
||||||
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith('user-123', '127.0.0.1', logger);
|
expect(mockedDb.adminRepo.resetFailedLoginAttempts).toHaveBeenCalledWith(mockAuthableProfile.user.user_id, '127.0.0.1', logger);
|
||||||
// The strategy now just strips auth fields.
|
// The strategy now just strips auth fields.
|
||||||
const { password_hash, failed_login_attempts, last_failed_login, created_at, updated_at, last_login_ip, refresh_token, email, ...expectedUserProfile } = mockAuthableProfile;
|
const { password_hash, failed_login_attempts, last_failed_login, last_login_ip, refresh_token, ...expectedUserProfile } = mockAuthableProfile;
|
||||||
expect(done).toHaveBeenCalledWith(null, expectedUserProfile);
|
expect(done).toHaveBeenCalledWith(null, expectedUserProfile);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +148,6 @@ describe('Passport Configuration', () => {
|
|||||||
it('should call done(null, false) and increment failed attempts on password mismatch', async () => {
|
it('should call done(null, false) and increment failed attempts on password mismatch', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
...createMockUserProfile({
|
...createMockUserProfile({
|
||||||
user_id: 'user-123',
|
|
||||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||||
points: 0,
|
points: 0,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -170,7 +168,7 @@ describe('Passport Configuration', () => {
|
|||||||
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
|
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith('user-123', logger);
|
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger);
|
||||||
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
action: 'login_failed_password',
|
action: 'login_failed_password',
|
||||||
details: { source_ip: '127.0.0.1', new_attempt_count: 2 },
|
details: { source_ip: '127.0.0.1', new_attempt_count: 2 },
|
||||||
@@ -181,7 +179,6 @@ describe('Passport Configuration', () => {
|
|||||||
it('should return a lockout message immediately if the final attempt fails', async () => {
|
it('should return a lockout message immediately if the final attempt fails', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
...createMockUserProfile({
|
...createMockUserProfile({
|
||||||
user_id: 'user-123',
|
|
||||||
user: { user_id: 'user-123', email: 'test@test.com' },
|
user: { user_id: 'user-123', email: 'test@test.com' },
|
||||||
points: 0,
|
points: 0,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -202,7 +199,7 @@ describe('Passport Configuration', () => {
|
|||||||
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
|
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith('user-123', logger);
|
expect(mockedDb.adminRepo.incrementFailedLoginAttempts).toHaveBeenCalledWith(mockUser.user.user_id, logger);
|
||||||
// It should now return the lockout message, not the generic "incorrect password"
|
// It should now return the lockout message, not the generic "incorrect password"
|
||||||
expect(done).toHaveBeenCalledWith(null, false, { message: expect.stringContaining('Account is temporarily locked') });
|
expect(done).toHaveBeenCalledWith(null, false, { message: expect.stringContaining('Account is temporarily locked') });
|
||||||
});
|
});
|
||||||
@@ -210,7 +207,6 @@ describe('Passport Configuration', () => {
|
|||||||
it('should call done(null, false) for an OAuth user (no password hash)', async () => {
|
it('should call done(null, false) for an OAuth user (no password hash)', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
...createMockUserProfile({
|
...createMockUserProfile({
|
||||||
user_id: 'oauth-user',
|
|
||||||
user: { user_id: 'oauth-user', email: 'oauth@test.com' },
|
user: { user_id: 'oauth-user', email: 'oauth@test.com' },
|
||||||
points: 0,
|
points: 0,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -234,7 +230,6 @@ describe('Passport Configuration', () => {
|
|||||||
it('should call done(null, false) if account is locked', async () => {
|
it('should call done(null, false) if account is locked', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
...createMockUserProfile({
|
...createMockUserProfile({
|
||||||
user_id: 'locked-user',
|
|
||||||
user: { user_id: 'locked-user', email: 'locked@test.com' },
|
user: { user_id: 'locked-user', email: 'locked@test.com' },
|
||||||
points: 0,
|
points: 0,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -259,7 +254,6 @@ describe('Passport Configuration', () => {
|
|||||||
it('should allow login if lockout period has expired', async () => {
|
it('should allow login if lockout period has expired', async () => {
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
...createMockUserProfile({
|
...createMockUserProfile({
|
||||||
user_id: 'expired-lock-user',
|
|
||||||
user: { user_id: 'expired-lock-user', email: 'expired@test.com' },
|
user: { user_id: 'expired-lock-user', email: 'expired@test.com' },
|
||||||
points: 0,
|
points: 0,
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -300,7 +294,7 @@ describe('Passport Configuration', () => {
|
|||||||
it('should call done(null, userProfile) on successful authentication', async () => {
|
it('should call done(null, userProfile) on successful authentication', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const jwtPayload = { user_id: 'user-123' };
|
const jwtPayload = { user_id: 'user-123' };
|
||||||
const mockProfile = { user_id: 'user-123', role: 'user', points: 100, user: { user_id: 'user-123', email: 'test@test.com' } } as UserProfile;
|
const mockProfile = { role: 'user', points: 100, user: { user_id: 'user-123', email: 'test@test.com' } } as UserProfile;
|
||||||
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockProfile);
|
vi.mocked(mockedDb.userRepo.findUserProfileById).mockResolvedValue(mockProfile);
|
||||||
const done = vi.fn();
|
const done = vi.fn();
|
||||||
|
|
||||||
@@ -366,7 +360,7 @@ describe('Passport Configuration', () => {
|
|||||||
it('should call next() if user has "admin" role', () => {
|
it('should call next() if user has "admin" role', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const mockReq: Partial<Request> = {
|
const mockReq: Partial<Request> = {
|
||||||
user: createMockUserProfile({ user_id: 'admin-id', role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }),
|
user: createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -380,14 +374,14 @@ describe('Passport Configuration', () => {
|
|||||||
it('should return 403 Forbidden if user does not have "admin" role', () => {
|
it('should return 403 Forbidden if user does not have "admin" role', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const mockReq: Partial<Request> = {
|
const mockReq: Partial<Request> = {
|
||||||
user: createMockUserProfile({ user_id: 'user-id', role: 'user', user: { user_id: 'user-id', email: 'user@test.com' } }),
|
user: createMockUserProfile({ role: 'user', user: { user_id: 'user-id', email: 'user@test.com' } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
|
||||||
});
|
});
|
||||||
@@ -400,7 +394,7 @@ describe('Passport Configuration', () => {
|
|||||||
isAdmin(mockReq, mockRes as Response, mockNext);
|
isAdmin(mockReq, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -417,7 +411,7 @@ describe('Passport Configuration', () => {
|
|||||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockNext).not.toHaveBeenCalled();
|
expect(mockNext).not.toHaveBeenCalled(); // This was a duplicate, fixed.
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
|
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Forbidden: Administrator access required.' });
|
||||||
});
|
});
|
||||||
@@ -434,7 +428,7 @@ describe('Passport Configuration', () => {
|
|||||||
it('should populate req.user and call next() if authentication succeeds', () => {
|
it('should populate req.user and call next() if authentication succeeds', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const mockReq = {} as Request;
|
const mockReq = {} as Request;
|
||||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
const mockUser = createMockUserProfile({ role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } });
|
||||||
// Mock passport.authenticate to call its callback with a user
|
// Mock passport.authenticate to call its callback with a user
|
||||||
vi.mocked(passport.authenticate).mockImplementation(
|
vi.mocked(passport.authenticate).mockImplementation(
|
||||||
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined)
|
(_strategy, _options, callback) => () => callback?.(null, mockUser, undefined)
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ const LOCKOUT_DURATION_MINUTES = 15;
|
|||||||
* @returns True if the object is a UserProfile, false otherwise.
|
* @returns True if the object is a UserProfile, false otherwise.
|
||||||
*/
|
*/
|
||||||
function isUserProfile(user: unknown): user is UserProfile {
|
function isUserProfile(user: unknown): user is UserProfile {
|
||||||
return typeof user === 'object' && user !== null && 'user_id' in user && 'role' in user;
|
return (
|
||||||
|
typeof user === 'object' &&
|
||||||
|
user !== null &&
|
||||||
|
'role' in user &&
|
||||||
|
'user' in user &&
|
||||||
|
typeof (user as { user: unknown }).user === 'object' &&
|
||||||
|
(user as { user: unknown }).user !== null &&
|
||||||
|
'user_id' in ((user as { user: unknown }).user as object)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Passport Local Strategy (for email/password login) ---
|
// --- Passport Local Strategy (for email/password login) ---
|
||||||
@@ -53,7 +61,7 @@ passport.use(new LocalStrategy(
|
|||||||
if (timeSinceLockout < lockoutDurationMs) {
|
if (timeSinceLockout < lockoutDurationMs) {
|
||||||
logger.warn(`Login attempt for locked account: ${email}`);
|
logger.warn(`Login attempt for locked account: ${email}`);
|
||||||
// Refresh the lockout timestamp on each attempt to prevent probing.
|
// Refresh the lockout timestamp on each attempt to prevent probing.
|
||||||
await db.adminRepo.incrementFailedLoginAttempts(user.user_id, req.log);
|
await db.adminRepo.incrementFailedLoginAttempts(user.user.user_id, req.log);
|
||||||
return done(null, false, { message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.` });
|
return done(null, false, { message: `Account is temporarily locked. Please try again in ${LOCKOUT_DURATION_MINUTES} minutes.` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,15 +81,15 @@ passport.use(new LocalStrategy(
|
|||||||
// Password does not match
|
// Password does not match
|
||||||
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
logger.warn(`Login attempt failed for user ${email} due to incorrect password.`);
|
||||||
// Increment failed attempts and get the new count.
|
// Increment failed attempts and get the new count.
|
||||||
const newAttemptCount = await db.adminRepo.incrementFailedLoginAttempts(user.user_id, req.log);
|
const newAttemptCount = await db.adminRepo.incrementFailedLoginAttempts(user.user.user_id, req.log);
|
||||||
|
|
||||||
// Log this security event.
|
// Log this security event.
|
||||||
await db.adminRepo.logActivity({
|
await db.adminRepo.logActivity({
|
||||||
userId: user.user_id,
|
userId: user.user.user_id,
|
||||||
action: 'login_failed_password',
|
action: 'login_failed_password',
|
||||||
displayText: `Failed login attempt for user ${user.email}.`,
|
displayText: `Failed login attempt for user ${user.user.email}.`,
|
||||||
icon: 'shield-alert',
|
icon: 'shield-alert',
|
||||||
details: { source_ip: req.ip ?? null, new_attempt_count: newAttemptCount },
|
details: { source_ip: req.ip ?? null, new_attempt_count: newAttemptCount }, // The user.email is correct here as it's part of the Omit type
|
||||||
}, req.log);
|
}, req.log);
|
||||||
|
|
||||||
// If this attempt just locked the account, inform the user immediately.
|
// If this attempt just locked the account, inform the user immediately.
|
||||||
@@ -94,15 +102,16 @@ passport.use(new LocalStrategy(
|
|||||||
|
|
||||||
// 3. Success! Return the user object (without password_hash for security).
|
// 3. Success! Return the user object (without password_hash for security).
|
||||||
// Reset failed login attempts upon successful login.
|
// Reset failed login attempts upon successful login.
|
||||||
await db.adminRepo.resetFailedLoginAttempts(user.user_id, req.ip ?? 'unknown', req.log);
|
await db.adminRepo.resetFailedLoginAttempts(user.user.user_id, req.ip ?? 'unknown', req.log);
|
||||||
|
|
||||||
logger.info(`User successfully authenticated: ${email}`);
|
logger.info(`User successfully authenticated: ${email}`);
|
||||||
|
|
||||||
// The `user` object from `findUserWithProfileByEmail` is now a fully formed
|
// The `user` object from `findUserWithProfileByEmail` is now a fully formed
|
||||||
// UserProfile object with additional authentication fields. We must strip these
|
// UserProfile object with additional authentication fields. We must strip these
|
||||||
// sensitive fields before passing the profile to the session.
|
// sensitive fields before passing the profile to the session.
|
||||||
// The `...userProfile` rest parameter will contain the clean UserProfile object.
|
// The `...userProfile` rest parameter will contain the clean UserProfile object,
|
||||||
const { password_hash, failed_login_attempts, last_failed_login, refresh_token, email: _, ...userProfile } = user;
|
// which no longer has a top-level email property.
|
||||||
|
const { password_hash, failed_login_attempts, last_failed_login, refresh_token, ...userProfile } = user;
|
||||||
return done(null, userProfile);
|
return done(null, userProfile);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
req.log.error({ error: err }, 'Error during local authentication strategy:');
|
req.log.error({ error: err }, 'Error during local authentication strategy:');
|
||||||
@@ -252,7 +261,7 @@ export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
|
|||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
// Check if userProfile is a valid UserProfile before accessing its properties for logging.
|
||||||
const userIdForLog = isUserProfile(userProfile) ? userProfile.user_id : 'unknown';
|
const userIdForLog = isUserProfile(userProfile) ? userProfile.user.user_id : 'unknown';
|
||||||
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
logger.warn(`Admin access denied for user: ${userIdForLog}`);
|
||||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,8 +118,10 @@ describe('User Routes (/api/users)', () => {
|
|||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
// Set up the mock *before* the module is re-imported
|
// Set up the mock *before* the module is re-imported
|
||||||
vi.doMock('node:fs/promises', () => ({
|
vi.doMock('node:fs/promises', () => ({
|
||||||
// We only need to mock mkdir for this test.
|
default: {
|
||||||
mkdir: vi.fn().mockRejectedValue(mkdirError),
|
// We only need to mock mkdir for this test.
|
||||||
|
mkdir: vi.fn().mockRejectedValue(mkdirError),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
const { logger } = await import('../services/logger.server');
|
const { logger } = await import('../services/logger.server');
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when user is authenticated', () => {
|
describe('when user is authenticated', () => {
|
||||||
const mockUserProfile = createMockUserProfile({ user_id: 'user-123' });
|
const mockUserProfile = createMockUserProfile({ user: { user_id: 'user-123', email: 'test@test.com' } });
|
||||||
const app = createTestApp({ router: userRouter, basePath, authenticatedUser: mockUserProfile });
|
const app = createTestApp({ router: userRouter, basePath, authenticatedUser: mockUserProfile });
|
||||||
|
|
||||||
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
// Add a basic error handler to capture errors passed to next(err) and return JSON.
|
||||||
@@ -164,7 +166,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
const response = await supertest(app).get('/api/users/profile');
|
const response = await supertest(app).get('/api/users/profile');
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockUserProfile);
|
expect(response.body).toEqual(mockUserProfile);
|
||||||
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(mockUserProfile.user_id, expectLogger);
|
expect(db.userRepo.findUserProfileById).toHaveBeenCalledWith(mockUserProfile.user.user_id, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 if profile is not found in DB', async () => {
|
it('should return 404 if profile is not found in DB', async () => {
|
||||||
@@ -257,7 +259,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
vi.mocked(db.personalizationRepo.removeWatchedItem).mockResolvedValue(undefined);
|
vi.mocked(db.personalizationRepo.removeWatchedItem).mockResolvedValue(undefined);
|
||||||
const response = await supertest(app).delete(`/api/users/watched-items/99`);
|
const response = await supertest(app).delete(`/api/users/watched-items/99`);
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user_id, 99, expectLogger);
|
expect(db.personalizationRepo.removeWatchedItem).toHaveBeenCalledWith(mockUserProfile.user.user_id, 99, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 on a generic database error', async () => {
|
it('should return 500 on a generic database error', async () => {
|
||||||
@@ -271,7 +273,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
|
|
||||||
describe('Shopping List Routes', () => {
|
describe('Shopping List Routes', () => {
|
||||||
it('GET /shopping-lists should return all shopping lists for the user', async () => {
|
it('GET /shopping-lists should return all shopping lists for the user', async () => {
|
||||||
const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id })];
|
const mockLists = [createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user.user_id })];
|
||||||
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
|
vi.mocked(db.shoppingRepo.getShoppingLists).mockResolvedValue(mockLists);
|
||||||
const response = await supertest(app).get('/api/users/shopping-lists');
|
const response = await supertest(app).get('/api/users/shopping-lists');
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
@@ -287,7 +289,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('POST /shopping-lists should create a new list', async () => {
|
it('POST /shopping-lists should create a new list', async () => {
|
||||||
const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user_id, name: 'Party Supplies' });
|
const mockNewList = createMockShoppingList({ shopping_list_id: 2, user_id: mockUserProfile.user.user_id, name: 'Party Supplies' });
|
||||||
vi.mocked(db.shoppingRepo.createShoppingList).mockResolvedValue(mockNewList);
|
vi.mocked(db.shoppingRepo.createShoppingList).mockResolvedValue(mockNewList);
|
||||||
const response = await supertest(app)
|
const response = await supertest(app)
|
||||||
.post('/api/users/shopping-lists')
|
.post('/api/users/shopping-lists')
|
||||||
@@ -893,7 +895,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.avatar_url).toContain('/uploads/avatars/');
|
expect(response.body.avatar_url).toContain('/uploads/avatars/');
|
||||||
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user_id, { avatar_url: expect.any(String) }, expectLogger);
|
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(mockUserProfile.user.user_id, { avatar_url: expect.any(String) }, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 if updating the profile fails after upload', async () => {
|
it('should return 500 if updating the profile fails after upload', async () => {
|
||||||
@@ -950,7 +952,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
vi.mocked(db.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
|
vi.mocked(db.recipeRepo.deleteRecipe).mockResolvedValue(undefined);
|
||||||
const response = await supertest(app).delete('/api/users/recipes/1');
|
const response = await supertest(app).delete('/api/users/recipes/1');
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(1, mockUserProfile.user_id, false, expectLogger);
|
expect(db.recipeRepo.deleteRecipe).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, false, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE /recipes/:recipeId should return 500 on a generic database error', async () => {
|
it('DELETE /recipes/:recipeId should return 500 on a generic database error', async () => {
|
||||||
@@ -972,7 +974,7 @@ describe('User Routes (/api/users)', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockUpdatedRecipe);
|
expect(response.body).toEqual(mockUpdatedRecipe);
|
||||||
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(1, mockUserProfile.user_id, updates, expectLogger);
|
expect(db.recipeRepo.updateRecipe).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, updates, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PUT /recipes/:recipeId should return 404 if recipe not found', async () => {
|
it('PUT /recipes/:recipeId should return 404 if recipe not found', async () => {
|
||||||
@@ -1005,12 +1007,12 @@ describe('User Routes (/api/users)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
|
it('GET /shopping-lists/:listId should return a single shopping list', async () => {
|
||||||
const mockList = createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user_id });
|
const mockList = createMockShoppingList({ shopping_list_id: 1, user_id: mockUserProfile.user.user_id });
|
||||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
|
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(mockList);
|
||||||
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
const response = await supertest(app).get('/api/users/shopping-lists/1');
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockList);
|
expect(response.body).toEqual(mockList);
|
||||||
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(1, mockUserProfile.user_id, expectLogger);
|
expect(db.shoppingRepo.getShoppingListById).toHaveBeenCalledWith(1, mockUserProfile.user.user_id, expectLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /shopping-lists/:listId should return 500 on a generic database error', async () => {
|
it('GET /shopping-lists/:listId should return 500 on a generic database error', async () => {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ router.post(
|
|||||||
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||||
const updatedProfile = await db.userRepo.updateUserProfile(userProfile.user_id, { avatar_url: avatarUrl }, req.log);
|
const updatedProfile = await db.userRepo.updateUserProfile(userProfile.user.user_id, { avatar_url: avatarUrl }, req.log);
|
||||||
res.json(updatedProfile);
|
res.json(updatedProfile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -150,7 +150,7 @@ router.get(
|
|||||||
// Explicitly convert to numbers to ensure the repo receives correct types
|
// Explicitly convert to numbers to ensure the repo receives correct types
|
||||||
const limit = query.limit ? Number(query.limit) : 20;
|
const limit = query.limit ? Number(query.limit) : 20;
|
||||||
const offset = query.offset ? Number(query.offset) : 0;
|
const offset = query.offset ? Number(query.offset) : 0;
|
||||||
const notifications = await db.notificationRepo.getNotificationsForUser(userProfile.user_id, limit, offset, req.log);
|
const notifications = await db.notificationRepo.getNotificationsForUser(userProfile.user.user_id, limit, offset, req.log);
|
||||||
res.json(notifications);
|
res.json(notifications);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -167,7 +167,7 @@ router.post(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user_id, req.log);
|
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user.user_id, req.log);
|
||||||
res.status(204).send(); // No Content
|
res.status(204).send(); // No Content
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -187,7 +187,7 @@ router.post(
|
|||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as MarkNotificationReadRequest;
|
const { params } = req as unknown as MarkNotificationReadRequest;
|
||||||
await db.notificationRepo.markNotificationAsRead(params.notificationId, userProfile.user_id, req.log);
|
await db.notificationRepo.markNotificationAsRead(params.notificationId, userProfile.user.user_id, req.log);
|
||||||
res.status(204).send(); // Success, no content to return
|
res.status(204).send(); // Success, no content to return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -202,8 +202,8 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
|
|||||||
logger.debug(`[ROUTE] GET /api/users/profile - ENTER`);
|
logger.debug(`[ROUTE] GET /api/users/profile - ENTER`);
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
try {
|
try {
|
||||||
logger.debug(`[ROUTE] Calling db.userRepo.findUserProfileById for user: ${userProfile.user_id}`);
|
logger.debug(`[ROUTE] Calling db.userRepo.findUserProfileById for user: ${userProfile.user.user_id}`);
|
||||||
const fullUserProfile = await db.userRepo.findUserProfileById(userProfile.user_id, req.log);
|
const fullUserProfile = await db.userRepo.findUserProfileById(userProfile.user.user_id, req.log);
|
||||||
res.json(fullUserProfile);
|
res.json(fullUserProfile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
|
logger.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
|
||||||
@@ -221,7 +221,7 @@ router.put('/profile', validateRequest(updateProfileSchema), async (req, res, ne
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { body } = req as unknown as UpdateProfileRequest;
|
const { body } = req as unknown as UpdateProfileRequest;
|
||||||
try {
|
try {
|
||||||
const updatedProfile = await db.userRepo.updateUserProfile(userProfile.user_id, body, req.log);
|
const updatedProfile = await db.userRepo.updateUserProfile(userProfile.user.user_id, body, req.log);
|
||||||
res.json(updatedProfile);
|
res.json(updatedProfile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
logger.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
|
||||||
@@ -242,7 +242,7 @@ router.put('/profile/password', validateRequest(updatePasswordSchema), async (re
|
|||||||
try {
|
try {
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
|
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
|
||||||
await db.userRepo.updateUserPassword(userProfile.user_id, hashedPassword, req.log);
|
await db.userRepo.updateUserPassword(userProfile.user.user_id, hashedPassword, req.log);
|
||||||
res.status(200).json({ message: 'Password updated successfully.' });
|
res.status(200).json({ message: 'Password updated successfully.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
logger.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
|
||||||
@@ -261,7 +261,7 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
|
|||||||
const { body } = req as unknown as DeleteAccountRequest;
|
const { body } = req as unknown as DeleteAccountRequest;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userProfile.user_id, req.log);
|
const userWithHash = await db.userRepo.findUserWithPasswordHashById(userProfile.user.user_id, req.log);
|
||||||
if (!userWithHash || !userWithHash.password_hash) {
|
if (!userWithHash || !userWithHash.password_hash) {
|
||||||
return res.status(404).json({ message: 'User not found or password not set.' });
|
return res.status(404).json({ message: 'User not found or password not set.' });
|
||||||
}
|
}
|
||||||
@@ -271,7 +271,7 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
|
|||||||
return res.status(403).json({ message: 'Incorrect password.' });
|
return res.status(403).json({ message: 'Incorrect password.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.userRepo.deleteUserById(userProfile.user_id, req.log);
|
await db.userRepo.deleteUserById(userProfile.user.user_id, req.log);
|
||||||
res.status(200).json({ message: 'Account deleted successfully.' });
|
res.status(200).json({ message: 'Account deleted successfully.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
logger.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
|
||||||
@@ -286,7 +286,7 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
|
|||||||
logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
|
logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
try {
|
try {
|
||||||
const items = await db.personalizationRepo.getWatchedItems(userProfile.user_id, req.log);
|
const items = await db.personalizationRepo.getWatchedItems(userProfile.user.user_id, req.log);
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
logger.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
|
||||||
@@ -304,7 +304,7 @@ router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req,
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { body } = req as unknown as AddWatchedItemRequest;
|
const { body } = req as unknown as AddWatchedItemRequest;
|
||||||
try {
|
try {
|
||||||
const newItem = await db.personalizationRepo.addWatchedItem(userProfile.user_id, body.itemName, body.category, req.log);
|
const newItem = await db.personalizationRepo.addWatchedItem(userProfile.user.user_id, body.itemName, body.category, req.log);
|
||||||
res.status(201).json(newItem);
|
res.status(201).json(newItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForeignKeyConstraintError) {
|
if (error instanceof ForeignKeyConstraintError) {
|
||||||
@@ -330,7 +330,7 @@ router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchem
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as DeleteWatchedItemRequest;
|
const { params } = req as unknown as DeleteWatchedItemRequest;
|
||||||
try {
|
try {
|
||||||
await db.personalizationRepo.removeWatchedItem(userProfile.user_id, params.masterItemId, req.log);
|
await db.personalizationRepo.removeWatchedItem(userProfile.user.user_id, params.masterItemId, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
logger.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
|
||||||
@@ -345,7 +345,7 @@ router.get('/shopping-lists', validateRequest(emptySchema), async (req, res, nex
|
|||||||
logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
|
logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
try {
|
try {
|
||||||
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user_id, req.log);
|
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user.user_id, req.log);
|
||||||
res.json(lists);
|
res.json(lists);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
logger.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
|
||||||
@@ -363,7 +363,7 @@ router.get('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), asy
|
|||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
const { params } = req as unknown as GetShoppingListRequest;
|
const { params } = req as unknown as GetShoppingListRequest;
|
||||||
try {
|
try {
|
||||||
const list = await db.shoppingRepo.getShoppingListById(params.listId, userProfile.user_id, req.log);
|
const list = await db.shoppingRepo.getShoppingListById(params.listId, userProfile.user.user_id, req.log);
|
||||||
res.json(list);
|
res.json(list);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, listId: params.listId }, `[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`);
|
logger.error({ error, listId: params.listId }, `[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`);
|
||||||
@@ -381,7 +381,7 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { body } = req as unknown as CreateShoppingListRequest;
|
const { body } = req as unknown as CreateShoppingListRequest;
|
||||||
try {
|
try {
|
||||||
const newList = await db.shoppingRepo.createShoppingList(userProfile.user_id, body.name, req.log);
|
const newList = await db.shoppingRepo.createShoppingList(userProfile.user.user_id, body.name, req.log);
|
||||||
res.status(201).json(newList);
|
res.status(201).json(newList);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForeignKeyConstraintError) {
|
if (error instanceof ForeignKeyConstraintError) {
|
||||||
@@ -405,7 +405,7 @@ router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema),
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as GetShoppingListRequest;
|
const { params } = req as unknown as GetShoppingListRequest;
|
||||||
try {
|
try {
|
||||||
await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user_id, req.log);
|
await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user.user_id, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||||
@@ -498,7 +498,7 @@ router.put('/profile/preferences', validateRequest(updatePreferencesSchema), asy
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { body } = req as unknown as UpdatePreferencesRequest;
|
const { body } = req as unknown as UpdatePreferencesRequest;
|
||||||
try {
|
try {
|
||||||
const updatedProfile = await db.userRepo.updateUserPreferences(userProfile.user_id, body, req.log);
|
const updatedProfile = await db.userRepo.updateUserPreferences(userProfile.user.user_id, body, req.log);
|
||||||
res.json(updatedProfile);
|
res.json(updatedProfile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
logger.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
|
||||||
@@ -510,7 +510,7 @@ router.get('/me/dietary-restrictions', validateRequest(emptySchema), async (req,
|
|||||||
logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
|
logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
try {
|
try {
|
||||||
const restrictions = await db.personalizationRepo.getUserDietaryRestrictions(userProfile.user_id, req.log);
|
const restrictions = await db.personalizationRepo.getUserDietaryRestrictions(userProfile.user.user_id, req.log);
|
||||||
res.json(restrictions);
|
res.json(restrictions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
logger.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
|
||||||
@@ -528,7 +528,7 @@ router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { body } = req as unknown as SetUserRestrictionsRequest;
|
const { body } = req as unknown as SetUserRestrictionsRequest;
|
||||||
try {
|
try {
|
||||||
await db.personalizationRepo.setUserDietaryRestrictions(userProfile.user_id, body.restrictionIds, req.log);
|
await db.personalizationRepo.setUserDietaryRestrictions(userProfile.user.user_id, body.restrictionIds, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForeignKeyConstraintError) {
|
if (error instanceof ForeignKeyConstraintError) {
|
||||||
@@ -547,7 +547,7 @@ router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next
|
|||||||
logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
|
logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
|
||||||
const userProfile = req.user as UserProfile;
|
const userProfile = req.user as UserProfile;
|
||||||
try {
|
try {
|
||||||
const appliances = await db.personalizationRepo.getUserAppliances(userProfile.user_id, req.log);
|
const appliances = await db.personalizationRepo.getUserAppliances(userProfile.user.user_id, req.log);
|
||||||
res.json(appliances);
|
res.json(appliances);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
logger.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
|
||||||
@@ -565,7 +565,7 @@ router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (re
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { body } = req as unknown as SetUserAppliancesRequest;
|
const { body } = req as unknown as SetUserAppliancesRequest;
|
||||||
try {
|
try {
|
||||||
await db.personalizationRepo.setUserAppliances(userProfile.user_id, body.applianceIds, req.log);
|
await db.personalizationRepo.setUserAppliances(userProfile.user.user_id, body.applianceIds, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForeignKeyConstraintError) {
|
if (error instanceof ForeignKeyConstraintError) {
|
||||||
@@ -644,7 +644,7 @@ router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req,
|
|||||||
// Apply ADR-003 pattern for type safety
|
// Apply ADR-003 pattern for type safety
|
||||||
const { params } = req as unknown as DeleteRecipeRequest;
|
const { params } = req as unknown as DeleteRecipeRequest;
|
||||||
try {
|
try {
|
||||||
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user_id, false, req.log);
|
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, false, req.log);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, params: req.params }, `[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`);
|
logger.error({ error, params: req.params }, `[ROUTE] DELETE /api/users/recipes/:recipeId - ERROR`);
|
||||||
@@ -674,7 +674,7 @@ router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req
|
|||||||
const { params, body } = req as unknown as UpdateRecipeRequest;
|
const { params, body } = req as unknown as UpdateRecipeRequest;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedRecipe = await db.recipeRepo.updateRecipe(params.recipeId, userProfile.user_id, body, req.log);
|
const updatedRecipe = await db.recipeRepo.updateRecipe(params.recipeId, userProfile.user.user_id, body, req.log);
|
||||||
res.json(updatedRecipe);
|
res.json(updatedRecipe);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/recipes/:recipeId - ERROR`);
|
logger.error({ error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/recipes/:recipeId - ERROR`);
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Flyer, FlyerItem, MasterGroceryItem, GroundedResponse, Source } from '.
|
|||||||
import * as aiApiClient from './aiApiClient';
|
import * as aiApiClient from './aiApiClient';
|
||||||
import { logger } from './logger.client';
|
import { logger } from './logger.client';
|
||||||
|
|
||||||
|
interface RawSource {
|
||||||
|
web?: {
|
||||||
|
uri?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A service class to encapsulate all AI analysis API calls and related business logic.
|
* A service class to encapsulate all AI analysis API calls and related business logic.
|
||||||
* This decouples the React components and hooks from the data fetching implementation.
|
* This decouples the React components and hooks from the data fetching implementation.
|
||||||
@@ -36,9 +43,9 @@ export class AiAnalysisService {
|
|||||||
async searchWeb(items: FlyerItem[]): Promise<GroundedResponse> {
|
async searchWeb(items: FlyerItem[]): Promise<GroundedResponse> {
|
||||||
logger.info('[AiAnalysisService] searchWeb called.');
|
logger.info('[AiAnalysisService] searchWeb called.');
|
||||||
// The API client returns a specific shape that we need to await the JSON from
|
// The API client returns a specific shape that we need to await the JSON from
|
||||||
const response: { text: string; sources: any[] } = await aiApiClient.searchWeb(items).then(res => res.json());
|
const response: { text: string; sources: RawSource[] } = await aiApiClient.searchWeb(items).then(res => res.json());
|
||||||
// Normalize sources to a consistent format.
|
// Normalize sources to a consistent format.
|
||||||
const mappedSources = (response.sources || []).map((s: any) => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source);
|
const mappedSources = (response.sources || []).map((s: RawSource) => (s.web ? { uri: s.web.uri || '', title: s.web.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source);
|
||||||
return { ...response, sources: mappedSources };
|
return { ...response, sources: mappedSources };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +69,9 @@ export class AiAnalysisService {
|
|||||||
*/
|
*/
|
||||||
async compareWatchedItemPrices(watchedItems: MasterGroceryItem[]): Promise<GroundedResponse> {
|
async compareWatchedItemPrices(watchedItems: MasterGroceryItem[]): Promise<GroundedResponse> {
|
||||||
logger.info('[AiAnalysisService] compareWatchedItemPrices called.');
|
logger.info('[AiAnalysisService] compareWatchedItemPrices called.');
|
||||||
const response: { text: string; sources: any[] } = await aiApiClient.compareWatchedItemPrices(watchedItems).then(res => res.json());
|
const response: { text: string; sources: RawSource[] } = await aiApiClient.compareWatchedItemPrices(watchedItems).then(res => res.json());
|
||||||
// Normalize sources to a consistent format.
|
// Normalize sources to a consistent format.
|
||||||
const mappedSources = (response.sources || []).map((s: any) => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source);
|
const mappedSources = (response.sources || []).map((s: RawSource) => (s.web ? { uri: s.web.uri || '', title: s.web.title || 'Untitled' } : { uri: '', title: 'Untitled' }) as Source);
|
||||||
return { ...response, sources: mappedSources };
|
return { ...response, sources: mappedSources };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ vi.mock('sharp', () => ({
|
|||||||
const mockGenerateContent = vi.fn();
|
const mockGenerateContent = vi.fn();
|
||||||
vi.mock('@google/genai', () => {
|
vi.mock('@google/genai', () => {
|
||||||
return {
|
return {
|
||||||
GoogleGenAI: vi.fn(() => ({
|
GoogleGenAI: vi.fn(function() {
|
||||||
models: {
|
return {
|
||||||
generateContent: mockGenerateContent
|
models: {
|
||||||
}
|
generateContent: mockGenerateContent
|
||||||
}))
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -303,26 +303,44 @@ describe('Flyer DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getFlyers', () => {
|
describe('getFlyers', () => {
|
||||||
|
const expectedQuery = `
|
||||||
|
SELECT
|
||||||
|
f.*,
|
||||||
|
json_build_object(
|
||||||
|
'store_id', s.store_id,
|
||||||
|
'name', s.name,
|
||||||
|
'logo_url', s.logo_url
|
||||||
|
) as store
|
||||||
|
FROM public.flyers f
|
||||||
|
JOIN public.stores s ON f.store_id = s.store_id
|
||||||
|
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
|
||||||
|
|
||||||
it('should use default limit and offset when none are provided', async () => {
|
it('should use default limit and offset when none are provided', async () => {
|
||||||
|
console.log('[TEST DEBUG] Running test: getFlyers > should use default limit and offset');
|
||||||
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
|
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
|
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
|
||||||
|
|
||||||
await flyerRepo.getFlyers(mockLogger);
|
await flyerRepo.getFlyers(mockLogger);
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] mockPoolInstance.query calls:', JSON.stringify(mockPoolInstance.query.mock.calls, null, 2));
|
||||||
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||||
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
|
expectedQuery,
|
||||||
[20, 0] // Default values
|
[20, 0] // Default values
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use provided limit and offset values', async () => {
|
it('should use provided limit and offset values', async () => {
|
||||||
|
console.log('[TEST DEBUG] Running test: getFlyers > should use provided limit and offset');
|
||||||
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
|
const mockFlyers: Flyer[] = [createMockFlyer({ flyer_id: 1 })];
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
|
mockPoolInstance.query.mockResolvedValue({ rows: mockFlyers });
|
||||||
|
|
||||||
await flyerRepo.getFlyers(mockLogger, 10, 5);
|
await flyerRepo.getFlyers(mockLogger, 10, 5);
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] mockPoolInstance.query calls:', JSON.stringify(mockPoolInstance.query.mock.calls, null, 2));
|
||||||
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(
|
||||||
'SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2',
|
expectedQuery,
|
||||||
[10, 5] // Provided values
|
[10, 5] // Provided values
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,12 +47,25 @@ describe('Personalization DB Service', () => {
|
|||||||
|
|
||||||
describe('getAllMasterItems', () => {
|
describe('getAllMasterItems', () => {
|
||||||
it('should execute the correct query and return master items', async () => {
|
it('should execute the correct query and return master items', async () => {
|
||||||
|
console.log('[TEST DEBUG] Running test: getAllMasterItems > should execute the correct query');
|
||||||
const mockItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
|
const mockItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
|
||||||
mockQuery.mockResolvedValue({ rows: mockItems });
|
mockQuery.mockResolvedValue({ rows: mockItems });
|
||||||
|
|
||||||
const result = await personalizationRepo.getAllMasterItems(mockLogger);
|
const result = await personalizationRepo.getAllMasterItems(mockLogger);
|
||||||
|
|
||||||
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
|
const expectedQuery = `
|
||||||
|
SELECT
|
||||||
|
mgi.*,
|
||||||
|
c.name as category_name
|
||||||
|
FROM public.master_grocery_items mgi
|
||||||
|
LEFT JOIN public.categories c ON mgi.category_id = c.category_id
|
||||||
|
ORDER BY mgi.name ASC`;
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] mockQuery calls:', JSON.stringify(mockQuery.mock.calls, null, 2));
|
||||||
|
|
||||||
|
// The query string in the implementation has a lot of whitespace from the template literal.
|
||||||
|
// This updated expectation matches the new query exactly.
|
||||||
|
expect(mockQuery).toHaveBeenCalledWith(expectedQuery);
|
||||||
expect(result).toEqual(mockItems);
|
expect(result).toEqual(mockItems);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/services/db/personalization.db.ts
|
// src/services/db/personalization.db.ts
|
||||||
import type { Pool, PoolClient } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
import { getPool, withTransaction } from './connection.db';
|
import { getPool, withTransaction } from './connection.db';
|
||||||
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
|
import { ForeignKeyConstraintError } from './errors.db';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
import {
|
import {
|
||||||
MasterGroceryItem,
|
MasterGroceryItem,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { withTransaction } from './connection.db';
|
|||||||
import { UserRepository, exportUserData } from './user.db';
|
import { UserRepository, exportUserData } from './user.db';
|
||||||
|
|
||||||
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
|
||||||
|
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
||||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||||
import type { Profile, ActivityLogItem, SearchQuery, UserProfile } from '../../types';
|
import type { Profile, ActivityLogItem, SearchQuery, UserProfile } from '../../types';
|
||||||
|
|
||||||
@@ -87,19 +88,22 @@ describe('User DB Service', () => {
|
|||||||
describe('createUser', () => {
|
describe('createUser', () => {
|
||||||
it('should execute a transaction to create a user and profile', async () => {
|
it('should execute a transaction to create a user and profile', async () => {
|
||||||
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
|
const mockUser = { user_id: 'new-user-id', email: 'new@example.com' };
|
||||||
|
const now = new Date().toISOString();
|
||||||
// This is the flat structure returned by the DB query inside createUser
|
// This is the flat structure returned by the DB query inside createUser
|
||||||
const mockDbProfile = { user_id: 'new-user-id', email: 'new@example.com', role: 'user', full_name: 'New User', avatar_url: null, points: 0, preferences: null };
|
const mockDbProfile = {
|
||||||
|
user_id: 'new-user-id', email: 'new@example.com', role: 'user', full_name: 'New User',
|
||||||
|
avatar_url: null, points: 0, preferences: null, created_at: now, updated_at: now
|
||||||
|
};
|
||||||
// This is the nested structure the function is expected to return
|
// This is the nested structure the function is expected to return
|
||||||
const expectedProfile: UserProfile = {
|
const expectedProfile: UserProfile = {
|
||||||
user: { user_id: 'new-user-id', email: 'new@example.com' },
|
user: { user_id: 'new-user-id', email: 'new@example.com' },
|
||||||
user_id: 'new-user-id',
|
|
||||||
full_name: 'New User',
|
full_name: 'New User',
|
||||||
avatar_url: null,
|
avatar_url: null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
points: 0,
|
points: 0,
|
||||||
preferences: null,
|
preferences: null,
|
||||||
created_at: expect.any(String), // We can't know the exact timestamp from the DB function call in this test context easily, but we know it should be there.
|
created_at: now,
|
||||||
updated_at: expect.any(String),
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
vi.mocked(withTransaction).mockImplementation(async (callback) => {
|
||||||
@@ -113,11 +117,11 @@ describe('User DB Service', () => {
|
|||||||
|
|
||||||
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' }, mockLogger);
|
const result = await userRepo.createUser('new@example.com', 'hashedpass', { full_name: 'New User' }, mockLogger);
|
||||||
|
|
||||||
// Use objectContaining to match the structure, as created_at/updated_at are dynamic
|
console.log('[TEST DEBUG] createUser - Result from function:', JSON.stringify(result, null, 2));
|
||||||
expect(result).toEqual(expect.objectContaining({
|
console.log('[TEST DEBUG] createUser - Expected result:', JSON.stringify(expectedProfile, null, 2));
|
||||||
...expectedProfile,
|
|
||||||
created_at: undefined, // The implementation doesn't actually return these from the mock above, so let's adjust the expectation or the mock.
|
// Use objectContaining because the real implementation might have other DB-generated fields.
|
||||||
}));
|
expect(result).toEqual(expect.objectContaining(expectedProfile));
|
||||||
expect(withTransaction).toHaveBeenCalledTimes(1);
|
expect(withTransaction).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,13 +198,50 @@ describe('User DB Service', () => {
|
|||||||
|
|
||||||
describe('findUserWithProfileByEmail', () => {
|
describe('findUserWithProfileByEmail', () => {
|
||||||
it('should query for a user and their profile by email', async () => {
|
it('should query for a user and their profile by email', async () => {
|
||||||
const mockUserWithProfile = { user_id: '123', email: 'test@example.com', full_name: 'Test User', role: 'user' };
|
const now = new Date().toISOString();
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockUserWithProfile] });
|
const mockDbResult = {
|
||||||
|
user_id: '123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password_hash: 'hash',
|
||||||
|
refresh_token: 'token',
|
||||||
|
failed_login_attempts: 0,
|
||||||
|
last_failed_login: null,
|
||||||
|
full_name: 'Test User',
|
||||||
|
avatar_url: null,
|
||||||
|
role: 'user' as const,
|
||||||
|
points: 0,
|
||||||
|
preferences: null,
|
||||||
|
address_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockDbResult] });
|
||||||
|
|
||||||
|
const expectedResult = {
|
||||||
|
user_id: '123',
|
||||||
|
full_name: 'Test User',
|
||||||
|
avatar_url: null,
|
||||||
|
role: 'user',
|
||||||
|
points: 0,
|
||||||
|
preferences: null,
|
||||||
|
address_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
user: { user_id: '123', email: 'test@example.com' },
|
||||||
|
email: 'test@example.com',
|
||||||
|
password_hash: 'hash',
|
||||||
|
failed_login_attempts: 0,
|
||||||
|
last_failed_login: null,
|
||||||
|
refresh_token: 'token',
|
||||||
|
};
|
||||||
|
|
||||||
const result = await userRepo.findUserWithProfileByEmail('test@example.com', mockLogger);
|
const result = await userRepo.findUserWithProfileByEmail('test@example.com', mockLogger);
|
||||||
|
|
||||||
|
console.log('[TEST DEBUG] findUserWithProfileByEmail - Result from function:', JSON.stringify(result, null, 2));
|
||||||
|
console.log('[TEST DEBUG] findUserWithProfileByEmail - Expected result:', JSON.stringify(expectedResult, null, 2));
|
||||||
|
|
||||||
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('JOIN public.profiles'), ['test@example.com']);
|
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('JOIN public.profiles'), ['test@example.com']);
|
||||||
expect(result).toEqual(mockUserWithProfile);
|
expect(result).toEqual(expect.objectContaining(expectedResult));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined if user is not found', async () => {
|
it('should return undefined if user is not found', async () => {
|
||||||
@@ -281,7 +322,7 @@ describe('User DB Service', () => {
|
|||||||
|
|
||||||
describe('updateUserProfile', () => {
|
describe('updateUserProfile', () => {
|
||||||
it('should execute an UPDATE query for the user profile', async () => {
|
it('should execute an UPDATE query for the user profile', async () => {
|
||||||
const mockProfile: Profile = { user_id: '123', full_name: 'Updated Name', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
const mockProfile: Profile = { full_name: 'Updated Name', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||||
|
|
||||||
await userRepo.updateUserProfile('123', { full_name: 'Updated Name' }, mockLogger);
|
await userRepo.updateUserProfile('123', { full_name: 'Updated Name' }, mockLogger);
|
||||||
@@ -290,7 +331,7 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should execute an UPDATE query for avatar_url', async () => {
|
it('should execute an UPDATE query for avatar_url', async () => {
|
||||||
const mockProfile: Profile = { user_id: '123', avatar_url: 'new-avatar.png', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
const mockProfile: Profile = { avatar_url: 'new-avatar.png', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||||
|
|
||||||
await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' }, mockLogger);
|
await userRepo.updateUserProfile('123', { avatar_url: 'new-avatar.png' }, mockLogger);
|
||||||
@@ -299,7 +340,7 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should execute an UPDATE query for address_id', async () => {
|
it('should execute an UPDATE query for address_id', async () => {
|
||||||
const mockProfile: Profile = { user_id: '123', address_id: 99, role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
const mockProfile: Profile = { address_id: 99, role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||||
|
|
||||||
await userRepo.updateUserProfile('123', { address_id: 99 }, mockLogger);
|
await userRepo.updateUserProfile('123', { address_id: 99 }, mockLogger);
|
||||||
@@ -308,7 +349,7 @@ describe('User DB Service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch the current profile if no update fields are provided', async () => {
|
it('should fetch the current profile if no update fields are provided', async () => {
|
||||||
const mockProfile: Profile = { user_id: '123', full_name: 'Current Name', role: 'user', points: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
const mockProfile: Profile = createMockUserProfile({ user: { user_id: '123', email: '123@example.com' }, full_name: 'Current Name' });
|
||||||
// FIX: Instead of mocking `mockResolvedValue` on the instance method which might fail if not spied correctly,
|
// FIX: Instead of mocking `mockResolvedValue` on the instance method which might fail if not spied correctly,
|
||||||
// we mock the underlying `db.query` call that `findUserProfileById` makes.
|
// we mock the underlying `db.query` call that `findUserProfileById` makes.
|
||||||
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
mockPoolInstance.query.mockResolvedValue({ rows: [mockProfile] });
|
||||||
@@ -521,7 +562,7 @@ describe('User DB Service', () => {
|
|||||||
const { PersonalizationRepository } = await import('./personalization.db');
|
const { PersonalizationRepository } = await import('./personalization.db');
|
||||||
|
|
||||||
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
const findProfileSpy = vi.spyOn(UserRepository.prototype, 'findUserProfileById');
|
||||||
findProfileSpy.mockResolvedValue({ user_id: '123' } as Profile);
|
findProfileSpy.mockResolvedValue(createMockUserProfile({ user: { user_id: '123', email: '123@example.com' } }));
|
||||||
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems');
|
const getWatchedItemsSpy = vi.spyOn(PersonalizationRepository.prototype, 'getWatchedItems');
|
||||||
getWatchedItemsSpy.mockResolvedValue([]);
|
getWatchedItemsSpy.mockResolvedValue([]);
|
||||||
const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists');
|
const getShoppingListsSpy = vi.spyOn(ShoppingRepository.prototype, 'getShoppingLists');
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ export class UserRepository {
|
|||||||
|
|
||||||
// Construct the nested UserProfile object to match the type definition.
|
// Construct the nested UserProfile object to match the type definition.
|
||||||
const fullUserProfile: UserProfile = {
|
const fullUserProfile: UserProfile = {
|
||||||
|
// user_id is now correctly part of the nested user object, not at the top level.
|
||||||
user: {
|
user: {
|
||||||
user_id: flatProfile.user_id,
|
user_id: flatProfile.user_id,
|
||||||
email: flatProfile.email,
|
email: flatProfile.email,
|
||||||
},
|
},
|
||||||
user_id: flatProfile.user_id,
|
|
||||||
full_name: flatProfile.full_name,
|
full_name: flatProfile.full_name,
|
||||||
avatar_url: flatProfile.avatar_url,
|
avatar_url: flatProfile.avatar_url,
|
||||||
role: flatProfile.role,
|
role: flatProfile.role,
|
||||||
@@ -127,7 +127,7 @@ export class UserRepository {
|
|||||||
* @param email The email of the user to find.
|
* @param email The email of the user to find.
|
||||||
* @returns A promise that resolves to the combined user and profile object or undefined if not found.
|
* @returns A promise that resolves to the combined user and profile object or undefined if not found.
|
||||||
*/
|
*/
|
||||||
async findUserWithProfileByEmail(email: string, logger: Logger): Promise<(UserProfile & DbUser) | undefined> {
|
async findUserWithProfileByEmail(email: string, logger: Logger): Promise<(UserProfile & Omit<DbUser, 'user_id' | 'email'>) | undefined> {
|
||||||
logger.debug({ email }, `[DB findUserWithProfileByEmail] Searching for user.`);
|
logger.debug({ email }, `[DB findUserWithProfileByEmail] Searching for user.`);
|
||||||
try {
|
try {
|
||||||
const query = `
|
const query = `
|
||||||
@@ -139,7 +139,7 @@ export class UserRepository {
|
|||||||
JOIN public.profiles p ON u.user_id = p.user_id
|
JOIN public.profiles p ON u.user_id = p.user_id
|
||||||
WHERE u.email = $1;
|
WHERE u.email = $1;
|
||||||
`;
|
`;
|
||||||
const res = await this.db.query<any>(query, [email]);
|
const res = await this.db.query<DbUser & Profile>(query, [email]);
|
||||||
const flatUser = res.rows[0];
|
const flatUser = res.rows[0];
|
||||||
|
|
||||||
if (!flatUser) {
|
if (!flatUser) {
|
||||||
@@ -147,8 +147,7 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Manually construct the nested UserProfile object and add auth fields
|
// Manually construct the nested UserProfile object and add auth fields
|
||||||
const authableProfile: UserProfile & DbUser = {
|
const authableProfile: UserProfile & Omit<DbUser, 'user_id' | 'email'> = {
|
||||||
user_id: flatUser.user_id,
|
|
||||||
full_name: flatUser.full_name,
|
full_name: flatUser.full_name,
|
||||||
avatar_url: flatUser.avatar_url,
|
avatar_url: flatUser.avatar_url,
|
||||||
role: flatUser.role,
|
role: flatUser.role,
|
||||||
@@ -161,7 +160,6 @@ export class UserRepository {
|
|||||||
user_id: flatUser.user_id,
|
user_id: flatUser.user_id,
|
||||||
email: flatUser.email,
|
email: flatUser.email,
|
||||||
},
|
},
|
||||||
email: flatUser.email,
|
|
||||||
password_hash: flatUser.password_hash,
|
password_hash: flatUser.password_hash,
|
||||||
failed_login_attempts: flatUser.failed_login_attempts,
|
failed_login_attempts: flatUser.failed_login_attempts,
|
||||||
last_failed_login: flatUser.last_failed_login,
|
last_failed_login: flatUser.last_failed_login,
|
||||||
@@ -231,8 +229,7 @@ export class UserRepository {
|
|||||||
async findUserProfileById(userId: string, logger: Logger): Promise<UserProfile> {
|
async findUserProfileById(userId: string, logger: Logger): Promise<UserProfile> {
|
||||||
try {
|
try {
|
||||||
const res = await this.db.query<UserProfile>(
|
const res = await this.db.query<UserProfile>(
|
||||||
`SELECT
|
`SELECT p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points,
|
||||||
p.user_id, p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points,
|
|
||||||
p.created_at, p.updated_at,
|
p.created_at, p.updated_at,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'user_id', u.user_id,
|
'user_id', u.user_id,
|
||||||
|
|||||||
@@ -315,8 +315,10 @@ describe('FlyerProcessingService', () => {
|
|||||||
it('should throw an error and not enqueue cleanup if icon generation fails', async () => {
|
it('should throw an error and not enqueue cleanup if icon generation fails', async () => {
|
||||||
const job = createMockJob({});
|
const job = createMockJob({});
|
||||||
const iconError = new Error('Icon generation failed.');
|
const iconError = new Error('Icon generation failed.');
|
||||||
// Mock the dependency that is called deep inside the process
|
// The `transform` method calls `generateFlyerIcon`. In `beforeEach`, `transform` is mocked
|
||||||
vi.mocked(imageProcessor.generateFlyerIcon).mockRejectedValue(iconError);
|
// to always succeed. For this test, we override that mock to simulate a failure
|
||||||
|
// bubbling up from the icon generation step.
|
||||||
|
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockRejectedValue(iconError);
|
||||||
|
|
||||||
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
|
||||||
|
|
||||||
@@ -356,9 +358,8 @@ describe('FlyerProcessingService', () => {
|
|||||||
valid_from: '2024-01-01',
|
valid_from: '2024-01-01',
|
||||||
items: [],
|
items: [],
|
||||||
} as any);
|
} as any);
|
||||||
const privateMethod = (service as any)._extractFlyerDataWithAI;
|
|
||||||
|
|
||||||
await expect(privateMethod([], jobData, logger)).rejects.toThrow(AiDataValidationError);
|
await expect((service as any)._extractFlyerDataWithAI([], jobData, logger)).rejects.toThrow(AiDataValidationError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
return async () => {
|
return async () => {
|
||||||
if (regularUser) {
|
if (regularUser) {
|
||||||
// First, delete dependent records, then delete the user.
|
// First, delete dependent records, then delete the user.
|
||||||
await getPool().query('DELETE FROM public.suggested_corrections WHERE user_id = $1', [regularUser.user_id]);
|
await getPool().query('DELETE FROM public.suggested_corrections WHERE user_id = $1', [regularUser.user.user_id]);
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [regularUser.user_id]);
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [regularUser.user.user_id]);
|
||||||
}
|
}
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUser.user_id]);
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [adminUser.user.user_id]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -141,7 +141,7 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
const correctionRes = await getPool().query(
|
const correctionRes = await getPool().query(
|
||||||
`INSERT INTO public.suggested_corrections (flyer_item_id, user_id, correction_type, suggested_value, status)
|
`INSERT INTO public.suggested_corrections (flyer_item_id, user_id, correction_type, suggested_value, status)
|
||||||
VALUES ($1, $2, 'WRONG_PRICE', '250', 'pending') RETURNING suggested_correction_id`,
|
VALUES ($1, $2, 'WRONG_PRICE', '250', 'pending') RETURNING suggested_correction_id`,
|
||||||
[testFlyerItemId, regularUser.user_id]
|
[testFlyerItemId, regularUser.user.user_id]
|
||||||
);
|
);
|
||||||
testCorrectionId = correctionRes.rows[0].suggested_correction_id;
|
testCorrectionId = correctionRes.rows[0].suggested_correction_id;
|
||||||
});
|
});
|
||||||
@@ -191,7 +191,7 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
// Create a recipe specifically for this test
|
// Create a recipe specifically for this test
|
||||||
const recipeRes = await getPool().query(
|
const recipeRes = await getPool().query(
|
||||||
`INSERT INTO public.recipes (name, instructions, user_id) VALUES ('Admin Test Recipe', 'Cook it', $1) RETURNING recipe_id`,
|
`INSERT INTO public.recipes (name, instructions, user_id) VALUES ('Admin Test Recipe', 'Cook it', $1) RETURNING recipe_id`,
|
||||||
[regularUser.user_id]
|
[regularUser.user.user_id]
|
||||||
);
|
);
|
||||||
const recipeId = recipeRes.rows[0].recipe_id;
|
const recipeId = recipeRes.rows[0].recipe_id;
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('Authentication API Integration', () => {
|
|||||||
expect(data).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
expect(data.userprofile).toBeDefined();
|
expect(data.userprofile).toBeDefined();
|
||||||
expect(data.userprofile.user.email).toBe(testUserEmail);
|
expect(data.userprofile.user.email).toBe(testUserEmail);
|
||||||
expect(data.userprofile.user_id).toBeTypeOf('string');
|
expect(data.userprofile.user.user_id).toBeTypeOf('string');
|
||||||
expect(data.token).toBeTypeOf('string');
|
expect(data.token).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
|
|
||||||
// Assert 4: Verify user association is correct.
|
// Assert 4: Verify user association is correct.
|
||||||
if (token) {
|
if (token) {
|
||||||
expect(savedFlyer?.uploaded_by).toBe(user?.user_id);
|
expect(savedFlyer?.uploaded_by).toBe(user?.user.user_id);
|
||||||
} else {
|
} else {
|
||||||
expect(savedFlyer?.uploaded_by).toBe(null);
|
expect(savedFlyer?.uploaded_by).toBe(null);
|
||||||
}
|
}
|
||||||
@@ -105,16 +105,16 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
|||||||
it('should successfully process a flyer for an AUTHENTICATED user via the background queue', async ({ onTestFinished }) => {
|
it('should successfully process a flyer for an AUTHENTICATED user via the background queue', async ({ onTestFinished }) => {
|
||||||
// Arrange: Create a new user specifically for this test.
|
// Arrange: Create a new user specifically for this test.
|
||||||
const email = `auth-flyer-user-${Date.now()}@example.com`;
|
const email = `auth-flyer-user-${Date.now()}@example.com`;
|
||||||
const { user, token } = await createAndLoginUser({ email, fullName: 'Flyer Uploader' });
|
const { user: authUser, token } = await createAndLoginUser({ email, fullName: 'Flyer Uploader' });
|
||||||
createdUserIds.push(user.user_id); // Track for cleanup
|
createdUserIds.push(authUser.user.user_id); // Track for cleanup
|
||||||
|
|
||||||
// Use a cleanup function to delete the user even if the test fails.
|
// Use a cleanup function to delete the user even if the test fails.
|
||||||
onTestFinished(async () => {
|
onTestFinished(async () => {
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [user.user_id]);
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [authUser.user.user_id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await runBackgroundProcessingTest(user, token);
|
await runBackgroundProcessingTest(authUser, token);
|
||||||
}, 120000); // Increase timeout to 120 seconds for this long-running test
|
}, 120000); // Increase timeout to 120 seconds for this long-running test
|
||||||
|
|
||||||
it('should successfully process a flyer for an ANONYMOUS user via the background queue', async () => {
|
it('should successfully process a flyer for an ANONYMOUS user via the background queue', async () => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
// Create a recipe
|
// Create a recipe
|
||||||
const recipeRes = await pool.query(
|
const recipeRes = await pool.query(
|
||||||
`INSERT INTO public.recipes (name, instructions, user_id, status) VALUES ('Public Test Recipe', 'Instructions here', $1, 'public') RETURNING *`,
|
`INSERT INTO public.recipes (name, instructions, user_id, status) VALUES ('Public Test Recipe', 'Instructions here', $1, 'public') RETURNING *`,
|
||||||
[testUser.user_id]
|
[testUser.user.user_id]
|
||||||
);
|
);
|
||||||
testRecipe = recipeRes.rows[0];
|
testRecipe = recipeRes.rows[0];
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
await pool.query('DELETE FROM public.recipes WHERE recipe_id = $1', [testRecipe.recipe_id]);
|
await pool.query('DELETE FROM public.recipes WHERE recipe_id = $1', [testRecipe.recipe_id]);
|
||||||
}
|
}
|
||||||
if (testUser) {
|
if (testUser) {
|
||||||
await pool.query('DELETE FROM public.users WHERE user_id = $1', [testUser.user_id]);
|
await pool.query('DELETE FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||||
}
|
}
|
||||||
if (testFlyer) {
|
if (testFlyer) {
|
||||||
await pool.query('DELETE FROM public.flyers WHERE flyer_id = $1', [testFlyer.flyer_id]);
|
await pool.query('DELETE FROM public.flyers WHERE flyer_id = $1', [testFlyer.flyer_id]);
|
||||||
@@ -157,7 +157,7 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
// Add a comment to our test recipe first
|
// Add a comment to our test recipe first
|
||||||
await getPool().query(
|
await getPool().query(
|
||||||
`INSERT INTO public.recipe_comments (recipe_id, user_id, content) VALUES ($1, $2, 'Test comment')`,
|
`INSERT INTO public.recipe_comments (recipe_id, user_id, content) VALUES ($1, $2, 'Test comment')`,
|
||||||
[testRecipe.recipe_id, testUser.user_id]
|
[testRecipe.recipe_id, testUser.user.user_id]
|
||||||
);
|
);
|
||||||
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
|
const response = await request.get(`/api/recipes/${testRecipe.recipe_id}/comments`);
|
||||||
const comments: RecipeComment[] = response.body;
|
const comments: RecipeComment[] = response.body;
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
// Assert: Verify the profile data matches the created user.
|
// Assert: Verify the profile data matches the created user.
|
||||||
expect(profile).toBeDefined();
|
expect(profile).toBeDefined();
|
||||||
expect(profile.user_id).toBe(testUser.user_id);
|
expect(profile.user.user_id).toBe(testUser.user.user_id);
|
||||||
expect(profile.user.email).toBe(testUser.user.email);
|
expect(profile.user.email).toBe(testUser.user.email); // This was already correct
|
||||||
expect(profile.full_name).toBe('Test User');
|
expect(profile.full_name).toBe('Test User');
|
||||||
expect(profile.role).toBe('user');
|
expect(profile.role).toBe('user');
|
||||||
});
|
});
|
||||||
@@ -165,7 +165,7 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false);
|
const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false);
|
||||||
const loginData = await loginResponse.json();
|
const loginData = await loginResponse.json();
|
||||||
expect(loginData.userprofile).toBeDefined();
|
expect(loginData.userprofile).toBeDefined();
|
||||||
expect(loginData.userprofile.user_id).toBe(resetUser.user_id);
|
expect(loginData.userprofile.user.user_id).toBe(resetUser.user.user_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (testUser) {
|
if (testUser) {
|
||||||
// Clean up the created user from the database
|
// Clean up the created user from the database
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUser.user_id]);
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,33 +49,28 @@ export const createMockUser = (overrides: Partial<User> = {}): User => {
|
|||||||
* @returns A complete and type-safe UserProfile object.
|
* @returns A complete and type-safe UserProfile object.
|
||||||
*/
|
*/
|
||||||
export const createMockUserProfile = (overrides: Partial<UserProfile & { user: Partial<User> }> = {}): UserProfile => {
|
export const createMockUserProfile = (overrides: Partial<UserProfile & { user: Partial<User> }> = {}): UserProfile => {
|
||||||
// Ensure the user_id is consistent between the profile and the nested user object
|
// The user object is the source of truth for user_id and email.
|
||||||
const userOverrides: Partial<User> = overrides.user || {};
|
const user = createMockUser(overrides.user);
|
||||||
if (overrides.user_id && !userOverrides.user_id) {
|
|
||||||
userOverrides.user_id = overrides.user_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = createMockUser(userOverrides);
|
|
||||||
|
|
||||||
const defaultProfile: UserProfile = {
|
const defaultProfile: UserProfile = {
|
||||||
user_id: user.user_id,
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
points: 0,
|
points: 0,
|
||||||
full_name: 'Test User',
|
full_name: 'Test User',
|
||||||
avatar_url: null,
|
avatar_url: null,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
|
address_id: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
created_by: null,
|
created_by: null,
|
||||||
address: null,
|
address: null,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
delete (defaultProfile as Partial<UserProfile>).address_id;
|
|
||||||
|
|
||||||
// Exclude 'user' from overrides to prevent overwriting the complete user object with a partial one
|
// Exclude 'user' from overrides to prevent overwriting the complete user object with a partial one
|
||||||
const { user: _, ...profileOverrides } = overrides;
|
const { user: _, ...profileOverrides } = overrides;
|
||||||
|
|
||||||
return { ...defaultProfile, ...profileOverrides, user_id: user.user_id };
|
// Combine defaults, overrides, and the fully constructed user object.
|
||||||
|
return { ...defaultProfile, ...profileOverrides, user };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1166,10 +1161,7 @@ export const createMockUserWithPasswordHash = (overrides: Partial<UserWithPasswo
|
|||||||
* @returns A complete and type-safe Profile object.
|
* @returns A complete and type-safe Profile object.
|
||||||
*/
|
*/
|
||||||
export const createMockProfile = (overrides: Partial<Profile> = {}): Profile => {
|
export const createMockProfile = (overrides: Partial<Profile> = {}): Profile => {
|
||||||
const userId = overrides.user_id ?? `user-${getNextId()}`;
|
|
||||||
|
|
||||||
const defaultProfile: Profile = {
|
const defaultProfile: Profile = {
|
||||||
user_id: userId,
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
full_name: 'Mock Profile User',
|
full_name: 'Mock Profile User',
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ export interface UserWithPasswordHash extends User {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
user_id: string; // UUID
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// src/utils/unitConverter.ts
|
// src/utils/unitConverter.ts
|
||||||
import type { UnitPrice } from '../types';
|
import type { UnitPrice } from '../types';
|
||||||
import { logger } from '../services/logger.client';
|
|
||||||
|
|
||||||
const CONVERSIONS = {
|
const CONVERSIONS = {
|
||||||
metric: {
|
metric: {
|
||||||
|
|||||||
Reference in New Issue
Block a user