whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
This commit is contained in:
364
src/tests/e2e/receipt-journey.e2e.test.ts
Normal file
364
src/tests/e2e/receipt-journey.e2e.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// src/tests/e2e/receipt-journey.e2e.test.ts
|
||||
/**
|
||||
* End-to-End test for the Receipt processing user journey.
|
||||
* Tests the complete flow from user registration to uploading receipts and managing items.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import { cleanupDb } from '../utils/cleanup';
|
||||
import { poll } from '../utils/poll';
|
||||
import { getPool } from '../../services/db/connection.db';
|
||||
import FormData from 'form-data';
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
||||
|
||||
// Helper to make authenticated API calls
|
||||
const authedFetch = async (
|
||||
path: string,
|
||||
options: RequestInit & { token?: string } = {},
|
||||
): Promise<Response> => {
|
||||
const { token, ...fetchOptions } = options;
|
||||
const headers: Record<string, string> = {
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// Only add Content-Type for JSON (not for FormData)
|
||||
if (!(fetchOptions.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return fetch(`${API_BASE_URL}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
describe('E2E Receipt Processing Journey', () => {
|
||||
const uniqueId = Date.now();
|
||||
const userEmail = `receipt-e2e-${uniqueId}@example.com`;
|
||||
const userPassword = 'StrongReceiptPassword123!';
|
||||
|
||||
let authToken: string;
|
||||
let userId: string | null = null;
|
||||
const createdReceiptIds: number[] = [];
|
||||
const createdInventoryIds: number[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
const pool = getPool();
|
||||
|
||||
// Clean up inventory items
|
||||
if (createdInventoryIds.length > 0) {
|
||||
await pool.query('DELETE FROM public.user_inventory WHERE inventory_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_logs WHERE receipt_id = ANY($1::int[])',
|
||||
[createdReceiptIds],
|
||||
);
|
||||
await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::int[])', [
|
||||
createdReceiptIds,
|
||||
]);
|
||||
}
|
||||
|
||||
// Clean up user
|
||||
await cleanupDb({
|
||||
userIds: [userId],
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete receipt journey: Register -> Upload -> View -> Manage Items -> Add to Inventory', async () => {
|
||||
// Step 1: Register a new user
|
||||
const registerResponse = await apiClient.registerUser(
|
||||
userEmail,
|
||||
userPassword,
|
||||
'Receipt E2E User',
|
||||
);
|
||||
expect(registerResponse.status).toBe(201);
|
||||
|
||||
// Step 2: Login to get auth token
|
||||
const { response: loginResponse, responseBody: loginResponseBody } = await poll(
|
||||
async () => {
|
||||
const response = await apiClient.loginUser(userEmail, userPassword, false);
|
||||
const responseBody = response.ok ? await response.clone().json() : {};
|
||||
return { response, responseBody };
|
||||
},
|
||||
(result) => result.response.ok,
|
||||
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
|
||||
);
|
||||
|
||||
expect(loginResponse.status).toBe(200);
|
||||
authToken = loginResponseBody.data.token;
|
||||
userId = loginResponseBody.data.userprofile.user.user_id;
|
||||
expect(authToken).toBeDefined();
|
||||
|
||||
// Step 3: Create a receipt directly in the database (simulating a completed upload)
|
||||
// In a real E2E test with full BullMQ setup, we would upload and wait for processing
|
||||
const pool = getPool();
|
||||
const receiptResult = await pool.query(
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_name, total_amount, transaction_date)
|
||||
VALUES ($1, '/uploads/receipts/e2e-test.jpg', 'completed', 'E2E Test Store', 49.99, '2024-01-15')
|
||||
RETURNING receipt_id`,
|
||||
[userId],
|
||||
);
|
||||
const receiptId = receiptResult.rows[0].receipt_id;
|
||||
createdReceiptIds.push(receiptId);
|
||||
|
||||
// Add receipt items
|
||||
const itemsResult = await pool.query(
|
||||
`INSERT INTO public.receipt_items (receipt_id, raw_text, parsed_name, quantity, unit_price, total_price, status, added_to_inventory)
|
||||
VALUES
|
||||
($1, 'MILK 2% 4L', 'Milk 2%', 1, 5.99, 5.99, 'matched', false),
|
||||
($1, 'BREAD WHITE', 'White Bread', 2, 2.49, 4.98, 'unmatched', false),
|
||||
($1, 'EGGS LARGE 12', 'Large Eggs', 1, 4.99, 4.99, 'matched', false)
|
||||
RETURNING receipt_item_id`,
|
||||
[receiptId],
|
||||
);
|
||||
const itemIds = itemsResult.rows.map((r) => r.receipt_item_id);
|
||||
|
||||
// Step 4: View receipt list
|
||||
const listResponse = await authedFetch('/receipts', {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
const listData = await listResponse.json();
|
||||
expect(listData.success).toBe(true);
|
||||
expect(listData.data.receipts.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find our receipt
|
||||
const ourReceipt = listData.data.receipts.find(
|
||||
(r: { receipt_id: number }) => r.receipt_id === receiptId,
|
||||
);
|
||||
expect(ourReceipt).toBeDefined();
|
||||
expect(ourReceipt.store_name).toBe('E2E Test Store');
|
||||
|
||||
// Step 5: View receipt details
|
||||
const detailResponse = await authedFetch(`/receipts/${receiptId}`, {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(detailResponse.status).toBe(200);
|
||||
const detailData = await detailResponse.json();
|
||||
expect(detailData.data.receipt.receipt_id).toBe(receiptId);
|
||||
expect(detailData.data.items.length).toBe(3);
|
||||
|
||||
// Step 6: View receipt items
|
||||
const itemsResponse = await authedFetch(`/receipts/${receiptId}/items`, {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(itemsResponse.status).toBe(200);
|
||||
const itemsData = await itemsResponse.json();
|
||||
expect(itemsData.data.items.length).toBe(3);
|
||||
|
||||
// Step 7: Update an item's status
|
||||
const updateItemResponse = await authedFetch(`/receipts/${receiptId}/items/${itemIds[1]}`, {
|
||||
method: 'PUT',
|
||||
token: authToken,
|
||||
body: JSON.stringify({
|
||||
status: 'matched',
|
||||
match_confidence: 0.85,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(updateItemResponse.status).toBe(200);
|
||||
const updateItemData = await updateItemResponse.json();
|
||||
expect(updateItemData.data.status).toBe('matched');
|
||||
|
||||
// Step 8: View unadded items
|
||||
const unaddedResponse = await authedFetch(`/receipts/${receiptId}/items/unadded`, {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(unaddedResponse.status).toBe(200);
|
||||
const unaddedData = await unaddedResponse.json();
|
||||
expect(unaddedData.data.items.length).toBe(3); // None added yet
|
||||
|
||||
// Step 9: Confirm items to add to inventory
|
||||
const confirmResponse = await authedFetch(`/receipts/${receiptId}/confirm`, {
|
||||
method: 'POST',
|
||||
token: authToken,
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
receipt_item_id: itemIds[0],
|
||||
include: true,
|
||||
item_name: 'Milk 2%',
|
||||
quantity: 1,
|
||||
location: 'fridge',
|
||||
expiry_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
},
|
||||
{
|
||||
receipt_item_id: itemIds[1],
|
||||
include: true,
|
||||
item_name: 'White Bread',
|
||||
quantity: 2,
|
||||
location: 'pantry',
|
||||
},
|
||||
{
|
||||
receipt_item_id: itemIds[2],
|
||||
include: false, // Skip the eggs
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(confirmResponse.status).toBe(200);
|
||||
const confirmData = await confirmResponse.json();
|
||||
expect(confirmData.data.count).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Track inventory items for cleanup
|
||||
if (confirmData.data.added_items) {
|
||||
confirmData.data.added_items.forEach((item: { inventory_id: number }) => {
|
||||
if (item.inventory_id) {
|
||||
createdInventoryIds.push(item.inventory_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step 10: Verify items in inventory
|
||||
const inventoryResponse = await authedFetch('/inventory', {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(inventoryResponse.status).toBe(200);
|
||||
const inventoryData = await inventoryResponse.json();
|
||||
// Should have at least the items we added
|
||||
expect(inventoryData.data.items.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Step 11: Add processing logs (simulating backend activity)
|
||||
await pool.query(
|
||||
`INSERT INTO public.receipt_processing_logs (receipt_id, step, status, message)
|
||||
VALUES
|
||||
($1, 'ocr', 'completed', 'OCR completed successfully'),
|
||||
($1, 'item_extraction', 'completed', 'Extracted 3 items'),
|
||||
($1, 'matching', 'completed', 'Matched 2 items')`,
|
||||
[receiptId],
|
||||
);
|
||||
|
||||
// Step 12: View processing logs
|
||||
const logsResponse = await authedFetch(`/receipts/${receiptId}/logs`, {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(logsResponse.status).toBe(200);
|
||||
const logsData = await logsResponse.json();
|
||||
expect(logsData.data.logs.length).toBe(3);
|
||||
|
||||
// Step 13: Verify another user cannot access our receipt
|
||||
const otherUserEmail = `other-receipt-e2e-${uniqueId}@example.com`;
|
||||
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Receipt User');
|
||||
|
||||
const { responseBody: otherLoginData } = await poll(
|
||||
async () => {
|
||||
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
|
||||
const responseBody = response.ok ? await response.clone().json() : {};
|
||||
return { response, responseBody };
|
||||
},
|
||||
(result) => result.response.ok,
|
||||
{ timeout: 10000, interval: 1000, description: 'other user login' },
|
||||
);
|
||||
|
||||
const otherToken = otherLoginData.data.token;
|
||||
const otherUserId = otherLoginData.data.userprofile.user.user_id;
|
||||
|
||||
// Other user should not see our receipt
|
||||
const otherDetailResponse = await authedFetch(`/receipts/${receiptId}`, {
|
||||
method: 'GET',
|
||||
token: otherToken,
|
||||
});
|
||||
|
||||
expect(otherDetailResponse.status).toBe(404);
|
||||
|
||||
// Clean up other user
|
||||
await cleanupDb({ userIds: [otherUserId] });
|
||||
|
||||
// Step 14: Create a second receipt to test listing and filtering
|
||||
const receipt2Result = await pool.query(
|
||||
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_name, total_amount)
|
||||
VALUES ($1, '/uploads/receipts/e2e-test-2.jpg', 'failed', 'Failed Store', 25.00)
|
||||
RETURNING receipt_id`,
|
||||
[userId],
|
||||
);
|
||||
createdReceiptIds.push(receipt2Result.rows[0].receipt_id);
|
||||
|
||||
// Step 15: Test filtering by status
|
||||
const completedResponse = await authedFetch('/receipts?status=completed', {
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(completedResponse.status).toBe(200);
|
||||
const completedData = await completedResponse.json();
|
||||
completedData.data.receipts.forEach((r: { status: string }) => {
|
||||
expect(r.status).toBe('completed');
|
||||
});
|
||||
|
||||
// Step 16: Test reprocessing a failed receipt
|
||||
const reprocessResponse = await authedFetch(
|
||||
`/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: authToken,
|
||||
},
|
||||
);
|
||||
|
||||
expect(reprocessResponse.status).toBe(200);
|
||||
const reprocessData = await reprocessResponse.json();
|
||||
expect(reprocessData.data.message).toContain('reprocessing');
|
||||
|
||||
// Step 17: Delete the failed receipt
|
||||
const deleteResponse = await authedFetch(`/receipts/${receipt2Result.rows[0].receipt_id}`, {
|
||||
method: 'DELETE',
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(204);
|
||||
|
||||
// Remove from cleanup list since we deleted it
|
||||
const deleteIndex = createdReceiptIds.indexOf(receipt2Result.rows[0].receipt_id);
|
||||
if (deleteIndex > -1) {
|
||||
createdReceiptIds.splice(deleteIndex, 1);
|
||||
}
|
||||
|
||||
// Step 18: Verify deletion
|
||||
const verifyDeleteResponse = await authedFetch(
|
||||
`/receipts/${receipt2Result.rows[0].receipt_id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
token: authToken,
|
||||
},
|
||||
);
|
||||
|
||||
expect(verifyDeleteResponse.status).toBe(404);
|
||||
|
||||
// Step 19: Delete account
|
||||
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
|
||||
tokenOverride: authToken,
|
||||
});
|
||||
|
||||
expect(deleteAccountResponse.status).toBe(200);
|
||||
userId = null;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user