All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m42s
733 lines
26 KiB
TypeScript
733 lines
26 KiB
TypeScript
// src/tests/integration/receipt.integration.test.ts
|
|
/**
|
|
* Integration tests for Receipt processing workflow.
|
|
* Tests the complete flow from receipt upload to item extraction and inventory addition.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import path from 'path';
|
|
import type { UserProfile } from '../../types';
|
|
import { createAndLoginUser } from '../utils/testHelpers';
|
|
import { cleanupDb } from '../utils/cleanup';
|
|
import { getPool } from '../../services/db/connection.db';
|
|
import {
|
|
createStoreWithLocation,
|
|
cleanupStoreLocations,
|
|
type CreatedStoreLocation,
|
|
} from '../utils/storeHelpers';
|
|
import { cleanupFiles } from '../utils/cleanupFiles';
|
|
|
|
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
// Storage path for test files
|
|
const testStoragePath =
|
|
process.env.STORAGE_PATH || path.resolve(__dirname, '../../../uploads/receipts');
|
|
|
|
// Mock storage service to write files to disk AND return URLs (like flyer-processing)
|
|
vi.mock('../../services/storage/storageService', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const fsModule = require('node:fs/promises');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const pathModule = require('path');
|
|
|
|
return {
|
|
storageService: {
|
|
upload: vi
|
|
.fn()
|
|
.mockImplementation(
|
|
async (
|
|
fileData: Buffer | string | { name?: string; path?: string },
|
|
fileName?: string,
|
|
) => {
|
|
const name =
|
|
fileName ||
|
|
(fileData && typeof fileData === 'object' && 'name' in fileData && fileData.name) ||
|
|
(typeof fileData === 'string'
|
|
? pathModule.basename(fileData)
|
|
: `upload-${Date.now()}.jpg`);
|
|
|
|
// Use the STORAGE_PATH from the environment (set by global setup to temp directory)
|
|
const uploadDir =
|
|
process.env.STORAGE_PATH || pathModule.join(process.cwd(), 'uploads', 'receipts');
|
|
await fsModule.mkdir(uploadDir, { recursive: true });
|
|
const destPath = pathModule.join(uploadDir, name);
|
|
|
|
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)', () => {
|
|
let request: ReturnType<typeof supertest>;
|
|
let authToken = '';
|
|
let testUser: UserProfile;
|
|
const createdUserIds: string[] = [];
|
|
const createdReceiptIds: number[] = [];
|
|
const createdInventoryIds: number[] = [];
|
|
const createdStoreLocations: CreatedStoreLocation[] = [];
|
|
const createdFilePaths: string[] = [];
|
|
|
|
const originalFrontendUrl = process.env.FRONTEND_URL;
|
|
|
|
beforeAll(async () => {
|
|
// Stub FRONTEND_URL to ensure valid absolute URLs
|
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
|
vi.stubEnv('STORAGE_PATH', testStoragePath);
|
|
process.env.FRONTEND_URL = 'https://example.com';
|
|
|
|
const appModule = await import('../../../server');
|
|
const app = appModule.default;
|
|
request = supertest(app);
|
|
|
|
// Create a user for receipt tests
|
|
const { user, token } = await createAndLoginUser({
|
|
email: `receipt-test-user-${Date.now()}@example.com`,
|
|
fullName: 'Receipt Test User',
|
|
request,
|
|
});
|
|
testUser = user;
|
|
authToken = token;
|
|
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 () => {
|
|
// Restore original value
|
|
process.env.FRONTEND_URL = originalFrontendUrl;
|
|
|
|
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();
|
|
|
|
// Clean up inventory items
|
|
if (createdInventoryIds.length > 0) {
|
|
await pool.query('DELETE FROM public.pantry_items WHERE pantry_item_id = ANY($1::int[])', [
|
|
createdInventoryIds,
|
|
]);
|
|
}
|
|
|
|
// Clean up receipt items and receipts
|
|
if (createdReceiptIds.length > 0) {
|
|
await pool.query('DELETE FROM public.receipt_items WHERE receipt_id = ANY($1::int[])', [
|
|
createdReceiptIds,
|
|
]);
|
|
await pool.query(
|
|
'DELETE FROM public.receipt_processing_log WHERE receipt_id = ANY($1::int[])',
|
|
[createdReceiptIds],
|
|
);
|
|
await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::int[])', [
|
|
createdReceiptIds,
|
|
]);
|
|
}
|
|
|
|
await cleanupDb({ userIds: createdUserIds });
|
|
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', () => {
|
|
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 () => {
|
|
// Create a simple test image buffer
|
|
const testImageBuffer = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
|
|
const response = await request
|
|
.post('/api/receipts')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.attach('receipt', testImageBuffer, 'test-receipt.png')
|
|
.field('store_location_id', testStoreLocationId.toString())
|
|
.field('transaction_date', '2024-01-15');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.receipt_id).toBeDefined();
|
|
expect(response.body.data.job_id).toBeDefined(); // Real queue job 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 () => {
|
|
const testImageBuffer = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
|
|
const response = await request
|
|
.post('/api/receipts')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.attach('receipt', testImageBuffer, 'test-receipt-2.png');
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.data.receipt_id).toBeDefined();
|
|
|
|
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 () => {
|
|
const response = await request
|
|
.post('/api/receipts')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should reject unauthenticated requests', async () => {
|
|
const testImageBuffer = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
|
|
const response = await request
|
|
.post('/api/receipts')
|
|
.attach('receipt', testImageBuffer, 'test-receipt.png');
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/receipts - List Receipts', () => {
|
|
beforeAll(async () => {
|
|
// Create some receipts for testing
|
|
const pool = getPool();
|
|
for (let i = 0; i < 3; i++) {
|
|
const result = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING receipt_id`,
|
|
[
|
|
testUser.user.user_id,
|
|
`/uploads/receipts/test-${i}.jpg`,
|
|
i === 0 ? 'completed' : 'pending',
|
|
],
|
|
);
|
|
createdReceiptIds.push(result.rows[0].receipt_id);
|
|
}
|
|
});
|
|
|
|
it('should return paginated list of receipts', async () => {
|
|
const response = await request
|
|
.get('/api/receipts')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.receipts).toBeDefined();
|
|
expect(Array.isArray(response.body.data.receipts)).toBe(true);
|
|
expect(response.body.data.total).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('should support status filter', async () => {
|
|
const response = await request
|
|
.get('/api/receipts')
|
|
.query({ status: 'completed' })
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
response.body.data.receipts.forEach((receipt: { status: string }) => {
|
|
expect(receipt.status).toBe('completed');
|
|
});
|
|
});
|
|
|
|
it('should support pagination', async () => {
|
|
const response = await request
|
|
.get('/api/receipts')
|
|
.query({ limit: 2, offset: 0 })
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.receipts.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it('should only return receipts for the authenticated user', async () => {
|
|
// Create another user
|
|
const { user: otherUser, token: otherToken } = await createAndLoginUser({
|
|
email: `other-receipt-user-${Date.now()}@example.com`,
|
|
fullName: 'Other Receipt User',
|
|
request,
|
|
});
|
|
createdUserIds.push(otherUser.user.user_id);
|
|
|
|
const response = await request
|
|
.get('/api/receipts')
|
|
.set('Authorization', `Bearer ${otherToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
// Other user should have no receipts
|
|
expect(response.body.data.total).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/receipts/:receiptId - Get Receipt Details', () => {
|
|
let testReceiptId: number;
|
|
|
|
beforeAll(async () => {
|
|
const pool = getPool();
|
|
|
|
// First create or get a test store
|
|
const store = await createStoreWithLocation(pool, {
|
|
name: `Receipt Test Store - ${Date.now()}`,
|
|
address: '999 Receipt St',
|
|
city: 'Toronto',
|
|
province: 'ON',
|
|
postalCode: 'M5V 4A4',
|
|
});
|
|
createdStoreLocations.push(store);
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_location_id, total_amount_cents)
|
|
VALUES ($1, $2, 'completed', $3, 9999)
|
|
RETURNING receipt_id`,
|
|
[testUser.user.user_id, '/uploads/receipts/detail-test.jpg', store.storeLocationId],
|
|
);
|
|
testReceiptId = result.rows[0].receipt_id;
|
|
createdReceiptIds.push(testReceiptId);
|
|
|
|
// Add some items to the receipt
|
|
await pool.query(
|
|
`INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status)
|
|
VALUES ($1, 'MILK 2% 4L', 1, 599, 'matched'),
|
|
($1, 'BREAD WHITE', 2, 498, 'unmatched')`,
|
|
[testReceiptId],
|
|
);
|
|
});
|
|
|
|
it('should return receipt with items', async () => {
|
|
const response = await request
|
|
.get(`/api/receipts/${testReceiptId}`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.receipt).toBeDefined();
|
|
expect(response.body.data.receipt.receipt_id).toBe(testReceiptId);
|
|
expect(response.body.data.receipt.store_location_id).toBeDefined();
|
|
expect(response.body.data.items).toBeDefined();
|
|
expect(response.body.data.items.length).toBe(2);
|
|
});
|
|
|
|
it('should return 404 for non-existent receipt', async () => {
|
|
const response = await request
|
|
.get('/api/receipts/999999')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("should not allow accessing another user's receipt", async () => {
|
|
const { user: otherUser, token: otherToken } = await createAndLoginUser({
|
|
email: `receipt-access-test-${Date.now()}@example.com`,
|
|
fullName: 'Receipt Access Test',
|
|
request,
|
|
});
|
|
createdUserIds.push(otherUser.user.user_id);
|
|
|
|
const response = await request
|
|
.get(`/api/receipts/${testReceiptId}`)
|
|
.set('Authorization', `Bearer ${otherToken}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/receipts/:receiptId - Delete Receipt', () => {
|
|
it('should delete a receipt', async () => {
|
|
// Create a receipt to delete
|
|
const pool = getPool();
|
|
const result = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
|
VALUES ($1, '/uploads/receipts/delete-test.jpg', 'pending')
|
|
RETURNING receipt_id`,
|
|
[testUser.user.user_id],
|
|
);
|
|
const receiptId = result.rows[0].receipt_id;
|
|
|
|
const response = await request
|
|
.delete(`/api/receipts/${receiptId}`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(204);
|
|
|
|
// Verify deletion
|
|
const verifyResponse = await request
|
|
.get(`/api/receipts/${receiptId}`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(verifyResponse.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/receipts/:receiptId/reprocess - Reprocess Receipt', () => {
|
|
let failedReceiptId: number;
|
|
|
|
beforeAll(async () => {
|
|
const pool = getPool();
|
|
const result = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status, error_details)
|
|
VALUES ($1, '/uploads/receipts/failed-test.jpg', 'failed', '{"message": "OCR failed"}'::jsonb)
|
|
RETURNING receipt_id`,
|
|
[testUser.user.user_id],
|
|
);
|
|
failedReceiptId = result.rows[0].receipt_id;
|
|
createdReceiptIds.push(failedReceiptId);
|
|
});
|
|
|
|
it('should queue a failed receipt for reprocessing', async () => {
|
|
const response = await request
|
|
.post(`/api/receipts/${failedReceiptId}/reprocess`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.message).toContain('reprocessing');
|
|
expect(response.body.data.job_id).toBeDefined(); // Real queue job ID
|
|
});
|
|
|
|
it('should return 404 for non-existent receipt', async () => {
|
|
const response = await request
|
|
.post('/api/receipts/999999/reprocess')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('Receipt Items Management', () => {
|
|
let receiptWithItemsId: number;
|
|
let testItemId: number;
|
|
|
|
beforeAll(async () => {
|
|
const pool = getPool();
|
|
const receiptResult = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
|
VALUES ($1, '/uploads/receipts/items-test.jpg', 'completed')
|
|
RETURNING receipt_id`,
|
|
[testUser.user.user_id],
|
|
);
|
|
receiptWithItemsId = receiptResult.rows[0].receipt_id;
|
|
createdReceiptIds.push(receiptWithItemsId);
|
|
|
|
const itemResult = await pool.query(
|
|
`INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status)
|
|
VALUES ($1, 'EGGS LARGE 12CT', 1, 499, 'unmatched')
|
|
RETURNING receipt_item_id`,
|
|
[receiptWithItemsId],
|
|
);
|
|
testItemId = itemResult.rows[0].receipt_item_id;
|
|
});
|
|
|
|
describe('GET /api/receipts/:receiptId/items', () => {
|
|
it('should return all receipt items', async () => {
|
|
const response = await request
|
|
.get(`/api/receipts/${receiptWithItemsId}/items`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.items).toBeDefined();
|
|
expect(response.body.data.items.length).toBeGreaterThanOrEqual(1);
|
|
expect(response.body.data.total).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/receipts/:receiptId/items/:itemId', () => {
|
|
it('should update item status', async () => {
|
|
const response = await request
|
|
.put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ status: 'matched', match_confidence: 0.95 });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.status).toBe('matched');
|
|
});
|
|
|
|
it('should reject invalid status', async () => {
|
|
const response = await request
|
|
.put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ status: 'invalid_status' });
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/receipts/:receiptId/items/unadded', () => {
|
|
it('should return unadded items', async () => {
|
|
const response = await request
|
|
.get(`/api/receipts/${receiptWithItemsId}/items/unadded`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.items).toBeDefined();
|
|
expect(Array.isArray(response.body.data.items)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /api/receipts/:receiptId/confirm - Confirm Items to Inventory', () => {
|
|
let receiptForConfirmId: number;
|
|
let itemToConfirmId: number;
|
|
|
|
beforeAll(async () => {
|
|
const pool = getPool();
|
|
const receiptResult = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
|
VALUES ($1, '/uploads/receipts/confirm-test.jpg', 'completed')
|
|
RETURNING receipt_id`,
|
|
[testUser.user.user_id],
|
|
);
|
|
receiptForConfirmId = receiptResult.rows[0].receipt_id;
|
|
createdReceiptIds.push(receiptForConfirmId);
|
|
|
|
const itemResult = await pool.query(
|
|
`INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status, added_to_pantry)
|
|
VALUES ($1, 'YOGURT GREEK', 2, 798, 'matched', false)
|
|
RETURNING receipt_item_id`,
|
|
[receiptForConfirmId],
|
|
);
|
|
itemToConfirmId = itemResult.rows[0].receipt_item_id;
|
|
});
|
|
|
|
it('should confirm items and add to inventory', async () => {
|
|
const response = await request
|
|
.post(`/api/receipts/${receiptForConfirmId}/confirm`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
items: [
|
|
{
|
|
receipt_item_id: itemToConfirmId,
|
|
include: true,
|
|
item_name: 'Greek Yogurt',
|
|
quantity: 2,
|
|
location: 'fridge',
|
|
expiry_date: '2024-02-15',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.added_items).toBeDefined();
|
|
expect(response.body.data.count).toBeGreaterThanOrEqual(0);
|
|
|
|
// Track created inventory items for cleanup
|
|
if (response.body.data.added_items) {
|
|
response.body.data.added_items.forEach((item: { inventory_id: number }) => {
|
|
if (item.inventory_id) {
|
|
createdInventoryIds.push(item.inventory_id);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
it('should skip items with include: false', async () => {
|
|
const pool = getPool();
|
|
const itemResult = await pool.query(
|
|
`INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status, added_to_pantry)
|
|
VALUES ($1, 'CHIPS BBQ', 1, 499, 'matched', false)
|
|
RETURNING receipt_item_id`,
|
|
[receiptForConfirmId],
|
|
);
|
|
const skipItemId = itemResult.rows[0].receipt_item_id;
|
|
|
|
const response = await request
|
|
.post(`/api/receipts/${receiptForConfirmId}/confirm`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
items: [
|
|
{
|
|
receipt_item_id: skipItemId,
|
|
include: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
// No items should be added when all are excluded
|
|
});
|
|
|
|
it('should reject invalid location', async () => {
|
|
const response = await request
|
|
.post(`/api/receipts/${receiptForConfirmId}/confirm`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
items: [
|
|
{
|
|
receipt_item_id: itemToConfirmId,
|
|
include: true,
|
|
location: 'invalid_location',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/receipts/:receiptId/logs - Processing Logs', () => {
|
|
let receiptWithLogsId: number;
|
|
|
|
beforeAll(async () => {
|
|
const pool = getPool();
|
|
const receiptResult = await pool.query(
|
|
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
|
|
VALUES ($1, '/uploads/receipts/logs-test.jpg', 'completed')
|
|
RETURNING receipt_id`,
|
|
[testUser.user.user_id],
|
|
);
|
|
receiptWithLogsId = receiptResult.rows[0].receipt_id;
|
|
createdReceiptIds.push(receiptWithLogsId);
|
|
|
|
// Add processing logs - using correct table name and column names
|
|
// processing_step must be one of: upload, ocr_extraction, text_parsing, store_detection,
|
|
// item_extraction, item_matching, price_parsing, finalization
|
|
await pool.query(
|
|
`INSERT INTO public.receipt_processing_log (receipt_id, processing_step, status, error_message)
|
|
VALUES ($1, 'ocr_extraction', 'completed', 'OCR completed successfully'),
|
|
($1, 'item_extraction', 'completed', 'Extracted 5 items'),
|
|
($1, 'item_matching', 'completed', 'Matched 3 items')`,
|
|
[receiptWithLogsId],
|
|
);
|
|
});
|
|
|
|
it('should return processing logs', async () => {
|
|
const response = await request
|
|
.get(`/api/receipts/${receiptWithLogsId}/logs`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.logs).toBeDefined();
|
|
expect(response.body.data.logs.length).toBe(3);
|
|
expect(response.body.data.total).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('Complete Receipt Workflow', () => {
|
|
it('should handle full upload-process-confirm workflow', async () => {
|
|
// Note: Full workflow with actual processing would require BullMQ worker
|
|
// This test verifies the API contract works correctly
|
|
|
|
// Step 1: Upload receipt
|
|
const testImageBuffer = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
|
|
const uploadResponse = await request
|
|
.post('/api/receipts')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.attach('receipt', testImageBuffer, 'workflow-test.png')
|
|
.field('transaction_date', '2024-01-20');
|
|
|
|
expect(uploadResponse.status).toBe(201);
|
|
const receiptId = uploadResponse.body.data.receipt_id;
|
|
createdReceiptIds.push(receiptId);
|
|
|
|
// Step 2: Verify receipt was created
|
|
const getResponse = await request
|
|
.get(`/api/receipts/${receiptId}`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(getResponse.status).toBe(200);
|
|
expect(getResponse.body.data.receipt.receipt_id).toBe(receiptId);
|
|
|
|
// Step 3: Check it appears in list
|
|
const listResponse = await request
|
|
.get('/api/receipts')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(listResponse.status).toBe(200);
|
|
const found = listResponse.body.data.receipts.find(
|
|
(r: { receipt_id: number }) => r.receipt_id === receiptId,
|
|
);
|
|
expect(found).toBeDefined();
|
|
|
|
// Step 4: Verify logs endpoint works (empty for new receipt)
|
|
const logsResponse = await request
|
|
.get(`/api/receipts/${receiptId}/logs`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(logsResponse.status).toBe(200);
|
|
expect(Array.isArray(logsResponse.body.data.logs)).toBe(true);
|
|
});
|
|
});
|
|
});
|