more unit test fixes + some integration
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 1h21m56s

This commit is contained in:
2025-12-21 14:54:41 -08:00
parent 0cf4ca02b7
commit 15f759cbc4
44 changed files with 836 additions and 150 deletions

View File

@@ -316,7 +316,7 @@ describe('App Component', () => {
});
describe('Theme and Unit System Synchronization', () => {
it('should set dark mode based on user profile preferences', () => {
it('should set dark mode based on user profile preferences', async () => {
const profileWithDarkMode: UserProfile = createMockUserProfile({
user_id: 'user-1', user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }), role: 'user', points: 0,
preferences: { darkMode: true }
@@ -328,10 +328,13 @@ describe('App Component', () => {
});
renderApp();
expect(document.documentElement).toHaveClass('dark');
// The useEffect that sets the theme is asynchronous. We must wait for the update.
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set light mode based on user profile preferences', () => {
it('should set light mode based on user profile preferences', async () => {
const profileWithLightMode: UserProfile = createMockUserProfile({
user_id: 'user-1', user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }), role: 'user', points: 0,
preferences: { darkMode: false }
@@ -342,19 +345,25 @@ describe('App Component', () => {
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
});
renderApp();
expect(document.documentElement).not.toHaveClass('dark');
await waitFor(() => {
expect(document.documentElement).not.toHaveClass('dark');
});
});
it('should set dark mode based on localStorage if profile has no preference', () => {
it('should set dark mode based on localStorage if profile has no preference', async () => {
localStorageMock.setItem('darkMode', 'true');
renderApp();
expect(document.documentElement).toHaveClass('dark');
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set dark mode based on system preference if no other setting exists', () => {
it('should set dark mode based on system preference if no other setting exists', async () => {
matchMediaMock.mockImplementationOnce(query => ({ matches: true, media: query }));
renderApp();
expect(document.documentElement).toHaveClass('dark');
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set unit system based on user profile preferences', async () => {
@@ -633,28 +642,37 @@ describe('App Component', () => {
});
describe('Dynamic Toaster Styles', () => {
it('should render the correct CSS variables for toast styling in light mode', () => {
it('should render the correct CSS variables for toast styling in light mode', async () => {
renderApp();
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
});
});
it('should render the correct CSS variables for toast styling in dark mode', () => {
it('should render the correct CSS variables for toast styling in dark mode', async () => {
localStorageMock.setItem('darkMode', 'true');
renderApp();
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
});
});
});
describe('Profile and Login Handlers', () => {
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
const mockUpdateProfile = vi.fn();
// To test profile updates, the user must be authenticated to see the "Update Profile" button.
mockUseAuth.mockReturnValue({
...mockUseAuth(),
userProfile: createMockUserProfile({ user_id: 'test-user', role: 'user' }),
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: mockUpdateProfile,
});
@@ -663,8 +681,9 @@ describe('App Component', () => {
const profileManager = await screen.findByTestId('profile-manager-mock');
fireEvent.click(within(profileManager).getByText('Update Profile'));
// The mock now returns a more complete object
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated' }));
await waitFor(() => {
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated' }));
});
});
it('should set an error state if login fails inside handleLoginSuccess', async () => {

View File

@@ -54,7 +54,7 @@ describe('useProfileAddress Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers(); // Use real timers for debounce tests
vi.useFakeTimers(); // Use fake timers for debounce tests
mockGeocode = vi.fn();
mockFetchAddress = vi.fn();
@@ -74,6 +74,10 @@ describe('useProfileAddress Hook', () => {
});
});
afterEach(() => {
vi.useRealTimers();
});
it('should initialize with empty address and initialAddress', () => {
const { result } = renderHook(() => useProfileAddress(null, false));
expect(result.current.address).toEqual({});
@@ -136,7 +140,7 @@ describe('useProfileAddress Hook', () => {
});
expect(result.current.address).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null or undefined'));
expect(logger.warn).toHaveBeenCalledWith(`[useProfileAddress] Fetch returned null for addressId: ${mockUserProfile.address_id}.`);
});
});
@@ -225,8 +229,8 @@ describe('useProfileAddress Hook', () => {
expect(mockGeocode).not.toHaveBeenCalled();
// Wait for debounce period
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1600));
act(() => {
vi.advanceTimersByTime(1600);
});
await waitFor(() => {
@@ -247,8 +251,8 @@ describe('useProfileAddress Hook', () => {
result.current.handleAddressChange('city', 'NewCity');
});
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1600));
act(() => {
vi.advanceTimersByTime(1600);
});
expect(mockGeocode).not.toHaveBeenCalled();
@@ -262,8 +266,8 @@ describe('useProfileAddress Hook', () => {
await waitFor(() => expect(result.current.address.city).toBe('Anytown'));
// Wait to see if debounce triggers
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1600));
act(() => {
vi.advanceTimersByTime(1600);
});
// It shouldn't because the address hasn't been changed by the user yet

View File

@@ -402,15 +402,11 @@ describe('useShoppingLists Hook', () => {
expect(currentLists[0].items[0]).toEqual(newItem);
});
it('should not add a duplicate item (by master_item_id) to a list', async () => {
console.log('TEST: should not add a duplicate item (by master_item_id) to a list');
it('should not call the API if a duplicate item (by master_item_id) is added', async () => {
console.log('TEST: should not call the API if a duplicate item (by master_item_id) is added');
const existingItem = createMockShoppingListItem({ shopping_list_item_id: 100, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk' });
// Override state for this specific test
currentLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', items: [existingItem] })];
// This is what the API would return for adding master_item_id 5 again. It has a new shopping_list_item_id.
const newItemFromApi = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk' });
mockAddItemApi.mockResolvedValue(newItemFromApi);
const { result, rerender } = renderHook(() => useShoppingLists());
console.log(' LOG: Initial item count:', currentLists[0].items.length);
@@ -420,12 +416,12 @@ describe('useShoppingLists Hook', () => {
rerender();
});
expect(mockAddItemApi).toHaveBeenCalledWith(1, { masterItemId: 5 });
// The API should not have been called because the duplicate was caught client-side.
expect(mockAddItemApi).not.toHaveBeenCalled();
console.log(' LOG: Final item count:', currentLists[0].items.length);
console.log(' LOG: Final items:', JSON.stringify(currentLists[0].items));
expect(currentLists[0].items).toHaveLength(1); // Length should remain 1
expect(currentLists[0].items[0].shopping_list_item_id).toBe(100); // It should be the original item
console.log(' LOG: SUCCESS! Duplicate was not added.');
console.log(' LOG: SUCCESS! Duplicate was not added and API was not called.');
});
});

View File

@@ -86,29 +86,40 @@ const useShoppingListsHook = () => {
const addItemToList = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
if (!userProfile) return;
// Find the target list first to check for duplicates *before* the API call.
const targetList = shoppingLists.find(l => l.shopping_list_id === listId);
if (!targetList) {
console.error(`useShoppingLists: List with ID ${listId} not found.`);
return; // Or throw an error
}
// Prevent adding a duplicate master item.
if (item.masterItemId) {
const itemExists = targetList.items.some(i => i.master_item_id === item.masterItemId);
if (itemExists) {
// Optionally, we could show a toast notification here.
console.log(`useShoppingLists: Item with master ID ${item.masterItemId} already in list.`);
return; // Exit without calling the API.
}
}
try {
const newItem = await addItemApi(listId, item);
if (newItem) {
setShoppingLists(prevLists =>
prevLists.map(list => {
if (list.shopping_list_id === listId) {
// Prevent adding a duplicate item if it's a master item and already exists in the list.
// We don't prevent duplicates for custom items as they don't have a unique ID.
const itemExists = newItem.master_item_id
? list.items.some(i => i.master_item_id === newItem.master_item_id)
: false;
if (itemExists) return list;
return { ...list, items: [...list.items, newItem] };
}
return list;
}));
// The duplicate check is now handled above, so we can just add the item.
return { ...list, items: [...list.items, newItem] };
}
return list;
}));
}
} catch (e) {
console.error('useShoppingLists: Failed to add item.', e);
}
}, [userProfile, setShoppingLists, addItemApi]);
}, [userProfile, shoppingLists, setShoppingLists, addItemApi]);
const updateItemInList = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
if (!userProfile || !activeListId) return;

View File

@@ -144,6 +144,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(vi.mocked(mockedDb.adminRepo.approveCorrection)).toHaveBeenCalledWith(correctionId, expect.anything());
});
it('POST /corrections/:id/approve should return 500 on DB error', async () => {
const correctionId = 123;
vi.mocked(mockedDb.adminRepo.approveCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
expect(response.status).toBe(500);
});
it('POST /corrections/:id/reject should reject a correction', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockResolvedValue(undefined);
@@ -152,6 +159,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
});
it('POST /corrections/:id/reject should return 500 on DB error', async () => {
const correctionId = 789;
vi.mocked(mockedDb.adminRepo.rejectCorrection).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
expect(response.status).toBe(500);
});
it('PUT /corrections/:id should update a correction', async () => {
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
@@ -197,6 +211,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(vi.mocked(mockedDb.adminRepo.updateBrandLogo)).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'), expect.anything());
});
it('POST /brands/:id/logo should return 500 on DB error', async () => {
const brandId = 55;
vi.mocked(mockedDb.adminRepo.updateBrandLogo).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post(`/api/admin/brands/${brandId}/logo`).attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(500);
});
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
const response = await supertest(app).post('/api/admin/brands/55/logo');
expect(response.status).toBe(400);
@@ -225,6 +246,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
});
it('DELETE /recipes/:recipeId should return 500 on DB error', async () => {
const recipeId = 300;
vi.mocked(mockedDb.recipeRepo.deleteRecipe).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).delete(`/api/admin/recipes/${recipeId}`);
expect(response.status).toBe(500);
});
it('PUT /recipes/:id/status should update a recipe status', async () => {
const recipeId = 201;
const requestBody = { status: 'public' as const };
@@ -242,6 +270,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
});
it('PUT /recipes/:id/status should return 500 on DB error', async () => {
const recipeId = 201;
const requestBody = { status: 'public' as const };
vi.mocked(mockedDb.adminRepo.updateRecipeStatus).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
expect(response.status).toBe(500);
});
it('PUT /comments/:id/status should update a comment status', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
@@ -259,6 +295,14 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
});
it('PUT /comments/:id/status should return 500 on DB error', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
vi.mocked(mockedDb.adminRepo.updateRecipeCommentStatus).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
expect(response.status).toBe(500);
});
});
describe('Unmatched Items Route', () => {
@@ -270,6 +314,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUnmatchedItems);
});
it('GET /unmatched-items should return 500 on DB error', async () => {
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(500);
});
});
describe('Flyer Routes', () => {

View File

@@ -97,6 +97,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.body).toEqual(mockUsers);
expect(adminRepo.getAllUsers).toHaveBeenCalledTimes(1);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error');
vi.mocked(adminRepo.getAllUsers).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/admin/users');
expect(response.status).toBe(500);
});
});
describe('GET /users/:id', () => {
@@ -116,6 +123,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.status).toBe(404);
expect(response.body.message).toBe('User not found.');
});
it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Error');
vi.mocked(userRepo.findUserProfileById).mockRejectedValue(dbError);
const response = await supertest(app).get(`/api/admin/users/${userId}`);
expect(response.status).toBe(500);
});
});
describe('PUT /users/:id', () => {
@@ -141,6 +155,14 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.body.message).toBe(`User with ID ${missingId} not found.`);
});
it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Error');
vi.mocked(adminRepo.updateUserRole).mockRejectedValue(dbError);
const response = await supertest(app).put(`/api/admin/users/${userId}`).send({ role: 'admin' });
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid role', async () => {
const response = await supertest(app)
.put(`/api/admin/users/${userId}`)
@@ -164,5 +186,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.body.message).toMatch(/Admins cannot delete their own account/);
expect(userRepo.deleteUserById).not.toHaveBeenCalled();
});
it('should return 500 on a generic database error', async () => {
const targetId = '123e4567-e89b-12d3-a456-426614174999';
const dbError = new Error('DB Error');
vi.mocked(userRepo.deleteUserById).mockRejectedValue(dbError);
const response = await supertest(app).delete(`/api/admin/users/${targetId}`);
expect(response.status).toBe(500);
});
});
});

View File

@@ -1,6 +1,7 @@
// src/routes/ai.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import fs from 'node:fs';
import { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path';
import type { Job } from 'bullmq';
@@ -69,6 +70,30 @@ describe('AI Routes (/api/ai)', () => {
});
const app = createTestApp({ router: aiRouter, basePath: '/api/ai' });
describe('Module-level error handling', () => {
it('should log an error if storage path creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
vi.resetModules(); // Reset modules to re-run top-level code
vi.doMock('node:fs', () => ({
default: {
...fs, // Keep other fs functions
mkdirSync: vi.fn().mockImplementation(() => {
throw mkdirError;
}),
},
}));
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the mkdirSync call
await import('./ai.routes');
// 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.`);
vi.doUnmock('node:fs'); // Cleanup
});
});
// Add a basic error handler to capture errors passed to next(err) and return JSON.
// This prevents unhandled error crashes in tests and ensures we get the 500 response we expect.
app.use((err: any, req: any, res: any, next: any) => {
@@ -314,6 +339,10 @@ describe('AI Routes (/api/ai)', () => {
// verify the flyerData.store_name passed to DB was the fallback string
const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store');
// Also verify the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
'extractedData.store_name missing; using fallback store name to avoid DB constraint error.'
);
});
it('should handle a generic error during flyer creation', async () => {
@@ -382,6 +411,14 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.is_flyer).toBe(true);
});
it('should return 500 on a generic error', async () => {
// To trigger the catch block, we can cause the middleware to fail.
// A simple way is to mock the service to throw an error.
vi.mocked(aiService.aiService.extractTextFromImageArea).mockRejectedValue(new Error('Generic Error')); // Not used by route, but triggers catch
const response = await supertest(app).post('/api/ai/check-flyer').attach('image', Buffer.from('')); // Empty buffer might cause issues
expect(response.status).toBe(500);
});
});
describe('POST /rescan-area', () => {
@@ -402,6 +439,14 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toMatch(/cropArea must be a valid JSON string|Required/i);
});
it('should return 400 if cropArea is malformed JSON', async () => {
const response = await supertest(app)
.post('/api/ai/rescan-area')
.attach('image', imagePath)
.field('cropArea', '{ "x": 0, "y": 0, "width": 10, "height": 10'); // Malformed
expect(response.status).toBe(400);
});
});
describe('POST /extract-address', () => {
@@ -416,6 +461,12 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.address).toBe('not identified');
});
it('should return 500 on a generic error', async () => {
// 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(''));
expect(response.status).toBe(500);
});
});
describe('POST /extract-logo', () => {
@@ -430,6 +481,12 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.store_logo_base_64).toBeNull();
});
it('should return 500 on a generic error', async () => {
// 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(''));
expect(response.status).toBe(500);
});
});
describe('POST /rescan-area (authenticated)', () => { // This was a duplicate, fixed.
@@ -494,6 +551,15 @@ describe('AI Routes (/api/ai)', () => {
expect(response.body.text).toContain('server-generated quick insight');
});
it('POST /quick-insights should return 500 on a generic error', async () => {
// To hit the catch block, we can simulate an error by making the logger throw.
vi.mocked(mockLogger.info).mockImplementation(() => { throw new Error('Logging failed'); });
const response = await supertest(app)
.post('/api/ai/quick-insights')
.send({ items: [] });
expect(response.status).toBe(500);
});
it('POST /deep-dive should return the stubbed response', async () => {
const response = await supertest(app)
.post('/api/ai/deep-dive')

View File

@@ -32,6 +32,8 @@ const requiredString = (message: string) => z.preprocess((val) => val ?? '', z.s
const uploadAndProcessSchema = z.object({
body: z.object({
checksum: requiredString('File checksum is required.'),
// Potential improvement: If checksum is always a specific format (e.g., SHA-256),
// you could add `.length(64).regex(/^[a-f0-9]+$/)` for stricter validation.
}),
});
@@ -48,6 +50,13 @@ const errMsg = (e: unknown) => {
return String(e || 'An unknown error occurred.');
};
const cropAreaObjectSchema = z.object({
x: z.number(),
y: z.number(),
width: z.number().positive('Crop area width must be positive.'),
height: z.number().positive('Crop area height must be positive.'),
});
const rescanAreaSchema = z.object({
body: z.object({
cropArea: requiredString('cropArea must be a valid JSON string.').transform((val, ctx) => {
@@ -57,30 +66,37 @@ const rescanAreaSchema = z.object({
logger.warn({ error: errMsg(err), receivedValue: val }, 'Failed to parse cropArea in rescanAreaSchema');
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cropArea must be a valid JSON string.' }); return z.NEVER;
}
}).pipe(cropAreaObjectSchema), // Further validate the structure of the parsed object
extractionType: z.enum(['store_name', 'dates', 'item_details'], { // This is the line with the error
message: "extractionType must be one of 'store_name', 'dates', or 'item_details'."
}),
extractionType: requiredString('extractionType is required.'),
}),
});
const flyerItemForAnalysisSchema = z.object({
name: requiredString("Item name is required."),
// Allow other properties to pass through without validation
}).passthrough();
const insightsSchema = z.object({
body: z.object({
items: z.array(z.any()), // Define more strictly if item structure is known
items: z.array(flyerItemForAnalysisSchema).nonempty("The 'items' array cannot be empty."),
}),
});
const comparePricesSchema = z.object({
body: z.object({
items: z.array(z.any()), // Define more strictly if item structure is known
items: z.array(flyerItemForAnalysisSchema).nonempty("The 'items' array cannot be empty."),
}),
});
const planTripSchema = z.object({
body: z.object({
items: z.array(z.any()),
store: z.object({ name: z.string() }),
items: z.array(flyerItemForAnalysisSchema),
store: z.object({ name: requiredString("Store name is required.") }),
userLocation: z.object({
latitude: z.number(),
longitude: z.number(),
latitude: z.number().min(-90, 'Latitude must be between -90 and 90.').max(90, 'Latitude must be between -90 and 90.'),
longitude: z.number().min(-180, 'Longitude must be between -180 and 180.').max(180, 'Longitude must be between -180 and 180.'),
}),
}),
});

View File

@@ -134,6 +134,13 @@ describe('Budget Routes (/api/budgets)', () => {
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(4);
});
it('should return 400 if required fields are missing', async () => {
// This test covers the `val ?? ''` part of the `requiredString` helper
const response = await supertest(app).post('/api/budgets').send({ amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Budget name is required.');
});
});
describe('PUT /:id', () => {

View File

@@ -223,7 +223,7 @@ describe('Flyer Routes (/api/flyers)', () => {
});
describe('POST /items/:itemId/track', () => {
it('should return 202 Accepted and call the tracking function', async () => {
it('should return 202 Accepted and call the tracking function for "click"', async () => {
const response = await supertest(app)
.post('/api/flyers/items/99/track')
.send({ type: 'click' });
@@ -232,6 +232,15 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(99, 'click', expectLogger);
});
it('should return 202 Accepted and call the tracking function for "view"', async () => {
const response = await supertest(app)
.post('/api/flyers/items/101/track')
.send({ type: 'view' });
expect(response.status).toBe(202);
expect(db.flyerRepo.trackFlyerItemInteraction).toHaveBeenCalledWith(101, 'view', expectLogger);
});
it('should return 400 for an invalid item ID', async () => {
const response = await supertest(app)
.post('/api/flyers/items/abc/track')

View File

@@ -201,6 +201,19 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body.errors).toHaveLength(2);
});
it('should return 400 if userId or achievementName are missing', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => { req.user = mockAdminProfile; next(); });
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response1 = await supertest(adminApp).post('/api/achievements/award').send({ achievementName: 'Test Award' });
expect(response1.status).toBe(400);
expect(response1.body.errors[0].message).toBe('userId is required.');
const response2 = await supertest(adminApp).post('/api/achievements/award').send({ userId: 'user-789' });
expect(response2.status).toBe(400);
expect(response2.body.errors[0].message).toBe('achievementName is required.');
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;

View File

@@ -169,6 +169,18 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith({ error: 'DB connection failed' }, 'Error during DB schema check:');
});
it('should return 500 if the database check fails with a non-Error object', async () => {
// Arrange: Mock the service function to reject with a non-error object
const dbError = { message: 'DB connection failed' };
mockedDbConnection.checkTablesExist.mockRejectedValue(dbError);
const response = await supertest(app).get('/api/health/db-schema');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection failed');
expect(logger.error).toHaveBeenCalledWith({ error: dbError }, 'Error during DB schema check:');
});
});
describe('GET /storage', () => {
@@ -198,6 +210,20 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith({ error: 'EACCES: permission denied' }, expect.stringContaining('Storage check failed for path:'));
});
it('should return 500 if storage check fails with a non-Error object', async () => {
// Arrange: Mock fs.access to reject with a non-error object.
const accessError = { message: 'EACCES: permission denied' };
mockedFs.access.mockRejectedValue(accessError);
// Act
const response = await supertest(app).get('/api/health/storage');
// Assert
expect(response.status).toBe(500);
expect(response.body.message).toContain('Storage check failed.');
expect(logger.error).toHaveBeenCalledWith({ error: accessError }, expect.stringContaining('Storage check failed for path:'));
});
});
describe('GET /db-pool', () => {
@@ -249,4 +275,16 @@ describe('Health Routes (/api/health)', () => {
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith({ error: 'Pool is not initialized' }, 'Error during DB pool health check:');
});
it('should return 500 if getPoolStatus throws a non-Error object', async () => {
// Arrange: Mock getPoolStatus to throw a non-error object
const poolError = { message: 'Pool is not initialized' };
mockedDbConnection.getPoolStatus.mockImplementation(() => { throw poolError; });
const response = await supertest(app).get('/api/health/db-pool');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Pool is not initialized');
expect(logger.error).toHaveBeenCalledWith({ error: poolError }, 'Error during DB pool health check:');
});
});

View File

@@ -497,6 +497,23 @@ describe('Passport Configuration', () => {
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should log info.toString() if info object has no message property', () => {
// Arrange
const mockReq = {} as Request;
const mockInfo = { custom: 'some info' };
// Mock passport.authenticate to call its callback with a custom info object
vi.mocked(passport.authenticate).mockImplementation(
(_strategy, _options, callback) => () => callback?.(null, false, mockInfo as any)
);
// Act
optionalAuth(mockReq, mockRes as Response, mockNext);
// Assert
expect(logger.info).toHaveBeenCalledWith({ info: mockInfo.toString() }, 'Optional auth info:');
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should call next() and not populate user if passport returns an error', () => {
// Arrange
const mockReq = {} as Request;

View File

@@ -109,6 +109,29 @@ const expectLogger = expect.objectContaining({
error: expect.any(Function),
});
describe('User Routes (/api/users)', () => {
// This test needs to be separate because the code it tests runs on module load.
describe('Avatar Upload Directory Creation', () => {
it('should log an error if avatar directory creation fails', async () => {
// Arrange
const mkdirError = new Error('EACCES: permission denied');
// Reset modules to force re-import with a new mock implementation
vi.resetModules();
// Set up the mock *before* the module is re-imported
vi.doMock('node:fs/promises', () => ({
// We only need to mock mkdir for this test.
mkdir: vi.fn().mockRejectedValue(mkdirError),
}));
const { logger } = await import('../services/logger.server');
// Act: Dynamically import the router to trigger the top-level fs.mkdir call
await import('./user.routes');
// Assert
expect(logger.error).toHaveBeenCalledWith('Failed to create avatar upload directory:', mkdirError);
vi.doUnmock('node:fs/promises'); // Clean up
});
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -894,6 +917,19 @@ describe('User Routes (/api/users)', () => {
expect(response.body.message).toBe('Only image files are allowed!');
});
it('should return 400 if the uploaded file is too large', async () => {
// This test relies on the `fileSize` limit set in the multer config in user.routes.ts
const largeFile = Buffer.alloc(2 * 1024 * 1024, 'a'); // 2MB file, assuming limit is smaller
const dummyImagePath = 'large-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.attach('avatar', largeFile, dummyImagePath);
expect(response.status).toBe(400);
expect(response.body.message).toContain('File too large');
});
it('should return 400 if no file is uploaded', async () => {
const response = await supertest(app)
.post('/api/users/profile/avatar'); // No .attach() call

View File

@@ -103,6 +103,7 @@ const avatarStorage = multer.diskStorage({
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB file size limit
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);

View File

@@ -22,6 +22,30 @@ describe('AiAnalysisService', () => {
service = new AiAnalysisService();
});
describe('getQuickInsights', () => {
it('should return insights string on success', async () => {
const mockResponse = 'Quick insights result';
vi.mocked(aiApiClient.getQuickInsights).mockResolvedValue({
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.getQuickInsights([]);
expect(result).toBe(mockResponse);
});
});
describe('getDeepDiveAnalysis', () => {
it('should return analysis string on success', async () => {
const mockResponse = 'Deep dive result';
vi.mocked(aiApiClient.getDeepDiveAnalysis).mockResolvedValue({
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.getDeepDiveAnalysis([]);
expect(result).toBe(mockResponse);
});
});
describe('searchWeb', () => {
it('should return a grounded response on success', async () => {
const mockResponse = {
@@ -142,6 +166,16 @@ describe('AiAnalysisService', () => {
});
describe('compareWatchedItemPrices', () => {
it('should return grounded response on success', async () => {
const mockResponse = { text: 'Comparison', sources: [] };
vi.mocked(aiApiClient.compareWatchedItemPrices).mockResolvedValue({
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.compareWatchedItemPrices([]);
expect(result.text).toBe('Comparison');
});
it('should re-throw an error if the API call fails', async () => {
const apiError = new Error('API is down');
vi.mocked(aiApiClient.compareWatchedItemPrices).mockRejectedValue(apiError);
@@ -151,6 +185,16 @@ describe('AiAnalysisService', () => {
});
describe('generateImageFromText', () => {
it('should return image string on success', async () => {
const mockResponse = 'base64image';
vi.mocked(aiApiClient.generateImageFromText).mockResolvedValue({
json: () => Promise.resolve(mockResponse),
} as Response);
const result = await service.generateImageFromText('prompt');
expect(result).toBe(mockResponse);
});
it('should re-throw an error if the API call fails', async () => {
const apiError = new Error('API is down');
vi.mocked(aiApiClient.generateImageFromText).mockRejectedValue(apiError);

View File

@@ -6,7 +6,7 @@ import { http, HttpResponse } from 'msw';
// Ensure the module under test is NOT mocked.
vi.unmock('./aiApiClient');
import { createMockFlyerItem, createMockStore } from '../tests/utils/mockFactories';
import { createMockFlyerItem, createMockStore, createMockMasterGroceryItem } from '../tests/utils/mockFactories';
import * as aiApiClient from './aiApiClient';
// 1. Mock logger to keep output clean
@@ -387,4 +387,17 @@ describe('AI API Client (Network Mocking with MSW)', () => {
);
});
});
describe('compareWatchedItemPrices', () => {
it('should send items as JSON in the body', async () => {
const items = [createMockMasterGroceryItem({ name: 'apple' })];
await aiApiClient.compareWatchedItemPrices(items);
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
expect(req.endpoint).toBe('compare-prices');
expect(req.body).toEqual({ items });
});
});
});

View File

@@ -27,6 +27,17 @@ vi.mock('sharp', () => ({
default: mockSharp,
}));
// Mock @google/genai
const mockGenerateContent = vi.fn();
vi.mock('@google/genai', () => {
return {
GoogleGenAI: vi.fn(() => ({
models: {
generateContent: mockGenerateContent
}
}))
};
});
describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service
@@ -103,6 +114,38 @@ describe('AI Service (Server)', () => {
expect(mockLoggerInstance.warn).toHaveBeenCalledWith('[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.');
await expect((service as any).aiClient.generateContent({ contents: [] })).resolves.toBeDefined();
});
it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => {
process.env.GEMINI_API_KEY = 'test-key';
// We need to force the constructor to use the real client logic, not the injected mock.
// So we instantiate AIService without passing aiClient.
// Reset modules to pick up the mock for @google/genai
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
// Access the private aiClient (which is now the adapter)
const adapter = (service as any).aiClient;
const request = { contents: [{ parts: [{ text: 'test' }] }] };
await adapter.generateContent(request);
expect(mockGenerateContent).toHaveBeenCalledWith({
model: 'gemini-2.5-flash',
...request
});
});
it('should throw error if adapter is called without content', async () => {
process.env.GEMINI_API_KEY = 'test-key';
vi.resetModules();
const { AIService } = await import('./aiService.server');
const service = new AIService(mockLoggerInstance);
const adapter = (service as any).aiClient;
await expect(adapter.generateContent({})).rejects.toThrow('AIService.generateContent requires at least one content element.');
});
});
describe('extractItemsFromReceiptImage', () => {

View File

@@ -506,7 +506,7 @@ export class AIService {
logger.warn("[AIService] planTripWithMaps called, but feature is disabled. Throwing error.");
throw new Error("The 'planTripWithMaps' feature is currently disabled due to API costs.");
const topItems = items.slice(0, 5).map(i => i.item).join(', ');
/* const topItems = items.slice(0, 5).map(i => i.item).join(', ');
const storeName = store?.name || 'the grocery store';
try {
@@ -532,6 +532,7 @@ export class AIService {
logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps");
throw apiError;
}
*/
}
}

View File

@@ -180,6 +180,18 @@ describe('API Client', () => {
expect(finalData.state).toBe('completed');
expect(localStorage.getItem('authToken')).toBe('new-refreshed-token');
});
it('should return the response immediately if the refresh token endpoint itself returns 401', async () => {
// Mock fetch to return 401 for the refresh token endpoint
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Unauthorized' }),
} as Response);
const response = await apiClient.apiFetch('/auth/refresh-token');
expect(response.status).toBe(401);
});
});
describe('Analytics API Functions', () => {

View File

@@ -128,11 +128,13 @@ describe('Background Job Service', () => {
user_id: 'user-1',
content: 'You have 1 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
{
user_id: 'user-2',
content: 'You have 2 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
}
]));
});
@@ -156,8 +158,14 @@ describe('Background Job Service', () => {
// The email queue add should be attempted for both users.
expect(mockEmailQueue.add).toHaveBeenCalledTimes(2);
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
expect(mockNotificationRepo.createBulkNotifications.mock.calls[0][0]).toHaveLength(1); // Only one notification created
expect(mockNotificationRepo.createBulkNotifications.mock.calls[0][0][0].user_id).toBe('user-2');
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
expect(notificationPayload).toHaveLength(1); // Only one notification created
expect(notificationPayload[0]).toEqual({
user_id: 'user-2',
content: 'You have 2 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
});
});
it('should log a critical error if getBestSalePricesForAllUsers fails', async () => {
@@ -383,5 +391,18 @@ describe('Background Job Service', () => {
// Assert
expect(globalMockLogger.error).toHaveBeenCalledWith({ err: queueError }, '[BackgroundJob] Failed to enqueue token cleanup job.');
});
it('should log a critical error if scheduling fails', () => {
mockCronSchedule.mockImplementation(() => {
throw new Error('Scheduling failed');
});
startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, mockTokenCleanupQueue, globalMockLogger);
expect(globalMockLogger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'[BackgroundJob] Failed to schedule a cron job. This is a critical setup error.'
);
});
});
});

View File

@@ -137,17 +137,19 @@ describe('Admin DB Service', () => {
.mockResolvedValueOnce({ rows: [{ count: '20' }] }) // userCount
.mockResolvedValueOnce({ rows: [{ count: '300' }] }) // flyerItemCount
.mockResolvedValueOnce({ rows: [{ count: '5' }] }) // storeCount
.mockResolvedValueOnce({ rows: [{ count: '2' }] }); // pendingCorrectionCount
.mockResolvedValueOnce({ rows: [{ count: '2' }] }) // pendingCorrectionCount
.mockResolvedValueOnce({ rows: [{ count: '15' }] }); // recipeCount
const stats = await adminRepo.getApplicationStats(mockLogger);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(5);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(6);
expect(stats).toEqual({
flyerCount: 10,
userCount: 20,
flyerItemCount: 300,
storeCount: 5,
pendingCorrectionCount: 2,
recipeCount: 15,
});
});

View File

@@ -1,5 +1,6 @@
// src/services/db/connection.db.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import type { PoolClient } from 'pg';
// Define mocks first using vi.hoisted to allow usage in vi.mock factory
const mocks = vi.hoisted(() => {
@@ -55,6 +56,15 @@ describe('DB Connection Service', () => {
// Reset specific method behaviors
mocks.mockPoolInstance.query.mockReset();
// Mock pool.on to capture the error handler
let capturedErrorHandler: ((err: Error, client: PoolClient) => void) | undefined;
vi.mocked(mocks.mockPoolInstance.on).mockImplementation((event, handler) => {
if (event === 'error') {
capturedErrorHandler = handler as (err: Error, client: PoolClient) => void;
}
return mocks.mockPoolInstance; // Return the mock instance for chaining
});
vi.resetModules();
});
@@ -85,6 +95,20 @@ describe('DB Connection Service', () => {
// pool1 triggers new Pool(). pool2 returns cached pool1.
expect(pool1).toBe(pool2);
});
it('should log an error if an unexpected error occurs on an idle client', async () => {
const { getPool } = await import('./connection.db');
getPool(); // Ensure the pool is initialized and the 'on' handler is registered
const unexpectedError = new Error('Simulated unexpected pool error');
const mockClient = { query: vi.fn(), release: vi.fn() } as unknown as PoolClient;
// Manually invoke the captured error handler
const errorHandler = vi.mocked(mocks.mockPoolInstance.on).mock.calls.find(call => call[0] === 'error')?.[1] as ((err: Error, client: PoolClient) => void);
errorHandler(unexpectedError, mockClient);
expect(logger.error).toHaveBeenCalledWith({ err: unexpectedError, client: mockClient }, 'Unexpected error on idle client in pool');
});
});
it('should throw an error if the Pool constructor fails', async () => {

View File

@@ -173,7 +173,18 @@ export class FlyerRepository {
*/
async getFlyers(logger: Logger, limit: number = 20, offset: number = 0): Promise<Flyer[]> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers ORDER BY created_at DESC LIMIT $1 OFFSET $2', [limit, offset]);
const query = `
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`;
const res = await this.db.query<Flyer>(query, [limit, offset]);
return res.rows;
} catch (error) {
logger.error({ err: error, limit, offset }, 'Database error in getFlyers');

View File

@@ -135,7 +135,7 @@ describe('Personalization DB Service', () => {
expect(result).toEqual(mockNewItem);
});
it('should not throw an error if the item is already in the watchlist', async () => {
it('should not throw an error if the item is already in the watchlist (ON CONFLICT DO NOTHING)', async () => {
const mockClientQuery = vi.fn();
const mockExistingItem = createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Existing Item' });
vi.mocked(withTransaction).mockImplementation(async (callback) => {
@@ -143,13 +143,13 @@ describe('Personalization DB Service', () => {
mockClientQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [mockExistingItem] }) // Find master item
.mockResolvedValueOnce({ rows: [] }); // INSERT...ON CONFLICT
.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // INSERT...ON CONFLICT DO NOTHING
return callback(mockClient as unknown as PoolClient);
});
// The function should resolve successfully without throwing an error.
await expect(personalizationRepo.addWatchedItem('user-123', 'Existing Item', 'Produce', mockLogger)).resolves.toEqual(mockExistingItem);
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT (user_id, master_item_id) DO NOTHING'), ['user-123', 1]);
expect(mockClientQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_watched_items'), ['user-123', 1]);
});
it('should throw an error if the category is not found', async () => {

View File

@@ -28,7 +28,14 @@ export class PersonalizationRepository {
*/
async getAllMasterItems(logger: Logger): Promise<MasterGroceryItem[]> {
try {
const res = await this.db.query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
const query = `
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`;
const res = await this.db.query<MasterGroceryItem>(query);
return res.rows;
} catch (error) {
logger.error({ err: error }, 'Database error in getAllMasterItems');
@@ -132,10 +139,7 @@ export class PersonalizationRepository {
} catch (error) {
// The withTransaction helper will handle rollback. We just need to handle specific errors.
if (error instanceof Error && 'code' in error) {
if (error.code === '23505') { // unique_violation
// This case is handled by ON CONFLICT, but it's good practice for other functions.
throw new UniqueConstraintError('This item is already in the watchlist.');
} else if (error.code === '23503') { // foreign_key_violation
if (error.code === '23503') { // foreign_key_violation
throw new ForeignKeyConstraintError('The specified user or category does not exist.');
}
}

View File

@@ -507,4 +507,25 @@ describe('Shopping DB Service', () => {
expect(mockLogger.error).toHaveBeenCalledWith({ err: dbError, receiptId: 1 }, 'Database error in findDealsForReceipt');
});
});
describe('processReceiptItems error handling', () => {
it('should log an error if updating receipt status to "failed" also fails', async () => {
const transactionError = new Error('Transaction failed');
const updateStatusError = new Error('Failed to update status');
// Mock withTransaction to throw an error
vi.mocked(withTransaction).mockImplementation(async () => {
throw transactionError;
});
// Mock the subsequent update query to also throw an error
mockPoolInstance.query.mockRejectedValueOnce(updateStatusError);
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await expect(shoppingRepo.processReceiptItems(1, items, mockLogger)).rejects.toThrow('Failed to process and save receipt items.');
expect(mockLogger.error).toHaveBeenCalledWith({ err: transactionError, receiptId: 1 }, 'Database transaction error in processReceiptItems');
expect(mockLogger.error).toHaveBeenCalledWith({ updateError: updateStatusError, receiptId: 1 }, 'Failed to update receipt status to "failed" after transaction rollback.');
});
});
});

View File

@@ -228,11 +228,16 @@ export class UserRepository {
* @returns A promise that resolves to the user's profile object or undefined if not found.
*/
// prettier-ignore
async findUserProfileById(userId: string, logger: Logger): Promise<Profile> {
async findUserProfileById(userId: string, logger: Logger): Promise<UserProfile> {
try {
const res = await this.db.query<Profile>(
const res = await this.db.query<UserProfile>(
`SELECT
p.user_id, p.full_name, p.avatar_url, p.address_id, p.preferences, p.role, p.points,
p.created_at, p.updated_at,
json_build_object(
'user_id', u.user_id,
'email', u.email
) as user,
CASE
WHEN a.address_id IS NOT NULL THEN json_build_object(
'address_id', a.address_id,
@@ -246,6 +251,7 @@ export class UserRepository {
ELSE NULL
END as address
FROM public.profiles p
JOIN public.users u ON p.user_id = u.user_id
LEFT JOIN public.addresses a ON p.address_id = a.address_id
WHERE p.user_id = $1`,
[userId]

View File

@@ -53,8 +53,8 @@ import * as db from './db/index.db';
import { createFlyerAndItems } from './db/flyer.db';
import * as imageProcessor from '../utils/imageProcessor';
import { createMockFlyer } from '../tests/utils/mockFactories';
import { FlyerDataTransformer } from './flyerDataTransformer'; // This is a duplicate, fixed.
import { AiDataValidationError, PdfConversionError } from './processingErrors';
import { FlyerDataTransformer } from './flyerDataTransformer';
import { AiDataValidationError, PdfConversionError, UnsupportedFileTypeError } from './processingErrors';
// Mock dependencies
vi.mock('./aiService.server', () => ({
@@ -297,12 +297,69 @@ describe('FlyerProcessingService', () => {
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
const job = createMockJob({ filePath: '/tmp/document.txt', originalFileName: 'document.txt' });
await expect(service.processJob(job)).rejects.toThrow(UnsupportedFileTypeError);
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
const job = createMockJob({});
vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
await expect(service.processJob(job)).rejects.toThrow();
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
it('should throw an error and not enqueue cleanup if icon generation fails', async () => {
const job = createMockJob({});
const iconError = new Error('Icon generation failed.');
// Mock the dependency that is called deep inside the process
vi.mocked(imageProcessor.generateFlyerIcon).mockRejectedValue(iconError);
await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');
expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Icon generation failed.' });
expect(mockCleanupQueue.add).not.toHaveBeenCalled();
});
});
describe('_prepareImageInputs (private method)', () => {
it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
const { logger } = await import('./logger.server');
const job = createMockJob({ filePath: '/tmp/unsupported.doc', originalFileName: 'unsupported.doc' });
const privateMethod = (service as any)._prepareImageInputs;
await expect(privateMethod('/tmp/unsupported.doc', job, logger)).rejects.toThrow(UnsupportedFileTypeError);
});
});
describe('_convertImageToPng (private method)', () => {
it('should throw an error if sharp fails', async () => {
const { logger } = await import('./logger.server');
const sharpError = new Error('Sharp failed');
vi.mocked(mockSharpInstance.toFile).mockRejectedValue(sharpError);
const privateMethod = (service as any)._convertImageToPng;
await expect(privateMethod('/tmp/image.gif', logger)).rejects.toThrow('Image conversion to PNG failed for image.gif');
expect(logger.error).toHaveBeenCalledWith({ err: sharpError, filePath: '/tmp/image.gif' }, 'Failed to convert image to PNG using sharp.');
});
});
describe('_extractFlyerDataWithAI (private method)', () => {
it('should throw AiDataValidationError if AI response validation fails', async () => {
const { logger } = await import('./logger.server');
const jobData = createMockJob({}).data;
// Mock AI to return data missing a required field ('store_name')
vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({
valid_from: '2024-01-01',
items: [],
} as any);
const privateMethod = (service as any)._extractFlyerDataWithAI;
await expect(privateMethod([], jobData, logger)).rejects.toThrow(AiDataValidationError);
});
});
describe('_enqueueCleanup (private method)', () => {

View File

@@ -39,6 +39,16 @@ describe('Client Logger', () => {
expect(spy).toHaveBeenCalledWith(`[INFO] ${message}`, data, 'extra');
});
it('logger.info calls console.log correctly when only an object is provided', () => {
const spy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => {});
const data = { foo: 'bar' };
logger.info(data);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('[INFO] ', data);
});
it('logger.warn calls console.warn with [WARN] prefix', () => {
const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => {});
const message = 'test warn';
@@ -60,6 +70,16 @@ describe('Client Logger', () => {
expect(spy).toHaveBeenCalledWith(`[WARN] ${message}`, data);
});
it('logger.warn calls console.warn correctly when only an object is provided', () => {
const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => {});
const data = { foo: 'bar' };
logger.warn(data);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('[WARN] ', data);
});
it('logger.error calls console.error with [ERROR] prefix', () => {
const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {});
const message = 'test error';
@@ -82,6 +102,16 @@ describe('Client Logger', () => {
expect(spy).toHaveBeenCalledWith(`[ERROR] ${message}`, data);
});
it('logger.error calls console.error correctly when only an object is provided', () => {
const spy = vi.spyOn(globalThis.console, 'error').mockImplementation(() => {});
const data = { errorCode: 123 };
logger.error(data);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('[ERROR] ', data);
});
it('logger.debug calls console.debug with [DEBUG] prefix', () => {
const spy = vi.spyOn(globalThis.console, 'debug').mockImplementation(() => {});
const message = 'test debug';
@@ -102,4 +132,14 @@ describe('Client Logger', () => {
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(`[DEBUG] ${message}`, data);
});
it('logger.debug calls console.debug correctly when only an object is provided', () => {
const spy = vi.spyOn(globalThis.console, 'debug').mockImplementation(() => {});
const data = { details: 'verbose' };
logger.debug(data);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('[DEBUG] ', data);
});
});

View File

@@ -73,4 +73,19 @@ describe('Nominatim Geocoding Service', () => {
expect(result).toBeNull();
expect(mockLogger.error).toHaveBeenCalledWith({ err: networkError, address: 'Any Address' }, '[NominatimService] An error occurred while calling the Nominatim API.');
});
it('should return null and log an error if the API response is not ok', async () => {
// Arrange: Mock a non-ok response
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
} as Response);
// Act
const result = await nominatimGeocodingService.geocode('Any Address', mockLogger);
// Assert
expect(result).toBeNull();
expect(mockLogger.error).toHaveBeenCalledWith({ err: new Error('Nominatim API returned status 500'), address: 'Any Address' }, '[NominatimService] An error occurred while calling the Nominatim API.');
});
});

View File

@@ -4,6 +4,8 @@ import {
FlyerProcessingError,
PdfConversionError,
AiDataValidationError,
GeocodingFailedError,
UnsupportedFileTypeError,
} from './processingErrors';
describe('Processing Errors', () => {
@@ -58,4 +60,30 @@ describe('Processing Errors', () => {
expect(error.rawData).toEqual(rawData);
});
});
describe('GeocodingFailedError', () => {
it('should create an error with the correct message, name, and inheritance', () => {
const message = 'All geocoding providers failed.';
const error = new GeocodingFailedError(message);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(FlyerProcessingError);
expect(error).toBeInstanceOf(GeocodingFailedError);
expect(error.message).toBe(message);
expect(error.name).toBe('GeocodingFailedError');
});
});
describe('UnsupportedFileTypeError', () => {
it('should create an error with the correct message, name, and inheritance', () => {
const message = 'Unsupported file type: .txt';
const error = new UnsupportedFileTypeError(message);
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(FlyerProcessingError);
expect(error).toBeInstanceOf(UnsupportedFileTypeError);
expect(error.message).toBe(message);
expect(error.name).toBe('UnsupportedFileTypeError');
});
});
});

View File

@@ -162,6 +162,20 @@ describe('Queue Workers', () => {
expect(mocks.sendEmail).toHaveBeenCalledWith(jobData, expect.anything());
});
it('should log and re-throw an error if sendEmail fails with a non-Error object', async () => {
const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' });
const emailError = 'SMTP server is down'; // Reject with a string
mocks.sendEmail.mockRejectedValue(emailError);
await expect(emailProcessor(job)).rejects.toBe(emailError);
// The worker should wrap the string in an Error object for logging
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: new Error(emailError), jobData: job.data },
`[EmailWorker] Job ${job.id} failed. Attempt ${job.attemptsMade}/${job.opts.attempts}.`
);
});
it('should re-throw an error if sendEmail fails', async () => {
const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' });
const emailError = new Error('SMTP server is down');
@@ -247,6 +261,12 @@ describe('Queue Workers', () => {
mocks.unlink.mockRejectedValue(permissionError);
await expect(cleanupProcessor(job)).rejects.toThrow('Permission denied');
// Verify the error was logged by the worker's catch block
expect(mockLogger.error).toHaveBeenCalledWith(
{ err: permissionError },
expect.stringContaining(`[CleanupWorker] Job ${job.id} for flyer ${job.data.flyerId} failed.`)
);
});
});

View File

@@ -2,13 +2,13 @@
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import type { User } from '../../types';
import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
describe('Admin API Routes Integration Tests', () => {
let adminToken: string;
let adminUser: User;
let regularUser: User;
let adminUser: UserProfile;
let regularUser: UserProfile;
let regularUserToken: string;
@@ -111,19 +111,24 @@ describe('Admin API Routes Integration Tests', () => {
});
describe('Admin Data Modification Routes', () => {
let testStoreId: number;
let testFlyerItemId: number;
let testCorrectionId: number;
// Create a store and flyer once for all tests in this block.
beforeAll(async () => {
// Create a dummy store and flyer to ensure foreign keys exist
// Use a unique name to prevent conflicts if tests are run in parallel or without full DB reset.
const storeName = `Admin Test Store - ${Date.now()}`;
const storeRes = await getPool().query(`INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id`, [storeName]);
testStoreId = storeRes.rows[0].store_id;
});
// Before each modification test, create a fresh flyer item and a correction for it.
beforeEach(async () => {
// Create a dummy store and flyer to ensure foreign keys exist
const storeRes = await getPool().query(`INSERT INTO public.stores (name) VALUES ('Admin Test Store') RETURNING store_id`);
const storeId = storeRes.rows[0].store_id;
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, item_count, checksum)
VALUES ($1, 'admin-test.jpg', 'http://test.com/img.jpg', 1, $2) RETURNING flyer_id`,
[storeId, `checksum-${Date.now()}-${Math.random()}`]
VALUES ($1, 'admin-test.jpg', 'http://test.com/img.jpg', 1, $2) RETURNING flyer_id`, [testStoreId, `checksum-${Date.now()}-${Math.random()}`]
);
const flyerId = flyerRes.rows[0].flyer_id;
@@ -141,6 +146,13 @@ describe('Admin API Routes Integration Tests', () => {
testCorrectionId = correctionRes.rows[0].suggested_correction_id;
});
afterAll(async () => {
// Clean up the created store and any associated flyers/items
if (testStoreId) {
await getPool().query('DELETE FROM public.stores WHERE store_id = $1', [testStoreId]);
}
});
it('should allow an admin to approve a correction', async () => {
// Act: Approve the correction.
const response = await apiClient.approveCorrection(testCorrectionId, adminToken);

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { loginUser } from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
import type { User } from '../../types';
import type { UserProfile } from '../../types';
/**
* @vitest-environment node
@@ -36,11 +36,11 @@ describe('Authentication API Integration', () => {
// --- END DEBUG LOGGING ---
let testUserEmail: string;
let testUser: User;
let testUser: UserProfile;
beforeAll(async () => {
({ user: testUser } = await createAndLoginUser({ fullName: 'Auth Test User' }));
testUserEmail = testUser.email;
testUserEmail = testUser.user.email;
});
afterAll(async () => {
@@ -57,9 +57,9 @@ describe('Authentication API Integration', () => {
// Assert that the API returns the expected structure
expect(data).toBeDefined();
expect(data.user).toBeDefined();
expect(data.user.email).toBe(testUserEmail);
expect(data.user.user_id).toBeTypeOf('string');
expect(data.userprofile).toBeDefined();
expect(data.userprofile.user.email).toBe(testUserEmail);
expect(data.userprofile.user_id).toBeTypeOf('string');
expect(data.token).toBeTypeOf('string');
});

View File

@@ -7,7 +7,7 @@ import * as db from '../../services/db/index.db';
import { getPool } from '../../services/db/connection.db';
import { generateFileChecksum } from '../../utils/checksum';
import { logger } from '../../services/logger.server';
import type { User } from '../../types';
import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
/**
@@ -52,7 +52,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
* This is the new end-to-end test for the background job processing flow.
* It uploads a file, polls for completion, and verifies the result in the database.
*/
const runBackgroundProcessingTest = async (user?: User, token?: string) => {
const runBackgroundProcessingTest = async (user?: UserProfile, token?: string) => {
// Arrange: Load a mock flyer PDF.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
const imageBuffer = await fs.readFile(imagePath);

View File

@@ -1,15 +1,16 @@
// src/tests/integration/public.routes.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import type { Flyer, FlyerItem, Recipe, RecipeComment, DietaryRestriction, Appliance, User } from '../../types';
import type { Flyer, FlyerItem, Recipe, RecipeComment, DietaryRestriction, Appliance, UserProfile } from '../../types';
import { getPool } from '../../services/db/connection.db';
import { createAndLoginUser } from '../utils/testHelpers';
const API_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
const request = supertest(API_URL.replace('/api', '')); // supertest needs the server's base URL
describe('Public API Routes Integration Tests', () => {
// Shared state for tests
let testUser: User;
let testUser: UserProfile;
let testRecipe: Recipe;
let testFlyer: Flyer;
@@ -17,9 +18,10 @@ describe('Public API Routes Integration Tests', () => {
const pool = getPool();
// Create a user to own the recipe
const userEmail = `public-routes-user-${Date.now()}@example.com`;
const userRes = await pool.query(`INSERT INTO users (email, password_hash) VALUES ($1, 'test-hash') RETURNING user_id, email`, [userEmail]);
testUser = userRes.rows[0];
// Use the helper to create a user and ensure a full UserProfile is returned,
// which also handles activity logging correctly.
const { user: createdUser } = await createAndLoginUser({ email: userEmail, password: 'test-hash', fullName: 'Public Routes Test User' });
testUser = createdUser;
// 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 *`,

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { logger } from '../../services/logger.server';
import { getPool } from '../../services/db/connection.db';
import type { User, MasterGroceryItem, ShoppingList } from '../../types';
import type { UserProfile, MasterGroceryItem, ShoppingList } from '../../types';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
/**
@@ -11,7 +11,7 @@ import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
*/
describe('User API Routes Integration Tests', () => {
let testUser: User;
let testUser: UserProfile;
let authToken: string;
// --- START DEBUG LOGGING ---
@@ -58,7 +58,7 @@ describe('User API Routes Integration Tests', () => {
// Assert: Verify the profile data matches the created user.
expect(profile).toBeDefined();
expect(profile.user_id).toBe(testUser.user_id);
expect(profile.user.email).toBe(testUser.email);
expect(profile.user.email).toBe(testUser.user.email);
expect(profile.full_name).toBe('Test User');
expect(profile.role).toBe('user');
});
@@ -164,8 +164,8 @@ describe('User API Routes Integration Tests', () => {
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false);
const loginData = await loginResponse.json();
expect(loginData.user).toBeDefined();
expect(loginData.user.user_id).toBe(resetUser.user_id);
expect(loginData.userprofile).toBeDefined();
expect(loginData.userprofile.user_id).toBe(resetUser.user_id);
});
describe('User Data Routes (Watched Items & Shopping Lists)', () => {

View File

@@ -2,14 +2,14 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import { getPool } from '../../services/db/connection.db';
import type { User } from '../../types';
import type { UserProfile } from '../../types';
const API_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
const request = supertest(API_URL.replace('/api', '')); // supertest needs the server's base URL
let authToken = '';
let createdListId: number;
let testUser: User;
let testUser: UserProfile;
const testPassword = 'password-for-user-routes-test';
describe('User Routes Integration Tests (/api/users)', () => {
@@ -36,7 +36,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
expect(loginResponse.status).toBe(200);
expect(loginResponse.body.token).toBeDefined();
authToken = loginResponse.body.token;
testUser = loginResponse.body.user;
testUser = loginResponse.body.userprofile;
});
afterAll(async () => {
@@ -54,7 +54,7 @@ describe('User Routes Integration Tests (/api/users)', () => {
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
expect(response.body.user.email).toBe(testUser.email);
expect(response.body.user.email).toBe(testUser.user.email);
expect(response.body.role).toBe('user');
});

View File

@@ -1,7 +1,7 @@
// src/tests/utils/testHelpers.ts
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import type { User } from '../../types';
import type { UserProfile } from '../../types';
export const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
@@ -13,7 +13,7 @@ interface CreateUserOptions {
}
interface CreateUserResult {
user: User;
user: UserProfile;
token: string;
}
@@ -39,6 +39,6 @@ export const createAndLoginUser = async (options: CreateUserOptions = {}): Promi
}
const loginResponse = await apiClient.loginUser(email, password, false);
const { user, token } = await loginResponse.json();
return { user, token };
const { userprofile, token } = await loginResponse.json();
return { user: userprofile, token };
};

View File

@@ -98,7 +98,6 @@ export const convertPdfToImageFiles = async (
): Promise<{ imageFiles: File[], pageCount: number }> => {
const pdf = await getPdfDocument(pdfFile);
const pageCount = pdf.numPages;
const imageFiles: File[] = [];
const scale = 1.5;
// Create an array of promises, one for each page rendering task.
@@ -122,12 +121,9 @@ export const convertPdfToImageFiles = async (
throw firstRejected.reason;
}
// Collect all successfully rendered image files.
settledResults.forEach(result => {
if (result.status === 'fulfilled') {
imageFiles.push(result.value);
}
});
// Collect all successfully rendered image files. Since we've already checked for rejections,
// we know all results are fulfilled and can safely extract their values.
const imageFiles = settledResults.map(result => (result as PromiseFulfilledResult<File>).value);
if (imageFiles.length === 0 && pageCount > 0) {
throw new Error('PDF conversion resulted in zero images, though the PDF has pages. It might be corrupted or contain non-standard content.');

View File

@@ -31,6 +31,8 @@ export const parsePriceToCents = (price: string): number | null => {
const numericString = dollarsMatch[0].replace(/,|\$/g, '');
const numericValue = parseFloat(numericString);
// This is a defensive check. The regex is designed to prevent this from ever being NaN.
// istanbul ignore next
if (isNaN(numericValue)) return null;
return Math.round(numericValue * 100);

View File

@@ -1,5 +1,5 @@
// src/utils/unitConverter.test.ts
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatUnitPrice, convertToMetric } from './unitConverter';
import type { UnitPrice } from '../types';
@@ -10,6 +10,10 @@ vi.mock('../services/logger.client', () => ({
},
}));
afterEach(() => {
vi.clearAllMocks();
});
describe('formatUnitPrice', () => {
it('should return a placeholder for null or invalid input', () => {
expect(formatUnitPrice(null, 'metric')).toEqual({ price: '—', unit: null });
@@ -61,9 +65,7 @@ describe('formatUnitPrice', () => {
it('should convert an imperial price (oz) to metric (g)', () => {
const unitPrice: UnitPrice = { value: 50, unit: 'oz' }; // $0.50/oz
// $0.50/oz / 28.3495 g/oz -> This seems wrong in the original test. Let's re-evaluate.
// The logic is price/unit. To get price/g from price/oz, we divide by the oz->g factor.
// $0.50/oz / 28.3495 g/oz = $0.0176.../g -> $0.018/g. The test is correct.
// $0.50/oz / 28.3495 g/oz = $0.0176.../g -> rounds to $0.018/g
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$0.018', unit: '/g' }); // This test remains correct
});
@@ -93,14 +95,11 @@ describe('formatUnitPrice', () => {
});
describe('formatUnitPrice graceful failures', () => {
it('should not convert if a metric unit is missing a conversion entry', () => {
it('should not convert and should not warn if unit is unknown', async () => {
const { logger } = await import('../services/logger.client');
const unitPrice: UnitPrice = { value: 100, unit: 'kl' as 'l' }; // Treat 'kl' as a metric unit for testing
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$1.00', unit: '/kl' });
});
it('should not convert if an imperial unit is missing a conversion entry', () => {
const unitPrice: UnitPrice = { value: 100, unit: 'gallon' as 'lb' }; // Treat 'gallon' as an imperial unit for testing
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$1.00', unit: '/gallon' });
expect(logger.warn).not.toHaveBeenCalled();
});
});

View File

@@ -2,22 +2,23 @@
import type { UnitPrice } from '../types';
import { logger } from '../services/logger.client';
const METRIC_UNITS = ['g', 'kg', 'ml', 'l'];
const IMPERIAL_UNITS = ['oz', 'lb', 'fl oz'];
const CONVERSIONS: Record<string, { to: string; factor: number }> = {
// metric to imperial
g: { to: 'oz', factor: 0.035274 },
kg: { to: 'lb', factor: 2.20462 },
ml: { to: 'fl oz', factor: 0.033814 },
l: { to: 'fl oz', factor: 33.814 },
// imperial to metric
oz: { to: 'g', factor: 28.3495 },
lb: { to: 'kg', factor: 0.453592 },
'fl oz': { to: 'ml', factor: 29.5735 },
const CONVERSIONS = {
metric: {
g: { to: 'oz', factor: 0.035274 },
kg: { to: 'lb', factor: 2.20462 },
ml: { to: 'fl oz', factor: 0.033814 },
l: { to: 'fl oz', factor: 33.814 },
},
imperial: {
oz: { to: 'g', factor: 28.3495 },
lb: { to: 'kg', factor: 0.453592 },
'fl oz': { to: 'ml', factor: 29.5735 },
}
};
const METRIC_UNITS = Object.keys(CONVERSIONS.metric);
const IMPERIAL_UNITS = Object.keys(CONVERSIONS.imperial);
interface FormattedPrice {
price: string;
unit: string | null;
@@ -45,11 +46,11 @@ const convertUnitPrice = (unitPrice: UnitPrice, targetSystem: 'metric' | 'imperi
return unitPrice; // Return original object if no conversion is needed
}
const conversion = CONVERSIONS[unit];
if (!conversion) {
logger.warn(`No conversion found for unit: ${unit}`);
return unitPrice; // Return original if no conversion rule exists
}
// The source system is determined by which list the unit belongs to.
// We access the specific map based on the detected system to satisfy TypeScript.
const conversion = isMetric
? CONVERSIONS.metric[unit as keyof typeof CONVERSIONS.metric]
: CONVERSIONS.imperial[unit as keyof typeof CONVERSIONS.imperial];
// When converting price per unit, the factor logic is inverted.
// e.g., to get price per lb from price per kg, you divide by the kg-to-lb factor.
@@ -107,6 +108,5 @@ export const convertToMetric = (unitPrice: UnitPrice | null | undefined): UnitPr
}
// The logic is now simply a call to the centralized converter.
// Note: The conversion logic is different here. We are converting the *quantity*, not the price per unit.
return convertUnitPrice(unitPrice, 'metric');
};