moar unit test !
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 6m4s

This commit is contained in:
2025-12-07 02:00:17 -08:00
parent 6d5cafda38
commit a148ff8a45
9 changed files with 606 additions and 604 deletions

View File

@@ -1,123 +1,143 @@
// src/services/db/flyer.db.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockPoolInstance } from '../../tests/setup/tests-setup-unit';
import { createMockFlyer } from '../../tests/utils/mockFactories';
import { createMockFlyer, createMockFlyerItem } from '../../tests/utils/mockFactories';
// Un-mock the module we are testing
// Un-mock the module we are testing to ensure we use the real implementation
vi.unmock('./flyer.db');
import {
getFlyers,
findFlyerByChecksum,
createFlyerAndItems,
trackFlyerItemInteraction,
getHistoricalPriceDataForItems,
} from './flyer.db';
import { insertFlyer, insertFlyerItems, createFlyerAndItems } from './flyer.db';
import type { FlyerInsert, FlyerItemInsert } from '../../types';
// Mock dependencies
vi.mock('../logger.server', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../geocodingService.server', () => ({
geocodeAddress: vi.fn().mockResolvedValue({ lat: 48, lng: -123 }),
}));
describe('Flyer DB Service', () => {
beforeEach(() => {
// To correctly mock a transaction, the `connect` method should return an object
// that has `query` and `release` methods. Here, we make `connect` return the
// pool instance itself, and ensure the `release` method is present on it.
vi.mocked(mockPoolInstance.connect).mockResolvedValue(mockPoolInstance as any);
(mockPoolInstance as any).release = vi.fn(); // Add the missing release method
vi.clearAllMocks();
});
describe('getFlyers', () => {
it('should throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
await expect(getFlyers()).rejects.toThrow('Failed to retrieve flyers from database.');
describe('insertFlyer', () => {
it('should execute an INSERT query and return the new flyer', async () => {
const flyerData: FlyerInsert = {
file_name: 'test.jpg',
image_url: '/images/test.jpg',
icon_url: '/images/icons/test.jpg',
checksum: 'checksum123',
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Test St',
item_count: 10,
uploaded_by: 'user-1',
};
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 1 });
mockPoolInstance.query.mockResolvedValue({ rows: [mockFlyer] });
const result = await insertFlyer(flyerData, mockPoolInstance as any);
expect(result).toEqual(mockFlyer);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO flyers'),
[
'test.jpg',
'/images/test.jpg',
'/images/icons/test.jpg',
'checksum123',
'Test Store',
'2024-01-01',
'2024-01-07',
'123 Test St',
10,
'user-1',
]
);
});
});
describe('findFlyerByChecksum', () => {
it('should return undefined if no flyer is found', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const result = await findFlyerByChecksum('non-existent-checksum');
expect(result).toBeUndefined();
});
});
describe('insertFlyerItems', () => {
it('should build a bulk INSERT query and return the new items', async () => {
const itemsData: FlyerItemInsert[] = [
{ item: 'Milk', price_display: '$3.99', price_in_cents: 399, quantity: '1L', category_name: 'Dairy', view_count: 0, click_count: 0 },
{ item: 'Bread', price_display: '$2.49', price_in_cents: 249, quantity: '1 loaf', category_name: 'Bakery', view_count: 0, click_count: 0 },
];
const mockItems = itemsData.map((item, i) => createMockFlyerItem({ ...item, flyer_item_id: i + 1, flyer_id: 1 }));
mockPoolInstance.query.mockResolvedValue({ rows: mockItems });
describe('createFlyerAndItems', () => {
const mockFlyerData = {
file_name: 'test.jpg',
image_url: 'test.jpg',
checksum: 'test-checksum',
store_name: 'Test Store',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store_address: '123 Test St',
uploaded_by: 'user-1',
item_count: 0, // This was missing and is required by the type
};
const result = await insertFlyerItems(1, itemsData, mockPoolInstance as any);
it('should rollback the transaction if an item insert fails', async () => {
const mockFlyer = createMockFlyer();
const mockItems = [{ item: 'Good Item' }, { item: 'Bad Item' }];
// Setup a failing query on the second item insert
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] }) // Find store
.mockResolvedValueOnce({ rows: [{ address_id: 1 }] }) // Insert/Select address
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }] }) // Upsert store_location
.mockResolvedValueOnce({ rows: [mockFlyer] }) // INSERT flyer
.mockResolvedValueOnce({ rows: [] }) // Link flyer_location
.mockResolvedValueOnce({ rows: [] }) // INSERT first item (success)
.mockRejectedValueOnce(new Error('Item insert failed')) // INSERT second item (fail)
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(createFlyerAndItems(mockFlyerData, mockItems as any)).rejects.toThrow('Failed to save flyer and its items.');
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockPoolInstance.query).not.toHaveBeenCalledWith('COMMIT');
expect(result).toEqual(mockItems);
expect(mockPoolInstance.query).toHaveBeenCalledTimes(1);
// Check that the query string has two value placeholders
expect(mockPoolInstance.query.mock.calls[0][0]).toContain('VALUES ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16)');
// Check that the values array is correctly flattened
expect(mockPoolInstance.query.mock.calls[0][1]).toEqual([
1, 'Milk', '$3.99', 399, '1L', 'Dairy', 0, 0,
1, 'Bread', '$2.49', 249, '1 loaf', 'Bakery', 0, 0,
]);
});
it('should handle an empty items array gracefully', async () => {
const mockFlyer = createMockFlyer();
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ store_id: 1 }] })
.mockResolvedValueOnce({ rows: [{ address_id: 1 }] })
.mockResolvedValueOnce({ rows: [{ store_location_id: 1 }] })
.mockResolvedValueOnce({ rows: [mockFlyer] })
.mockResolvedValueOnce({ rows: [] }) // Link flyer_location
.mockResolvedValueOnce({ rows: [] }); // COMMIT
// Mock geocodeAddress to handle the null address case gracefully
const { geocodeAddress } = await import('../geocodingService.server');
vi.mocked(geocodeAddress).mockResolvedValue(null);
await createFlyerAndItems(mockFlyerData, []);
// Also test the case where store_address is null
const mockFlyerDataNoAddress = { ...mockFlyerData, store_address: null };
await createFlyerAndItems(mockFlyerDataNoAddress, []);
// Verify it doesn't try to insert items but still commits the flyer
expect(mockPoolInstance.query).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO public.flyer_items'));
expect(mockPoolInstance.query).toHaveBeenCalledWith('COMMIT');
});
});
describe('trackFlyerItemInteraction', () => {
it('should not throw an error if the database query fails', async () => {
mockPoolInstance.query.mockRejectedValue(new Error('DB Connection Error'));
// The function is designed to swallow errors, so we expect it to resolve to undefined
await expect(trackFlyerItemInteraction(1, 'click')).resolves.toBeUndefined();
});
});
describe('getHistoricalPriceDataForItems', () => {
it('should return an empty array if no masterItemIds are provided', async () => {
const result = await getHistoricalPriceDataForItems([]);
it('should return an empty array and not query the DB if items array is empty', async () => {
const result = await insertFlyerItems(1, [], mockPoolInstance as any);
expect(result).toEqual([]);
expect(mockPoolInstance.query).not.toHaveBeenCalled();
});
});
describe('createFlyerAndItems', () => {
it('should execute a transaction with BEGIN, INSERTs, and COMMIT', async () => {
const flyerData: FlyerInsert = { file_name: 'transact.jpg', store_name: 'Transaction Store' } as FlyerInsert;
const itemsData: FlyerItemInsert[] = [{ item: 'Transactional Item' } as FlyerItemInsert];
const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 99 });
const mockItems = [createMockFlyerItem({ ...itemsData[0], flyer_id: 99, flyer_item_id: 101 })];
// Mock the sequence of calls within the transaction
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [mockFlyer] }) // insertFlyer
.mockResolvedValueOnce({ rows: mockItems }); // insertFlyerItems
const result = await createFlyerAndItems(flyerData, itemsData);
expect(result).toEqual({ flyer: mockFlyer, items: mockItems });
// Verify transaction control
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith('COMMIT');
expect(mockPoolInstance.query).not.toHaveBeenCalledWith('ROLLBACK');
expect(mockPoolInstance.release).toHaveBeenCalled();
// Verify the individual functions were called with the client
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyers'), expect.any(Array));
expect(mockPoolInstance.query).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO flyer_items'), expect.any(Array));
});
it('should ROLLBACK the transaction if an error occurs', async () => {
const flyerData: FlyerInsert = { file_name: 'fail.jpg', store_name: 'Fail Store' } as FlyerInsert;
const itemsData: FlyerItemInsert[] = [{ item: 'Failing Item' } as FlyerItemInsert];
const dbError = new Error('DB connection lost');
// Mock insertFlyer to succeed, but insertFlyerItems to fail
mockPoolInstance.query
.mockResolvedValueOnce({ rows: [createMockFlyer()] }) // insertFlyer
.mockRejectedValueOnce(dbError); // insertFlyerItems fails
await expect(createFlyerAndItems(flyerData, itemsData)).rejects.toThrow(dbError);
// Verify transaction control
expect(mockPoolInstance.connect).toHaveBeenCalled();
expect(mockPoolInstance.query).toHaveBeenCalledWith('BEGIN');
expect(mockPoolInstance.query).toHaveBeenCalledWith('ROLLBACK');
expect(mockPoolInstance.query).not.toHaveBeenCalledWith('COMMIT');
expect(mockPoolInstance.release).toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
// src/services/db/index.db.ts
export * from './connection.db';
export * from './errors.db';
export * from './user.db';
export * from './flyer.db';
export * from './shopping.db';
export * from './personalization.db';
@@ -9,5 +8,6 @@ export * from './recipe.db';
export * from './admin.db';
export * from './notification.db';
export * from './gamification.db';
export * from './budget.db';
export * from './address.db'; // Add the new address service exports
export * from './budget.db';
export * from './address.db';
export * from './user.db';

View File

@@ -14,6 +14,21 @@ import {
Recipe,
} from '../../types';
/**
* Retrieves all master grocery items from the database.
* This is used to provide a list of known items to the AI for better matching.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
export async function getAllMasterItems(): Promise<MasterGroceryItem[]> {
try {
const res = await getPool().query<MasterGroceryItem>('SELECT * FROM public.master_grocery_items ORDER BY name ASC');
return res.rows;
} catch (error) {
logger.error('Database error in getAllMasterItems:', { error });
throw new Error('Failed to retrieve master grocery items.');
}
}
/**
* Retrieves all watched master items for a specific user.
* @param userId The UUID of the user.