Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728b1a20d3 | ||
| f248f7cbd0 | |||
|
|
0ad9bb16c2 | ||
| 510787bc5b | |||
|
|
9f696e7676 | ||
|
|
a77105316f | ||
| cadacb63f5 | |||
|
|
62592f707e | ||
| 023e48d99a | |||
|
|
99efca0371 | ||
| 1448950b81 | |||
|
|
a811fdac63 | ||
| 1201fe4d3c | |||
|
|
ba9228c9cb | ||
| b392b82c25 |
@@ -185,7 +185,17 @@ jobs:
|
||||
- name: Show PM2 Environment for Production
|
||||
run: |
|
||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api ---"
|
||||
sleep 5
|
||||
pm2 describe flyer-crawler-api || echo "Could not find production pm2 process."
|
||||
pm2 logs flyer-crawler-api --lines 20 --nostream || echo "Could not find production pm2 process."
|
||||
pm2 env flyer-crawler-api || echo "Could not find production pm2 process."
|
||||
sleep 5 # Wait a few seconds for the app to start and log its output.
|
||||
|
||||
# Resolve the PM2 ID dynamically to ensure we target the correct process
|
||||
PM2_ID=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api'); console.log(app ? app.pm2_env.pm_id : ''); } catch(e) { console.log(''); }")
|
||||
|
||||
if [ -n "$PM2_ID" ]; then
|
||||
echo "Found process ID: $PM2_ID"
|
||||
pm2 describe "$PM2_ID" || echo "Failed to describe process $PM2_ID"
|
||||
pm2 logs "$PM2_ID" --lines 20 --nostream || echo "Failed to get logs for $PM2_ID"
|
||||
pm2 env "$PM2_ID" || echo "Failed to get env for $PM2_ID"
|
||||
else
|
||||
echo "Could not find process 'flyer-crawler-api' in pm2 list."
|
||||
pm2 list # Fallback to listing everything to help debug
|
||||
fi
|
||||
|
||||
@@ -461,7 +461,17 @@ jobs:
|
||||
run: |
|
||||
echo "--- Displaying recent PM2 logs for flyer-crawler-api-test ---"
|
||||
# After a reload, the server restarts. We'll show the last 20 lines of the log to see the startup messages.
|
||||
sleep 5 # Wait a few seconds for the app to start and log its output.
|
||||
pm2 describe flyer-crawler-api-test || echo "Could not find test pm2 process."
|
||||
pm2 logs flyer-crawler-api-test --lines 20 --nostream || echo "Could not find test pm2 process."
|
||||
pm2 env flyer-crawler-api-test || echo "Could not find test pm2 process."
|
||||
sleep 5
|
||||
|
||||
# Resolve the PM2 ID dynamically to ensure we target the correct process
|
||||
PM2_ID=$(pm2 jlist | node -e "try { const list = JSON.parse(require('fs').readFileSync(0, 'utf-8')); const app = list.find(p => p.name === 'flyer-crawler-api-test'); console.log(app ? app.pm2_env.pm_id : ''); } catch(e) { console.log(''); }")
|
||||
|
||||
if [ -n "$PM2_ID" ]; then
|
||||
echo "Found process ID: $PM2_ID"
|
||||
pm2 describe "$PM2_ID" || echo "Failed to describe process $PM2_ID"
|
||||
pm2 logs "$PM2_ID" --lines 20 --nostream || echo "Failed to get logs for $PM2_ID"
|
||||
pm2 env "$PM2_ID" || echo "Failed to get env for $PM2_ID"
|
||||
else
|
||||
echo "Could not find process 'flyer-crawler-api-test' in pm2 list."
|
||||
pm2 list # Fallback to listing everything to help debug
|
||||
fi
|
||||
|
||||
@@ -21,6 +21,7 @@ module.exports = {
|
||||
{
|
||||
// --- API Server ---
|
||||
name: 'flyer-crawler-api',
|
||||
// Note: The process names below are referenced in .gitea/workflows/ for status checks.
|
||||
script: './node_modules/.bin/tsx',
|
||||
args: 'server.ts',
|
||||
max_memory_restart: '500M',
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.2",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -277,8 +277,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expired')).toHaveClass('text-red-500');
|
||||
expect(screen.getByText('• Expired')).toBeInTheDocument();
|
||||
expect(screen.getByText('• Expired')).toHaveClass('text-red-500');
|
||||
});
|
||||
|
||||
it('should show "Expires today" when valid_to is today', () => {
|
||||
@@ -291,8 +291,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expires today')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expires today')).toHaveClass('text-orange-500');
|
||||
expect(screen.getByText('• Expires today')).toBeInTheDocument();
|
||||
expect(screen.getByText('• Expires today')).toHaveClass('text-orange-500');
|
||||
});
|
||||
|
||||
it('should show "Expires in X days" (orange) for <= 3 days', () => {
|
||||
@@ -305,8 +305,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expires in 2 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expires in 2 days')).toHaveClass('text-orange-500');
|
||||
expect(screen.getByText('• Expires in 2 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('• Expires in 2 days')).toHaveClass('text-orange-500');
|
||||
});
|
||||
|
||||
it('should show "Expires in X days" (green) for > 3 days', () => {
|
||||
@@ -319,8 +319,8 @@ describe('FlyerList', () => {
|
||||
profile={mockProfile}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Expires in 6 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Expires in 6 days')).toHaveClass('text-green-600');
|
||||
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,12 +9,21 @@ import { useNavigate, MemoryRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/aiApiClient');
|
||||
vi.mock('../../services/aiApiClient', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../services/aiApiClient')>();
|
||||
return {
|
||||
...actual,
|
||||
uploadAndProcessFlyer: vi.fn(),
|
||||
getJobStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../services/logger.client', () => ({
|
||||
// Keep the original logger.info/error but also spy on it for test assertions if needed
|
||||
logger: {
|
||||
info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)),
|
||||
error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)),
|
||||
warn: vi.fn((...args) => console.warn('[LOGGER.WARN]', ...args)),
|
||||
debug: vi.fn((...args) => console.debug('[LOGGER.DEBUG]', ...args)),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/checksum', () => ({
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useApi<T, TArgs extends unknown[]>(
|
||||
const [isRefetching, setIsRefetching] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const hasBeenExecuted = useRef(false);
|
||||
const lastErrorMessageRef = useRef<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController>(new AbortController());
|
||||
|
||||
// This effect ensures that when the component using the hook unmounts,
|
||||
@@ -52,6 +53,7 @@ export function useApi<T, TArgs extends unknown[]>(
|
||||
async (...args: TArgs): Promise<T | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
lastErrorMessageRef.current = null;
|
||||
if (hasBeenExecuted.current) {
|
||||
setIsRefetching(true);
|
||||
}
|
||||
@@ -106,7 +108,13 @@ export function useApi<T, TArgs extends unknown[]>(
|
||||
error: err.message,
|
||||
functionName: apiFunction.name,
|
||||
});
|
||||
setError(err);
|
||||
// Only set a new error object if the message is different from the last one.
|
||||
// This prevents creating new object references for the same error (e.g. repeated timeouts)
|
||||
// and helps break infinite loops in components that depend on the `error` object.
|
||||
if (err.message !== lastErrorMessageRef.current) {
|
||||
setError(err);
|
||||
lastErrorMessageRef.current = err.message;
|
||||
}
|
||||
notifyError(err.message); // Optionally notify the user automatically.
|
||||
return null; // Return null on failure.
|
||||
} finally {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function useInfiniteQuery<T>(
|
||||
|
||||
// Use a ref to store the cursor for the next page.
|
||||
const nextCursorRef = useRef<number | string | null | undefined>(initialCursor);
|
||||
const lastErrorMessageRef = useRef<string | null>(null);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (cursor?: number | string | null) => {
|
||||
@@ -59,6 +60,7 @@ export function useInfiniteQuery<T>(
|
||||
setIsFetchingNextPage(true);
|
||||
}
|
||||
setError(null);
|
||||
lastErrorMessageRef.current = null;
|
||||
|
||||
try {
|
||||
const response = await apiFunction(cursor);
|
||||
@@ -99,7 +101,10 @@ export function useInfiniteQuery<T>(
|
||||
error: err.message,
|
||||
functionName: apiFunction.name,
|
||||
});
|
||||
setError(err);
|
||||
if (err.message !== lastErrorMessageRef.current) {
|
||||
setError(err);
|
||||
lastErrorMessageRef.current = err.message;
|
||||
}
|
||||
notifyError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -125,6 +130,7 @@ export function useInfiniteQuery<T>(
|
||||
// Function to be called by the UI to refetch the entire query from the beginning.
|
||||
const refetch = useCallback(() => {
|
||||
setIsRefetching(true);
|
||||
lastErrorMessageRef.current = null;
|
||||
setData([]);
|
||||
fetchPage(initialCursor);
|
||||
}, [fetchPage, initialCursor]);
|
||||
|
||||
@@ -672,24 +672,14 @@ describe('useShoppingLists Hook', () => {
|
||||
},
|
||||
{
|
||||
name: 'updateItemInList',
|
||||
action: (hook: any) => {
|
||||
act(() => {
|
||||
hook.setActiveListId(1);
|
||||
});
|
||||
return hook.updateItemInList(101, { is_purchased: true });
|
||||
},
|
||||
action: (hook: any) => hook.updateItemInList(101, { is_purchased: true }),
|
||||
apiMock: mockUpdateItemApi,
|
||||
mockIndex: 3,
|
||||
errorMessage: 'Update failed',
|
||||
},
|
||||
{
|
||||
name: 'removeItemFromList',
|
||||
action: (hook: any) => {
|
||||
act(() => {
|
||||
hook.setActiveListId(1);
|
||||
});
|
||||
return hook.removeItemFromList(101);
|
||||
},
|
||||
action: (hook: any) => hook.removeItemFromList(101),
|
||||
apiMock: mockRemoveItemApi,
|
||||
mockIndex: 4,
|
||||
errorMessage: 'Removal failed',
|
||||
@@ -697,6 +687,17 @@ describe('useShoppingLists Hook', () => {
|
||||
])(
|
||||
'should set an error for $name if the API call fails',
|
||||
async ({ action, apiMock, mockIndex, errorMessage }) => {
|
||||
// Setup a default list so activeListId is set automatically
|
||||
const mockList = createMockShoppingList({ shopping_list_id: 1, name: 'List 1' });
|
||||
mockedUseUserData.mockReturnValue({
|
||||
shoppingLists: [mockList],
|
||||
setShoppingLists: mockSetShoppingLists,
|
||||
watchedItems: [],
|
||||
setWatchedItems: vi.fn(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const apiMocksWithError = [...defaultApiMocks];
|
||||
apiMocksWithError[mockIndex] = {
|
||||
...apiMocksWithError[mockIndex],
|
||||
@@ -709,6 +710,10 @@ describe('useShoppingLists Hook', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useShoppingLists());
|
||||
|
||||
// Wait for the effect to set the active list ID
|
||||
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await action(result.current);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// src/middleware/multer.middleware.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import multer from 'multer';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { createUploadMiddleware, handleMulterError } from './multer.middleware';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
|
||||
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -30,15 +31,41 @@ vi.mock('../services/logger.server', () => ({
|
||||
}));
|
||||
|
||||
// 4. Mock multer to prevent it from doing anything during import.
|
||||
vi.mock('multer', () => ({
|
||||
default: vi.fn(() => ({
|
||||
single: vi.fn().mockImplementation(() => (req: Request, res: Response, next: NextFunction) => next()),
|
||||
array: vi.fn().mockImplementation(() => (req: Request, res: Response, next: NextFunction) => next()),
|
||||
})),
|
||||
// We mock diskStorage to capture the options passed to it, but let it return a plain object.
|
||||
// The actual storage logic is what we want to test.
|
||||
diskStorage: vi.fn((options) => options),
|
||||
}));
|
||||
vi.mock('multer', () => {
|
||||
const diskStorage = vi.fn((options) => options);
|
||||
// A more realistic mock for MulterError that maps error codes to messages,
|
||||
// similar to how the actual multer library works.
|
||||
class MulterError extends Error {
|
||||
code: string;
|
||||
field?: string;
|
||||
|
||||
constructor(code: string, field?: string) {
|
||||
const messages: { [key: string]: string } = {
|
||||
LIMIT_FILE_SIZE: 'File too large',
|
||||
LIMIT_UNEXPECTED_FILE: 'Unexpected file',
|
||||
// Add other codes as needed for tests
|
||||
};
|
||||
const message = messages[code] || code;
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = 'MulterError';
|
||||
if (field) {
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
const multer = vi.fn(() => ({
|
||||
single: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
||||
array: vi.fn().mockImplementation(() => (req: any, res: any, next: any) => next()),
|
||||
}));
|
||||
(multer as any).diskStorage = diskStorage;
|
||||
(multer as any).MulterError = MulterError;
|
||||
return {
|
||||
default: multer,
|
||||
diskStorage,
|
||||
MulterError,
|
||||
};
|
||||
});
|
||||
|
||||
describe('Multer Middleware Directory Creation', () => {
|
||||
beforeEach(() => {
|
||||
@@ -95,6 +122,7 @@ describe('createUploadMiddleware', () => {
|
||||
|
||||
describe('Avatar Storage', () => {
|
||||
it('should generate a unique filename for an authenticated user', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
createUploadMiddleware({ storageType: 'avatar' });
|
||||
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||
const cb = vi.fn();
|
||||
@@ -150,7 +178,7 @@ describe('createUploadMiddleware', () => {
|
||||
|
||||
expect(cb).toHaveBeenCalledWith(
|
||||
null,
|
||||
expect.stringMatching(/^flyerFile-\d+-\d+-my-flyer-special.pdf$/),
|
||||
expect.stringMatching(/^flyerFile-\d+-\d+-my-flyer-special\.pdf$/i),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,9 +218,11 @@ describe('createUploadMiddleware', () => {
|
||||
const cb = vi.fn();
|
||||
const mockTextFile = { mimetype: 'text/plain' } as Express.Multer.File;
|
||||
|
||||
multerOptions!.fileFilter!({} as Request, mockTextFile, cb);
|
||||
multerOptions!.fileFilter!({} as Request, { ...mockTextFile, fieldname: 'test' }, cb);
|
||||
|
||||
expect(cb).toHaveBeenCalledWith(new Error('Only image files are allowed!'));
|
||||
const error = (cb as Mock).mock.calls[0][0];
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect(error.validationErrors[0].message).toBe('Only image files are allowed!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -221,13 +251,13 @@ describe('handleMulterError Middleware', () => {
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle the custom image file filter error', () => {
|
||||
// This test covers lines 59-61
|
||||
const err = new Error('Only image files are allowed!');
|
||||
it('should pass on a ValidationError to the next handler', () => {
|
||||
const err = new ValidationError([], 'Only image files are allowed!');
|
||||
handleMulterError(err, mockRequest as Request, mockResponse as Response, mockNext);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({ message: 'Only image files are allowed!' });
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
// It should now pass the error to the global error handler
|
||||
expect(mockNext).toHaveBeenCalledWith(err);
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass on non-multer errors to the next error handler', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { UserProfile } from '../types';
|
||||
import { sanitizeFilename } from '../utils/stringUtils';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
export const flyerStoragePath =
|
||||
@@ -69,8 +70,9 @@ const imageFileFilter = (req: Request, file: Express.Multer.File, cb: multer.Fil
|
||||
cb(null, true);
|
||||
} else {
|
||||
// Reject the file with a specific error that can be caught by a middleware.
|
||||
const err = new Error('Only image files are allowed!');
|
||||
cb(err);
|
||||
const validationIssue = { path: ['file', file.fieldname], message: 'Only image files are allowed!' };
|
||||
const err = new ValidationError([validationIssue], 'Only image files are allowed!');
|
||||
cb(err as Error); // Cast to Error to satisfy multer's type, though ValidationError extends Error.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,9 +116,6 @@ export const handleMulterError = (
|
||||
if (err instanceof multer.MulterError) {
|
||||
// A Multer error occurred when uploading (e.g., file too large).
|
||||
return res.status(400).json({ message: `File upload error: ${err.message}` });
|
||||
} else if (err && err.message === 'Only image files are allowed!') {
|
||||
// A custom error from our fileFilter.
|
||||
return res.status(400).json({ message: err.message });
|
||||
}
|
||||
// If it's not a multer error, pass it on.
|
||||
next(err);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/pages/admin/FlyerReviewPage.test.tsx
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FlyerReviewPage } from './FlyerReviewPage';
|
||||
@@ -108,7 +109,7 @@ describe('FlyerReviewPage', () => {
|
||||
expect(screen.getByText('flyer3.jpg')).toBeInTheDocument();
|
||||
const unknownStoreItem = screen.getByText('Unknown Store').closest('li');
|
||||
const unknownStoreImage = within(unknownStoreItem!).getByRole('img');
|
||||
expect(unknownStoreImage).toHaveAttribute('src', '');
|
||||
expect(unknownStoreImage).not.toHaveAttribute('src');
|
||||
expect(unknownStoreImage).not.toHaveAttribute('alt');
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export const FlyerReviewPage: React.FC = () => {
|
||||
flyers.map((flyer) => (
|
||||
<li key={flyer.flyer_id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<Link to={`/flyers/${flyer.flyer_id}`} className="flex items-center space-x-4">
|
||||
<img src={flyer.icon_url || ''} alt={flyer.store?.name} className="w-12 h-12 rounded-md object-cover" />
|
||||
<img src={flyer.icon_url || undefined} alt={flyer.store?.name} className="w-12 h-12 rounded-md object-cover" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-800 dark:text-white">{flyer.store?.name || 'Unknown Store'}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{flyer.file_name}</p>
|
||||
|
||||
@@ -76,10 +76,12 @@ vi.mock('node:fs/promises', () => ({
|
||||
// Named exports
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
// FIX: Add default export to handle `import fs from ...` syntax.
|
||||
default: {
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
vi.mock('../services/backgroundJobService');
|
||||
@@ -325,7 +327,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
.attach('logoImage', Buffer.from('this is not an image'), 'document.txt');
|
||||
expect(response.status).toBe(400);
|
||||
// This message comes from the handleMulterError middleware for the imageFileFilter
|
||||
expect(response.body.message).toBe('File upload error: Only image files are allowed!');
|
||||
expect(response.body.message).toBe('Only image files are allowed!');
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
|
||||
|
||||
@@ -84,7 +84,11 @@ const emptySchema = z.object({});
|
||||
|
||||
const router = Router();
|
||||
|
||||
const upload = createUploadMiddleware({ storageType: 'flyer' });
|
||||
const brandLogoUpload = createUploadMiddleware({
|
||||
storageType: 'flyer', // Using flyer storage path is acceptable for brand logos.
|
||||
fileSize: 2 * 1024 * 1024, // 2MB limit for logos
|
||||
fileFilter: 'image',
|
||||
});
|
||||
|
||||
// --- Bull Board (Job Queue UI) Setup ---
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
@@ -239,7 +243,7 @@ router.put(
|
||||
router.post(
|
||||
'/brands/:id/logo',
|
||||
validateRequest(numericIdParam('id')),
|
||||
upload.single('logoImage'),
|
||||
brandLogoUpload.single('logoImage'),
|
||||
requireFileUpload('logoImage'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
|
||||
|
||||
@@ -201,7 +201,6 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
vi.mocked(userService.deleteUserAsAdmin).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(userRepo.deleteUserById).toHaveBeenCalledWith(targetId, expect.any(Object));
|
||||
expect(userService.deleteUserAsAdmin).toHaveBeenCalledWith(adminId, targetId, expect.any(Object));
|
||||
});
|
||||
|
||||
|
||||
0
src/routes/personalization.db.ts
Normal file
0
src/routes/personalization.db.ts
Normal file
@@ -325,7 +325,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
return HttpResponse.text('Gateway Timeout', { status: 504, statusText: 'Gateway Timeout' });
|
||||
}),
|
||||
);
|
||||
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('API Error: 504 Gateway Timeout');
|
||||
await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Gateway Timeout');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// src/services/aiService.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import { createMockLogger } from '../tests/utils/mockLogger';
|
||||
import type { Logger } from 'pino';
|
||||
import type { MasterGroceryItem } from '../types';
|
||||
import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
|
||||
// Import the class, not the singleton instance, so we can instantiate it with mocks.
|
||||
import { AIService, AiFlyerDataSchema, aiService as aiServiceSingleton } from './aiService.server';
|
||||
import {
|
||||
AIService,
|
||||
AiFlyerDataSchema,
|
||||
aiService as aiServiceSingleton,
|
||||
DuplicateFlyerError,
|
||||
} from './aiService.server';
|
||||
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
|
||||
import { ValidationError } from './db/errors.db';
|
||||
|
||||
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
|
||||
vi.mock('./logger.server', () => ({
|
||||
@@ -45,6 +51,55 @@ vi.mock('@google/genai', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// --- New Mocks for Database and Queue ---
|
||||
vi.mock('./db/index.db', () => ({
|
||||
flyerRepo: {
|
||||
findFlyerByChecksum: vi.fn(),
|
||||
},
|
||||
adminRepo: {
|
||||
logActivity: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./queueService.server', () => ({
|
||||
flyerQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./db/flyer.db', () => ({
|
||||
createFlyerAndItems: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/imageProcessor', () => ({
|
||||
generateFlyerIcon: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules to assert on them
|
||||
import * as dbModule from './db/index.db';
|
||||
import { flyerQueue } from './queueService.server';
|
||||
import { createFlyerAndItems } from './db/flyer.db';
|
||||
import { generateFlyerIcon } from '../utils/imageProcessor';
|
||||
|
||||
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
|
||||
// This helps ensure type safety in mocks without relying on 'any'.
|
||||
interface MockFlyer {
|
||||
flyer_id: number;
|
||||
file_name: string;
|
||||
image_url: string;
|
||||
icon_url: string;
|
||||
checksum: string;
|
||||
store_name: string;
|
||||
valid_from: string | null;
|
||||
valid_to: string | null;
|
||||
store_address: string | null;
|
||||
item_count: number;
|
||||
status: FlyerStatus;
|
||||
uploaded_by: string | null | undefined;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
describe('AI Service (Server)', () => {
|
||||
// Create mock dependencies that will be injected into the service
|
||||
const mockAiClient = { generateContent: vi.fn() };
|
||||
@@ -167,7 +222,7 @@ describe('AI Service (Server)', () => {
|
||||
await adapter.generateContent(request);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledWith({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
});
|
||||
@@ -221,21 +276,22 @@ describe('AI Service (Server)', () => {
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check first call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
|
||||
model: 'gemini-2.5-flash',
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
|
||||
// Check second call
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
|
||||
model: 'gemini-3-flash',
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
|
||||
model: 'gemini-2.5-flash',
|
||||
...request,
|
||||
});
|
||||
|
||||
// Check that a warning was logged
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
// The warning should be for the model that failed ('gemini-3-flash-preview'), not the next one.
|
||||
expect.stringContaining(
|
||||
"Model 'gemini-2.5-flash' failed due to quota/rate limit. Trying next model.",
|
||||
"Model 'gemini-3-flash-preview' failed due to quota/rate limit. Trying next model.",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -258,8 +314,8 @@ describe('AI Service (Server)', () => {
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error: nonRetriableError },
|
||||
`[AIService Adapter] Model 'gemini-2.5-flash' failed with a non-retriable error.`,
|
||||
{ error: nonRetriableError }, // The first model in the list is now 'gemini-3-flash-preview'
|
||||
`[AIService Adapter] Model 'gemini-3-flash-preview' failed with a non-retriable error.`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,15 +342,15 @@ describe('AI Service (Server)', () => {
|
||||
);
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(3);
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { // The first model in the list is now 'gemini-3-flash-preview'
|
||||
model: 'gemini-3-flash-preview',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { // The second model in the list is 'gemini-2.5-flash'
|
||||
model: 'gemini-2.5-flash',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
|
||||
model: 'gemini-3-flash',
|
||||
...request,
|
||||
});
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, {
|
||||
expect(mockGenerateContent).toHaveBeenNthCalledWith(3, { // The third model in the list is 'gemini-2.5-flash-lite'
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
...request,
|
||||
});
|
||||
@@ -718,6 +774,285 @@ describe('AI Service (Server)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enqueueFlyerProcessing', () => {
|
||||
const mockFile = {
|
||||
path: '/tmp/test.pdf',
|
||||
originalname: 'test.pdf',
|
||||
} as Express.Multer.File;
|
||||
const mockProfile = {
|
||||
user: { user_id: 'user123' },
|
||||
address: {
|
||||
address_line_1: '123 St',
|
||||
city: 'City',
|
||||
country: 'Country', // This was a duplicate, fixed.
|
||||
},
|
||||
} as UserProfile;
|
||||
|
||||
it('should throw DuplicateFlyerError if flyer already exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 99 } as any);
|
||||
|
||||
await expect(
|
||||
aiServiceInstance.enqueueFlyerProcessing(
|
||||
mockFile,
|
||||
'checksum123',
|
||||
mockProfile,
|
||||
'127.0.0.1',
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow(DuplicateFlyerError);
|
||||
});
|
||||
|
||||
it('should enqueue job with user address if profile exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job123' } as any);
|
||||
|
||||
const result = await aiServiceInstance.enqueueFlyerProcessing(
|
||||
mockFile,
|
||||
'checksum123',
|
||||
mockProfile,
|
||||
'127.0.0.1',
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', {
|
||||
filePath: mockFile.path,
|
||||
originalFileName: mockFile.originalname,
|
||||
checksum: 'checksum123',
|
||||
userId: 'user123',
|
||||
submitterIp: '127.0.0.1',
|
||||
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
|
||||
});
|
||||
expect(result.id).toBe('job123');
|
||||
});
|
||||
|
||||
it('should enqueue job without address if profile is missing', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||
vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job456' } as any);
|
||||
|
||||
await aiServiceInstance.enqueueFlyerProcessing(
|
||||
mockFile,
|
||||
'checksum123',
|
||||
undefined, // No profile
|
||||
'127.0.0.1',
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(flyerQueue.add).toHaveBeenCalledWith(
|
||||
'process-flyer',
|
||||
expect.objectContaining({
|
||||
userId: undefined,
|
||||
userProfileAddress: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processLegacyFlyerUpload', () => {
|
||||
const mockFile = {
|
||||
path: '/tmp/upload.jpg',
|
||||
filename: 'upload.jpg',
|
||||
originalname: 'orig.jpg',
|
||||
} as Express.Multer.File; // This was a duplicate, fixed.
|
||||
const mockProfile = { user: { user_id: 'u1' } } as UserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default success mocks
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
|
||||
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
|
||||
vi.mocked(createFlyerAndItems).mockResolvedValue({
|
||||
flyer: {
|
||||
flyer_id: 100,
|
||||
file_name: 'orig.jpg',
|
||||
image_url: '/flyer-images/upload.jpg',
|
||||
icon_url: '/flyer-images/icons/icon.jpg',
|
||||
checksum: 'mock-checksum-123',
|
||||
store_name: 'Mock Store',
|
||||
valid_from: null,
|
||||
valid_to: null,
|
||||
store_address: null,
|
||||
item_count: 0,
|
||||
status: 'processed',
|
||||
uploaded_by: 'u1',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as MockFlyer, // Use the more specific MockFlyer type
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ValidationError if checksum is missing', async () => {
|
||||
const body = { data: JSON.stringify({}) }; // No checksum
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw DuplicateFlyerError if checksum exists', async () => {
|
||||
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({ flyer_id: 55 } as any);
|
||||
const body = { checksum: 'dup-sum' };
|
||||
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow(DuplicateFlyerError);
|
||||
});
|
||||
|
||||
it('should parse "data" string property containing extractedData', async () => {
|
||||
const payload = {
|
||||
checksum: 'abc',
|
||||
originalFileName: 'test.jpg',
|
||||
extractedData: {
|
||||
store_name: 'My Store',
|
||||
items: [{ item: 'Milk', price_in_cents: 200 }],
|
||||
},
|
||||
};
|
||||
const body = { data: JSON.stringify(payload) };
|
||||
|
||||
await aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(createFlyerAndItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
store_name: 'My Store',
|
||||
checksum: 'abc',
|
||||
}),
|
||||
expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
|
||||
mockLoggerInstance,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle direct object body with extractedData', async () => {
|
||||
const body = {
|
||||
checksum: 'xyz',
|
||||
extractedData: {
|
||||
store_name: 'Direct Store',
|
||||
valid_from: '2023-01-01',
|
||||
},
|
||||
};
|
||||
|
||||
await aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(createFlyerAndItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
store_name: 'Direct Store',
|
||||
valid_from: '2023-01-01',
|
||||
}),
|
||||
[], // No items
|
||||
mockLoggerInstance,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback for missing store name and normalize items', async () => {
|
||||
const body = {
|
||||
checksum: 'fallback',
|
||||
extractedData: {
|
||||
// store_name missing
|
||||
items: [{ item: 'Bread' }], // minimal item
|
||||
},
|
||||
};
|
||||
|
||||
await aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(createFlyerAndItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
store_name: 'Unknown Store (auto)',
|
||||
}),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
item: 'Bread',
|
||||
quantity: 1, // Default
|
||||
view_count: 0,
|
||||
}),
|
||||
]),
|
||||
mockLoggerInstance,
|
||||
);
|
||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('extractedData.store_name missing'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log activity and return the new flyer', async () => {
|
||||
const body = { checksum: 'act', extractedData: { store_name: 'Act Store' } };
|
||||
const result = await aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('flyer_id', 100);
|
||||
expect(dbModule.adminRepo.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'flyer_processed',
|
||||
userId: 'u1',
|
||||
}),
|
||||
mockLoggerInstance,
|
||||
);
|
||||
});
|
||||
|
||||
it('should catch JSON parsing errors in _parseLegacyPayload and log warning (errMsg coverage)', async () => {
|
||||
// Sending a body where 'data' is a malformed JSON string to trigger the catch block in _parseLegacyPayload
|
||||
const body = { data: '{ "malformed": json ' };
|
||||
|
||||
// This will eventually throw ValidationError because checksum won't be found
|
||||
await expect(
|
||||
aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
|
||||
// Verify that the error was caught and logged using errMsg logic
|
||||
expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
'[AIService] Failed to parse nested "data" property string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle body as a string', async () => {
|
||||
const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
await aiServiceInstance.processLegacyFlyerUpload(
|
||||
mockFile,
|
||||
body,
|
||||
mockProfile,
|
||||
mockLoggerInstance,
|
||||
);
|
||||
|
||||
expect(createFlyerAndItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ checksum: 'str-body' }),
|
||||
expect.anything(),
|
||||
mockLoggerInstance,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton Export', () => {
|
||||
it('should export a singleton instance of AIService', () => {
|
||||
expect(aiServiceSingleton).toBeInstanceOf(AIService);
|
||||
|
||||
@@ -787,56 +787,37 @@ async enqueueFlyerProcessing(
|
||||
logger: Logger,
|
||||
): { parsed: FlyerProcessPayload; extractedData: Partial<ExtractedCoreData> | null | undefined } {
|
||||
let parsed: FlyerProcessPayload = {};
|
||||
let extractedData: Partial<ExtractedCoreData> | null | undefined = {};
|
||||
|
||||
try {
|
||||
if (body && (body.data || body.extractedData)) {
|
||||
const raw = body.data ?? body.extractedData;
|
||||
try {
|
||||
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ error: errMsg(err) },
|
||||
'[AIService] Failed to JSON.parse raw extractedData; falling back to direct assign',
|
||||
);
|
||||
parsed = (
|
||||
typeof raw === 'string' ? JSON.parse(String(raw).slice(0, 2000)) : raw
|
||||
) as FlyerProcessPayload;
|
||||
}
|
||||
extractedData = 'extractedData' in parsed ? parsed.extractedData : (parsed as Partial<ExtractedCoreData>);
|
||||
} else {
|
||||
try {
|
||||
parsed = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ error: errMsg(err) },
|
||||
'[AIService] Failed to JSON.parse req.body; using empty object',
|
||||
);
|
||||
parsed = (body as FlyerProcessPayload) || {};
|
||||
}
|
||||
if (parsed.data) {
|
||||
try {
|
||||
const inner = typeof parsed.data === 'string' ? JSON.parse(parsed.data) : parsed.data;
|
||||
extractedData = inner.extractedData ?? inner;
|
||||
} catch (err) {
|
||||
logger.warn({ error: errMsg(err) }, '[AIService] Failed to parse parsed.data; falling back');
|
||||
extractedData = parsed.data as unknown as Partial<ExtractedCoreData>;
|
||||
}
|
||||
} else if (parsed.extractedData) {
|
||||
extractedData = parsed.extractedData;
|
||||
} else {
|
||||
if ('items' in parsed || 'store_name' in parsed || 'valid_from' in parsed) {
|
||||
extractedData = parsed as Partial<ExtractedCoreData>;
|
||||
} else {
|
||||
extractedData = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, '[AIService] Unexpected error while parsing legacy request body');
|
||||
parsed = {};
|
||||
extractedData = {};
|
||||
parsed = typeof body === 'string' ? JSON.parse(body) : body || {};
|
||||
} catch (e) {
|
||||
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse top-level request body string.');
|
||||
return { parsed: {}, extractedData: {} };
|
||||
}
|
||||
return { parsed, extractedData };
|
||||
|
||||
// If the real payload is nested inside a 'data' property (which could be a string),
|
||||
// we parse it out but keep the original `parsed` object for top-level properties like checksum.
|
||||
let potentialPayload: FlyerProcessPayload = parsed;
|
||||
if (parsed.data) {
|
||||
if (typeof parsed.data === 'string') {
|
||||
try {
|
||||
potentialPayload = JSON.parse(parsed.data);
|
||||
} catch (e) {
|
||||
logger.warn({ error: errMsg(e) }, '[AIService] Failed to parse nested "data" property string.');
|
||||
}
|
||||
} else if (typeof parsed.data === 'object') {
|
||||
potentialPayload = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
// The extracted data is either in an `extractedData` key or is the payload itself.
|
||||
const extractedData = potentialPayload.extractedData ?? potentialPayload;
|
||||
|
||||
// Merge for checksum lookup: properties in the outer `parsed` object (like a top-level checksum)
|
||||
// take precedence over any same-named properties inside `potentialPayload`.
|
||||
const finalParsed = { ...potentialPayload, ...parsed };
|
||||
|
||||
return { parsed: finalParsed, extractedData };
|
||||
}
|
||||
|
||||
async processLegacyFlyerUpload(
|
||||
|
||||
@@ -92,5 +92,37 @@ describe('Address DB Service', () => {
|
||||
expect(query).toContain('ON CONFLICT (address_id) DO UPDATE');
|
||||
expect(values).toEqual([1, '789 Old Rd', 'Oldtown']);
|
||||
});
|
||||
|
||||
it('should throw UniqueConstraintError on unique constraint violation', async () => {
|
||||
const addressData = { address_line_1: '123 Duplicate St' };
|
||||
const dbError = new Error('duplicate key value violates unique constraint');
|
||||
(dbError as any).code = '23505';
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
|
||||
UniqueConstraintError,
|
||||
);
|
||||
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
|
||||
'An identical address already exists.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: addressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a generic error if the database query fails for other reasons', async () => {
|
||||
const addressData = { address_line_1: '789 Failure Rd' };
|
||||
const dbError = new Error('DB Connection Error');
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(addressRepo.upsertAddress(addressData, mockLogger)).rejects.toThrow(
|
||||
'Failed to upsert address.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
{ err: dbError, address: addressData },
|
||||
'Database error in upsertAddress',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { AdminRepository } from './admin.db';
|
||||
import type { SuggestedCorrection, AdminUserView, Profile } from '../../types';
|
||||
import type { SuggestedCorrection, AdminUserView, Profile, Flyer } from '../../types';
|
||||
import {
|
||||
createMockSuggestedCorrection,
|
||||
createMockAdminUserView,
|
||||
createMockProfile,
|
||||
createMockFlyer,
|
||||
} from '../../tests/utils/mockFactories';
|
||||
// Un-mock the module we are testing
|
||||
vi.unmock('./admin.db');
|
||||
@@ -712,4 +713,28 @@ describe('Admin DB Service', () => {
|
||||
'Database error in updateUserRole',
|
||||
);
|
||||
});
|
||||
|
||||
describe('getFlyersForReview', () => {
|
||||
it('should retrieve flyers with "needs_review" status', async () => {
|
||||
const mockFlyers: Flyer[] = [createMockFlyer({ status: 'needs_review' })];
|
||||
mockDb.query.mockResolvedValue({ rows: mockFlyers });
|
||||
|
||||
const result = await adminRepo.getFlyersForReview(mockLogger);
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("WHERE f.status = 'needs_review'"),
|
||||
);
|
||||
expect(result).toEqual(mockFlyers);
|
||||
});
|
||||
|
||||
it('should throw an error if the database query fails', async () => {
|
||||
const dbError = new Error('DB Error');
|
||||
mockDb.query.mockRejectedValue(dbError);
|
||||
|
||||
await expect(adminRepo.getFlyersForReview(mockLogger)).rejects.toThrow(
|
||||
'Failed to retrieve flyers for review.',
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError }, 'Database error in getFlyersForReview');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('./logger.server', () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
child: vi.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -37,10 +38,13 @@ import {
|
||||
sendPasswordResetEmail,
|
||||
sendWelcomeEmail,
|
||||
sendDealNotificationEmail,
|
||||
processEmailJob,
|
||||
} from './emailService.server';
|
||||
import type { WatchedItemDeal } from '../types';
|
||||
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
|
||||
import { logger } from './logger.server';
|
||||
import type { Job } from 'bullmq';
|
||||
import type { EmailJobData } from '../types/job-data';
|
||||
|
||||
describe('Email Service (Server)', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -219,4 +223,51 @@ describe('Email Service (Server)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEmailJob', () => {
|
||||
const mockJobData: EmailJobData = {
|
||||
to: 'job@example.com',
|
||||
subject: 'Job Email',
|
||||
html: '<p>Job</p>',
|
||||
text: 'Job',
|
||||
};
|
||||
|
||||
const createMockJob = (data: EmailJobData): Job<EmailJobData> =>
|
||||
({
|
||||
id: 'job-123',
|
||||
name: 'email-job',
|
||||
data,
|
||||
attemptsMade: 1,
|
||||
} as unknown as Job<EmailJobData>);
|
||||
|
||||
it('should call sendMail with job data and log success', async () => {
|
||||
const job = createMockJob(mockJobData);
|
||||
mocks.sendMail.mockResolvedValue({ messageId: 'job-test-id' });
|
||||
|
||||
await processEmailJob(job);
|
||||
|
||||
expect(mocks.sendMail).toHaveBeenCalledTimes(1);
|
||||
const mailOptions = mocks.sendMail.mock.calls[0][0];
|
||||
expect(mailOptions.to).toBe(mockJobData.to);
|
||||
expect(mailOptions.subject).toBe(mockJobData.subject);
|
||||
expect(logger.info).toHaveBeenCalledWith('Picked up email job.');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ to: 'job@example.com', subject: 'Job Email', messageId: 'job-test-id' },
|
||||
'Email sent successfully.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should log an error and re-throw if sendMail fails', async () => {
|
||||
const job = createMockJob(mockJobData);
|
||||
const emailError = new Error('SMTP Connection Failed');
|
||||
mocks.sendMail.mockRejectedValue(emailError);
|
||||
|
||||
await expect(processEmailJob(job)).rejects.toThrow(emailError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: emailError, jobData: mockJobData, attemptsMade: 1 },
|
||||
'Email job failed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,29 +19,30 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a fresh admin user and a regular user for this test suite
|
||||
// Using unique emails to prevent test pollution from other integration test files.
|
||||
({ user: adminUser, token: adminToken } = await createAndLoginUser({
|
||||
email: `admin-integration-${Date.now()}@test.com`,
|
||||
role: 'admin',
|
||||
fullName: 'Admin Test User',
|
||||
request, // Pass supertest request to ensure user is created in the test DB
|
||||
}));
|
||||
({ user: regularUser, token: regularUserToken } = await createAndLoginUser({
|
||||
email: `regular-integration-${Date.now()}@test.com`,
|
||||
fullName: 'Regular User',
|
||||
request, // Pass supertest request
|
||||
}));
|
||||
|
||||
// Cleanup the created user after all tests in this file are done
|
||||
return async () => {
|
||||
if (regularUser) {
|
||||
// First, delete dependent records, then delete the user.
|
||||
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.user_id,
|
||||
]);
|
||||
}
|
||||
if (adminUser) {
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [
|
||||
adminUser.user.user_id,
|
||||
]);
|
||||
// Consolidate cleanup to prevent foreign key issues and handle all created entities.
|
||||
const userIds = [adminUser?.user.user_id, regularUser?.user.user_id].filter(
|
||||
(id): id is string => !!id,
|
||||
);
|
||||
if (userIds.length > 0) {
|
||||
// Delete dependent records first to avoid foreign key violations.
|
||||
await getPool().query('DELETE FROM public.suggested_corrections WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
// Then delete the users themselves.
|
||||
await getPool().query('DELETE FROM public.users WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -52,6 +53,10 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
.get('/api/admin/stats')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
const stats = response.body;
|
||||
// DEBUG: Log response if it fails expectation
|
||||
if (response.status !== 200) {
|
||||
console.error('[DEBUG] GET /api/admin/stats failed:', response.status, response.body);
|
||||
}
|
||||
expect(stats).toBeDefined();
|
||||
expect(stats).toHaveProperty('flyerCount');
|
||||
expect(stats).toHaveProperty('userCount');
|
||||
@@ -174,7 +179,7 @@ describe('Admin API Routes Integration Tests', () => {
|
||||
const correctionRes = await getPool().query(
|
||||
`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`,
|
||||
[testFlyerItemId, regularUser.user.user_id],
|
||||
[testFlyerItemId, adminUser.user.user_id],
|
||||
);
|
||||
testCorrectionId = correctionRes.rows[0].suggested_correction_id;
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('AI API Routes Integration Tests', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create and log in as a new user for authenticated tests.
|
||||
({ token: authToken } = await createAndLoginUser({ fullName: 'AI Tester' }));
|
||||
({ token: authToken } = await createAndLoginUser({ fullName: 'AI Tester', request }));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -83,6 +83,10 @@ describe('AI API Routes Integration Tests', () => {
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ items: [{ item: 'test' }] });
|
||||
const result = response.body;
|
||||
// DEBUG: Log response if it fails expectation
|
||||
if (response.status !== 200 || !result.text) {
|
||||
console.log('[DEBUG] POST /api/ai/quick-insights response:', response.status, response.body);
|
||||
}
|
||||
expect(response.status).toBe(200);
|
||||
expect(result.text).toBe('This is a server-generated quick insight: buy the cheap stuff!');
|
||||
});
|
||||
@@ -93,6 +97,10 @@ describe('AI API Routes Integration Tests', () => {
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ items: [{ item: 'test' }] });
|
||||
const result = response.body;
|
||||
// DEBUG: Log response if it fails expectation
|
||||
if (response.status !== 200 || !result.text) {
|
||||
console.log('[DEBUG] POST /api/ai/deep-dive response:', response.status, response.body);
|
||||
}
|
||||
expect(response.status).toBe(200);
|
||||
expect(result.text).toBe('This is a server-generated deep dive analysis. It is very detailed.');
|
||||
});
|
||||
@@ -103,6 +111,10 @@ describe('AI API Routes Integration Tests', () => {
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ query: 'test query' });
|
||||
const result = response.body;
|
||||
// DEBUG: Log response if it fails expectation
|
||||
if (response.status !== 200 || !result.text) {
|
||||
console.log('[DEBUG] POST /api/ai/search-web response:', response.status, response.body);
|
||||
}
|
||||
expect(response.status).toBe(200);
|
||||
expect(result).toEqual({ text: 'The web says this is good.', sources: [] });
|
||||
});
|
||||
@@ -141,6 +153,10 @@ describe('AI API Routes Integration Tests', () => {
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ items: [], store: mockStore, userLocation: mockLocation });
|
||||
// The service for this endpoint is disabled and throws an error, which results in a 500.
|
||||
// DEBUG: Log response if it fails expectation
|
||||
if (response.status !== 500) {
|
||||
console.log('[DEBUG] POST /api/ai/plan-trip response:', response.status, response.body);
|
||||
}
|
||||
expect(response.status).toBe(500);
|
||||
const errorResult = response.body;
|
||||
expect(errorResult.message).toContain('planTripWithMaps');
|
||||
|
||||
@@ -23,7 +23,9 @@ describe('Authentication API Integration', () => {
|
||||
let testUser: UserProfile;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ user: testUser } = await createAndLoginUser({ fullName: 'Auth Test User' }));
|
||||
// Use a unique email for this test suite to prevent collisions with other tests.
|
||||
const email = `auth-integration-test-${Date.now()}@example.com`;
|
||||
({ user: testUser } = await createAndLoginUser({ email, fullName: 'Auth Test User', request }));
|
||||
testUserEmail = testUser.user.email;
|
||||
});
|
||||
|
||||
@@ -41,6 +43,10 @@ describe('Authentication API Integration', () => {
|
||||
.send({ email: testUserEmail, password: TEST_PASSWORD, rememberMe: false });
|
||||
const data = response.body;
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.error('[DEBUG] Login failed:', response.status, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// Assert that the API returns the expected structure
|
||||
expect(data).toBeDefined();
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -101,6 +101,11 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
}
|
||||
|
||||
// Assert 2: Check that the job completed successfully.
|
||||
if (jobStatus?.state === 'failed') {
|
||||
console.error('[DEBUG] Job failed with reason:', jobStatus.failedReason);
|
||||
console.error('[DEBUG] Job stack trace:', jobStatus.stacktrace);
|
||||
console.error('[DEBUG] Full Job Status:', JSON.stringify(jobStatus, null, 2));
|
||||
}
|
||||
expect(jobStatus?.state).toBe('completed');
|
||||
const flyerId = jobStatus?.returnValue?.flyerId;
|
||||
expect(flyerId).toBeTypeOf('number');
|
||||
@@ -132,6 +137,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
const { user: authUser, token } = await createAndLoginUser({
|
||||
email,
|
||||
fullName: 'Flyer Uploader',
|
||||
request,
|
||||
});
|
||||
createdUserIds.push(authUser.user.user_id); // Track for cleanup
|
||||
|
||||
|
||||
@@ -38,6 +38,29 @@ describe('Public API Routes Integration Tests', () => {
|
||||
fullName: 'Public Routes Test User',
|
||||
});
|
||||
testUser = createdUser;
|
||||
|
||||
// DEBUG: Verify user existence in DB
|
||||
console.log(`[DEBUG] createAndLoginUser returned user ID: ${testUser.user.user_id}`);
|
||||
const userCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||
console.log(`[DEBUG] DB check for user found ${userCheck.rowCount ?? 0} rows.`);
|
||||
if (!userCheck.rowCount) {
|
||||
console.error(`[DEBUG] CRITICAL: User ${testUser.user.user_id} does not exist in public.users table! Attempting to wait...`);
|
||||
// Wait loop to ensure user persistence if there's a race condition
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const retryCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||
if (retryCheck.rowCount && retryCheck.rowCount > 0) {
|
||||
console.log(`[DEBUG] User found after retry ${i + 1}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final check before proceeding to avoid FK error
|
||||
const finalCheck = await pool.query('SELECT user_id FROM public.users WHERE user_id = $1', [testUser.user.user_id]);
|
||||
if (!finalCheck.rowCount) {
|
||||
throw new Error(`User ${testUser.user.user_id} failed to persist in DB. Cannot continue test.`);
|
||||
}
|
||||
|
||||
// Create a recipe
|
||||
const recipeRes = await pool.query(
|
||||
`INSERT INTO public.recipes (name, instructions, user_id, status) VALUES ('Public Test Recipe', 'Instructions here', $1, 'public') RETURNING *`,
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('User API Routes Integration Tests', () => {
|
||||
// The token will be used for all subsequent API calls in this test suite.
|
||||
beforeAll(async () => {
|
||||
const email = `user-test-${Date.now()}@example.com`;
|
||||
const { user, token } = await createAndLoginUser({ email, fullName: 'Test User' });
|
||||
const { user, token } = await createAndLoginUser({ email, fullName: 'Test User', request });
|
||||
testUser = user;
|
||||
authToken = token;
|
||||
});
|
||||
@@ -130,7 +130,7 @@ describe('User API Routes Integration Tests', () => {
|
||||
it('should allow a user to delete their own account and then fail to log in', async () => {
|
||||
// Arrange: Create a new, separate user just for this deletion test.
|
||||
const deletionEmail = `delete-me-${Date.now()}@example.com`;
|
||||
const { token: deletionToken } = await createAndLoginUser({ email: deletionEmail });
|
||||
const { token: deletionToken } = await createAndLoginUser({ email: deletionEmail, request });
|
||||
|
||||
// Act: Call the delete endpoint with the correct password and token.
|
||||
const response = await request
|
||||
@@ -155,7 +155,7 @@ describe('User API Routes Integration Tests', () => {
|
||||
it('should allow a user to reset their password and log in with the new one', async () => {
|
||||
// Arrange: Create a new user for the password reset flow.
|
||||
const resetEmail = `reset-me-${Date.now()}@example.com`;
|
||||
const { user: resetUser } = await createAndLoginUser({ email: resetEmail });
|
||||
const { user: resetUser } = await createAndLoginUser({ email: resetEmail, request });
|
||||
|
||||
// Act 1: Request a password reset. In our test environment, the token is returned in the response.
|
||||
const resetRequestRawResponse = await request
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
|
||||
// Use the helper to create and log in a user in one step.
|
||||
const { user, token } = await createAndLoginUser({
|
||||
fullName: 'User Routes Test User',
|
||||
request,
|
||||
});
|
||||
testUser = user;
|
||||
authToken = token;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import type { UserProfile } from '../../types';
|
||||
import supertest from 'supertest';
|
||||
|
||||
export const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
|
||||
|
||||
@@ -10,6 +11,8 @@ interface CreateUserOptions {
|
||||
password?: string;
|
||||
fullName?: string;
|
||||
role?: 'admin' | 'user';
|
||||
// Use ReturnType to match the actual return type of supertest(app) to avoid type mismatches (e.g. TestAgent vs SuperTest)
|
||||
request?: ReturnType<typeof supertest>;
|
||||
}
|
||||
|
||||
interface CreateUserResult {
|
||||
@@ -31,16 +34,53 @@ export const createAndLoginUser = async (
|
||||
const password = options.password || TEST_PASSWORD;
|
||||
const fullName = options.fullName || 'Test User';
|
||||
|
||||
await apiClient.registerUser(email, password, fullName);
|
||||
if (options.request) {
|
||||
// Use supertest for integration tests (hits the app instance directly)
|
||||
const registerRes = await options.request
|
||||
.post('/api/auth/register')
|
||||
.send({ email, password, full_name: fullName });
|
||||
|
||||
if (options.role === 'admin') {
|
||||
await getPool().query(
|
||||
`UPDATE public.profiles SET role = 'admin' FROM public.users WHERE public.profiles.user_id = public.users.user_id AND public.users.email = $1`,
|
||||
[email],
|
||||
);
|
||||
if (registerRes.status !== 201 && registerRes.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to register user via supertest: ${registerRes.status} ${JSON.stringify(registerRes.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.role === 'admin') {
|
||||
await getPool().query(
|
||||
`UPDATE public.profiles SET role = 'admin' FROM public.users WHERE public.profiles.user_id = public.users.user_id AND public.users.email = $1`,
|
||||
[email],
|
||||
);
|
||||
}
|
||||
|
||||
const loginRes = await options.request
|
||||
.post('/api/auth/login')
|
||||
.send({ email, password, rememberMe: false });
|
||||
|
||||
if (loginRes.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to login user via supertest: ${loginRes.status} ${JSON.stringify(loginRes.body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { userprofile, token } = loginRes.body;
|
||||
return { user: userprofile, token };
|
||||
} else {
|
||||
// Use apiClient for E2E tests (hits the external URL via fetch)
|
||||
await apiClient.registerUser(email, password, fullName);
|
||||
|
||||
if (options.role === 'admin') {
|
||||
await getPool().query(
|
||||
`UPDATE public.profiles SET role = 'admin' FROM public.users WHERE public.profiles.user_id = public.users.user_id AND public.users.email = $1`,
|
||||
[email],
|
||||
);
|
||||
}
|
||||
|
||||
const loginResponse = await apiClient.loginUser(email, password, false);
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error(`Failed to login user via apiClient: ${loginResponse.status}`);
|
||||
}
|
||||
const { userprofile, token } = await loginResponse.json();
|
||||
return { user: userprofile, token };
|
||||
}
|
||||
|
||||
const loginResponse = await apiClient.loginUser(email, password, false);
|
||||
const { userprofile, token } = await loginResponse.json();
|
||||
return { user: userprofile, token };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user