Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions
10cdd78e22 ci: Bump version to 0.12.4 [skip ci] 2026-01-22 00:47:30 +05:00
521943bec0 make e2e actually e2e - sigh
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m55s
2026-01-21 11:43:39 -08:00
14 changed files with 708 additions and 857 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.12.3",
"version": "0.12.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.12.3",
"version": "0.12.4",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.12.3",
"version": "0.12.4",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -1,14 +1,18 @@
// src/tests/e2e/admin-authorization.e2e.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { createAndLoginUser } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
import { app } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('Admin Route Authorization', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
let regularUser: UserProfile;
let regularUserAuthToken: string;
@@ -17,6 +21,7 @@ describe('Admin Route Authorization', () => {
const { user, token } = await createAndLoginUser({
email: `e2e-authz-user-${Date.now()}@example.com`,
fullName: 'E2E AuthZ User',
request: getRequest(),
});
regularUser = user;
regularUserAuthToken = token;
@@ -34,47 +39,41 @@ describe('Admin Route Authorization', () => {
{
method: 'GET',
path: '/admin/stats',
action: (token: string) => apiClient.getApplicationStats(token),
},
{
method: 'GET',
path: '/admin/users',
action: (token: string) => apiClient.authedGet('/admin/users', { tokenOverride: token }),
},
{
method: 'GET',
path: '/admin/corrections',
action: (token: string) => apiClient.getSuggestedCorrections(token),
},
{
method: 'POST',
path: '/admin/corrections/1/approve',
action: (token: string) => apiClient.approveCorrection(1, token),
},
{
method: 'POST',
path: '/admin/trigger/daily-deal-check',
action: (token: string) =>
apiClient.authedPostEmpty('/admin/trigger/daily-deal-check', { tokenOverride: token }),
},
{
method: 'GET',
path: '/admin/queues/status',
action: (token: string) =>
apiClient.authedGet('/admin/queues/status', { tokenOverride: token }),
},
];
it.each(adminEndpoints)(
'should return 403 Forbidden for a regular user trying to access $method $path',
async ({ action }) => {
async ({ method, path }) => {
// Act: Attempt to access the admin endpoint with the regular user's token
const response = await action(regularUserAuthToken);
const requestBuilder = method === 'GET' ? getRequest().get(path) : getRequest().post(path);
const response = await requestBuilder
.set('Authorization', `Bearer ${regularUserAuthToken}`)
.send();
// Assert: The request should be forbidden
expect(response.status).toBe(403);
const responseBody = await response.json();
expect(responseBody.error.message).toBe('Forbidden: Administrator access required.');
expect(response.body.error.message).toBe('Forbidden: Administrator access required.');
},
);
});

View File

@@ -1,14 +1,18 @@
// src/tests/e2e/admin-dashboard.e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { getPool } from '../../services/db/connection.db';
import { cleanupDb } from '../utils/cleanup';
import { app } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E Admin Dashboard Flow', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
// Use a unique email for every run to avoid collisions
const uniqueId = Date.now();
const adminEmail = `e2e-admin-${uniqueId}@example.com`;
@@ -26,15 +30,12 @@ describe('E2E Admin Dashboard Flow', () => {
it('should allow an admin to log in and access dashboard features', async () => {
// 1. Register a new user (initially a regular user)
const registerResponse = await apiClient.registerUser(
adminEmail,
adminPassword,
'E2E Admin User',
);
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email: adminEmail, password: adminPassword, full_name: 'E2E Admin User' });
expect(registerResponse.status).toBe(201);
const registerResponseBody = await registerResponse.json();
const registeredUser = registerResponseBody.data.userprofile.user;
const registeredUser = registerResponse.body.data.userprofile.user;
adminUserId = registeredUser.user_id;
expect(adminUserId).toBeDefined();
@@ -49,49 +50,47 @@ describe('E2E Admin Dashboard Flow', () => {
// and to provide a buffer for any rate limits from previous tests.
await new Promise((resolve) => setTimeout(resolve, 2000));
const loginResponse = await apiClient.loginUser(adminEmail, adminPassword, false);
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Failed to log in as admin: ${loginResponse.status} ${errorText}`);
}
const loginResponseBody = await loginResponse.json();
const loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: adminEmail, password: adminPassword, rememberMe: false });
expect(loginResponse.status).toBe(200);
authToken = loginResponseBody.data.token;
authToken = loginResponse.body.data.token;
expect(authToken).toBeDefined();
// Verify the role returned in the login response is now 'admin'
expect(loginResponseBody.data.userprofile.role).toBe('admin');
expect(loginResponse.body.data.userprofile.role).toBe('admin');
// 4. Fetch System Stats (Protected Admin Route)
const statsResponse = await apiClient.getApplicationStats(authToken);
const statsResponse = await getRequest()
.get('/admin/stats')
.set('Authorization', `Bearer ${authToken}`);
expect(statsResponse.status).toBe(200);
const statsResponseBody = await statsResponse.json();
expect(statsResponseBody.data).toHaveProperty('userCount');
expect(statsResponseBody.data).toHaveProperty('flyerCount');
expect(statsResponse.body.data).toHaveProperty('userCount');
expect(statsResponse.body.data).toHaveProperty('flyerCount');
// 5. Fetch User List (Protected Admin Route)
const usersResponse = await apiClient.authedGet('/admin/users', { tokenOverride: authToken });
const usersResponse = await getRequest()
.get('/admin/users')
.set('Authorization', `Bearer ${authToken}`);
expect(usersResponse.status).toBe(200);
const usersResponseBody = await usersResponse.json();
expect(usersResponseBody.data).toHaveProperty('users');
expect(usersResponseBody.data).toHaveProperty('total');
expect(Array.isArray(usersResponseBody.data.users)).toBe(true);
expect(usersResponse.body.data).toHaveProperty('users');
expect(usersResponse.body.data).toHaveProperty('total');
expect(Array.isArray(usersResponse.body.data.users)).toBe(true);
// The list should contain the admin user we just created
const self = usersResponseBody.data.users.find((u: any) => u.user_id === adminUserId);
const self = usersResponse.body.data.users.find((u: any) => u.user_id === adminUserId);
expect(self).toBeDefined();
// 6. Check Queue Status (Protected Admin Route)
const queueResponse = await apiClient.authedGet('/admin/queues/status', {
tokenOverride: authToken,
});
const queueResponse = await getRequest()
.get('/admin/queues/status')
.set('Authorization', `Bearer ${authToken}`);
expect(queueResponse.status).toBe(200);
const queueResponseBody = await queueResponse.json();
expect(Array.isArray(queueResponseBody.data)).toBe(true);
expect(Array.isArray(queueResponse.body.data)).toBe(true);
// Verify that the 'flyer-processing' queue is present in the status report
const flyerQueue = queueResponseBody.data.find((q: any) => q.name === 'flyer-processing');
const flyerQueue = queueResponse.body.data.find((q: any) => q.name === 'flyer-processing');
expect(flyerQueue).toBeDefined();
expect(flyerQueue.counts).toBeDefined();
});

View File

@@ -1,16 +1,19 @@
// src/tests/e2e/auth.e2e.test.ts
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
import { app } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('Authentication E2E Flow', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
let testUser: UserProfile;
let testUserAuthToken: string;
const createdUserIds: string[] = [];
@@ -21,6 +24,7 @@ describe('Authentication E2E Flow', () => {
const { user, token } = await createAndLoginUser({
email: `e2e-login-user-${Date.now()}@example.com`,
fullName: 'E2E Login User',
request: getRequest(),
});
testUserAuthToken = token;
testUser = user;
@@ -43,18 +47,19 @@ describe('Authentication E2E Flow', () => {
const fullName = 'E2E Register User';
// Act
const response = await apiClient.registerUser(email, TEST_PASSWORD, fullName);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: fullName });
// Assert
expect(response.status).toBe(201);
expect(responseBody.data.message).toBe('User registered successfully!');
expect(responseBody.data.userprofile).toBeDefined();
expect(responseBody.data.userprofile.user.email).toBe(email);
expect(responseBody.data.token).toBeTypeOf('string');
expect(response.body.data.message).toBe('User registered successfully!');
expect(response.body.data.userprofile).toBeDefined();
expect(response.body.data.userprofile.user.email).toBe(email);
expect(response.body.data.token).toBeTypeOf('string');
// Add to cleanup
createdUserIds.push(responseBody.data.userprofile.user.user_id);
createdUserIds.push(response.body.data.userprofile.user.user_id);
});
it('should fail to register a user with a weak password', async () => {
@@ -62,12 +67,13 @@ describe('Authentication E2E Flow', () => {
const weakPassword = '123';
// Act
const response = await apiClient.registerUser(email, weakPassword, 'Weak Pass User');
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/register')
.send({ email, password: weakPassword, full_name: 'Weak Pass User' });
// Assert
expect(response.status).toBe(400);
expect(responseBody.error.details[0].message).toContain(
expect(response.body.error.details[0].message).toContain(
'Password must be at least 8 characters long.',
);
});
@@ -76,18 +82,20 @@ describe('Authentication E2E Flow', () => {
const email = `e2e-register-duplicate-${Date.now()}@example.com`;
// Act 1: Register the user successfully
const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const firstResponseBody = await firstResponse.json();
const firstResponse = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Duplicate User' });
expect(firstResponse.status).toBe(201);
createdUserIds.push(firstResponseBody.data.userprofile.user.user_id);
createdUserIds.push(firstResponse.body.data.userprofile.user.user_id);
// Act 2: Attempt to register the same user again
const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const secondResponseBody = await secondResponse.json();
const secondResponse = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Duplicate User' });
// Assert
expect(secondResponse.status).toBe(409); // Conflict
expect(secondResponseBody.error.message).toContain(
expect(secondResponse.body.error.message).toContain(
'A user with this email address already exists.',
);
});
@@ -96,32 +104,35 @@ describe('Authentication E2E Flow', () => {
describe('Login Flow', () => {
it('should successfully log in a registered user', async () => {
// Act: Attempt to log in with the user created in beforeAll
const response = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/login')
.send({ email: testUser.user.email, password: TEST_PASSWORD, rememberMe: false });
// Assert
expect(response.status).toBe(200);
expect(responseBody.data.userprofile).toBeDefined();
expect(responseBody.data.userprofile.user.email).toBe(testUser.user.email);
expect(responseBody.data.token).toBeTypeOf('string');
expect(response.body.data.userprofile).toBeDefined();
expect(response.body.data.userprofile.user.email).toBe(testUser.user.email);
expect(response.body.data.token).toBeTypeOf('string');
});
it('should fail to log in with an incorrect password', async () => {
// Act: Attempt to log in with the wrong password
const response = await apiClient.loginUser(testUser.user.email, 'wrong-password', false);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/login')
.send({ email: testUser.user.email, password: 'wrong-password', rememberMe: false });
// Assert
expect(response.status).toBe(401);
expect(responseBody.error.message).toBe('Incorrect email or password.');
expect(response.body.error.message).toBe('Incorrect email or password.');
});
it('should fail to log in with a non-existent email', async () => {
const response = await apiClient.loginUser('no-one-here@example.com', TEST_PASSWORD, false);
const responseBody = await response.json();
const response = await getRequest()
.post('/api/auth/login')
.send({ email: 'no-one-here@example.com', password: TEST_PASSWORD, rememberMe: false });
expect(response.status).toBe(401);
expect(responseBody.error.message).toBe('Incorrect email or password.');
expect(response.body.error.message).toBe('Incorrect email or password.');
});
it('should be able to access a protected route after logging in', async () => {
@@ -130,15 +141,16 @@ describe('Authentication E2E Flow', () => {
expect(token).toBeDefined();
// Act: Use the token to access a protected route
const profileResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: token });
const responseBody = await profileResponse.json();
const response = await getRequest()
.get('/api/users/me')
.set('Authorization', `Bearer ${token}`);
// Assert
expect(profileResponse.status).toBe(200);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.user_id).toBe(testUser.user.user_id);
expect(responseBody.data.user.email).toBe(testUser.user.email);
expect(responseBody.data.role).toBe('user');
expect(response.status).toBe(200);
expect(response.body.data).toBeDefined();
expect(response.body.data.user.user_id).toBe(testUser.user.user_id);
expect(response.body.data.user.email).toBe(testUser.user.email);
expect(response.body.data.role).toBe('user');
});
it('should allow an authenticated user to update their profile', async () => {
@@ -152,23 +164,24 @@ describe('Authentication E2E Flow', () => {
};
// Act: Call the update endpoint
const updateResponse = await apiClient.updateUserProfile(profileUpdates, {
tokenOverride: token,
});
const updateResponseBody = await updateResponse.json();
const updateResponse = await getRequest()
.put('/api/users/me')
.set('Authorization', `Bearer ${token}`)
.send(profileUpdates);
// Assert: Check the response from the update call
expect(updateResponse.status).toBe(200);
expect(updateResponseBody.data.full_name).toBe(profileUpdates.full_name);
expect(updateResponseBody.data.avatar_url).toBe(profileUpdates.avatar_url);
expect(updateResponse.body.data.full_name).toBe(profileUpdates.full_name);
expect(updateResponse.body.data.avatar_url).toBe(profileUpdates.avatar_url);
// Act 2: Fetch the profile again to verify persistence
const verifyResponse = await apiClient.getAuthenticatedUserProfile({ tokenOverride: token });
const verifyResponseBody = await verifyResponse.json();
const verifyResponse = await getRequest()
.get('/api/users/me')
.set('Authorization', `Bearer ${token}`);
// Assert 2: Check the fetched data
expect(verifyResponseBody.data.full_name).toBe(profileUpdates.full_name);
expect(verifyResponseBody.data.avatar_url).toBe(profileUpdates.avatar_url);
expect(verifyResponse.body.data.full_name).toBe(profileUpdates.full_name);
expect(verifyResponse.body.data.avatar_url).toBe(profileUpdates.avatar_url);
});
});
@@ -176,27 +189,29 @@ describe('Authentication E2E Flow', () => {
it('should allow a user to reset their password and log in with the new one', async () => {
// Arrange: Create a user to reset the password for
const email = `e2e-reset-pass-${Date.now()}@example.com`;
const registerResponse = await apiClient.registerUser(
email,
TEST_PASSWORD,
'Reset Pass User',
);
const registerResponseBody = await registerResponse.json();
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email, password: TEST_PASSWORD, full_name: 'Reset Pass User' });
expect(registerResponse.status).toBe(201);
createdUserIds.push(registerResponseBody.data.userprofile.user.user_id);
createdUserIds.push(registerResponse.body.data.userprofile.user.user_id);
// Poll until the user can log in, confirming the record has propagated.
await poll(
() => apiClient.loginUser(email, TEST_PASSWORD, false),
(response) => response.ok,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
// Verify user can log in (confirming registration completed)
let loginAttempts = 0;
let loginResponse;
while (loginAttempts < 10) {
loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email, password: TEST_PASSWORD, rememberMe: false });
if (loginResponse.status === 200) break;
await new Promise((resolve) => setTimeout(resolve, 1000));
loginAttempts++;
}
expect(loginResponse?.status).toBe(200);
// Request password reset (do not poll, as this endpoint is rate-limited)
const forgotResponse = await apiClient.requestPasswordReset(email);
const forgotResponse = await getRequest().post('/api/auth/forgot-password').send({ email });
expect(forgotResponse.status).toBe(200);
const forgotResponseBody = await forgotResponse.json();
const resetToken = forgotResponseBody.data.token;
const resetToken = forgotResponse.body.data.token;
// Assert 1: Check that we received a token.
expect(
@@ -207,20 +222,22 @@ describe('Authentication E2E Flow', () => {
// Act 2: Use the token to set a new password.
const newPassword = 'my-new-e2e-password-!@#$';
const resetResponse = await apiClient.resetPassword(resetToken, newPassword);
const resetResponseBody = await resetResponse.json();
const resetResponse = await getRequest()
.post('/api/auth/reset-password')
.send({ token: resetToken, newPassword });
// Assert 2: Check for a successful password reset message.
expect(resetResponse.status).toBe(200);
expect(resetResponseBody.data.message).toBe('Password has been reset successfully.');
expect(resetResponse.body.data.message).toBe('Password has been reset successfully.');
// Act 3: Log in with the NEW password
const loginResponse = await apiClient.loginUser(email, newPassword, false);
const loginResponseBody = await loginResponse.json();
const newLoginResponse = await getRequest()
.post('/api/auth/login')
.send({ email, password: newPassword, rememberMe: false });
expect(loginResponse.status).toBe(200);
expect(loginResponseBody.data.userprofile).toBeDefined();
expect(loginResponseBody.data.userprofile.user.email).toBe(email);
expect(newLoginResponse.status).toBe(200);
expect(newLoginResponse.body.data.userprofile).toBeDefined();
expect(newLoginResponse.body.data.userprofile.user.email).toBe(email);
});
it('should return a generic success message for a non-existent email to prevent enumeration', async () => {
@@ -228,73 +245,71 @@ describe('Authentication E2E Flow', () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
const nonExistentEmail = `non-existent-e2e-${Date.now()}@example.com`;
const response = await apiClient.requestPasswordReset(nonExistentEmail);
const response = await getRequest()
.post('/api/auth/forgot-password')
.send({ email: nonExistentEmail });
// Check for rate limiting or other errors before parsing JSON to avoid SyntaxError
if (!response.ok) {
const text = await response.text();
throw new Error(`Request failed with status ${response.status}: ${text}`);
}
const responseBody = await response.json();
expect(response.status).toBe(200);
expect(responseBody.data.message).toBe(
expect(response.body.data.message).toBe(
'If an account with that email exists, a password reset link has been sent.',
);
expect(responseBody.data.token).toBeUndefined();
expect(response.body.data.token).toBeUndefined();
});
});
describe('Token Refresh Flow', () => {
it('should allow an authenticated user to refresh their access token and use it', async () => {
// 1. Log in to get the refresh token cookie and an initial access token.
const loginResponse = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: testUser.user.email, password: TEST_PASSWORD, rememberMe: false });
expect(loginResponse.status).toBe(200);
const loginResponseBody = await loginResponse.json();
const initialAccessToken = loginResponseBody.data.token;
const initialAccessToken = loginResponse.body.data.token;
// 2. Extract the refresh token from the 'set-cookie' header.
const setCookieHeader = loginResponse.headers.get('set-cookie');
const setCookieHeaders = loginResponse.headers['set-cookie'];
expect(
setCookieHeader,
setCookieHeaders,
'Set-Cookie header should be present in login response',
).toBeDefined();
// A typical Set-Cookie header might be 'refreshToken=...; Path=/; HttpOnly; Max-Age=...'. We just need the 'refreshToken=...' part.
const refreshTokenCookie = setCookieHeader!.split(';')[0];
// Find the refreshToken cookie
const refreshTokenCookie = Array.isArray(setCookieHeaders)
? setCookieHeaders.find((cookie: string) => cookie.startsWith('refreshToken='))
: setCookieHeaders;
expect(refreshTokenCookie).toBeDefined();
// Wait for >1 second to ensure the 'iat' (Issued At) claim in the new JWT changes.
// JWT timestamps have second-level precision.
await new Promise((resolve) => setTimeout(resolve, 1100));
// 3. Call the refresh token endpoint, passing the cookie.
// This assumes a new method in apiClient to handle this specific request.
const refreshResponse = await apiClient.refreshToken(refreshTokenCookie);
const refreshResponse = await getRequest()
.post('/api/auth/refresh-token')
.set('Cookie', refreshTokenCookie!);
// 4. Assert the refresh was successful and we got a new token.
expect(refreshResponse.status).toBe(200);
const refreshResponseBody = await refreshResponse.json();
const newAccessToken = refreshResponseBody.data.token;
const newAccessToken = refreshResponse.body.data.token;
expect(newAccessToken).toBeDefined();
expect(newAccessToken).not.toBe(initialAccessToken);
// 5. Use the new access token to access a protected route.
const profileResponse = await apiClient.getAuthenticatedUserProfile({
tokenOverride: newAccessToken,
});
const profileResponse = await getRequest()
.get('/api/users/me')
.set('Authorization', `Bearer ${newAccessToken}`);
expect(profileResponse.status).toBe(200);
const profileResponseBody = await profileResponse.json();
expect(profileResponseBody.data.user.user_id).toBe(testUser.user.user_id);
expect(profileResponse.body.data.user.user_id).toBe(testUser.user.user_id);
});
it('should fail to refresh with an invalid or missing token', async () => {
// Case 1: No cookie provided. This assumes refreshToken can handle an empty string.
const noCookieResponse = await apiClient.refreshToken('');
// Case 1: No cookie provided
const noCookieResponse = await getRequest().post('/api/auth/refresh-token');
expect(noCookieResponse.status).toBe(401);
// Case 2: Invalid cookie provided
const invalidCookieResponse = await apiClient.refreshToken(
'refreshToken=invalid-garbage-token',
);
const invalidCookieResponse = await getRequest()
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=invalid-garbage-token');
expect(invalidCookieResponse.status).toBe(403);
});
});

View File

@@ -4,7 +4,7 @@
* Tests the complete flow from user registration to creating budgets, tracking spending, and managing finances.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
@@ -13,35 +13,16 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import { app } from '../setup/e2e-global-setup';
/**
* @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> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Budget Management Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
const uniqueId = Date.now();
const userEmail = `budget-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongBudgetPassword123!';
@@ -83,21 +64,23 @@ describe('E2E Budget Management Journey', () => {
it('should complete budget journey: Register -> Create Budget -> Track Spending -> Update -> Delete', async () => {
// Step 1: Register a new user
const registerResponse = await apiClient.registerUser(
userEmail,
userPassword,
'Budget E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Budget 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() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -111,73 +94,65 @@ describe('E2E Budget Management Journey', () => {
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const formatDate = (d: Date) => d.toISOString().split('T')[0];
const createBudgetResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const createBudgetResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Monthly Groceries',
amount_cents: 50000, // $500.00
period: 'monthly',
start_date: formatDate(startOfMonth),
}),
});
});
expect(createBudgetResponse.status).toBe(201);
const createBudgetData = await createBudgetResponse.json();
expect(createBudgetData.data.name).toBe('Monthly Groceries');
expect(createBudgetData.data.amount_cents).toBe(50000);
expect(createBudgetData.data.period).toBe('monthly');
const budgetId = createBudgetData.data.budget_id;
expect(createBudgetResponse.body.data.name).toBe('Monthly Groceries');
expect(createBudgetResponse.body.data.amount_cents).toBe(50000);
expect(createBudgetResponse.body.data.period).toBe('monthly');
const budgetId = createBudgetResponse.body.data.budget_id;
createdBudgetIds.push(budgetId);
// Step 4: Create a weekly budget
const weeklyBudgetResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const weeklyBudgetResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Weekly Dining Out',
amount_cents: 10000, // $100.00
period: 'weekly',
start_date: formatDate(today),
}),
});
});
expect(weeklyBudgetResponse.status).toBe(201);
const weeklyBudgetData = await weeklyBudgetResponse.json();
expect(weeklyBudgetData.data.period).toBe('weekly');
createdBudgetIds.push(weeklyBudgetData.data.budget_id);
expect(weeklyBudgetResponse.body.data.period).toBe('weekly');
createdBudgetIds.push(weeklyBudgetResponse.body.data.budget_id);
// Step 5: View all budgets
const listBudgetsResponse = await authedFetch('/budgets', {
method: 'GET',
token: authToken,
});
const listBudgetsResponse = await getRequest()
.get('/api/budgets')
.set('Authorization', `Bearer ${authToken}`);
expect(listBudgetsResponse.status).toBe(200);
const listBudgetsData = await listBudgetsResponse.json();
expect(listBudgetsData.data.length).toBe(2);
expect(listBudgetsResponse.body.data.length).toBe(2);
// Find our budgets
const monthlyBudget = listBudgetsData.data.find(
const monthlyBudget = listBudgetsResponse.body.data.find(
(b: { name: string }) => b.name === 'Monthly Groceries',
);
expect(monthlyBudget).toBeDefined();
expect(monthlyBudget.amount_cents).toBe(50000);
// Step 6: Update a budget
const updateBudgetResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const updateBudgetResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
amount_cents: 55000, // Increase to $550.00
name: 'Monthly Groceries (Updated)',
}),
});
});
expect(updateBudgetResponse.status).toBe(200);
const updateBudgetData = await updateBudgetResponse.json();
expect(updateBudgetData.data.amount_cents).toBe(55000);
expect(updateBudgetData.data.name).toBe('Monthly Groceries (Updated)');
expect(updateBudgetResponse.body.data.amount_cents).toBe(55000);
expect(updateBudgetResponse.body.data.name).toBe('Monthly Groceries (Updated)');
// Step 7: Create test spending data (receipts) to track against budget
const pool = getPool();
@@ -212,69 +187,67 @@ describe('E2E Budget Management Journey', () => {
// Step 8: Check spending analysis
const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const spendingResponse = await authedFetch(
`/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`,
{
method: 'GET',
token: authToken,
},
);
const spendingResponse = await getRequest()
.get(
`/api/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`,
)
.set('Authorization', `Bearer ${authToken}`);
expect(spendingResponse.status).toBe(200);
const spendingData = await spendingResponse.json();
expect(spendingData.success).toBe(true);
expect(Array.isArray(spendingData.data)).toBe(true);
expect(spendingResponse.body.success).toBe(true);
expect(Array.isArray(spendingResponse.body.data)).toBe(true);
// Verify we have spending data
// Note: The spending might be $0 or have data depending on how the backend calculates spending
// The test is mainly verifying the endpoint works
// Step 9: Test budget validation - try to create invalid budget
const invalidBudgetResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const invalidBudgetResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Invalid Budget',
amount_cents: -100, // Negative amount should be rejected
period: 'monthly',
start_date: formatDate(today),
}),
});
});
expect(invalidBudgetResponse.status).toBe(400);
// Step 10: Test budget validation - missing required fields
const missingFieldsResponse = await authedFetch('/budgets', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const missingFieldsResponse = await getRequest()
.post('/api/budgets')
.set('Authorization', `Bearer ${authToken}`)
.send({
name: 'Incomplete Budget',
// Missing amount_cents, period, start_date
}),
});
});
expect(missingFieldsResponse.status).toBe(400);
// Step 11: Test update validation - empty update
const emptyUpdateResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({}), // No fields to update
});
const emptyUpdateResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({}); // No fields to update
expect(emptyUpdateResponse.status).toBe(400);
// Step 12: Verify another user cannot access our budgets
const otherUserEmail = `other-budget-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Budget User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Budget User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -282,31 +255,27 @@ describe('E2E Budget Management Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our budgets
const otherBudgetsResponse = await authedFetch('/budgets', {
method: 'GET',
token: otherToken,
});
const otherBudgetsResponse = await getRequest()
.get('/api/budgets')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherBudgetsResponse.status).toBe(200);
const otherBudgetsData = await otherBudgetsResponse.json();
expect(otherBudgetsData.data.length).toBe(0);
expect(otherBudgetsResponse.body.data.length).toBe(0);
// Other user should not be able to update our budget
const otherUpdateResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'PUT',
token: otherToken,
body: JSON.stringify({
const otherUpdateResponse = await getRequest()
.put(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${otherToken}`)
.send({
amount_cents: 99999,
}),
});
});
expect(otherUpdateResponse.status).toBe(404); // Should not find the budget
// Other user should not be able to delete our budget
const otherDeleteAttemptResponse = await authedFetch(`/budgets/${budgetId}`, {
method: 'DELETE',
token: otherToken,
});
const otherDeleteAttemptResponse = await getRequest()
.delete(`/api/budgets/${budgetId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDeleteAttemptResponse.status).toBe(404);
@@ -314,38 +283,36 @@ describe('E2E Budget Management Journey', () => {
await cleanupDb({ userIds: [otherUserId] });
// Step 13: Delete the weekly budget
const deleteBudgetResponse = await authedFetch(`/budgets/${weeklyBudgetData.data.budget_id}`, {
method: 'DELETE',
token: authToken,
});
const deleteBudgetResponse = await getRequest()
.delete(`/api/budgets/${weeklyBudgetResponse.body.data.budget_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteBudgetResponse.status).toBe(204);
// Remove from cleanup list
const deleteIndex = createdBudgetIds.indexOf(weeklyBudgetData.data.budget_id);
const deleteIndex = createdBudgetIds.indexOf(weeklyBudgetResponse.body.data.budget_id);
if (deleteIndex > -1) {
createdBudgetIds.splice(deleteIndex, 1);
}
// Step 14: Verify deletion
const verifyDeleteResponse = await authedFetch('/budgets', {
method: 'GET',
token: authToken,
});
const verifyDeleteResponse = await getRequest()
.get('/api/budgets')
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(200);
const verifyDeleteData = await verifyDeleteResponse.json();
expect(verifyDeleteData.data.length).toBe(1); // Only monthly budget remains
expect(verifyDeleteResponse.body.data.length).toBe(1); // Only monthly budget remains
const deletedBudget = verifyDeleteData.data.find(
(b: { budget_id: number }) => b.budget_id === weeklyBudgetData.data.budget_id,
const deletedBudget = verifyDeleteResponse.body.data.find(
(b: { budget_id: number }) => b.budget_id === weeklyBudgetResponse.body.data.budget_id,
);
expect(deletedBudget).toBeUndefined();
// Step 15: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/user/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -4,7 +4,7 @@
* Tests the complete flow from user registration to watching items and viewing best prices.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
@@ -13,35 +13,16 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import { app } from '../setup/e2e-global-setup';
/**
* @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> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Deals and Price Tracking Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
const uniqueId = Date.now();
const userEmail = `deals-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongDealsPassword123!';
@@ -98,87 +79,70 @@ describe('E2E Deals and Price Tracking Journey', () => {
// will support both category names and IDs in the watched items API.
// Get all available categories
const categoriesResponse = await authedFetch('/categories', {
method: 'GET',
});
const categoriesResponse = await getRequest().get('/api/categories');
expect(categoriesResponse.status).toBe(200);
const categoriesData = await categoriesResponse.json();
expect(categoriesData.success).toBe(true);
expect(categoriesData.data.length).toBeGreaterThan(0);
expect(categoriesResponse.body.success).toBe(true);
expect(categoriesResponse.body.data.length).toBeGreaterThan(0);
// Find "Dairy & Eggs" category by name using the lookup endpoint
const categoryLookupResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
{
method: 'GET',
},
const categoryLookupResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
);
expect(categoryLookupResponse.status).toBe(200);
const categoryLookupData = await categoryLookupResponse.json();
expect(categoryLookupData.success).toBe(true);
expect(categoryLookupData.data.name).toBe('Dairy & Eggs');
expect(categoryLookupResponse.body.success).toBe(true);
expect(categoryLookupResponse.body.data.name).toBe('Dairy & Eggs');
const dairyEggsCategoryId = categoryLookupData.data.category_id;
const dairyEggsCategoryId = categoryLookupResponse.body.data.category_id;
expect(dairyEggsCategoryId).toBeGreaterThan(0);
// Verify we can retrieve the category by ID
const categoryByIdResponse = await authedFetch(`/categories/${dairyEggsCategoryId}`, {
method: 'GET',
});
const categoryByIdResponse = await getRequest().get(`/api/categories/${dairyEggsCategoryId}`);
expect(categoryByIdResponse.status).toBe(200);
const categoryByIdData = await categoryByIdResponse.json();
expect(categoryByIdData.success).toBe(true);
expect(categoryByIdData.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdData.data.name).toBe('Dairy & Eggs');
expect(categoryByIdResponse.body.success).toBe(true);
expect(categoryByIdResponse.body.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdResponse.body.data.name).toBe('Dairy & Eggs');
// Look up other category IDs we'll need
const bakeryResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
{ method: 'GET' },
const bakeryResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
);
const bakeryData = await bakeryResponse.json();
const bakeryCategoryId = bakeryData.data.category_id;
const bakeryCategoryId = bakeryResponse.body.data.category_id;
const beveragesResponse = await authedFetch('/categories/lookup?name=Beverages', {
method: 'GET',
});
const beveragesData = await beveragesResponse.json();
const beveragesCategoryId = beveragesData.data.category_id;
const beveragesResponse = await getRequest().get('/api/categories/lookup?name=Beverages');
const beveragesCategoryId = beveragesResponse.body.data.category_id;
const produceResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
{ method: 'GET' },
const produceResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
);
const produceData = await produceResponse.json();
const produceCategoryId = produceData.data.category_id;
const produceCategoryId = produceResponse.body.data.category_id;
const meatResponse = await authedFetch(
'/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
{ method: 'GET' },
const meatResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
);
const meatData = await meatResponse.json();
const meatCategoryId = meatData.data.category_id;
const meatCategoryId = meatResponse.body.data.category_id;
// 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
// to look up category IDs before creating watched items.
// Step 1: Register a new user
const registerResponse = await apiClient.registerUser(
userEmail,
userPassword,
'Deals E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Deals 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() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -280,18 +244,16 @@ describe('E2E Deals and Price Tracking Journey', () => {
);
// Step 4: Add items to watch list (using category_id from lookups above)
const watchItem1Response = await authedFetch('/users/watched-items', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const watchItem1Response = await getRequest()
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send({
itemName: 'E2E Milk 2%',
category_id: dairyEggsCategoryId,
}),
});
});
expect(watchItem1Response.status).toBe(201);
const watchItem1Data = await watchItem1Response.json();
expect(watchItem1Data.data.name).toBe('E2E Milk 2%');
expect(watchItem1Response.body.data.name).toBe('E2E Milk 2%');
// Add more items to watch list
const itemsToWatch = [
@@ -300,47 +262,42 @@ describe('E2E Deals and Price Tracking Journey', () => {
];
for (const item of itemsToWatch) {
const response = await authedFetch('/users/watched-items', {
method: 'POST',
token: authToken,
body: JSON.stringify(item),
});
const response = await getRequest()
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send(item);
expect(response.status).toBe(201);
}
// Step 5: View all watched items
const watchedListResponse = await authedFetch('/users/watched-items', {
method: 'GET',
token: authToken,
});
const watchedListResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`);
expect(watchedListResponse.status).toBe(200);
const watchedListData = await watchedListResponse.json();
expect(watchedListData.data.length).toBeGreaterThanOrEqual(3);
expect(watchedListResponse.body.data.length).toBeGreaterThanOrEqual(3);
// Find our watched items
const watchedMilk = watchedListData.data.find(
const watchedMilk = watchedListResponse.body.data.find(
(item: { name: string }) => item.name === 'E2E Milk 2%',
);
expect(watchedMilk).toBeDefined();
expect(watchedMilk.category_id).toBe(dairyEggsCategoryId);
// Step 6: Get best prices for watched items
const bestPricesResponse = await authedFetch('/deals/best-watched-prices', {
method: 'GET',
token: authToken,
});
const bestPricesResponse = await getRequest()
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${authToken}`);
expect(bestPricesResponse.status).toBe(200);
const bestPricesData = await bestPricesResponse.json();
expect(bestPricesData.success).toBe(true);
expect(bestPricesResponse.body.success).toBe(true);
// Verify we got deals for our watched items
expect(Array.isArray(bestPricesData.data)).toBe(true);
expect(Array.isArray(bestPricesResponse.body.data)).toBe(true);
// Find the milk deal and verify it's the best price (Store 2 at $4.99)
if (bestPricesData.data.length > 0) {
const milkDeal = bestPricesData.data.find(
if (bestPricesResponse.body.data.length > 0) {
const milkDeal = bestPricesResponse.body.data.find(
(deal: { item_name: string }) => deal.item_name === 'E2E Milk 2%',
);
@@ -356,38 +313,39 @@ describe('E2E Deals and Price Tracking Journey', () => {
// Step 8: Remove an item from watch list
const milkMasterItemId = createdMasterItemIds[0];
const removeResponse = await authedFetch(`/users/watched-items/${milkMasterItemId}`, {
method: 'DELETE',
token: authToken,
});
const removeResponse = await getRequest()
.delete(`/api/users/watched-items/${milkMasterItemId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(removeResponse.status).toBe(204);
// Step 9: Verify item was removed
const updatedWatchedListResponse = await authedFetch('/users/watched-items', {
method: 'GET',
token: authToken,
});
const updatedWatchedListResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`);
expect(updatedWatchedListResponse.status).toBe(200);
const updatedWatchedListData = await updatedWatchedListResponse.json();
const milkStillWatched = updatedWatchedListData.data.find(
const milkStillWatched = updatedWatchedListResponse.body.data.find(
(item: { item_name: string }) => item.item_name === 'E2E Milk 2%',
);
expect(milkStillWatched).toBeUndefined();
// Step 10: Verify another user cannot see our watched items
const otherUserEmail = `other-deals-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Deals User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Deals User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -395,32 +353,29 @@ describe('E2E Deals and Price Tracking Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user's watched items should be empty
const otherWatchedResponse = await authedFetch('/users/watched-items', {
method: 'GET',
token: otherToken,
});
const otherWatchedResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherWatchedResponse.status).toBe(200);
const otherWatchedData = await otherWatchedResponse.json();
expect(otherWatchedData.data.length).toBe(0);
expect(otherWatchedResponse.body.data.length).toBe(0);
// Other user's deals should be empty
const otherDealsResponse = await authedFetch('/deals/best-watched-prices', {
method: 'GET',
token: otherToken,
});
const otherDealsResponse = await getRequest()
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDealsResponse.status).toBe(200);
const otherDealsData = await otherDealsResponse.json();
expect(otherDealsData.data.length).toBe(0);
expect(otherDealsResponse.body.data.length).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 11: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/user/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -1,18 +1,22 @@
// src/tests/e2e/flyer-upload.e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import supertest from 'supertest';
import crypto from 'crypto';
import * as apiClient from '../../services/apiClient';
import { getPool } from '../../services/db/connection.db';
import path from 'path';
import fs from 'fs';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { app } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E Flyer Upload and Processing Workflow', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
const uniqueId = Date.now();
const userEmail = `e2e-uploader-${uniqueId}@example.com`;
const userPassword = 'StrongPassword123!';
@@ -33,19 +37,20 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
it('should allow a user to upload a flyer and wait for processing to complete', async () => {
// 1. Register a new user
const registerResponse = await apiClient.registerUser(
userEmail,
userPassword,
'E2E Flyer Uploader',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'E2E Flyer Uploader',
});
expect(registerResponse.status).toBe(201);
// 2. Login to get the access token
const loginResponse = await apiClient.loginUser(userEmail, userPassword, false);
const loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
expect(loginResponse.status).toBe(200);
const loginResponseBody = await loginResponse.json();
authToken = loginResponseBody.data.token;
userId = loginResponseBody.data.userprofile.user.user_id;
authToken = loginResponse.body.data.token;
userId = loginResponse.body.data.userprofile.user.user_id;
expect(authToken).toBeDefined();
// 3. Prepare the flyer file
@@ -69,29 +74,27 @@ describe('E2E Flyer Upload and Processing Workflow', () => {
]);
}
// Create a File object for the apiClient
// FIX: The Node.js `Buffer` type can be incompatible with the web `File` API's
// expected `BlobPart` type in some TypeScript configurations. Explicitly creating
// a `Uint8Array` from the buffer ensures compatibility and resolves the type error.
// `Uint8Array` is a valid `BufferSource`, which is a valid `BlobPart`.
const flyerFile = new File([new Uint8Array(fileBuffer)], fileName, { type: 'image/jpeg' });
// Calculate checksum (required by the API)
const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// 4. Upload the flyer
const uploadResponse = await apiClient.uploadAndProcessFlyer(flyerFile, checksum, authToken);
const uploadResponse = await getRequest()
.post('/api/flyers/upload')
.set('Authorization', `Bearer ${authToken}`)
.attach('flyer', fileBuffer, fileName)
.field('checksum', checksum);
expect(uploadResponse.status).toBe(202);
const uploadResponseBody = await uploadResponse.json();
const jobId = uploadResponseBody.data.jobId;
const jobId = uploadResponse.body.data.jobId;
expect(jobId).toBeDefined();
// 5. Poll for job completion using the new utility
const jobStatusResponse = await poll(
async () => {
const statusResponse = await apiClient.getJobStatus(jobId, authToken);
return statusResponse.json();
const statusResponse = await getRequest()
.get(`/api/jobs/${jobId}`)
.set('Authorization', `Bearer ${authToken}`);
return statusResponse.body;
},
(responseBody) =>
responseBody.data.state === 'completed' || responseBody.data.state === 'failed',

View File

@@ -4,39 +4,20 @@
* Tests the complete flow from adding inventory items to tracking expiry and alerts.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
import { app } from '../setup/e2e-global-setup';
/**
* @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> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E Inventory/Expiry Management Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
const uniqueId = Date.now();
const userEmail = `inventory-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongInventoryPassword123!';
@@ -76,21 +57,23 @@ describe('E2E Inventory/Expiry Management Journey', () => {
it('should complete inventory journey: Register -> Add Items -> Track Expiry -> Consume -> Configure Alerts', async () => {
// Step 1: Register a new user
const registerResponse = await apiClient.registerUser(
userEmail,
userPassword,
'Inventory E2E User',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: 'Inventory 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() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -172,16 +155,14 @@ describe('E2E Inventory/Expiry Management Journey', () => {
];
for (const item of items) {
const addResponse = await authedFetch('/inventory', {
method: 'POST',
token: authToken,
body: JSON.stringify(item),
});
const addResponse = await getRequest()
.post('/api/inventory')
.set('Authorization', `Bearer ${authToken}`)
.send(item);
expect(addResponse.status).toBe(201);
const addData = await addResponse.json();
expect(addData.data.item_name).toBe(item.item_name);
createdInventoryIds.push(addData.data.inventory_id);
expect(addResponse.body.data.item_name).toBe(item.item_name);
createdInventoryIds.push(addResponse.body.data.inventory_id);
}
// Add an expired item directly to the database for testing expired endpoint
@@ -217,159 +198,135 @@ describe('E2E Inventory/Expiry Management Journey', () => {
createdInventoryIds.push(expiredResult.rows[0].pantry_item_id);
// Step 4: View all inventory
const listResponse = await authedFetch('/inventory', {
method: 'GET',
token: authToken,
});
const listResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
const listData = await listResponse.json();
expect(listData.data.items.length).toBe(6); // All our items
expect(listData.data.total).toBe(6);
expect(listResponse.body.data.items.length).toBe(6); // All our items
expect(listResponse.body.data.total).toBe(6);
// Step 5: Filter by location
const fridgeResponse = await authedFetch('/inventory?location=fridge', {
method: 'GET',
token: authToken,
});
const fridgeResponse = await getRequest()
.get('/api/inventory?location=fridge')
.set('Authorization', `Bearer ${authToken}`);
expect(fridgeResponse.status).toBe(200);
const fridgeData = await fridgeResponse.json();
fridgeData.data.items.forEach((item: { location: string }) => {
fridgeResponse.body.data.items.forEach((item: { location: string }) => {
expect(item.location).toBe('fridge');
});
expect(fridgeData.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt
expect(fridgeResponse.body.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt
// Step 6: View expiring items
const expiringResponse = await authedFetch('/inventory/expiring?days=3', {
method: 'GET',
token: authToken,
});
const expiringResponse = await getRequest()
.get('/api/inventory/expiring?days=3')
.set('Authorization', `Bearer ${authToken}`);
expect(expiringResponse.status).toBe(200);
const expiringData = await expiringResponse.json();
// Should include the Milk (tomorrow)
expect(expiringData.data.items.length).toBeGreaterThanOrEqual(1);
expect(expiringResponse.body.data.items.length).toBeGreaterThanOrEqual(1);
// Step 7: View expired items
const expiredResponse = await authedFetch('/inventory/expired', {
method: 'GET',
token: authToken,
});
const expiredResponse = await getRequest()
.get('/api/inventory/expired')
.set('Authorization', `Bearer ${authToken}`);
expect(expiredResponse.status).toBe(200);
const expiredData = await expiredResponse.json();
expect(expiredData.data.items.length).toBeGreaterThanOrEqual(1);
expect(expiredResponse.body.data.items.length).toBeGreaterThanOrEqual(1);
// Find the expired yogurt
const expiredYogurt = expiredData.data.items.find(
const expiredYogurt = expiredResponse.body.data.items.find(
(i: { item_name: string }) => i.item_name === 'Expired Yogurt E2E',
);
expect(expiredYogurt).toBeDefined();
// Step 8: Get specific item details
const milkId = createdInventoryIds[0];
const detailResponse = await authedFetch(`/inventory/${milkId}`, {
method: 'GET',
token: authToken,
});
const detailResponse = await getRequest()
.get(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailResponse.status).toBe(200);
const detailData = await detailResponse.json();
expect(detailData.data.item_name).toBe('E2E Milk');
expect(detailData.data.quantity).toBe(2);
expect(detailResponse.body.data.item_name).toBe('E2E Milk');
expect(detailResponse.body.data.quantity).toBe(2);
// Step 9: Update item quantity and location
const updateResponse = await authedFetch(`/inventory/${milkId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const updateResponse = await getRequest()
.put(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
quantity: 1,
notes: 'One bottle used',
}),
});
});
expect(updateResponse.status).toBe(200);
const updateData = await updateResponse.json();
expect(updateData.data.quantity).toBe(1);
expect(updateResponse.body.data.quantity).toBe(1);
// Step 10: Consume some apples (partial consume via update, then mark fully consumed)
// First, reduce quantity via update
const applesId = createdInventoryIds[3];
const partialConsumeResponse = await authedFetch(`/inventory/${applesId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({ quantity: 4 }), // 6 - 2 = 4
});
const partialConsumeResponse = await getRequest()
.put(`/api/inventory/${applesId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ quantity: 4 }); // 6 - 2 = 4
expect(partialConsumeResponse.status).toBe(200);
const partialConsumeData = await partialConsumeResponse.json();
expect(partialConsumeData.data.quantity).toBe(4);
expect(partialConsumeResponse.body.data.quantity).toBe(4);
// Step 11: Configure alert settings for email
// The API uses PUT /inventory/alerts/:alertMethod with days_before_expiry and is_enabled
const alertSettingsResponse = await authedFetch('/inventory/alerts/email', {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const alertSettingsResponse = await getRequest()
.put('/api/inventory/alerts/email')
.set('Authorization', `Bearer ${authToken}`)
.send({
is_enabled: true,
days_before_expiry: 3,
}),
});
});
expect(alertSettingsResponse.status).toBe(200);
const alertSettingsData = await alertSettingsResponse.json();
expect(alertSettingsData.data.is_enabled).toBe(true);
expect(alertSettingsData.data.days_before_expiry).toBe(3);
expect(alertSettingsResponse.body.data.is_enabled).toBe(true);
expect(alertSettingsResponse.body.data.days_before_expiry).toBe(3);
// Step 12: Verify alert settings were saved
const getSettingsResponse = await authedFetch('/inventory/alerts', {
method: 'GET',
token: authToken,
});
const getSettingsResponse = await getRequest()
.get('/api/inventory/alerts')
.set('Authorization', `Bearer ${authToken}`);
expect(getSettingsResponse.status).toBe(200);
const getSettingsData = await getSettingsResponse.json();
// Should have email alerts enabled
const emailAlert = getSettingsData.data.find(
const emailAlert = getSettingsResponse.body.data.find(
(s: { alert_method: string }) => s.alert_method === 'email',
);
expect(emailAlert?.is_enabled).toBe(true);
// Step 13: Get recipe suggestions based on expiring items
const suggestionsResponse = await authedFetch('/inventory/recipes/suggestions', {
method: 'GET',
token: authToken,
});
const suggestionsResponse = await getRequest()
.get('/api/inventory/recipes/suggestions')
.set('Authorization', `Bearer ${authToken}`);
expect(suggestionsResponse.status).toBe(200);
const suggestionsData = await suggestionsResponse.json();
expect(Array.isArray(suggestionsData.data.recipes)).toBe(true);
expect(Array.isArray(suggestionsResponse.body.data.recipes)).toBe(true);
// Step 14: Fully consume an item (marks as consumed, returns 204)
const breadId = createdInventoryIds[2];
const fullConsumeResponse = await authedFetch(`/inventory/${breadId}/consume`, {
method: 'POST',
token: authToken,
});
const fullConsumeResponse = await getRequest()
.post(`/api/inventory/${breadId}/consume`)
.set('Authorization', `Bearer ${authToken}`);
expect(fullConsumeResponse.status).toBe(204);
// Verify the item is now marked as consumed
const consumedItemResponse = await authedFetch(`/inventory/${breadId}`, {
method: 'GET',
token: authToken,
});
const consumedItemResponse = await getRequest()
.get(`/api/inventory/${breadId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(consumedItemResponse.status).toBe(200);
const consumedItemData = await consumedItemResponse.json();
expect(consumedItemData.data.is_consumed).toBe(true);
expect(consumedItemResponse.body.data.is_consumed).toBe(true);
// Step 15: Delete an item
const riceId = createdInventoryIds[4];
const deleteResponse = await authedFetch(`/inventory/${riceId}`, {
method: 'DELETE',
token: authToken,
});
const deleteResponse = await getRequest()
.delete(`/api/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteResponse.status).toBe(204);
@@ -380,24 +337,27 @@ describe('E2E Inventory/Expiry Management Journey', () => {
}
// Step 16: Verify deletion
const verifyDeleteResponse = await authedFetch(`/inventory/${riceId}`, {
method: 'GET',
token: authToken,
});
const verifyDeleteResponse = await getRequest()
.get(`/api/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);
// Step 17: Verify another user cannot access our inventory
const otherUserEmail = `other-inventory-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other Inventory User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Inventory User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -405,58 +365,52 @@ describe('E2E Inventory/Expiry Management Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our inventory
const otherDetailResponse = await authedFetch(`/inventory/${milkId}`, {
method: 'GET',
token: otherToken,
});
const otherDetailResponse = await getRequest()
.get(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
// Other user's inventory should be empty
const otherListResponse = await authedFetch('/inventory', {
method: 'GET',
token: otherToken,
});
const otherListResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherListResponse.status).toBe(200);
const otherListData = await otherListResponse.json();
expect(otherListData.data.total).toBe(0);
expect(otherListResponse.body.data.total).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 18: Move frozen item to fridge (simulating thawing)
const pizzaId = createdInventoryIds[1];
const moveResponse = await authedFetch(`/inventory/${pizzaId}`, {
method: 'PUT',
token: authToken,
body: JSON.stringify({
const moveResponse = await getRequest()
.put(`/api/inventory/${pizzaId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
location: 'fridge',
expiry_date: formatDate(nextWeek), // Update expiry since thawed
notes: 'Thawed for dinner',
}),
});
});
expect(moveResponse.status).toBe(200);
const moveData = await moveResponse.json();
expect(moveData.data.location).toBe('fridge');
expect(moveResponse.body.data.location).toBe('fridge');
// Step 19: Final inventory check
const finalListResponse = await authedFetch('/inventory', {
method: 'GET',
token: authToken,
});
const finalListResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(finalListResponse.status).toBe(200);
const finalListData = await finalListResponse.json();
// We should have: Milk (1), Pizza (thawed, 3), Bread (consumed), Apples (4), Expired Yogurt (1)
// Rice was deleted, Bread was consumed
expect(finalListData.data.total).toBeLessThanOrEqual(5);
expect(finalListResponse.body.data.total).toBeLessThanOrEqual(5);
// Step 20: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/user/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -4,7 +4,7 @@
* 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 supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
@@ -13,40 +13,16 @@ import {
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import FormData from 'form-data';
import { app } from '../setup/e2e-global-setup';
/**
* @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', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
const uniqueId = Date.now();
const userEmail = `receipt-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongReceiptPassword123!';
@@ -92,21 +68,23 @@ describe('E2E Receipt Processing Journey', () => {
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',
);
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: '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() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -154,73 +132,63 @@ describe('E2E Receipt Processing Journey', () => {
const itemIds = itemsResult.rows.map((r) => r.receipt_item_id);
// Step 4: View receipt list
const listResponse = await authedFetch('/receipts', {
method: 'GET',
token: authToken,
});
const listResponse = await getRequest()
.get('/api/receipts')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
const listData = await listResponse.json();
expect(listData.success).toBe(true);
expect(listData.data.receipts.length).toBeGreaterThanOrEqual(1);
expect(listResponse.body.success).toBe(true);
expect(listResponse.body.data.receipts.length).toBeGreaterThanOrEqual(1);
// Find our receipt
const ourReceipt = listData.data.receipts.find(
const ourReceipt = listResponse.body.data.receipts.find(
(r: { receipt_id: number }) => r.receipt_id === receiptId,
);
expect(ourReceipt).toBeDefined();
expect(ourReceipt.store_location_id).toBe(storeLocationId);
// Step 5: View receipt details
const detailResponse = await authedFetch(`/receipts/${receiptId}`, {
method: 'GET',
token: authToken,
});
const detailResponse = await getRequest()
.get(`/api/receipts/${receiptId}`)
.set('Authorization', `Bearer ${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);
expect(detailResponse.body.data.receipt.receipt_id).toBe(receiptId);
expect(detailResponse.body.data.items.length).toBe(3);
// Step 6: View receipt items
const itemsResponse = await authedFetch(`/receipts/${receiptId}/items`, {
method: 'GET',
token: authToken,
});
const itemsResponse = await getRequest()
.get(`/api/receipts/${receiptId}/items`)
.set('Authorization', `Bearer ${authToken}`);
expect(itemsResponse.status).toBe(200);
const itemsData = await itemsResponse.json();
expect(itemsData.data.items.length).toBe(3);
expect(itemsResponse.body.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({
const updateItemResponse = await getRequest()
.put(`/api/receipts/${receiptId}/items/${itemIds[1]}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
status: 'matched',
match_confidence: 0.85,
}),
});
});
expect(updateItemResponse.status).toBe(200);
const updateItemData = await updateItemResponse.json();
expect(updateItemData.data.status).toBe('matched');
expect(updateItemResponse.body.data.status).toBe('matched');
// Step 8: View unadded items
const unaddedResponse = await authedFetch(`/receipts/${receiptId}/items/unadded`, {
method: 'GET',
token: authToken,
});
const unaddedResponse = await getRequest()
.get(`/api/receipts/${receiptId}/items/unadded`)
.set('Authorization', `Bearer ${authToken}`);
expect(unaddedResponse.status).toBe(200);
const unaddedData = await unaddedResponse.json();
expect(unaddedData.data.items.length).toBe(3); // None added yet
expect(unaddedResponse.body.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({
const confirmResponse = await getRequest()
.post(`/api/receipts/${receiptId}/confirm`)
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [
{
receipt_item_id: itemIds[0],
@@ -242,16 +210,14 @@ describe('E2E Receipt Processing Journey', () => {
include: false, // Skip the eggs
},
],
}),
});
});
expect(confirmResponse.status).toBe(200);
const confirmData = await confirmResponse.json();
expect(confirmData.data.count).toBeGreaterThanOrEqual(0);
expect(confirmResponse.body.data.count).toBeGreaterThanOrEqual(0);
// Track inventory items for cleanup
if (confirmData.data.added_items) {
confirmData.data.added_items.forEach((item: { inventory_id: number }) => {
if (confirmResponse.body.data.added_items) {
confirmResponse.body.data.added_items.forEach((item: { inventory_id: number }) => {
if (item.inventory_id) {
createdInventoryIds.push(item.inventory_id);
}
@@ -259,15 +225,13 @@ describe('E2E Receipt Processing Journey', () => {
}
// Step 10: Verify items in inventory
const inventoryResponse = await authedFetch('/inventory', {
method: 'GET',
token: authToken,
});
const inventoryResponse = await getRequest()
.get('/api/inventory')
.set('Authorization', `Bearer ${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);
expect(inventoryResponse.body.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
@@ -275,15 +239,19 @@ describe('E2E Receipt Processing Journey', () => {
// 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');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: '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() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -291,10 +259,9 @@ describe('E2E Receipt Processing Journey', () => {
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,
});
const otherDetailResponse = await getRequest()
.get(`/api/receipts/${receiptId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
@@ -312,35 +279,27 @@ describe('E2E Receipt Processing Journey', () => {
createdReceiptIds.push(receipt2Result.rows[0].receipt_id);
// Step 15: Test filtering by status
const completedResponse = await authedFetch('/receipts?status=completed', {
method: 'GET',
token: authToken,
});
const completedResponse = await getRequest()
.get('/api/receipts?status=completed')
.set('Authorization', `Bearer ${authToken}`);
expect(completedResponse.status).toBe(200);
const completedData = await completedResponse.json();
completedData.data.receipts.forEach((r: { status: string }) => {
completedResponse.body.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,
},
);
const reprocessResponse = await getRequest()
.post(`/api/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`)
.set('Authorization', `Bearer ${authToken}`);
expect(reprocessResponse.status).toBe(200);
const reprocessData = await reprocessResponse.json();
expect(reprocessData.data.message).toContain('reprocessing');
expect(reprocessResponse.body.data.message).toContain('reprocessing');
// Step 17: Delete the failed receipt
const deleteResponse = await authedFetch(`/receipts/${receipt2Result.rows[0].receipt_id}`, {
method: 'DELETE',
token: authToken,
});
const deleteResponse = await getRequest()
.delete(`/api/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteResponse.status).toBe(204);
@@ -351,20 +310,17 @@ describe('E2E Receipt Processing Journey', () => {
}
// Step 18: Verify deletion
const verifyDeleteResponse = await authedFetch(
`/receipts/${receipt2Result.rows[0].receipt_id}`,
{
method: 'GET',
token: authToken,
},
);
const verifyDeleteResponse = await getRequest()
.get(`/api/receipts/${receipt2Result.rows[0].receipt_id}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);
// Step 19: Delete account
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/user/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;

View File

@@ -4,39 +4,20 @@
* Tests the complete flow from user registration to scanning UPCs and viewing history.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
import { app } from '../setup/e2e-global-setup';
/**
* @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> = {
'Content-Type': 'application/json',
...(fetchOptions.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return fetch(`${API_BASE_URL}${path}`, {
...fetchOptions,
headers,
});
};
describe('E2E UPC Scanning Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
const uniqueId = Date.now();
const userEmail = `upc-e2e-${uniqueId}@example.com`;
const userPassword = 'StrongUpcPassword123!';
@@ -71,17 +52,21 @@ describe('E2E UPC Scanning Journey', () => {
it('should complete full UPC scanning journey: Register -> Scan -> Lookup -> History -> Stats', async () => {
// Step 1: Register a new user
const registerResponse = await apiClient.registerUser(userEmail, userPassword, 'UPC E2E User');
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email: userEmail, password: userPassword, full_name: 'UPC 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() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'user login after registration' },
);
@@ -114,110 +99,100 @@ describe('E2E UPC Scanning Journey', () => {
createdProductIds.push(productId);
// Step 4: Scan the UPC code
const scanResponse = await authedFetch('/upc/scan', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const scanResponse = await getRequest()
.post('/api/upc/scan')
.set('Authorization', `Bearer ${authToken}`)
.send({
upc_code: testUpc,
scan_source: 'manual_entry',
}),
});
});
expect(scanResponse.status).toBe(200);
const scanData = await scanResponse.json();
expect(scanData.success).toBe(true);
expect(scanData.data.upc_code).toBe(testUpc);
const scanId = scanData.data.scan_id;
expect(scanResponse.body.success).toBe(true);
expect(scanResponse.body.data.upc_code).toBe(testUpc);
const scanId = scanResponse.body.data.scan_id;
createdScanIds.push(scanId);
// Step 5: Lookup the product by UPC
const lookupResponse = await authedFetch(`/upc/lookup?upc_code=${testUpc}`, {
method: 'GET',
token: authToken,
});
const lookupResponse = await getRequest()
.get(`/api/upc/lookup?upc_code=${testUpc}`)
.set('Authorization', `Bearer ${authToken}`);
expect(lookupResponse.status).toBe(200);
const lookupData = await lookupResponse.json();
expect(lookupData.success).toBe(true);
expect(lookupData.data.product).toBeDefined();
expect(lookupData.data.product.name).toBe('E2E Test Product');
expect(lookupResponse.body.success).toBe(true);
expect(lookupResponse.body.data.product).toBeDefined();
expect(lookupResponse.body.data.product.name).toBe('E2E Test Product');
// Step 6: Scan a few more items to build history
for (let i = 0; i < 3; i++) {
const additionalScan = await authedFetch('/upc/scan', {
method: 'POST',
token: authToken,
body: JSON.stringify({
const additionalScan = await getRequest()
.post('/api/upc/scan')
.set('Authorization', `Bearer ${authToken}`)
.send({
upc_code: `00000000000${i}`,
scan_source: i % 2 === 0 ? 'manual_entry' : 'image_upload',
}),
});
});
if (additionalScan.ok) {
const additionalData = await additionalScan.json();
if (additionalData.data?.scan_id) {
createdScanIds.push(additionalData.data.scan_id);
if (additionalScan.status === 200) {
if (additionalScan.body.data?.scan_id) {
createdScanIds.push(additionalScan.body.data.scan_id);
}
}
}
// Step 7: View scan history
const historyResponse = await authedFetch('/upc/history', {
method: 'GET',
token: authToken,
});
const historyResponse = await getRequest()
.get('/api/upc/history')
.set('Authorization', `Bearer ${authToken}`);
expect(historyResponse.status).toBe(200);
const historyData = await historyResponse.json();
expect(historyData.success).toBe(true);
expect(historyData.data.scans.length).toBeGreaterThanOrEqual(4); // At least our 4 scans
expect(historyData.data.total).toBeGreaterThanOrEqual(4);
expect(historyResponse.body.success).toBe(true);
expect(historyResponse.body.data.scans.length).toBeGreaterThanOrEqual(4); // At least our 4 scans
expect(historyResponse.body.data.total).toBeGreaterThanOrEqual(4);
// Step 8: View specific scan details
const scanDetailResponse = await authedFetch(`/upc/history/${scanId}`, {
method: 'GET',
token: authToken,
});
const scanDetailResponse = await getRequest()
.get(`/api/upc/history/${scanId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(scanDetailResponse.status).toBe(200);
const scanDetailData = await scanDetailResponse.json();
expect(scanDetailData.data.scan_id).toBe(scanId);
expect(scanDetailData.data.upc_code).toBe(testUpc);
expect(scanDetailResponse.body.data.scan_id).toBe(scanId);
expect(scanDetailResponse.body.data.upc_code).toBe(testUpc);
// Step 9: Check user scan statistics
const statsResponse = await authedFetch('/upc/stats', {
method: 'GET',
token: authToken,
});
const statsResponse = await getRequest()
.get('/api/upc/stats')
.set('Authorization', `Bearer ${authToken}`);
expect(statsResponse.status).toBe(200);
const statsData = await statsResponse.json();
expect(statsData.success).toBe(true);
expect(statsData.data.total_scans).toBeGreaterThanOrEqual(4);
expect(statsResponse.body.success).toBe(true);
expect(statsResponse.body.data.total_scans).toBeGreaterThanOrEqual(4);
// Step 10: Test history filtering by scan_source
const filteredHistoryResponse = await authedFetch('/upc/history?scan_source=manual_entry', {
method: 'GET',
token: authToken,
});
const filteredHistoryResponse = await getRequest()
.get('/api/upc/history?scan_source=manual_entry')
.set('Authorization', `Bearer ${authToken}`);
expect(filteredHistoryResponse.status).toBe(200);
const filteredData = await filteredHistoryResponse.json();
filteredData.data.scans.forEach((scan: { scan_source: string }) => {
filteredHistoryResponse.body.data.scans.forEach((scan: { scan_source: string }) => {
expect(scan.scan_source).toBe('manual_entry');
});
// Step 11: Verify another user cannot see our scans
const otherUserEmail = `other-upc-e2e-${uniqueId}@example.com`;
await apiClient.registerUser(otherUserEmail, userPassword, 'Other UPC User');
await getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other UPC User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await apiClient.loginUser(otherUserEmail, userPassword, false);
const responseBody = response.ok ? await response.clone().json() : {};
const response = await getRequest()
.post('/api/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.ok,
(result) => result.response.status === 200,
{ timeout: 10000, interval: 1000, description: 'other user login' },
);
@@ -225,30 +200,28 @@ describe('E2E UPC Scanning Journey', () => {
const otherUserId = otherLoginData.data.userprofile.user.user_id;
// Other user should not see our scan
const otherScanDetailResponse = await authedFetch(`/upc/history/${scanId}`, {
method: 'GET',
token: otherToken,
});
const otherScanDetailResponse = await getRequest()
.get(`/api/upc/history/${scanId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherScanDetailResponse.status).toBe(404);
// Other user's history should be empty
const otherHistoryResponse = await authedFetch('/upc/history', {
method: 'GET',
token: otherToken,
});
const otherHistoryResponse = await getRequest()
.get('/api/upc/history')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherHistoryResponse.status).toBe(200);
const otherHistoryData = await otherHistoryResponse.json();
expect(otherHistoryData.data.total).toBe(0);
expect(otherHistoryResponse.body.data.total).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 12: Delete account (self-service)
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/user/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);

View File

@@ -1,14 +1,17 @@
// src/tests/e2e/user-journey.e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { app } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E User Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(app);
// Use a unique email for every run to avoid collisions
const uniqueId = Date.now();
const userEmail = `e2e-test-${uniqueId}@example.com`;
@@ -28,58 +31,61 @@ describe('E2E User Journey', () => {
it('should complete a full user lifecycle: Register -> Login -> Manage List -> Delete Account', async () => {
// 1. Register a new user
const registerResponse = await apiClient.registerUser(userEmail, userPassword, 'E2E Traveler');
const registerResponse = await getRequest()
.post('/api/auth/register')
.send({ email: userEmail, password: userPassword, full_name: 'E2E Traveler' });
expect(registerResponse.status).toBe(201);
const registerResponseBody = await registerResponse.json();
expect(registerResponseBody.data.message).toBe('User registered successfully!');
expect(registerResponse.body.data.message).toBe('User registered successfully!');
// 2. Login to get the access token.
// We poll here because even between two API calls (register and login),
// there can be a small delay before the newly created user record is visible
// to the transaction started by the login request. This prevents flaky test failures.
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' },
);
// to the transaction started by the login getRequest(). This prevents flaky test failures.
let loginResponse;
let loginAttempts = 0;
while (loginAttempts < 10) {
loginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
if (loginResponse.status === 200) break;
await new Promise((resolve) => setTimeout(resolve, 1000));
loginAttempts++;
}
expect(loginResponse.status).toBe(200);
authToken = loginResponseBody.data.token;
userId = loginResponseBody.data.userprofile.user.user_id;
expect(loginResponse?.status).toBe(200);
authToken = loginResponse!.body.data.token;
userId = loginResponse!.body.data.userprofile.user.user_id;
expect(authToken).toBeDefined();
expect(userId).toBeDefined();
// 3. Create a Shopping List
const createListResponse = await apiClient.createShoppingList('E2E Party List', authToken);
const createListResponse = await getRequest()
.post('/api/users/me/shopping-lists')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'E2E Party List' });
expect(createListResponse.status).toBe(201);
const createListResponseBody = await createListResponse.json();
shoppingListId = createListResponseBody.data.shopping_list_id;
shoppingListId = createListResponse.body.data.shopping_list_id;
expect(shoppingListId).toBeDefined();
// 4. Add an item to the list
const addItemResponse = await apiClient.addShoppingListItem(
shoppingListId,
{ customItemName: 'Chips' },
authToken,
);
const addItemResponse = await getRequest()
.post(`/api/users/me/shopping-lists/${shoppingListId}/items`)
.set('Authorization', `Bearer ${authToken}`)
.send({ customItemName: 'Chips' });
expect(addItemResponse.status).toBe(201);
const addItemResponseBody = await addItemResponse.json();
expect(addItemResponseBody.data.custom_item_name).toBe('Chips');
expect(addItemResponse.body.data.custom_item_name).toBe('Chips');
// 5. Verify the list and item exist via GET
const getListsResponse = await apiClient.fetchShoppingLists(authToken);
const getListsResponse = await getRequest()
.get('/api/users/me/shopping-lists')
.set('Authorization', `Bearer ${authToken}`);
expect(getListsResponse.status).toBe(200);
const getListsResponseBody = await getListsResponse.json();
const myLists = getListsResponseBody.data;
const myLists = getListsResponse.body.data;
const targetList = myLists.find((l: any) => l.shopping_list_id === shoppingListId);
expect(targetList).toBeDefined();
@@ -87,16 +93,18 @@ describe('E2E User Journey', () => {
expect(targetList.items[0].custom_item_name).toBe('Chips');
// 6. Delete the User Account (Self-Service)
const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, {
tokenOverride: authToken,
});
const deleteAccountResponse = await getRequest()
.delete('/api/users/me')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
const deleteResponseBody = await deleteAccountResponse.json();
expect(deleteResponseBody.data.message).toBe('Account deleted successfully.');
expect(deleteAccountResponse.body.data.message).toBe('Account deleted successfully.');
// 7. Verify Login is no longer possible
const failLoginResponse = await apiClient.loginUser(userEmail, userPassword, false);
const failLoginResponse = await getRequest()
.post('/api/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
expect(failLoginResponse.status).toBe(401);

View File

@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import path from 'path';
import os from 'os';
import type { Server } from 'http';
import type Express from 'express';
import { logger } from '../../services/logger.server';
import { getPool } from '../../services/db/connection.db';
@@ -20,6 +21,8 @@ let server: Server;
let globalPool: ReturnType<typeof getPool> | null = null;
// Temporary directory for test file storage (to avoid modifying committed fixtures)
let tempStorageDir: string | null = null;
// Export the Express app for use with supertest in e2e tests
export let app: Express.Application;
/**
* Cleans all BullMQ queues to ensure no stale jobs from previous test runs.
@@ -122,7 +125,7 @@ export async function setup() {
console.error(`[E2E-SETUP-DEBUG] About to import server module...`);
const appModule = await import('../../../server');
console.error(`[E2E-SETUP-DEBUG] Server module imported successfully`);
const app = appModule.default;
app = appModule.default; // Assign to exported app variable
console.error(`[E2E-SETUP-DEBUG] App object type: ${typeof app}`);
// Use a dedicated E2E test port (3098) to avoid conflicts with integration tests (3099)

View File

@@ -48,6 +48,25 @@ const e2eConfig = mergeConfig(
reportsDirectory: '.coverage/e2e',
reportOnFailure: true,
clean: true,
// Include server-side code for true e2e coverage measurement
include: [
'src/routes/**/*.{ts,tsx}',
'src/middleware/**/*.{ts,tsx}',
'src/controllers/**/*.{ts,tsx}',
'src/services/**/*.{ts,tsx}',
'src/config/**/*.{ts,tsx}',
'server.ts',
],
exclude: [
'src/tests/**',
'src/**/*.test.{ts,tsx}',
'src/**/*.d.ts',
'src/services/apiClient.ts', // Client-side wrapper, not server code
'src/services/logger.client.ts',
'src/services/sentry.client.ts',
'src/services/eventBus.ts', // Client-side only
'src/services/processingErrors.ts', // Client-side only
],
},
},
}),