Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
f3e233bf38 ci: Bump version to 0.11.17 [skip ci] 2026-01-20 10:30:14 +05:00
1696aeb54f minor fixin
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m42s
2026-01-19 21:28:44 -08:00
7 changed files with 194 additions and 175 deletions

View File

@@ -1 +1 @@
FORCE_COLOR=0 npx lint-staged FORCE_COLOR=0 npx lint-staged --quiet

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.11.16", "version": "0.11.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.11.16", "version": "0.11.17",
"dependencies": { "dependencies": {
"@bull-board/api": "^6.14.2", "@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2", "@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"private": true, "private": true,
"version": "0.11.16", "version": "0.11.17",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"", "dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -1,6 +1,6 @@
// src/features/shopping/WatchedItemsList.test.tsx // src/features/shopping/WatchedItemsList.test.tsx
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WatchedItemsList } from './WatchedItemsList'; import { WatchedItemsList } from './WatchedItemsList';
@@ -99,55 +99,6 @@ describe('WatchedItemsList (in shopping feature)', () => {
expect(screen.getByText('Bread')).toBeInTheDocument(); expect(screen.getByText('Bread')).toBeInTheDocument();
}); });
it('should allow adding a new item', async () => {
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
// Use getByDisplayValue to reliably select the category dropdown, which has no label.
// Also, use the correct category name from the CATEGORIES constant.
const categorySelect = screen.getByDisplayValue('Select a category');
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
fireEvent.submit(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => {
expect(mockOnAddItem).toHaveBeenCalledWith('Cheese', 'Dairy & Eggs');
});
// Check if form resets
expect(screen.getByPlaceholderText(/add item/i)).toHaveValue('');
});
it('should show a loading spinner while adding an item', async () => {
// Create a promise that we can resolve manually to control the loading state
let resolvePromise: (value: void | PromiseLike<void>) => void;
const mockPromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
mockOnAddItem.mockImplementation(() => mockPromise);
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
fireEvent.change(screen.getByDisplayValue('Select a category'), {
target: { value: 'Dairy & Eggs' },
});
const addButton = screen.getByRole('button', { name: 'Add' });
fireEvent.click(addButton);
// The button text is replaced by the spinner, so we use the captured reference
await waitFor(() => {
expect(addButton).toBeDisabled();
});
expect(addButton.querySelector('.animate-spin')).toBeInTheDocument();
// Resolve the promise to complete the async operation and allow the test to finish
await act(async () => {
resolvePromise();
await mockPromise;
});
});
it('should allow removing an item', async () => { it('should allow removing an item', async () => {
renderWithQueryClient(<WatchedItemsList {...defaultProps} />); renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const removeButton = screen.getByRole('button', { name: /remove apples/i }); const removeButton = screen.getByRole('button', { name: /remove apples/i });
@@ -219,55 +170,6 @@ describe('WatchedItemsList (in shopping feature)', () => {
expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument(); expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument();
}); });
describe('Form Validation and Disabled States', () => {
it('should disable the "Add" button if item name is empty or whitespace', () => {
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const categorySelect = screen.getByDisplayValue('Select a category');
const addButton = screen.getByRole('button', { name: 'Add' });
// Initially disabled
expect(addButton).toBeDisabled();
// With category but no name
fireEvent.change(categorySelect, { target: { value: 'Fruits & Vegetables' } });
expect(addButton).toBeDisabled();
// With whitespace name
fireEvent.change(nameInput, { target: { value: ' ' } });
expect(addButton).toBeDisabled();
// With valid name
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
expect(addButton).toBeEnabled();
});
it('should disable the "Add" button if category is not selected', () => {
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const addButton = screen.getByRole('button', { name: 'Add' });
// Initially disabled
expect(addButton).toBeDisabled();
// With name but no category
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
expect(addButton).toBeDisabled();
});
it('should not submit if form is submitted with invalid data', () => {
renderWithQueryClient(<WatchedItemsList {...defaultProps} />);
const nameInput = screen.getByPlaceholderText(/add item/i);
const form = nameInput.closest('form')!;
const categorySelect = screen.getByDisplayValue('Select a category');
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
fireEvent.change(nameInput, { target: { value: ' ' } });
fireEvent.submit(form);
expect(mockOnAddItem).not.toHaveBeenCalled();
});
});
describe('UI Edge Cases', () => { describe('UI Edge Cases', () => {
it('should display a specific message when a filter results in no items', () => { it('should display a specific message when a filter results in no items', () => {
const { rerender } = render(<WatchedItemsList {...defaultProps} />); const { rerender } = render(<WatchedItemsList {...defaultProps} />);

View File

@@ -60,13 +60,11 @@ describe('E2E Deals and Price Tracking Journey', () => {
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1', [userId]); await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1', [userId]);
} }
// Clean up flyer items (disable trigger to avoid issues with NULL master_item_id) // Clean up flyer items (master_item_id has ON DELETE SET NULL constraint, so no trigger disable needed)
if (createdFlyerIds.length > 0) { if (createdFlyerIds.length > 0) {
await pool.query('ALTER TABLE public.flyer_items DISABLE TRIGGER ALL');
await pool.query('DELETE FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])', [ await pool.query('DELETE FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])', [
createdFlyerIds, createdFlyerIds,
]); ]);
await pool.query('ALTER TABLE public.flyer_items ENABLE TRIGGER ALL');
} }
// Clean up flyers // Clean up flyers
@@ -147,6 +145,20 @@ describe('E2E Deals and Price Tracking Journey', () => {
const beveragesData = await beveragesResponse.json(); const beveragesData = await beveragesResponse.json();
const beveragesCategoryId = beveragesData.data.category_id; const beveragesCategoryId = beveragesData.data.category_id;
const produceResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
{ method: 'GET' },
);
const produceData = await produceResponse.json();
const produceCategoryId = produceData.data.category_id;
const meatResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
{ method: 'GET' },
);
const meatData = await meatResponse.json();
const meatCategoryId = meatData.data.category_id;
// NOTE: The watched items API now uses category_id (number) as of Phase 3. // NOTE: The watched items API now uses category_id (number) as of Phase 3.
// Category names are no longer accepted. Use the category discovery endpoints // Category names are no longer accepted. Use the category discovery endpoints
// to look up category IDs before creating watched items. // to look up category IDs before creating watched items.
@@ -199,21 +211,21 @@ describe('E2E Deals and Price Tracking Journey', () => {
createdStoreLocations.push(store2); createdStoreLocations.push(store2);
const store2Id = store2.storeId; const store2Id = store2.storeId;
// Create master grocery items // Create master grocery items with categories
const items = [ const items = [
'E2E Milk 2%', { name: 'E2E Milk 2%', category_id: dairyEggsCategoryId },
'E2E Bread White', { name: 'E2E Bread White', category_id: bakeryCategoryId },
'E2E Coffee Beans', { name: 'E2E Coffee Beans', category_id: beveragesCategoryId },
'E2E Bananas', { name: 'E2E Bananas', category_id: produceCategoryId },
'E2E Chicken Breast', { name: 'E2E Chicken Breast', category_id: meatCategoryId },
]; ];
for (const itemName of items) { for (const item of items) {
const result = await pool.query( const result = await pool.query(
`INSERT INTO public.master_grocery_items (name) `INSERT INTO public.master_grocery_items (name, category_id)
VALUES ($1) VALUES ($1, $2)
RETURNING master_grocery_item_id`, RETURNING master_grocery_item_id`,
[itemName], [item.name, item.category_id],
); );
createdMasterItemIds.push(result.rows[0].master_grocery_item_id); createdMasterItemIds.push(result.rows[0].master_grocery_item_id);
} }

View File

@@ -3,8 +3,9 @@
* Integration tests for Receipt processing workflow. * Integration tests for Receipt processing workflow.
* Tests the complete flow from receipt upload to item extraction and inventory addition. * Tests the complete flow from receipt upload to item extraction and inventory addition.
*/ */
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import path from 'path';
import type { UserProfile } from '../../types'; import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers'; import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup'; import { cleanupDb } from '../utils/cleanup';
@@ -14,50 +15,76 @@ import {
cleanupStoreLocations, cleanupStoreLocations,
type CreatedStoreLocation, type CreatedStoreLocation,
} from '../utils/storeHelpers'; } from '../utils/storeHelpers';
import { cleanupFiles } from '../utils/cleanupFiles';
/** /**
* @vitest-environment node * @vitest-environment node
*/ */
// Mock Bull Board to prevent BullMQAdapter from validating queue instances // Storage path for test files
vi.mock('@bull-board/api', () => ({ const testStoragePath =
createBullBoard: vi.fn(), process.env.STORAGE_PATH || path.resolve(__dirname, '../../../uploads/receipts');
}));
vi.mock('@bull-board/api/bullMQAdapter', () => ({
BullMQAdapter: vi.fn(),
}));
// Mock the queues to prevent actual background processing // Mock storage service to write files to disk AND return URLs (like flyer-processing)
// IMPORTANT: Must include all queue exports that are imported by workers.server.ts vi.mock('../../services/storage/storageService', () => {
vi.mock('../../services/queues.server', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports
receiptQueue: { const fsModule = require('node:fs/promises');
add: vi.fn().mockResolvedValue({ id: 'mock-job-id' }), // eslint-disable-next-line @typescript-eslint/no-require-imports
}, const pathModule = require('path');
cleanupQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-cleanup-job-id' }), return {
}, storageService: {
flyerQueue: { upload: vi
add: vi.fn().mockResolvedValue({ id: 'mock-flyer-job-id' }), .fn()
}, .mockImplementation(
emailQueue: { async (
add: vi.fn().mockResolvedValue({ id: 'mock-email-job-id' }), fileData: Buffer | string | { name?: string; path?: string },
}, fileName?: string,
analyticsQueue: { ) => {
add: vi.fn().mockResolvedValue({ id: 'mock-analytics-job-id' }), const name =
}, fileName ||
weeklyAnalyticsQueue: { (fileData && typeof fileData === 'object' && 'name' in fileData && fileData.name) ||
add: vi.fn().mockResolvedValue({ id: 'mock-weekly-analytics-job-id' }), (typeof fileData === 'string'
}, ? pathModule.basename(fileData)
tokenCleanupQueue: { : `upload-${Date.now()}.jpg`);
add: vi.fn().mockResolvedValue({ id: 'mock-token-cleanup-job-id' }),
}, // Use the STORAGE_PATH from the environment (set by global setup to temp directory)
expiryAlertQueue: { const uploadDir =
add: vi.fn().mockResolvedValue({ id: 'mock-expiry-alert-job-id' }), process.env.STORAGE_PATH || pathModule.join(process.cwd(), 'uploads', 'receipts');
}, await fsModule.mkdir(uploadDir, { recursive: true });
barcodeDetectionQueue: { const destPath = pathModule.join(uploadDir, name);
add: vi.fn().mockResolvedValue({ id: 'mock-barcode-job-id' }),
}, let content: Buffer = Buffer.from('');
})); if (Buffer.isBuffer(fileData)) {
content = Buffer.from(fileData);
} else if (typeof fileData === 'string') {
try {
content = await fsModule.readFile(fileData);
} catch {
/* ignore */
}
} else if (
fileData &&
typeof fileData === 'object' &&
'path' in fileData &&
fileData.path
) {
try {
content = await fsModule.readFile(fileData.path);
} catch {
/* ignore */
}
}
await fsModule.writeFile(destPath, content);
// Return a valid URL to satisfy the 'url_check' DB constraint
return `https://example.com/uploads/receipts/${name}`;
},
),
delete: vi.fn().mockResolvedValue(undefined),
},
};
});
describe('Receipt Processing Integration Tests (/api/receipts)', () => { describe('Receipt Processing Integration Tests (/api/receipts)', () => {
let request: ReturnType<typeof supertest>; let request: ReturnType<typeof supertest>;
@@ -67,10 +94,18 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
const createdReceiptIds: number[] = []; const createdReceiptIds: number[] = [];
const createdInventoryIds: number[] = []; const createdInventoryIds: number[] = [];
const createdStoreLocations: CreatedStoreLocation[] = []; const createdStoreLocations: CreatedStoreLocation[] = [];
const createdFilePaths: string[] = [];
const originalFrontendUrl = process.env.FRONTEND_URL;
beforeAll(async () => { beforeAll(async () => {
// Stub FRONTEND_URL to ensure valid absolute URLs
vi.stubEnv('FRONTEND_URL', 'https://example.com'); vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).default; vi.stubEnv('STORAGE_PATH', testStoragePath);
process.env.FRONTEND_URL = 'https://example.com';
const appModule = await import('../../../server');
const app = appModule.default;
request = supertest(app); request = supertest(app);
// Create a user for receipt tests // Create a user for receipt tests
@@ -84,14 +119,39 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
createdUserIds.push(user.user.user_id); createdUserIds.push(user.user.user_id);
}); });
// Reset mocks before each test to ensure isolation
beforeEach(async () => {
console.error('[TEST SETUP] Resetting mocks before test execution');
// Add any mock resets here if needed for receipt processing
});
afterAll(async () => { afterAll(async () => {
// Restore original value
process.env.FRONTEND_URL = originalFrontendUrl;
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.restoreAllMocks();
// CRITICAL: Close workers FIRST before any cleanup to ensure no pending jobs
try {
console.error('[TEST TEARDOWN] Closing in-process workers...');
const { closeWorkers } = await import('../../services/workers.server');
await closeWorkers();
// Give workers a moment to fully release resources
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (error) {
console.error('[TEST TEARDOWN] Error closing workers:', error);
}
// Close the shared redis connection used by the workers/queues
const { connection } = await import('../../services/redis.server');
await connection.quit();
const pool = getPool(); const pool = getPool();
// Clean up inventory items // Clean up inventory items
if (createdInventoryIds.length > 0) { if (createdInventoryIds.length > 0) {
await pool.query('DELETE FROM public.user_inventory WHERE inventory_id = ANY($1::int[])', [ await pool.query('DELETE FROM public.pantry_items WHERE pantry_item_id = ANY($1::int[])', [
createdInventoryIds, createdInventoryIds,
]); ]);
} }
@@ -112,9 +172,31 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
await cleanupDb({ userIds: createdUserIds }); await cleanupDb({ userIds: createdUserIds });
await cleanupStoreLocations(pool, createdStoreLocations); await cleanupStoreLocations(pool, createdStoreLocations);
// Clean up test files
await cleanupFiles(createdFilePaths);
// Final delay to let any remaining async operations settle
await new Promise((resolve) => setTimeout(resolve, 50));
}); });
describe('POST /api/receipts - Upload Receipt', () => { describe('POST /api/receipts - Upload Receipt', () => {
let testStoreLocationId: number;
beforeAll(async () => {
// Create a test store for receipt upload tests
const pool = getPool();
const store = await createStoreWithLocation(pool, {
name: `Receipt Upload Test Store - ${Date.now()}`,
address: '123 Receipt St',
city: 'Toronto',
province: 'ON',
postalCode: 'M5V 1A1',
});
createdStoreLocations.push(store);
testStoreLocationId = store.storeLocationId;
});
it('should upload a receipt image successfully', async () => { it('should upload a receipt image successfully', async () => {
// Create a simple test image buffer // Create a simple test image buffer
const testImageBuffer = Buffer.from( const testImageBuffer = Buffer.from(
@@ -126,15 +208,18 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
.post('/api/receipts') .post('/api/receipts')
.set('Authorization', `Bearer ${authToken}`) .set('Authorization', `Bearer ${authToken}`)
.attach('receipt', testImageBuffer, 'test-receipt.png') .attach('receipt', testImageBuffer, 'test-receipt.png')
.field('store_location_id', '1') .field('store_location_id', testStoreLocationId.toString())
.field('transaction_date', '2024-01-15'); .field('transaction_date', '2024-01-15');
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body.success).toBe(true); expect(response.body.success).toBe(true);
expect(response.body.data.receipt_id).toBeDefined(); expect(response.body.data.receipt_id).toBeDefined();
expect(response.body.data.job_id).toBe('mock-job-id'); expect(response.body.data.job_id).toBeDefined(); // Real queue job ID
createdReceiptIds.push(response.body.data.receipt_id); createdReceiptIds.push(response.body.data.receipt_id);
// Track the uploaded file for cleanup
createdFilePaths.push(path.join(testStoragePath, 'test-receipt.png'));
}); });
it('should upload receipt without optional fields', async () => { it('should upload receipt without optional fields', async () => {
@@ -152,6 +237,9 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
expect(response.body.data.receipt_id).toBeDefined(); expect(response.body.data.receipt_id).toBeDefined();
createdReceiptIds.push(response.body.data.receipt_id); createdReceiptIds.push(response.body.data.receipt_id);
// Track the uploaded file for cleanup
createdFilePaths.push(path.join(testStoragePath, 'test-receipt-2.png'));
}); });
it('should reject request without file', async () => { it('should reject request without file', async () => {
@@ -370,7 +458,7 @@ describe('Receipt Processing Integration Tests (/api/receipts)', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.success).toBe(true); expect(response.body.success).toBe(true);
expect(response.body.data.message).toContain('reprocessing'); expect(response.body.data.message).toContain('reprocessing');
expect(response.body.data.job_id).toBe('mock-job-id'); expect(response.body.data.job_id).toBeDefined(); // Real queue job ID
}); });
it('should return 404 for non-existent receipt', async () => { it('should return 404 for non-existent receipt', async () => {

View File

@@ -164,21 +164,38 @@ vi.mock('jsonwebtoken', () => ({
// Mock 'bcrypt'. The service uses `import * as bcrypt from 'bcrypt'`. // Mock 'bcrypt'. The service uses `import * as bcrypt from 'bcrypt'`.
vi.mock('bcrypt'); vi.mock('bcrypt');
// Mock 'crypto'. The service uses `import crypto from 'crypto'`. // Mock 'crypto'. Supports both default import and named imports.
vi.mock('crypto', () => ({ // Default: import crypto from 'crypto'; crypto.randomUUID()
default: { // Named: import { randomUUID } from 'crypto'; randomUUID()
randomBytes: vi.fn().mockReturnValue({ vi.mock('crypto', async () => {
toString: vi.fn().mockImplementation((encoding) => { const actual = await vi.importActual<typeof import('crypto')>('crypto');
const id = 'mocked_random_id'; const mockRandomUUID = vi.fn(() => actual.randomUUID());
console.log( const mockRandomBytes = vi.fn((size: number) => {
`[DEBUG] tests-setup-unit.ts: crypto.randomBytes mock returning "${id}" for encoding "${encoding}"`, const buffer = actual.randomBytes(size);
); // Add mocked toString for backward compatibility
return id; buffer.toString = vi.fn().mockImplementation((encoding) => {
}), const id = 'mocked_random_id';
}), console.log(
randomUUID: vi.fn().mockReturnValue('mocked_random_id'), `[DEBUG] tests-setup-unit.ts: crypto.randomBytes mock returning "${id}" for encoding "${encoding}"`,
}, );
})); return id;
});
return buffer;
});
return {
...actual,
// Named exports for: import { randomUUID } from 'crypto'
randomUUID: mockRandomUUID,
randomBytes: mockRandomBytes,
// Default export for: import crypto from 'crypto'
default: {
...actual,
randomUUID: mockRandomUUID,
randomBytes: mockRandomBytes,
},
};
});
// --- Global Mocks --- // --- Global Mocks ---