373 lines
12 KiB
TypeScript
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;
|
|
});
|
|
});
|