brand new unit tests finally
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 2m12s

This commit is contained in:
2025-11-27 20:58:22 -08:00
parent 5c76844a41
commit a9afd7644a
14 changed files with 1560 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
// src/services/db/admin.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getSuggestedCorrections,
approveCorrection,
rejectCorrection,
updateSuggestedCorrection,
getApplicationStats,
getDailyStatsForLast30Days,
logActivity,
incrementFailedLoginAttempts,
resetFailedLoginAttempts,
updateBrandLogo,
} from './admin';
import type { SuggestedCorrection } from '../../types';
// Mock the getPool function to return a mocked pool object.
// This allows us to control the behavior of database queries for all tests in this file.
const mockQuery = vi.fn();
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('Admin DB Service', () => {
beforeEach(() => {
// Clear mock history before each test
vi.clearAllMocks();
});
describe('getSuggestedCorrections', () => {
it('should execute the correct query and return corrections', async () => {
const mockCorrections: SuggestedCorrection[] = [
{ suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '250', status: 'pending', created_at: new Date().toISOString() },
];
mockQuery.mockResolvedValue({ rows: mockCorrections });
const result = await getSuggestedCorrections();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining("FROM public.suggested_corrections sc"));
expect(result).toEqual(mockCorrections);
});
});
describe('approveCorrection', () => {
it('should call the approve_correction database function', async () => {
mockQuery.mockResolvedValue({ rows: [] }); // Mock the function call
await approveCorrection(123);
expect(getPool().query).toHaveBeenCalledWith('SELECT public.approve_correction($1)', [123]);
});
});
describe('rejectCorrection', () => {
it('should update the correction status to rejected', async () => {
mockQuery.mockResolvedValue({ rowCount: 1 });
await rejectCorrection(123);
expect(getPool().query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET status = 'rejected'"),
[123]
);
});
});
describe('updateSuggestedCorrection', () => {
it('should update the suggested value and return the updated correction', async () => {
const mockCorrection: SuggestedCorrection = { suggested_correction_id: 1, flyer_item_id: 101, user_id: 'user-1', correction_type: 'WRONG_PRICE', suggested_value: '300', status: 'pending', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockCorrection], rowCount: 1 });
const result = await updateSuggestedCorrection(1, '300');
expect(getPool().query).toHaveBeenCalledWith(
expect.stringContaining("UPDATE public.suggested_corrections SET suggested_value = $1"),
['300', 1]
);
expect(result).toEqual(mockCorrection);
});
});
describe('getApplicationStats', () => {
it('should execute 5 parallel count queries and return the aggregated stats', async () => {
// Mock responses for each of the 5 parallel queries
mockQuery
.mockResolvedValueOnce({ rows: [{ count: '10' }] }) // flyerCount
.mockResolvedValueOnce({ rows: [{ count: '20' }] }) // userCount
.mockResolvedValueOnce({ rows: [{ count: '300' }] }) // flyerItemCount
.mockResolvedValueOnce({ rows: [{ count: '5' }] }) // storeCount
.mockResolvedValueOnce({ rows: [{ count: '2' }] }); // pendingCorrectionCount
const stats = await getApplicationStats();
expect(getPool().query).toHaveBeenCalledTimes(5);
expect(stats).toEqual({
flyerCount: 10,
userCount: 20,
flyerItemCount: 300,
storeCount: 5,
pendingCorrectionCount: 2,
});
});
});
describe('getDailyStatsForLast30Days', () => {
it('should execute the correct query to get daily stats', async () => {
const mockStats = [{ date: '2023-01-01', new_users: 5, new_flyers: 2 }];
mockQuery.mockResolvedValue({ rows: mockStats });
const result = await getDailyStatsForLast30Days();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining("WITH date_series AS"));
expect(result).toEqual(mockStats);
});
});
describe('logActivity', () => {
it('should insert a new activity log entry', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const logData = { userId: 'user-123', action: 'test_action', displayText: 'Test activity' };
await logActivity(logData);
expect(getPool().query).toHaveBeenCalledWith(
expect.stringContaining("INSERT INTO public.activity_log"),
[logData.userId, logData.action, logData.displayText, null, null]
);
});
});
// (Additional tests for other functions like updateBrandLogo, etc., would follow the same pattern)
});

View File

@@ -0,0 +1,90 @@
// src/services/db/connection.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Pool } from 'pg';
// Mock the 'pg' module BEFORE importing the connection service
const mockQuery = vi.fn();
const mockPoolInstance = {
query: mockQuery,
totalCount: 10,
idleCount: 5,
waitingCount: 0,
poolId: 'mock-pool-id',
};
vi.mock('pg', () => {
// Mock the Pool class constructor
const Pool = vi.fn(() => mockPoolInstance);
return { Pool };
});
// Mock the logger
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
describe('DB Connection Service', () => {
beforeEach(async () => {
vi.clearAllMocks();
// This is important to reset the singleton for each test
vi.resetModules();
});
describe('getPool', () => {
it('should create a new pool instance on the first call', async () => {
// Dynamically import to get the fresh, un-cached module
const { getPool } = await import('./connection');
const pool = getPool();
expect(Pool).toHaveBeenCalledTimes(1);
expect(pool).toBe(mockPoolInstance);
});
it('should return the same pool instance on subsequent calls', async () => {
const { getPool } = await import('./connection');
const pool1 = getPool();
const pool2 = getPool();
// The Pool constructor should only be called once because of the singleton pattern.
expect(Pool).toHaveBeenCalledTimes(1);
expect(pool1).toBe(mockPoolInstance);
expect(pool2).toBe(mockPoolInstance);
expect(pool1).toBe(pool2);
});
});
describe('checkTablesExist', () => {
it('should return an empty array if all tables exist', async () => {
const { checkTablesExist } = await import('./connection');
const tableNames = ['users', 'flyers'];
mockQuery.mockResolvedValue({ rows: [{ table_name: 'users' }, { table_name: 'flyers' }] });
const missingTables = await checkTablesExist(tableNames);
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), [tableNames]);
expect(missingTables).toEqual([]);
});
it('should return an array of missing tables', async () => {
const { checkTablesExist } = await import('./connection');
const tableNames = ['users', 'flyers', 'products'];
mockQuery.mockResolvedValue({ rows: [{ table_name: 'users' }] });
const missingTables = await checkTablesExist(tableNames);
expect(missingTables).toEqual(['flyers', 'products']);
});
});
describe('getPoolStatus', () => {
it('should return the status counts from the pool instance', async () => {
const { getPoolStatus } = await import('./connection');
const status = getPoolStatus();
expect(status).toEqual({ totalCount: 10, idleCount: 5, waitingCount: 0 });
});
});
});

View File

@@ -0,0 +1,171 @@
// src/services/db/flyer.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getFlyers,
getAllBrands,
getAllMasterItems,
getAllCategories,
findFlyerByChecksum,
createFlyerAndItems,
getFlyerItems,
getFlyerItemsForFlyers,
countFlyerItemsForFlyers,
updateStoreLogo,
trackFlyerItemInteraction,
getHistoricalPriceDataForItems,
} from './flyer';
import type { Flyer, FlyerItem } from '../../types';
// Mock the getPool function to return a mocked pool object.
const mockQuery = vi.fn();
const mockConnect = vi.fn(() => ({
query: mockQuery,
release: vi.fn(),
}));
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
connect: mockConnect,
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('Flyer DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getFlyers', () => {
it('should execute the correct query and return flyers', async () => {
const mockFlyers: Flyer[] = [{ flyer_id: 1, file_name: 'test.jpg', image_url: 'url', created_at: new Date().toISOString() }];
mockQuery.mockResolvedValue({ rows: mockFlyers });
const result = await getFlyers();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.flyers f'));
expect(result).toEqual(mockFlyers);
});
});
describe('getAllBrands', () => {
it('should execute the correct query to fetch all brands', async () => {
mockQuery.mockResolvedValue({ rows: [{ brand_id: 1, name: 'Test Brand' }] });
const result = await getAllBrands();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.brands b'));
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Test Brand');
});
});
describe('getAllMasterItems', () => {
it('should execute the correct query to fetch all master items', async () => {
mockQuery.mockResolvedValue({ rows: [{ master_grocery_item_id: 1, name: 'Test Item' }] });
const result = await getAllMasterItems();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items m'));
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Test Item');
});
});
describe('getAllCategories', () => {
it('should execute the correct query to fetch all categories', async () => {
mockQuery.mockResolvedValue({ rows: [{ category_id: 1, name: 'Test Category' }] });
const result = await getAllCategories();
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.categories'));
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Test Category');
});
});
describe('findFlyerByChecksum', () => {
it('should query for a flyer by its checksum', async () => {
const checksum = 'test-checksum';
mockQuery.mockResolvedValue({ rows: [{ flyer_id: 1, checksum }] });
const result = await findFlyerByChecksum(checksum);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
expect(result?.flyer_id).toBe(1);
});
});
describe('createFlyerAndItems', () => {
it('should execute a transaction to create a flyer and its items', async () => {
const flyerData = { file_name: 'flyer.jpg', image_url: '/img.jpg', checksum: 'cs', store_name: 'Test Store', valid_from: null, valid_to: null, store_address: null, uploaded_by: null };
const items: Omit<FlyerItem, 'flyer_item_id' | 'flyer_id' | 'created_at'>[] = [{ item: 'Test Item', price_display: '$1', price_in_cents: 100, quantity: 'each', view_count: 0, click_count: 0, updated_at: '' }];
// Mock the sequence of queries in the transaction
mockQuery
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // Find store
.mockResolvedValueOnce({ rows: [{ flyer_id: 123, ...flyerData }] }) // Insert flyer
.mockResolvedValueOnce({ rows: [] }); // Insert flyer item
await createFlyerAndItems(flyerData, items);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.flyers'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.flyer_items'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
});
});
describe('getFlyerItems', () => {
it('should query for items by a specific flyer ID', async () => {
mockQuery.mockResolvedValue({ rows: [{ item: 'Test Item' }] });
const result = await getFlyerItems(123);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('WHERE flyer_id = $1'), [123]);
expect(result).toHaveLength(1);
});
});
describe('getFlyerItemsForFlyers', () => {
it('should query for items from a list of flyer IDs', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getFlyerItemsForFlyers([1, 2, 3]);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('WHERE flyer_id = ANY($1::bigint[])'), [[1, 2, 3]]);
});
});
describe('countFlyerItemsForFlyers', () => {
it('should query for a count of items from a list of flyer IDs', async () => {
mockQuery.mockResolvedValue({ rows: [{ count: '42' }] });
const result = await countFlyerItemsForFlyers([1, 2, 3]);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('SELECT COUNT(*) FROM public.flyer_items'), [[1, 2, 3]]);
expect(result).toBe(42);
});
});
describe('updateStoreLogo', () => {
it('should execute an UPDATE query for the store logo', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await updateStoreLogo(1, '/logo.png');
expect(getPool().query).toHaveBeenCalledWith('UPDATE public.stores SET logo_url = $1 WHERE id = $2', ['/logo.png', 1]);
});
});
describe('trackFlyerItemInteraction', () => {
it('should execute an UPDATE query to increment the view_count', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await trackFlyerItemInteraction(101, 'view');
expect(getPool().query).toHaveBeenCalledWith('UPDATE public.flyer_items SET view_count = view_count + 1 WHERE flyer_item_id = $1', [101]);
});
});
describe('getHistoricalPriceDataForItems', () => {
it('should query the item_price_history table with a list of IDs', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getHistoricalPriceDataForItems([1, 2, 3]);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.item_price_history'), [[1, 2, 3]]);
});
});
});

View File

@@ -0,0 +1,195 @@
// src/services/db/personalization.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getWatchedItems,
addWatchedItem,
removeWatchedItem,
findRecipesFromPantry,
recommendRecipesForUser,
getBestSalePricesForUser,
suggestPantryItemConversions,
findPantryItemOwner,
getDietaryRestrictions,
getUserDietaryRestrictions,
setUserDietaryRestrictions,
getAppliances,
getUserAppliances,
setUserAppliances,
getRecipesForUserDiets,
} from './personalization';
import type { MasterGroceryItem, PantryRecipe, RecommendedRecipe, WatchedItemDeal, PantryItemConversion, DietaryRestriction, Appliance, UserAppliance } from '../../types';
// Mock the getPool function to return a mocked pool object.
const mockQuery = vi.fn();
const mockConnect = vi.fn(() => ({
query: mockQuery,
release: vi.fn(),
}));
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
connect: mockConnect,
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('Personalization DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getWatchedItems', () => {
it('should execute the correct query and return watched items', async () => {
const mockItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
mockQuery.mockResolvedValue({ rows: mockItems });
const result = await getWatchedItems('user-123');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.master_grocery_items mgi'), ['user-123']);
expect(result).toEqual(mockItems);
});
});
describe('addWatchedItem', () => {
it('should execute a transaction to add a watched item', async () => {
const mockItem: MasterGroceryItem = { master_grocery_item_id: 1, name: 'New Item', created_at: '' };
mockQuery
.mockResolvedValueOnce({ rows: [{ category_id: 1 }] }) // Find category
.mockResolvedValueOnce({ rows: [mockItem] }) // Find/create master item
.mockResolvedValueOnce({ rows: [] }); // Insert into watchlist
await addWatchedItem('user-123', 'New Item', 'Produce');
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT category_id FROM public.categories'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM public.master_grocery_items'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_watched_items'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
});
});
describe('removeWatchedItem', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await removeWatchedItem('user-123', 1);
expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.user_watched_items WHERE user_id = $1 AND master_item_id = $2', ['user-123', 1]);
});
});
describe('findRecipesFromPantry', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await findRecipesFromPantry('user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']);
});
});
describe('recommendRecipesForUser', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await recommendRecipesForUser('user-123', 5);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]);
});
});
describe('getBestSalePricesForUser', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getBestSalePricesForUser('user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']);
});
});
describe('suggestPantryItemConversions', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await suggestPantryItemConversions(1);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]);
});
});
describe('findPantryItemOwner', () => {
it('should execute a SELECT query to find the owner', async () => {
mockQuery.mockResolvedValue({ rows: [{ user_id: 'user-123' }] });
const result = await findPantryItemOwner(1);
expect(getPool().query).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]);
expect(result?.user_id).toBe('user-123');
});
});
describe('getDietaryRestrictions', () => {
it('should execute a SELECT query to get all restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getDietaryRestrictions();
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
});
});
describe('getUserDietaryRestrictions', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getUserDietaryRestrictions('user-123');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.dietary_restrictions dr'), ['user-123']);
});
});
describe('setUserDietaryRestrictions', () => {
it('should execute a transaction to set restrictions', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await setUserDietaryRestrictions('user-123', [1, 2]);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_dietary_restrictions'));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
});
});
describe('getAppliances', () => {
it('should execute a SELECT query to get all appliances', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getAppliances();
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.appliances ORDER BY name');
});
});
describe('getUserAppliances', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getUserAppliances('user-123');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']);
});
});
describe('setUserAppliances', () => {
it('should execute a transaction to set appliances', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await setUserAppliances('user-123', [1, 2]);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('BEGIN');
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_appliances WHERE user_id = $1', ['user-123']);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.user_appliances'), expect.any(Array));
expect(mockQuery).toHaveBeenCalledWith('COMMIT');
});
});
describe('getRecipesForUserDiets', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipesForUserDiets('user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']);
});
});
});

View File

@@ -0,0 +1,122 @@
// src/services/db/recipe.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getRecipesBySalePercentage,
getRecipesByMinSaleIngredients,
findRecipesByIngredientAndTag,
getUserFavoriteRecipes,
addFavoriteRecipe,
removeFavoriteRecipe,
getRecipeComments,
addRecipeComment,
forkRecipe,
} from './recipe';
import type { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
// Mock the getPool function to return a mocked pool object.
const mockQuery = vi.fn();
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('Recipe DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getRecipesBySalePercentage', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipesBySalePercentage(50);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [50]);
});
});
describe('getRecipesByMinSaleIngredients', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipesByMinSaleIngredients(3);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [3]);
});
});
describe('findRecipesByIngredientAndTag', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await findRecipesByIngredientAndTag('chicken', 'quick');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', ['chicken', 'quick']);
});
});
describe('getUserFavoriteRecipes', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getUserFavoriteRecipes('user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.get_user_favorite_recipes($1)', ['user-123']);
});
});
describe('addFavoriteRecipe', () => {
it('should execute an INSERT query and return the new favorite', async () => {
const mockFavorite: FavoriteRecipe = { user_id: 'user-123', recipe_id: 1, created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockFavorite] });
const result = await addFavoriteRecipe('user-123', 1);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.favorite_recipes'), ['user-123', 1]);
expect(result).toEqual(mockFavorite);
});
});
describe('removeFavoriteRecipe', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await removeFavoriteRecipe('user-123', 1);
expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]);
});
});
describe('getRecipeComments', () => {
it('should execute a SELECT query with a JOIN', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getRecipeComments(1);
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.recipe_comments rc'), [1]);
});
});
describe('addRecipeComment', () => {
it('should execute an INSERT query and return the new comment', async () => {
const mockComment: RecipeComment = { recipe_comment_id: 1, recipe_id: 1, user_id: 'user-123', content: 'Great!', status: 'visible', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockComment] });
const result = await addRecipeComment(1, 'user-123', 'Great!');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.recipe_comments'), [1, 'user-123', 'Great!', undefined]);
expect(result).toEqual(mockComment);
});
});
describe('forkRecipe', () => {
it('should call the fork_recipe database function', async () => {
const mockRecipe: Recipe = { recipe_id: 2, name: 'Forked Recipe', avg_rating: 0, rating_count: 0, status: 'private', created_at: new Date().toISOString() };
mockQuery.mockResolvedValue({ rows: [mockRecipe] });
const result = await forkRecipe('user-123', 1);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.fork_recipe($1, $2)', ['user-123', 1]);
expect(result).toEqual(mockRecipe);
});
});
});

View File

@@ -0,0 +1,195 @@
// src/services/db/shopping.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getPool } from './connection';
import {
getShoppingLists,
createShoppingList,
deleteShoppingList,
addShoppingListItem,
removeShoppingListItem,
generateShoppingListForMenuPlan,
addMenuPlanToShoppingList,
getPantryLocations,
createPantryLocation,
updateShoppingListItem,
completeShoppingList,
getShoppingTripHistory,
createReceipt,
processReceiptItems,
findDealsForReceipt,
findReceiptOwner,
} from './shopping';
import type { ShoppingList, ShoppingListItem, PantryLocation, Receipt } from '../../types';
// Mock the getPool function to return a mocked pool object.
const mockQuery = vi.fn();
vi.mock('./connection', () => ({
getPool: () => ({
query: mockQuery,
}),
}));
// Mock the logger to prevent console output during tests
vi.mock('../logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('Shopping DB Service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getShoppingLists', () => {
it('should execute the correct query and return shopping lists', async () => {
const mockLists: ShoppingList[] = [{ shopping_list_id: 1, name: 'My List', user_id: 'user-123', created_at: '', items: [] }];
mockQuery.mockResolvedValue({ rows: mockLists });
const result = await getShoppingLists('user-123');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_lists sl'), ['user-123']);
expect(result).toEqual(mockLists);
});
});
describe('createShoppingList', () => {
it('should execute an INSERT query and return the new list', async () => {
const mockList: ShoppingList = { shopping_list_id: 1, name: 'New List', user_id: 'user-123', created_at: '', items: [] };
mockQuery.mockResolvedValue({ rows: [mockList] });
const result = await createShoppingList('user-123', 'New List');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_lists'), ['user-123', 'New List']);
expect(result).toEqual({ ...mockList, items: [] }); // Ensure items array is empty
});
});
describe('deleteShoppingList', () => {
it('should execute a DELETE query with user ownership check', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await deleteShoppingList(1, 'user-123');
expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [1, 'user-123']);
});
});
describe('addShoppingListItem', () => {
it('should execute an INSERT query for a custom item', async () => {
const mockItem: ShoppingListItem = { shopping_list_item_id: 1, shopping_list_id: 1, custom_item_name: 'Custom Item', quantity: 1, is_purchased: false, added_at: '' };
mockQuery.mockResolvedValue({ rows: [mockItem] });
const result = await addShoppingListItem(1, { customItemName: 'Custom Item' });
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.shopping_list_items'), [1, undefined, 'Custom Item']);
expect(result).toEqual(mockItem);
});
});
describe('removeShoppingListItem', () => {
it('should execute a DELETE query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await removeShoppingListItem(101);
expect(getPool().query).toHaveBeenCalledWith('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [101]);
});
});
describe('generateShoppingListForMenuPlan', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await generateShoppingListForMenuPlan(1, 'user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [1, 'user-123']);
});
});
describe('addMenuPlanToShoppingList', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await addMenuPlanToShoppingList(1, 2, 'user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [1, 2, 'user-123']);
});
});
describe('getPantryLocations', () => {
it('should execute a SELECT query for pantry locations', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getPantryLocations('user-123');
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', ['user-123']);
});
});
describe('createPantryLocation', () => {
it('should execute an INSERT query and return the new location', async () => {
const mockLocation: PantryLocation = { pantry_location_id: 1, user_id: 'user-123', name: 'Fridge' };
mockQuery.mockResolvedValue({ rows: [mockLocation] });
const result = await createPantryLocation('user-123', 'Fridge');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.pantry_locations'), ['user-123', 'Fridge']);
expect(result).toEqual(mockLocation);
});
});
describe('updateShoppingListItem', () => {
it('should build and execute an UPDATE query', async () => {
const mockItem: ShoppingListItem = { shopping_list_item_id: 1, shopping_list_id: 1, custom_item_name: 'Item', quantity: 2, is_purchased: true, added_at: '' };
mockQuery.mockResolvedValue({ rows: [mockItem] });
const result = await updateShoppingListItem(1, { quantity: 2, is_purchased: true });
expect(getPool().query).toHaveBeenCalledWith(
'UPDATE public.shopping_list_items SET quantity = $1, is_purchased = $2 WHERE shopping_list_item_id = $3 RETURNING *',
[2, true, 1]
);
expect(result).toEqual(mockItem);
});
});
describe('completeShoppingList', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [{ complete_shopping_list: 10 }] });
const result = await completeShoppingList(1, 'user-123', 5000);
expect(getPool().query).toHaveBeenCalledWith('SELECT public.complete_shopping_list($1, $2, $3)', [1, 'user-123', 5000]);
expect(result).toBe(10);
});
});
describe('getShoppingTripHistory', () => {
it('should execute the correct query to get shopping trip history', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await getShoppingTripHistory('user-123');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('FROM public.shopping_trips st'), ['user-123']);
});
});
describe('createReceipt', () => {
it('should execute an INSERT query and return the new receipt', async () => {
const mockReceipt: Receipt = { receipt_id: 1, user_id: 'user-123', receipt_image_url: '/img.jpg', status: 'pending', created_at: '' };
mockQuery.mockResolvedValue({ rows: [mockReceipt] });
const result = await createReceipt('user-123', '/img.jpg');
expect(getPool().query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.receipts'), ['user-123', '/img.jpg']);
expect(result).toEqual(mockReceipt);
});
});
describe('findDealsForReceipt', () => {
it('should call the correct database function', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await findDealsForReceipt(1);
expect(getPool().query).toHaveBeenCalledWith('SELECT * FROM public.find_deals_for_receipt_items($1)', [1]);
});
});
describe('findReceiptOwner', () => {
it('should execute a SELECT query to find the owner of a receipt', async () => {
mockQuery.mockResolvedValue({ rows: [{ user_id: 'user-123' }] });
const result = await findReceiptOwner(1);
expect(getPool().query).toHaveBeenCalledWith('SELECT user_id FROM public.receipts WHERE receipt_id = $1', [1]);
expect(result?.user_id).toBe('user-123');
});
});
});

View File

@@ -0,0 +1,102 @@
// src/utils/audioUtils.test.ts
import { describe, it, expect, vi } from 'vitest';
import { encode, decode, decodeAudioData } from './audioUtils';
describe('audioUtils', () => {
describe('encode and decode', () => {
it('should correctly encode a Uint8Array to a base64 string', () => {
// "Hello" in UTF-8
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
const expectedBase64 = 'SGVsbG8=';
expect(encode(bytes)).toBe(expectedBase64);
});
it('should correctly decode a base64 string to a Uint8Array', () => {
const base64String = 'SGVsbG8=';
const expectedBytes = new Uint8Array([72, 101, 108, 108, 111]);
expect(decode(base64String)).toEqual(expectedBytes);
});
it('should be inverse functions of each other', () => {
const originalBytes = new Uint8Array([1, 2, 3, 255, 0, 128]);
const encoded = encode(originalBytes);
const decoded = decode(encoded);
expect(decoded).toEqual(originalBytes);
});
it('should handle empty arrays correctly', () => {
const emptyBytes = new Uint8Array([]);
expect(encode(emptyBytes)).toBe('');
expect(decode('')).toEqual(emptyBytes);
});
});
describe('decodeAudioData', () => {
// Mock the AudioContext and its methods for a Node.js environment
const mockGetChannelData = vi.fn();
const mockCreateBuffer = vi.fn(() => ({
getChannelData: mockGetChannelData,
}));
const mockAudioContext = {
createBuffer: mockCreateBuffer,
} as unknown as AudioContext;
beforeEach(() => {
vi.clearAllMocks();
});
it('should call createBuffer with the correct parameters for mono audio', async () => {
// 4 samples of 16-bit mono audio (8 bytes total)
const pcmData = new Uint8Array([0, 1, 0, 2, 0, 3, 0, 4]);
const sampleRate = 24000;
const numChannels = 1;
await decodeAudioData(pcmData, mockAudioContext, sampleRate, numChannels);
// 4 samples (Int16) / 1 channel = 4 frames
const expectedFrameCount = 4;
expect(mockCreateBuffer).toHaveBeenCalledWith(numChannels, expectedFrameCount, sampleRate);
});
it('should correctly normalize mono PCM data into the AudioBuffer', async () => {
// 16-bit PCM data for values 256 and -512
const pcmData = new Uint8Array([0, 1, 0, 254]); // Little-endian: 256, -512
const channelData = new Float32Array(2);
mockGetChannelData.mockReturnValue(channelData);
await decodeAudioData(pcmData, mockAudioContext, 24000, 1);
expect(mockGetChannelData).toHaveBeenCalledWith(0);
// Check that the values were correctly normalized to the [-1.0, 1.0] range
expect(channelData[0]).toBeCloseTo(256 / 32768.0);
expect(channelData[1]).toBeCloseTo(-512 / 32768.0);
});
it('should correctly de-interleave and normalize stereo PCM data', async () => {
// 16-bit PCM data for two stereo frames: [L1, R1, L2, R2]
// L1=256, R1=512, L2=-256, R2=-512
const pcmData = new Uint8Array([0, 1, 0, 2, 0, 255, 0, 254]);
const leftChannelData = new Float32Array(2);
const rightChannelData = new Float32Array(2);
// Mock getChannelData to return the correct channel array based on the channel index
mockGetChannelData.mockImplementation((channel: number) => {
return channel === 0 ? leftChannelData : rightChannelData;
});
await decodeAudioData(pcmData, mockAudioContext, 24000, 2);
expect(mockGetChannelData).toHaveBeenCalledWith(0); // Left channel
expect(mockGetChannelData).toHaveBeenCalledWith(1); // Right channel
// Check left channel values
expect(leftChannelData[0]).toBeCloseTo(256 / 32768.0);
expect(leftChannelData[1]).toBeCloseTo(-256 / 32768.0);
// Check right channel values
expect(rightChannelData[0]).toBeCloseTo(512 / 32768.0);
expect(rightChannelData[1]).toBeCloseTo(-512 / 32768.0);
});
});
});

View File

@@ -0,0 +1,45 @@
// src/utils/checksum.test.ts
import { describe, it, expect } from 'vitest';
import { generateFileChecksum } from './checksum';
describe('generateFileChecksum', () => {
it('should generate the correct SHA-256 checksum for a simple text file', async () => {
// Arrange
const fileContent = 'Hello, world!';
const file = new File([fileContent], 'hello.txt', { type: 'text/plain' });
// The known SHA-256 hash for "Hello, world!"
const expectedChecksum = '315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3';
// Act
const checksum = await generateFileChecksum(file);
// Assert
expect(checksum).toBe(expectedChecksum);
});
it('should generate the correct SHA-256 checksum for an empty file', async () => {
// Arrange
const file = new File([], 'empty.txt', { type: 'text/plain' });
// The known SHA-256 hash for an empty input
const expectedChecksum = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
// Act
const checksum = await generateFileChecksum(file);
// Assert
expect(checksum).toBe(expectedChecksum);
});
it('should generate a consistent checksum for a file with binary data', async () => {
// Arrange: Create a file with some arbitrary binary data
const binaryData = new Uint8Array([0, 1, 2, 3, 255, 254, 128]);
const file = new File([binaryData], 'data.bin', { type: 'application/octet-stream' });
const expectedChecksum = '8d3744339743449b433e2260b9e8334c25334edea12a8363722319385303159b';
// Act
const checksum = await generateFileChecksum(file);
// Assert
expect(checksum).toBe(expectedChecksum);
});
});

View File

@@ -0,0 +1,57 @@
// src/utils/objectUtils.test.ts
import { describe, it, expect } from 'vitest';
import { omit } from './objectUtils';
describe('omit', () => {
const sourceObject = {
a: 1,
b: 'hello',
c: true,
d: { nested: 'world' },
};
it('should omit a single key from an object', () => {
const result = omit(sourceObject, ['a']);
expect(result).toEqual({ b: 'hello', c: true, d: { nested: 'world' } });
expect(result).not.toHaveProperty('a');
});
it('should omit multiple keys from an object', () => {
const result = omit(sourceObject, ['a', 'c']);
expect(result).toEqual({ b: 'hello', d: { nested: 'world' } });
expect(result).not.toHaveProperty('a');
expect(result).not.toHaveProperty('c');
});
it('should return a new object without mutating the original', () => {
const original = { ...sourceObject };
omit(original, ['b']);
// Check that the original object is unchanged
expect(original).toEqual(sourceObject);
});
it('should return an identical object if no keys are omitted', () => {
const result = omit(sourceObject, []);
expect(result).toEqual(sourceObject);
// It should be a shallow copy, not the same object reference
expect(result).not.toBe(sourceObject);
});
it('should handle omitting keys that do not exist on the object', () => {
// The type system should ideally prevent this, but we test the runtime behavior.
const result = omit(sourceObject, ['e' as 'a']); // Cast to satisfy TypeScript
expect(result).toEqual(sourceObject);
});
it('should handle an empty source object', () => {
// @ts-expect-error - We are intentionally testing the runtime behavior of
// omitting a key from an object that doesn't have it.
const result = omit({}, ['a']);
expect(result).toEqual({});
});
it('should handle omitting all keys from an object', () => {
const result = omit(sourceObject, ['a', 'b', 'c', 'd']);
expect(result).toEqual({});
});
});

View File

@@ -0,0 +1,111 @@
// src/utils/pdfConverter.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { convertPdfToImageFiles } from './pdfConverter';
// Mock the entire pdfjs-dist library
const mockPdfPage = {
getViewport: vi.fn(() => ({ width: 100, height: 150 })),
render: vi.fn(() => ({ promise: Promise.resolve() })),
};
const mockPdfDocument = {
numPages: 3,
getPage: vi.fn(() => Promise.resolve(mockPdfPage)),
};
vi.mock('pdfjs-dist', () => ({
getDocument: vi.fn(() => ({ promise: Promise.resolve(mockPdfDocument) })),
}));
// Mock the browser's Canvas API
const mockToBlob = vi.fn((callback) => {
const blob = new Blob(['mock-jpeg-content'], { type: 'image/jpeg' });
callback(blob);
});
const mockGetContext = vi.fn(() => ({}));
// We need to mock document.createElement to return our mock canvas
const originalCreateElement = document.createElement;
Object.defineProperty(document, 'createElement', {
writable: true,
value: vi.fn((tag: string) => {
if (tag === 'canvas') {
return {
getContext: mockGetContext,
toBlob: mockToBlob,
width: 0,
height: 0,
};
}
return originalCreateElement.call(document, tag);
}),
});
// Mock the global File constructor
vi.stubGlobal('File', vi.fn((blobParts, fileName, options) => ({
blobParts,
name: fileName,
options,
})));
describe('pdfConverter', () => {
beforeEach(() => {
// Clear all mock history before each test
vi.clearAllMocks();
});
it('should convert a multi-page PDF to image files', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
const onProgress = vi.fn();
const { imageFiles, pageCount } = await convertPdfToImageFiles(pdfFile, onProgress);
// Verify that getDocument was called
const { getDocument } = await import('pdfjs-dist');
expect(getDocument).toHaveBeenCalled();
// Verify page count and file count
expect(pageCount).toBe(3);
expect(imageFiles).toHaveLength(3);
// Verify that getPage was called for each page
expect(mockPdfDocument.getPage).toHaveBeenCalledTimes(3);
expect(mockPdfDocument.getPage).toHaveBeenCalledWith(1);
expect(mockPdfDocument.getPage).toHaveBeenCalledWith(2);
expect(mockPdfDocument.getPage).toHaveBeenCalledWith(3);
// Verify that render was called for each page
expect(mockPdfPage.render).toHaveBeenCalledTimes(3);
// Verify that the File constructor was called with the correct names
expect(File).toHaveBeenCalledWith(expect.any(Array), 'flyer_page_1.jpeg', { type: 'image/jpeg' });
expect(File).toHaveBeenCalledWith(expect.any(Array), 'flyer_page_2.jpeg', { type: 'image/jpeg' });
expect(File).toHaveBeenCalledWith(expect.any(Array), 'flyer_page_3.jpeg', { type: 'image/jpeg' });
});
it('should call the onProgress callback for each page', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
const onProgress = vi.fn();
await convertPdfToImageFiles(pdfFile, onProgress);
expect(onProgress).toHaveBeenCalledTimes(3);
expect(onProgress).toHaveBeenCalledWith(1, 3);
expect(onProgress).toHaveBeenCalledWith(2, 3);
expect(onProgress).toHaveBeenCalledWith(3, 3);
});
it('should throw an error if conversion results in zero images for a non-empty PDF', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
// Mock toBlob to fail by returning null
mockToBlob.mockImplementationOnce((callback) => callback(null))
.mockImplementationOnce((callback) => callback(null))
.mockImplementationOnce((callback) => callback(null));
await expect(convertPdfToImageFiles(pdfFile)).rejects.toThrow(
'Failed to convert page 1 of PDF to blob.'
);
});
});

View File

@@ -0,0 +1,73 @@
// src/utils/priceParser.test.ts
import { describe, it, expect } from 'vitest';
import { parsePriceToCents } from './priceParser';
describe('parsePriceToCents', () => {
// Test cases for standard dollar formats
it('should parse standard dollar formats correctly', () => {
expect(parsePriceToCents('$10.99')).toBe(1099);
expect(parsePriceToCents('10.99')).toBe(1099);
expect(parsePriceToCents('$0.99')).toBe(99);
expect(parsePriceToCents('.99')).toBe(99);
expect(parsePriceToCents('$5')).toBe(500);
expect(parsePriceToCents('5')).toBe(500);
});
// Test cases for cents format
it('should parse cents format correctly', () => {
expect(parsePriceToCents('99¢')).toBe(99);
expect(parsePriceToCents('99 ¢')).toBe(99);
expect(parsePriceToCents('125¢')).toBe(125);
});
// Test cases for rounding
it('should round decimal cents to the nearest whole number', () => {
expect(parsePriceToCents('89.9¢')).toBe(90);
expect(parsePriceToCents('89.1¢')).toBe(89);
});
it('should round decimal dollars to the nearest cent', () => {
expect(parsePriceToCents('$10.995')).toBe(1100);
expect(parsePriceToCents('1.234')).toBe(123);
});
// Test cases for the special "already in cents" logic
it('should treat whole numbers over 50 as cents', () => {
expect(parsePriceToCents('399')).toBe(399); // e.g., for $3.99
expect(parsePriceToCents('1299')).toBe(1299); // e.g., for $12.99
expect(parsePriceToCents('51')).toBe(51);
});
it('should treat whole numbers 50 or less as dollars', () => {
expect(parsePriceToCents('49')).toBe(4900);
expect(parsePriceToCents('50')).toBe(5000);
expect(parsePriceToCents('1')).toBe(100);
});
// Test cases for invalid or unhandled formats
it('should return null for "X for Y" formats', () => {
expect(parsePriceToCents('2 for $5.00')).toBeNull();
expect(parsePriceToCents('3 for 10')).toBeNull();
});
it('should return null for non-price strings', () => {
expect(parsePriceToCents('FREE')).toBeNull();
expect(parsePriceToCents('See in store')).toBeNull();
expect(parsePriceToCents('abc')).toBeNull();
});
it('should return null for empty or invalid inputs', () => {
expect(parsePriceToCents('')).toBeNull();
expect(parsePriceToCents(' ')).toBeNull();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(parsePriceToCents(null as any)).toBeNull();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(parsePriceToCents(undefined as any)).toBeNull();
});
// Test cases for whitespace handling
it('should handle leading/trailing whitespace', () => {
expect(parsePriceToCents(' $10.99 ')).toBe(1099);
expect(parsePriceToCents(' 99¢ ')).toBe(99);
});
});

View File

@@ -0,0 +1,90 @@
// src/utils/processingTimer.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { recordProcessingTime, getAverageProcessingTime } from './processingTimer';
// Mock the logger to prevent console output during tests
vi.mock('../services/logger', () => ({
logger: {
error: vi.fn(),
},
}));
const PROCESSING_TIMES_KEY = 'flyerProcessingTimes';
// Create an in-memory mock for localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('processingTimer', () => {
beforeEach(() => {
// Clear localStorage before each test to ensure isolation
localStorage.clear();
});
describe('recordProcessingTime', () => {
it('should add a new time to an empty list', () => {
recordProcessingTime(50);
const stored = localStorage.getItem(PROCESSING_TIMES_KEY);
expect(JSON.parse(stored!)).toEqual([50]);
});
it('should add a new time to an existing list', () => {
localStorage.setItem(PROCESSING_TIMES_KEY, JSON.stringify([40, 45]));
recordProcessingTime(50);
const stored = localStorage.getItem(PROCESSING_TIMES_KEY);
expect(JSON.parse(stored!)).toEqual([40, 45, 50]);
});
it('should keep only the last 5 samples', () => {
localStorage.setItem(PROCESSING_TIMES_KEY, JSON.stringify([10, 20, 30, 40, 50]));
recordProcessingTime(60);
const stored = localStorage.getItem(PROCESSING_TIMES_KEY);
expect(JSON.parse(stored!)).toEqual([20, 30, 40, 50, 60]);
});
});
describe('getAverageProcessingTime', () => {
it('should return the default value of 45 if no data exists', () => {
expect(getAverageProcessingTime()).toBe(45);
});
it('should return the default value if the stored data is an empty array', () => {
localStorage.setItem(PROCESSING_TIMES_KEY, JSON.stringify([]));
expect(getAverageProcessingTime()).toBe(45);
});
it('should correctly calculate the average of stored times', () => {
localStorage.setItem(PROCESSING_TIMES_KEY, JSON.stringify([30, 40, 50]));
// (30 + 40 + 50) / 3 = 40
expect(getAverageProcessingTime()).toBe(40);
});
it('should round the average to the nearest integer', () => {
localStorage.setItem(PROCESSING_TIMES_KEY, JSON.stringify([31, 41, 51]));
// (31 + 41 + 51) / 3 = 41
expect(getAverageProcessingTime()).toBe(41);
});
it('should return the default value if localStorage contains invalid JSON', async () => {
localStorage.setItem(PROCESSING_TIMES_KEY, 'not-json');
expect(getAverageProcessingTime()).toBe(45);
// Also check that the error was logged
const { logger } = await import('../services/logger');
expect(logger.error).toHaveBeenCalled();
});
});
});

50
src/utils/timeout.test.ts Normal file
View File

@@ -0,0 +1,50 @@
// src/utils/timeout.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { withTimeout } from './timeout';
describe('withTimeout', () => {
beforeEach(() => {
// Use fake timers to control setTimeout and clearTimeout
vi.useFakeTimers();
});
afterEach(() => {
// Restore real timers after each test
vi.useRealTimers();
});
it('should resolve with the original promise value if it resolves before the timeout', async () => {
const slowPromise = new Promise<string>(resolve => setTimeout(() => resolve('Success'), 100));
const wrappedPromise = withTimeout(slowPromise, 500);
// Advance timers just enough for the promise to resolve
vi.advanceTimersByTime(100);
// The promise should resolve successfully
await expect(wrappedPromise).resolves.toBe('Success');
});
it('should reject with the original promise reason if it rejects before the timeout', async () => {
const failingPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Original error')), 100)
);
const wrappedPromise = withTimeout(failingPromise, 500);
// Advance timers just enough for the promise to reject
vi.advanceTimersByTime(100);
// The promise should reject with the original error
await expect(wrappedPromise).rejects.toThrow('Original error');
});
it('should reject with a timeout error if the promise does not resolve in time', async () => {
const verySlowPromise = new Promise<string>(resolve => setTimeout(() => resolve('Success'), 1000));
const wrappedPromise = withTimeout(verySlowPromise, 500);
// Advance timers past the timeout duration but before the promise resolves
vi.advanceTimersByTime(500);
// The promise should reject with the specific timeout error
await expect(wrappedPromise).rejects.toThrow('Operation timed out after 0.5 seconds');
});
});

View File

@@ -0,0 +1,117 @@
// src/utils/unitConverter.test.ts
import { describe, it, expect } from 'vitest';
import { formatUnitPrice, convertToMetric } from './unitConverter';
import type { UnitPrice } from '../types';
describe('formatUnitPrice', () => {
it('should return a placeholder for null or invalid input', () => {
expect(formatUnitPrice(null, 'metric')).toEqual({ price: '—', unit: null });
expect(formatUnitPrice(undefined, 'imperial')).toEqual({ price: '—', unit: null });
const invalidInput = { value: 'not-a-number' } as unknown as UnitPrice;
expect(formatUnitPrice(invalidInput, 'metric')).toEqual({ price: '—', unit: null });
});
// --- No Conversion Tests ---
it('should format a metric price correctly when the system is metric', () => {
const unitPrice: UnitPrice = { value: 220, unit: 'kg' }; // $2.20/kg
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$2.20', unit: '/kg' });
});
it('should format an imperial price correctly when the system is imperial', () => {
const unitPrice: UnitPrice = { value: 100, unit: 'lb' }; // $1.00/lb
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$1.00', unit: '/lb' });
});
it('should format an "each" price correctly regardless of system', () => {
const unitPrice: UnitPrice = { value: 500, unit: 'each' }; // $5.00/each
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$5.00', unit: '/each' });
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$5.00', unit: '/each' });
});
// --- Conversion Tests ---
it('should convert a metric price (kg) to imperial (lb)', () => {
const unitPrice: UnitPrice = { value: 220, unit: 'kg' }; // $2.20/kg
// 2.20 / 2.20462 = 0.9979... -> $1.00/lb
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$1.00', unit: '/lb' });
});
it('should convert an imperial price (lb) to metric (kg)', () => {
const unitPrice: UnitPrice = { value: 100, unit: 'lb' }; // $1.00/lb
// 1.00 / 0.453592 = 2.2046... -> $2.20/kg
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$2.20', unit: '/kg' });
});
it('should convert a metric price (g) to imperial (oz)', () => {
const unitPrice: UnitPrice = { value: 10, unit: 'g' }; // $0.10/g
// 0.10 / 0.035274 = 2.83... -> $2.83/oz
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$2.83', unit: '/oz' });
});
it('should convert an imperial price (oz) to metric (g)', () => {
const unitPrice: UnitPrice = { value: 50, unit: 'oz' }; // $0.50/oz
// 0.50 / 28.3495 = 0.0176... -> $0.018/g
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$0.018', unit: '/g' });
});
it('should convert a metric price (l) to imperial (fl oz)', () => {
const unitPrice: UnitPrice = { value: 100, unit: 'l' }; // $1.00/l
// 1.00 / 33.814 = 0.0295... -> $0.030/fl oz
expect(formatUnitPrice(unitPrice, 'imperial')).toEqual({ price: '$0.030', unit: '/fl oz' });
});
// --- Formatting Tests ---
it('should use 3 decimal places for prices less than 10 cents', () => {
const unitPrice: UnitPrice = { value: 5, unit: 'g' }; // $0.05/g
// 0.05 / 28.3495 = 0.00176... -> $0.002/g
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$0.050', unit: '/g' });
});
it('should use 2 decimal places for prices 10 cents or more', () => {
const unitPrice: UnitPrice = { value: 15, unit: 'g' }; // $0.15/g
expect(formatUnitPrice(unitPrice, 'metric')).toEqual({ price: '$0.15', unit: '/g' });
});
});
describe('convertToMetric', () => {
it('should return null or undefined if input is null or undefined', () => {
expect(convertToMetric(null)).toBeNull();
expect(convertToMetric(undefined)).toBeUndefined();
});
it('should not convert an already metric unit', () => {
const metricPrice: UnitPrice = { value: 199, unit: 'kg' };
expect(convertToMetric(metricPrice)).toEqual(metricPrice);
});
it('should not convert a non-standard unit like "each"', () => {
const eachPrice: UnitPrice = { value: 500, unit: 'each' };
expect(convertToMetric(eachPrice)).toEqual(eachPrice);
});
it('should convert from lb to kg', () => {
const imperialPrice: UnitPrice = { value: 100, unit: 'lb' }; // $1.00/lb
const expectedValue = 100 * 0.453592; // 45.3592
expect(convertToMetric(imperialPrice)).toEqual({
value: expectedValue,
unit: 'kg',
});
});
it('should convert from oz to g', () => {
const imperialPrice: UnitPrice = { value: 10, unit: 'oz' }; // $0.10/oz
const expectedValue = 10 * 28.3495; // 283.495
expect(convertToMetric(imperialPrice)).toEqual({
value: expectedValue,
unit: 'g',
});
});
it('should convert from fl oz to ml', () => {
const imperialPrice: UnitPrice = { value: 5, unit: 'fl oz' }; // $0.05/fl oz
const expectedValue = 5 * 29.5735; // 147.8675
expect(convertToMetric(imperialPrice)).toEqual({
value: expectedValue,
unit: 'ml',
});
});
});