diff --git a/src/services/db/flyer.db.test.ts b/src/services/db/flyer.db.test.ts index dd7af855..0bd47989 100644 --- a/src/services/db/flyer.db.test.ts +++ b/src/services/db/flyer.db.test.ts @@ -360,6 +360,58 @@ describe('Flyer DB Service', () => { 'Database error in insertFlyerItems', ); }); + + it('should sanitize empty or whitespace-only price_display to null', async () => { + const itemsData: FlyerItemInsert[] = [ + { + item: 'Free Item', + price_display: '', // Empty string + price_in_cents: 0, + quantity: '1', + category_name: 'Promo', + view_count: 0, + click_count: 0, + }, + { + item: 'Whitespace Item', + price_display: ' ', // Whitespace only + price_in_cents: null, + quantity: '1', + category_name: 'Promo', + 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 }); + + await flyerRepo.insertFlyerItems(1, itemsData, mockLogger); + + expect(mockPoolInstance.query).toHaveBeenCalledTimes(1); + + // Check that the values array passed to the query has null for price_display + const queryValues = mockPoolInstance.query.mock.calls[0][1]; + expect(queryValues).toEqual([ + 1, // flyerId for item 1 + 'Free Item', + null, // Sanitized price_display for item 1 + 0, + '1', + 'Promo', + 0, + 0, + 1, // flyerId for item 2 + 'Whitespace Item', + null, // Sanitized price_display for item 2 + null, + '1', + 'Promo', + 0, + 0, + ]); + }); }); describe('createFlyerAndItems', () => { @@ -433,6 +485,34 @@ describe('Flyer DB Service', () => { ); }); + it('should create a flyer with no items if items array is empty', async () => { + const flyerData: FlyerInsert = { + file_name: 'empty.jpg', + store_name: 'Empty Store', + } as FlyerInsert; + const itemsData: FlyerItemInsert[] = []; + const mockFlyer = createMockFlyer({ ...flyerData, flyer_id: 100, store_id: 2 }); + + const mockClient = { query: vi.fn() }; + mockClient.query + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // findOrCreateStore (insert) + .mockResolvedValueOnce({ rows: [{ store_id: 2 }] }) // findOrCreateStore (select) + .mockResolvedValueOnce({ rows: [mockFlyer] }); // insertFlyer + + const result = await createFlyerAndItems( + flyerData, + itemsData, + mockLogger, + mockClient as unknown as PoolClient, + ); + + expect(result).toEqual({ + flyer: mockFlyer, + items: [], + }); + expect(mockClient.query).toHaveBeenCalledTimes(3); + }); + it('should propagate an error if any step fails', async () => { const flyerData: FlyerInsert = { file_name: 'fail.jpg', diff --git a/src/services/db/flyer.db.ts b/src/services/db/flyer.db.ts index e544fbb8..723c2586 100644 --- a/src/services/db/flyer.db.ts +++ b/src/services/db/flyer.db.ts @@ -141,9 +141,12 @@ export class FlyerRepository { `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`, ); - // FIX: Sanitize price_display. Convert empty string to null. + // Sanitize price_display. The database requires a non-empty string. + // We provide a default value if the input is null, undefined, or an empty string. const priceDisplay = - item.price_display && item.price_display.trim() !== '' ? item.price_display : null; + item.price_display && item.price_display.trim() !== '' + ? item.price_display + : 'N/A'; values.push( flyerId, diff --git a/src/tests/integration/flyer-processing.integration.test.ts b/src/tests/integration/flyer-processing.integration.test.ts index 5ae81f23..cba6f118 100644 --- a/src/tests/integration/flyer-processing.integration.test.ts +++ b/src/tests/integration/flyer-processing.integration.test.ts @@ -113,6 +113,8 @@ describe('Flyer Processing Background Job Integration Test', () => { const runBackgroundProcessingTest = async (user?: UserProfile, token?: string) => { console.log(`[TEST START] runBackgroundProcessingTest. User: ${user?.user.email ?? 'ANONYMOUS'}`); // Arrange: Load a mock flyer PDF. + console.log('[TEST] about to read test-flyer-image.jpg') + const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); // Create a unique buffer and filename for each test run to ensure a unique checksum. @@ -121,11 +123,13 @@ describe('Flyer Processing Background Job Integration Test', () => { const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`; const mockImageFile = new File([new Uint8Array(uniqueContent)], uniqueFileName, { type: 'image/jpeg' }); const checksum = await generateFileChecksum(mockImageFile); + console.log('[TEST] mockImageFile created with uniqueFileName: ', uniqueFileName) console.log('[TEST DATA] Generated checksum for test:', checksum); // Track created files for cleanup const uploadDir = path.resolve(__dirname, '../../../flyer-images'); createdFilePaths.push(path.join(uploadDir, uniqueFileName)); + console.log('[TEST] createdFilesPaths after 1st push: ', createdFilePaths) // The icon name is derived from the original filename. const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`; createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); @@ -299,6 +303,10 @@ describe('Flyer Processing Background Job Integration Test', () => { const parser = exifParser.create(savedImageBuffer); const exifResult = parser.parse(); + console.log('[TEST] savedImagePath during EXIF data stripping: ', savedImagePath) + console.log('[TEST] exifResult.tags: ', exifResult.tags) + + // The `tags` object will be empty if no EXIF data is found. expect(exifResult.tags).toEqual({}); expect(exifResult.tags.Software).toBeUndefined(); @@ -380,6 +388,9 @@ describe('Flyer Processing Background Job Integration Test', () => { const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); createdFilePaths.push(savedImagePath); // Add final path for cleanup + console.log('[TEST] savedImagePath during PNG metadata stripping: ', savedImagePath) + + const savedImageMetadata = await sharp(savedImagePath).metadata(); // The test should fail here initially because PNGs are not processed. @@ -387,6 +398,7 @@ describe('Flyer Processing Background Job Integration Test', () => { expect(savedImageMetadata.exif).toBeUndefined(); }, 240000, + ); it( @@ -544,6 +556,7 @@ it( await expect(fs.access(tempFilePath), 'Expected temporary file to exist after job failure, but it was deleted.'); }, 240000, + ); }); diff --git a/src/tests/integration/gamification.integration.test.ts b/src/tests/integration/gamification.integration.test.ts index e000a933..f7a2321c 100644 --- a/src/tests/integration/gamification.integration.test.ts +++ b/src/tests/integration/gamification.integration.test.ts @@ -216,7 +216,7 @@ describe('Gamification Flow Integration Test', () => { checksum: checksum, extractedData: { store_name: storeName, - items: [{ item: 'Legacy Milk', price_in_cents: 250 }], + items: [{ item: 'Legacy Milk', price_in_cents: 250, price_display: '$2.50' }], }, }; diff --git a/src/tests/integration/public.routes.integration.test.ts b/src/tests/integration/public.routes.integration.test.ts index c533fdc9..189be943 100644 --- a/src/tests/integration/public.routes.integration.test.ts +++ b/src/tests/integration/public.routes.integration.test.ts @@ -227,24 +227,26 @@ describe('Public API Routes Integration Tests', () => { describe('Rate Limiting on Public Routes', () => { it('should block requests to /api/personalization/master-items after exceeding the limit', async () => { - const limit = 5; // Assume configured limit is 5 for testing + // The limit might be higher than 5. We loop enough times to ensure we hit the rate limit. + const maxRequests = 30; + let blockedResponse: any; - // Send requests up to the limit - for (let i = 0; i < limit; i++) { - await request + for (let i = 0; i < maxRequests; i++) { + const response = await request .get('/api/personalization/master-items') - .set('X-Test-Rate-Limit-Enable', 'true') // Enable rate limiter middleware - .expect(200); + .set('X-Test-Rate-Limit-Enable', 'true'); // Enable rate limiter middleware + + if (response.status === 429) { + blockedResponse = response; + break; + } + expect(response.status).toBe(200); } - // Exceed the limit - const response = await request - .get('/api/personalization/master-items') - .set('X-Test-Rate-Limit-Enable', 'true') // Enable rate limiter middleware - .expect(429); - - expect(response.headers).toHaveProperty('x-ratelimit-limit'); - expect(response.headers).toHaveProperty('x-ratelimit-remaining'); + expect(blockedResponse).toBeDefined(); + expect(blockedResponse.status).toBe(429); + expect(blockedResponse.headers).toHaveProperty('x-ratelimit-limit'); + expect(blockedResponse.headers).toHaveProperty('x-ratelimit-remaining'); }); }); });