Files
flyer-crawler.projectium.com/src/tests/e2e/receipt-journey.e2e.test.ts

373 lines
12 KiB
TypeScript

// 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 {
createStoreWithLocation,
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
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[] = [];
const createdStoreLocations: CreatedStoreLocation[] = [];
afterAll(async () => {
const pool = getPool();
// Clean up inventory items (pantry_items table)
if (createdInventoryIds.length > 0) {
await pool.query('DELETE FROM public.pantry_items WHERE pantry_item_id = ANY($1::bigint[])', [
createdInventoryIds,
]);
}
// Clean up receipt items and receipts
if (createdReceiptIds.length > 0) {
await pool.query('DELETE FROM public.receipt_items WHERE receipt_id = ANY($1::bigint[])', [
createdReceiptIds,
]);
await pool.query(
'DELETE FROM public.receipt_processing_log WHERE receipt_id = ANY($1::bigint[])',
[createdReceiptIds],
);
await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::bigint[])', [
createdReceiptIds,
]);
}
// Clean up stores and their locations
await cleanupStoreLocations(pool, createdStoreLocations);
// 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
// Note: receipts table uses store_id (FK to stores) and total_amount_cents (integer cents)
const pool = getPool();
// Create a test store with location
const store = await createStoreWithLocation(pool, {
name: `E2E Receipt Test Store ${uniqueId}`,
address: '456 Receipt Blvd',
city: 'Vancouver',
province: 'BC',
postalCode: 'V6B 1A1',
});
createdStoreLocations.push(store);
const storeId = store.storeId;
const receiptResult = await pool.query(
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents, transaction_date)
VALUES ($1, '/uploads/receipts/e2e-test.jpg', 'completed', $2, 4999, '2024-01-15')
RETURNING receipt_id`,
[userId, storeId],
);
const receiptId = receiptResult.rows[0].receipt_id;
createdReceiptIds.push(receiptId);
// Add receipt items
// receipt_items uses: raw_item_description, quantity, price_paid_cents, status
const itemsResult = 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'),
($1, 'EGGS LARGE 12', 1, 499, 'matched')
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_id).toBe(storeId);
// 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-12: Processing logs tests skipped - receipt_processing_logs table not implemented
// TODO: Add these steps back when the receipt_processing_logs table is added to the schema
// See: The route /receipts/:receiptId/logs exists but the backing table does not
// 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
// Use the same store_id we created earlier, and use total_amount_cents (integer cents)
const receipt2Result = await pool.query(
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents)
VALUES ($1, '/uploads/receipts/e2e-test-2.jpg', 'failed', $2, 2500)
RETURNING receipt_id`,
[userId, storeId],
);
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;
});
});