feat: add Playwright E2E and API regression test suite
Production-ready test infrastructure with Page Object Model pattern, reusable fixtures for auth/DB/test-data, and example tests covering login flow, dashboard, accounts CRUD API, and visual regression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
140
tests/api/accounts.api.spec.ts
Normal file
140
tests/api/accounts.api.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* API regression tests for the Accounts endpoint.
|
||||
*
|
||||
* Tests CRUD operations on /api/accounts.
|
||||
* Requires authentication — logs in first to get a Bearer token.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
|
||||
import { TEST_USERS, TEST_PREFIX } from '../fixtures/test-data';
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
|
||||
|
||||
let accessToken: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Login and switch to test org
|
||||
const tokens = await apiLogin(request, TEST_USERS.treasurer);
|
||||
if (tokens.organizations?.length > 0) {
|
||||
const orgId = (tokens.organizations[0] as any).id;
|
||||
try {
|
||||
const switched = await apiSwitchOrg(request, tokens.accessToken, orgId);
|
||||
accessToken = switched.accessToken;
|
||||
} catch {
|
||||
accessToken = tokens.accessToken;
|
||||
}
|
||||
} else {
|
||||
accessToken = tokens.accessToken;
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('GET /api/accounts', () => {
|
||||
test('should return accounts list', async ({ request }) => {
|
||||
const response = await request.get(`${API_BASE}/accounts`, {
|
||||
headers: authHeaders(accessToken),
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const accounts = await response.json();
|
||||
expect(Array.isArray(accounts)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return 401 without auth', async ({ request }) => {
|
||||
const response = await request.get(`${API_BASE}/accounts`);
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should filter by fund type', async ({ request }) => {
|
||||
const response = await request.get(`${API_BASE}/accounts?fundType=operating`, {
|
||||
headers: authHeaders(accessToken),
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
const accounts = await response.json();
|
||||
expect(Array.isArray(accounts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accounts CRUD', () => {
|
||||
let createdAccountId: string;
|
||||
|
||||
test('should create a new account', async ({ request }) => {
|
||||
const response = await request.post(`${API_BASE}/accounts`, {
|
||||
headers: authHeaders(accessToken),
|
||||
data: {
|
||||
name: `${TEST_PREFIX}Operating Checking`,
|
||||
number: '1099',
|
||||
accountType: 'asset',
|
||||
fundType: 'operating',
|
||||
description: 'E2E test account — safe to delete',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
|
||||
const account = await response.json();
|
||||
expect(account).toHaveProperty('id');
|
||||
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
|
||||
createdAccountId = account.id;
|
||||
});
|
||||
|
||||
test('should get the created account', async ({ request }) => {
|
||||
test.skip(!createdAccountId, 'No account was created');
|
||||
|
||||
const response = await request.get(`${API_BASE}/accounts/${createdAccountId}`, {
|
||||
headers: authHeaders(accessToken),
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const account = await response.json();
|
||||
expect(account.id).toBe(createdAccountId);
|
||||
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
|
||||
});
|
||||
|
||||
test('should update the account', async ({ request }) => {
|
||||
test.skip(!createdAccountId, 'No account was created');
|
||||
|
||||
const response = await request.put(`${API_BASE}/accounts/${createdAccountId}`, {
|
||||
headers: authHeaders(accessToken),
|
||||
data: {
|
||||
name: `${TEST_PREFIX}Updated Checking`,
|
||||
description: 'Updated by E2E test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const account = await response.json();
|
||||
expect(account.name).toBe(`${TEST_PREFIX}Updated Checking`);
|
||||
});
|
||||
|
||||
test('should appear in accounts list', async ({ request }) => {
|
||||
test.skip(!createdAccountId, 'No account was created');
|
||||
|
||||
const response = await request.get(`${API_BASE}/accounts`, {
|
||||
headers: authHeaders(accessToken),
|
||||
});
|
||||
|
||||
const accounts = await response.json();
|
||||
const found = accounts.find((a: any) => a.id === createdAccountId);
|
||||
expect(found).toBeTruthy();
|
||||
expect(found.name).toBe(`${TEST_PREFIX}Updated Checking`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('GET /api/accounts/trial-balance', () => {
|
||||
test('should return trial balance data', async ({ request }) => {
|
||||
const response = await request.get(`${API_BASE}/accounts/trial-balance`, {
|
||||
headers: authHeaders(accessToken),
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
// Trial balance returns an object or array with debit/credit totals
|
||||
expect(data).toBeDefined();
|
||||
});
|
||||
});
|
||||
124
tests/api/auth.api.spec.ts
Normal file
124
tests/api/auth.api.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* API regression tests for authentication endpoints.
|
||||
*
|
||||
* Tests the NestJS auth controller directly via HTTP.
|
||||
* No browser needed — uses Playwright's request context.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_USERS } from '../fixtures/test-data';
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
|
||||
|
||||
test.describe('POST /api/auth/login', () => {
|
||||
test('should return access token for valid credentials', async ({ request }) => {
|
||||
const response = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: TEST_USERS.treasurer.email,
|
||||
password: TEST_USERS.treasurer.password,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('accessToken');
|
||||
expect(body).toHaveProperty('user');
|
||||
expect(body.user).toHaveProperty('email', TEST_USERS.treasurer.email);
|
||||
expect(body).toHaveProperty('organizations');
|
||||
expect(Array.isArray(body.organizations)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return 401 for invalid password', async ({ request }) => {
|
||||
const response = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: TEST_USERS.treasurer.email,
|
||||
password: 'WrongPassword123!',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should return 401 for non-existent user', async ({ request }) => {
|
||||
const response = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'AnyPassword123!',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should set httpOnly refresh cookie on success', async ({ request }) => {
|
||||
const response = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: TEST_USERS.treasurer.email,
|
||||
password: TEST_USERS.treasurer.password,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
|
||||
// Check for Set-Cookie header with the refresh token
|
||||
const setCookie = response.headers()['set-cookie'] || '';
|
||||
expect(setCookie).toContain('ledgeriq_rt');
|
||||
expect(setCookie).toContain('HttpOnly');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('GET /api/auth/profile', () => {
|
||||
test('should return 401 without auth header', async ({ request }) => {
|
||||
const response = await request.get(`${API_BASE}/auth/profile`);
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should return user profile with valid token', async ({ request }) => {
|
||||
// Login first
|
||||
const loginResponse = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: TEST_USERS.treasurer.email,
|
||||
password: TEST_USERS.treasurer.password,
|
||||
},
|
||||
});
|
||||
const { accessToken } = await loginResponse.json();
|
||||
|
||||
// Fetch profile
|
||||
const response = await request.get(`${API_BASE}/auth/profile`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
const profile = await response.json();
|
||||
expect(profile).toHaveProperty('email', TEST_USERS.treasurer.email);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('POST /api/auth/logout', () => {
|
||||
test('should return success and clear cookie', async ({ request }) => {
|
||||
// Login first to get the cookie
|
||||
await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: TEST_USERS.treasurer.email,
|
||||
password: TEST_USERS.treasurer.password,
|
||||
},
|
||||
});
|
||||
|
||||
// Logout
|
||||
const response = await request.post(`${API_BASE}/auth/logout`);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('POST /api/auth/refresh', () => {
|
||||
test('should return 400 without refresh cookie', async ({ request }) => {
|
||||
// Create a fresh context without cookies
|
||||
const response = await request.post(`${API_BASE}/auth/refresh`);
|
||||
// Should fail — no refresh token cookie
|
||||
expect([400, 401]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user