fixes and refactor

This commit is contained in:
HotSwapp
2025-08-14 19:16:28 -05:00
parent 5111079149
commit bfc04a6909
61 changed files with 5689 additions and 767 deletions

69
e2e/global-setup.js Normal file
View File

@@ -0,0 +1,69 @@
// Global setup to seed admin user before Playwright tests
const { spawnSync } = require('child_process');
const fs = require('fs');
const jwt = require('jsonwebtoken');
module.exports = async () => {
const SECRET_KEY = process.env.SECRET_KEY || 'x'.repeat(32);
const path = require('path');
const dbPath = path.resolve(__dirname, '..', '.e2e-db.sqlite');
const DATABASE_URL = process.env.DATABASE_URL || `sqlite:////${dbPath}`;
// Ensure a clean database for deterministic tests
try { fs.rmSync(dbPath, { force: true }); } catch (_) {}
const pyCode = `
from sqlalchemy.orm import sessionmaker
from app.database.base import engine
from app.models import BaseModel
from app.models.user import User
from app.auth.security import get_password_hash
import os
# Ensure tables
BaseModel.metadata.create_all(bind=engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
admin = db.query(User).filter(User.username=='admin').first()
if not admin:
admin = User(
username=os.getenv('ADMIN_USERNAME','admin'),
email=os.getenv('ADMIN_EMAIL','admin@delphicg.local'),
full_name=os.getenv('ADMIN_FULLNAME','System Administrator'),
hashed_password=get_password_hash(os.getenv('ADMIN_PASSWORD','admin123')),
is_active=True,
is_admin=True,
)
db.add(admin)
db.commit()
print('Seeded admin user')
else:
print('Admin user already exists')
finally:
db.close()
`;
const env = {
...process.env,
SECRET_KEY,
DATABASE_URL,
ADMIN_EMAIL: 'admin@example.com',
ADMIN_USERNAME: 'admin',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
};
let res = spawnSync('python3', ['-c', pyCode], { env, stdio: 'inherit' });
if (res.error) {
res = spawnSync('python', ['-c', pyCode], { env, stdio: 'inherit' });
if (res.error) throw res.error;
}
// Pre-generate a valid access token to bypass login DB writes in tests
const token = jwt.sign({ sub: env.ADMIN_USERNAME, type: 'access' }, env.SECRET_KEY, { expiresIn: '4h' });
// Persist to a file for the tests to read
const tokenPath = path.resolve(__dirname, '..', '.e2e-token');
fs.writeFileSync(tokenPath, token, 'utf-8');
};

239
e2e/search.e2e.spec.js Normal file
View File

@@ -0,0 +1,239 @@
// Playwright E2E tests for Advanced Search UI
const { test, expect } = require('@playwright/test');
async function loginAndSetTokens(page) {
// Read pre-generated access token
const fs = require('fs');
const path = require('path');
const tokenPath = path.resolve(__dirname, '..', '.e2e-token');
const access = fs.readFileSync(tokenPath, 'utf-8').trim();
const refresh = '';
await page.addInitScript((a, r) => {
try { window.localStorage.setItem('auth_token', a); } catch (_) {}
try { if (r) window.localStorage.setItem('refresh_token', r); } catch (_) {}
}, access, refresh);
return access;
}
async function apiCreateCustomer(page, payload, token) {
// Use import endpoint to avoid multiple writes and simplify schema
const req = await page.request.post('/api/import/customers', {
data: { customers: [payload] },
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
expect(req.ok()).toBeTruthy();
// Return id directly
return payload.id;
}
async function apiCreateFile(page, payload, token) {
const req = await page.request.post('/api/import/files', {
data: { files: [payload] },
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
expect(req.ok()).toBeTruthy();
return payload.file_no;
}
test.describe('Advanced Search UI', () => {
test.beforeEach(async ({ page }) => {
// no-op here; call per test to capture token
});
test('returns highlighted results and enforces XSS safety', async ({ page }) => {
const token = `E2E-${Date.now()}`;
const accessToken = await loginAndSetTokens(page);
const malicious = `${token} <img src=x onerror=alert(1)>`;
await apiCreateCustomer(page, {
id: `E2E-CUST-${Date.now()}`,
first: 'Alice',
last: malicious,
email: `alice.${Date.now()}@example.com`,
city: 'Austin',
abrev: 'TX',
}, accessToken);
await page.goto('/search');
await page.fill('#searchQuery', token);
await page.click('#advancedSearchForm button[type="submit"]');
await page.waitForResponse(res => res.url().includes('/api/search/advanced') && res.request().method() === 'POST');
const results = page.locator('#searchResults .search-result-item');
await expect(results.first()).toBeVisible({ timeout: 10000 });
const matchHtml = page.locator('#searchResults .search-result-item .text-sm.text-info-600');
if (await matchHtml.count()) {
const html = await matchHtml.first().innerHTML();
expect(html).toContain('<strong>');
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script');
}
});
test('pagination works when results exceed page size', async ({ page }) => {
const token = `E2E-PAGE-${Date.now()}`;
const accessToken = await loginAndSetTokens(page);
const today = new Date().toISOString().slice(0, 10);
const ownerId = await apiCreateCustomer(page, {
id: `E2E-P-OWNER-${Date.now()}`,
first: 'Bob',
last: 'Pagination',
email: `bob.${Date.now()}@example.com`,
city: 'Austin',
abrev: 'TX',
}, accessToken);
for (let i = 0; i < 60; i++) {
await apiCreateFile(page, {
file_no: `E2E-F-${Date.now()}-${i}`,
id: ownerId,
regarding: `About ${token} #${i}`,
empl_num: 'E01',
file_type: 'CIVIL',
opened: today,
status: 'ACTIVE',
rate_per_hour: 150,
memo: 'seeded',
}, accessToken);
}
await page.goto('/search');
await page.fill('#searchQuery', token);
await page.click('#advancedSearchForm button[type="submit"]');
await page.waitForResponse(res => res.url().includes('/api/search/advanced') && res.request().method() === 'POST');
const pager = page.locator('#searchPagination');
await expect(pager).toBeVisible({ timeout: 10000 });
const firstPageActive = page.locator('#searchPagination button.bg-primary-600');
await expect(firstPageActive).toContainText('1');
const next = page.locator('#searchPagination button', { hasText: 'Next' });
await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/search/advanced') && res.request().method() === 'POST'),
next.click(),
]);
const active = page.locator('#searchPagination button.bg-primary-600');
await expect(active).not.toContainText('1');
});
test('suggestions dropdown renders safely and clicking populates input and triggers search', async ({ page }) => {
const token = `E2E-SUG-${Date.now()}`;
await loginAndSetTokens(page);
const suggestionOne = `${token} first`;
const suggestionTwo = `${token} second`;
// Stub the suggestions endpoint for our token
await page.route('**/api/search/suggestions*', async (route) => {
try {
const url = new URL(route.request().url());
const q = url.searchParams.get('q') || '';
if (q.includes(token)) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [
{ text: suggestionOne, category: 'customer', description: 'Name match' },
{ text: suggestionTwo, category: 'file', description: 'File regarding' },
],
}),
});
}
} catch (_) {}
return route.fallback();
});
// Stub the advanced search to assert it gets triggered with clicked suggestion
let receivedQuery = null;
await page.route('**/api/search/advanced', async (route) => {
try {
const body = route.request().postDataJSON();
receivedQuery = body?.query || null;
} catch (_) {}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
total_results: 0,
stats: { search_execution_time: 0.001 },
facets: { customer: {}, file: {}, ledger: {}, qdro: {}, document: {}, phone: {} },
results: [],
page_info: { current_page: 1, total_pages: 0, has_previous: false, has_next: false },
}),
});
});
await page.goto('/search');
// Type to trigger suggestions (debounced)
await page.fill('#searchQuery', token);
const dropdown = page.locator('#searchSuggestions');
const items = dropdown.locator('a');
await expect(items).toHaveCount(2, { timeout: 5000 });
await expect(dropdown).toBeVisible();
// Basic safety check — ensure no script tags ended up in suggestions markup
const dropdownHtml = await dropdown.innerHTML();
expect(dropdownHtml).not.toContain('<script');
// Click the first suggestion and expect a search to be performed with that query
await Promise.all([
page.waitForResponse((res) => res.url().includes('/api/search/advanced') && res.request().method() === 'POST'),
items.first().click(),
]);
await expect(page.locator('#searchQuery')).toHaveValue(new RegExp(`^${suggestionOne}`));
expect(receivedQuery || '').toContain(suggestionOne);
});
test('Escape hides suggestions dropdown without triggering a search', async ({ page }) => {
const token = `E2E-ESC-${Date.now()}`;
await loginAndSetTokens(page);
// Track whether advanced search is called
let calledAdvanced = false;
await page.route('**/api/search/advanced', async (route) => {
calledAdvanced = true;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
total_results: 0,
stats: { search_execution_time: 0.001 },
facets: { customer: {}, file: {}, ledger: {}, qdro: {}, document: {}, phone: {} },
results: [],
page_info: { current_page: 1, total_pages: 0, has_previous: false, has_next: false },
}),
});
});
// Stub suggestions so they appear
await page.route('**/api/search/suggestions*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
suggestions: [
{ text: `${token} foo`, category: 'customer', description: '' },
{ text: `${token} bar`, category: 'file', description: '' },
],
}),
});
});
await page.goto('/search');
await page.fill('#searchQuery', token);
const dropdown = page.locator('#searchSuggestions');
await expect(dropdown.locator('a')).toHaveCount(2, { timeout: 5000 });
await expect(dropdown).toBeVisible();
// Press Escape: should hide dropdown and not trigger search
await page.keyboard.press('Escape');
await expect(dropdown).toHaveClass(/hidden/);
expect(calledAdvanced).toBeFalsy();
});
});