Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e233bf38 | ||
| 1696aeb54f |
@@ -1 +1 @@
|
|||||||
FORCE_COLOR=0 npx lint-staged
|
FORCE_COLOR=0 npx lint-staged --quiet
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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\"",
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user