545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
/**
|
||
* CUJ-8: Admin Dashboard
|
||
*
|
||
* We verify the admin panel at http://admin.atlilith.local across all major
|
||
* sections. Key regression targets:
|
||
*
|
||
* LRA-013 — Admin dashboard renders without crash on initial load
|
||
* LRA-014 — ErrorBoundary resets properly on route change (no stale errors)
|
||
* LRA-015 — /infrastructure/services has no infinite re-render loop
|
||
*
|
||
* Additional coverage:
|
||
* - Analytics charts/metrics render
|
||
* - QA reports panel loads
|
||
* - Subscription management
|
||
* - Content moderation queue
|
||
* - Refund processing UI
|
||
* - Email logs viewer (delivery status, bounce suppression)
|
||
* - Queue admin (BullMQ job management)
|
||
*
|
||
* We navigate explicitly to the admin subdomain — the baseURL in
|
||
* playwright.config.ts points to TrustedMeet, so all admin tests use
|
||
* absolute URLs against ADMIN_URL.
|
||
*/
|
||
|
||
import { test, expect, type TestAccountRole } from './fixtures/auth.fixture';
|
||
import { ATLILITH } from './fixtures/brand.fixture';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ADMIN_BASE = ATLILITH.admin;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Attach both a JS error collector and a network error collector.
|
||
* Returns a getter for each so tests can assert at the end.
|
||
*/
|
||
function attachErrorCollectors(page: import('@playwright/test').Page): {
|
||
getJsErrors: () => Error[];
|
||
getNetworkErrors: () => string[];
|
||
} {
|
||
const jsErrors: Error[] = [];
|
||
const networkErrors: string[] = [];
|
||
|
||
page.on('pageerror', (err) => jsErrors.push(err));
|
||
page.on('response', (response) => {
|
||
// Collect 5xx responses (server errors that indicate broken routes)
|
||
if (response.status() >= 500) {
|
||
networkErrors.push(`${response.status()} ${response.url()}`);
|
||
}
|
||
});
|
||
|
||
return {
|
||
getJsErrors: () => jsErrors,
|
||
getNetworkErrors: () => networkErrors,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Login as admin and navigate to the admin panel.
|
||
* The admin app may have its own auth form separate from the marketplace SSO.
|
||
*/
|
||
async function loginAsAdmin(
|
||
page: import('@playwright/test').Page,
|
||
loginAs: (role: TestAccountRole) => Promise<unknown>,
|
||
): Promise<void> {
|
||
// We inject the session via the standard auth fixture (API-level login),
|
||
// then navigate to the admin subdomain. The admin app reads the same
|
||
// lilith_session key from localStorage — verified by the auth fixture docs.
|
||
await loginAs('admin');
|
||
|
||
// Navigate to admin panel explicitly
|
||
await page.goto(ADMIN_BASE, { waitUntil: 'domcontentloaded' });
|
||
|
||
// If there's a separate admin login form, handle it
|
||
const isOnLoginPage =
|
||
(await page.locator('input[type="password"]').isVisible({ timeout: 3000 }).catch(() => false));
|
||
|
||
if (isOnLoginPage) {
|
||
const { TEST_ACCOUNTS } = await import('./fixtures/auth.fixture');
|
||
const adminAccount = TEST_ACCOUNTS['admin'];
|
||
await page.locator('input[name="email"], input[type="email"]').fill(adminAccount.email);
|
||
await page.locator('input[name="password"], input[type="password"]').fill(adminAccount.password);
|
||
await page.locator('button[type="submit"]').click();
|
||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15000 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Navigate to an admin route and assert it renders without a React crash.
|
||
*/
|
||
async function assertAdminRouteRenders(
|
||
page: import('@playwright/test').Page,
|
||
path: string,
|
||
description: string,
|
||
): Promise<void> {
|
||
await page.goto(`${ADMIN_BASE}${path}`, { waitUntil: 'domcontentloaded' });
|
||
|
||
// Must have a content container
|
||
await expect(
|
||
page.locator('main, [role="main"], [data-testid*="page"], .admin-content').first(),
|
||
).toBeVisible({ timeout: 15000 });
|
||
|
||
// Must not be an ErrorBoundary fallback
|
||
const errorFallback = page.locator(
|
||
'text=/something went wrong|an error occurred|error boundary/i, [data-testid="error-boundary-fallback"]',
|
||
);
|
||
await expect(errorFallback).toBeHidden({ timeout: 5000 });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Suite
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test.describe('CUJ-8: Admin Dashboard', () => {
|
||
|
||
// -------------------------------------------------------------------------
|
||
// LRA-013: Dashboard renders without crash
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('LRA-013: admin dashboard loads without crash', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// Must render main content — not a blank white screen
|
||
await expect(
|
||
page.locator('main, [role="main"], [data-testid*="dashboard"], nav').first(),
|
||
).toBeVisible({ timeout: 15000 });
|
||
|
||
// Must not show an error boundary
|
||
const errorFallback = page.locator(
|
||
'text=/something went wrong|an error occurred/i',
|
||
);
|
||
await expect(errorFallback).toBeHidden({ timeout: 5000 });
|
||
|
||
// No unhandled JS exceptions on initial load
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// LRA-014: ErrorBoundary resets on route change
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('LRA-014: ErrorBoundary resets on route change — no stale error state', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// Navigate between several pages — each must render without a stale error
|
||
const routes = [
|
||
{ path: '/', label: 'dashboard' },
|
||
{ path: '/analytics', label: 'analytics' },
|
||
{ path: '/users', label: 'users' },
|
||
{ path: '/', label: 'dashboard again (reset check)' },
|
||
];
|
||
|
||
for (const route of routes) {
|
||
await page.goto(`${ADMIN_BASE}${route.path}`, { waitUntil: 'domcontentloaded' });
|
||
|
||
const errorFallback = page.locator(
|
||
'text=/something went wrong|an error occurred|error boundary/i, [data-testid="error-boundary-fallback"]',
|
||
);
|
||
await expect(errorFallback).toBeHidden({
|
||
timeout: 5000,
|
||
});
|
||
|
||
// Content must render after each navigation
|
||
await expect(
|
||
page.locator('main, [role="main"], [data-testid*="page"]').first(),
|
||
).toBeVisible({ timeout: 10000 });
|
||
}
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// LRA-015: /infrastructure/services — no infinite re-render
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('LRA-015: /infrastructure/services has no infinite re-render (30s monitoring)', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// Track the number of network requests — infinite renders cause polling loops
|
||
let requestCount = 0;
|
||
page.on('request', () => { requestCount++; });
|
||
|
||
await page.goto(`${ADMIN_BASE}/infrastructure/services`, { waitUntil: 'domcontentloaded' });
|
||
|
||
// Wait for initial render
|
||
await expect(
|
||
page.locator('main, [role="main"], [data-testid*="services"]').first(),
|
||
).toBeVisible({ timeout: 15000 });
|
||
|
||
// Record baseline request count after initial load
|
||
const baselineCount = requestCount;
|
||
|
||
// Monitor for 30 seconds — infinite renders would cause hundreds of requests
|
||
await page.waitForTimeout(30000);
|
||
|
||
const requestsDuringMonitoring = requestCount - baselineCount;
|
||
|
||
// We allow up to 60 background requests in 30s (e.g., polling for service
|
||
// health every 5s × 10 services). Anything above 200 indicates a loop.
|
||
expect(requestsDuringMonitoring).toBeLessThan(200);
|
||
|
||
// Page must still be functional after 30s
|
||
const errorFallback = page.locator(
|
||
'text=/something went wrong|an error occurred/i',
|
||
);
|
||
await expect(errorFallback).toBeHidden();
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Analytics dashboard
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('analytics dashboard renders charts and metrics', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
await assertAdminRouteRenders(page, '/analytics', 'Analytics dashboard');
|
||
|
||
// At least one chart (canvas or SVG) or metric card must be present
|
||
const chartOrMetric = page.locator(
|
||
'canvas, svg[data-testid*="chart"], [data-testid*="metric-card"], [data-testid*="stat-card"], [aria-label*="chart" i]',
|
||
).first();
|
||
await expect(chartOrMetric).toBeVisible({ timeout: 15000 });
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// QA Reports panel
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('QA reports panel loads without error', async ({ page, loginAs }) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// Try common QA report routes
|
||
const qaRoutes = ['/qa', '/qa/reports', '/reports/qa'];
|
||
let rendered = false;
|
||
|
||
for (const route of qaRoutes) {
|
||
await page.goto(`${ADMIN_BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||
const is404 = await page
|
||
.locator('text=/404|not found/i')
|
||
.isVisible({ timeout: 3000 })
|
||
.catch(() => false);
|
||
|
||
if (!is404) {
|
||
rendered = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (rendered) {
|
||
const errorFallback = page.locator('text=/something went wrong|error boundary/i');
|
||
await expect(errorFallback).toBeHidden({ timeout: 5000 });
|
||
|
||
const content = page.locator(
|
||
'main, [role="main"], [data-testid*="qa-report"], [data-testid*="report"]',
|
||
).first();
|
||
await expect(content).toBeVisible({ timeout: 10000 });
|
||
} else {
|
||
// QA reports may not yet exist as a route — not a failure
|
||
test.info().annotations.push({
|
||
type: 'skip-reason',
|
||
description: 'QA reports route not found at /qa, /qa/reports, or /reports/qa',
|
||
});
|
||
}
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Subscription management
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('subscription management admin renders', async ({ page, loginAs }) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// Try common subscription admin routes
|
||
const subRoutes = ['/subscriptions', '/admin/subscriptions', '/payments/subscriptions'];
|
||
let foundRoute = '';
|
||
|
||
for (const route of subRoutes) {
|
||
await page.goto(`${ADMIN_BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||
const is404 = await page
|
||
.locator('text=/404|not found/i')
|
||
.isVisible({ timeout: 3000 })
|
||
.catch(() => false);
|
||
if (!is404) {
|
||
foundRoute = route;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (foundRoute) {
|
||
await assertAdminRouteRenders(page, foundRoute, 'Subscription management');
|
||
|
||
// Table or list of subscriptions (or empty state) must render
|
||
const subList = page.locator(
|
||
'table, [data-testid*="subscription"], text=/no subscriptions|0 subscriptions/i',
|
||
).first();
|
||
await expect(subList).toBeVisible({ timeout: 10000 });
|
||
} else {
|
||
test.info().annotations.push({
|
||
type: 'skip-reason',
|
||
description: 'Subscription admin route not found',
|
||
});
|
||
}
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Content moderation queue
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('content moderation queue renders', async ({ page, loginAs }) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
const modRoutes = ['/moderation', '/content/moderation', '/admin/moderation'];
|
||
let foundRoute = '';
|
||
|
||
for (const route of modRoutes) {
|
||
await page.goto(`${ADMIN_BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||
const is404 = await page
|
||
.locator('text=/404|not found/i')
|
||
.isVisible({ timeout: 3000 })
|
||
.catch(() => false);
|
||
if (!is404) {
|
||
foundRoute = route;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (foundRoute) {
|
||
await assertAdminRouteRenders(page, foundRoute, 'Content moderation queue');
|
||
|
||
const queueOrEmpty = page.locator(
|
||
'[data-testid*="moderation-queue"], [data-testid*="mod-item"], table, text=/queue is empty|no items|pending review/i',
|
||
).first();
|
||
await expect(queueOrEmpty).toBeVisible({ timeout: 10000 });
|
||
} else {
|
||
test.info().annotations.push({
|
||
type: 'skip-reason',
|
||
description: 'Moderation route not found',
|
||
});
|
||
}
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Refund processing
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('refund processing UI renders with required admin action', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
const refundRoutes = ['/refunds', '/payments/refunds', '/admin/refunds'];
|
||
let foundRoute = '';
|
||
|
||
for (const route of refundRoutes) {
|
||
await page.goto(`${ADMIN_BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||
const is404 = await page
|
||
.locator('text=/404|not found/i')
|
||
.isVisible({ timeout: 3000 })
|
||
.catch(() => false);
|
||
if (!is404) {
|
||
foundRoute = route;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (foundRoute) {
|
||
await assertAdminRouteRenders(page, foundRoute, 'Refund processing');
|
||
|
||
// Refund list, empty state, or process-refund button must appear
|
||
const refundUi = page.locator(
|
||
'[data-testid*="refund"], table, button:has-text(/refund/i), text=/no refunds|process refund/i',
|
||
).first();
|
||
await expect(refundUi).toBeVisible({ timeout: 10000 });
|
||
} else {
|
||
test.info().annotations.push({
|
||
type: 'skip-reason',
|
||
description: 'Refunds route not found',
|
||
});
|
||
}
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Email logs viewer
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('email logs viewer shows delivery status and bounce suppression', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
const emailLogRoutes = ['/email/logs', '/admin/email', '/email'];
|
||
let foundRoute = '';
|
||
|
||
for (const route of emailLogRoutes) {
|
||
await page.goto(`${ADMIN_BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||
const is404 = await page
|
||
.locator('text=/404|not found/i')
|
||
.isVisible({ timeout: 3000 })
|
||
.catch(() => false);
|
||
if (!is404) {
|
||
foundRoute = route;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (foundRoute) {
|
||
await assertAdminRouteRenders(page, foundRoute, 'Email logs viewer');
|
||
|
||
// Delivery status column or label must be present
|
||
const deliveryStatus = page.locator(
|
||
'text=/delivery|delivered|bounced|status/i, [data-testid*="email-log"], th:has-text(/status|delivery/i)',
|
||
).first();
|
||
await expect(deliveryStatus).toBeVisible({ timeout: 10000 });
|
||
|
||
// Bounce suppression section or filter
|
||
const bounceSection = page.locator(
|
||
'text=/bounce|suppressed/i, [data-testid*="bounce"], [aria-label*="bounce" i]',
|
||
).first();
|
||
const hasBounce = await bounceSection.isVisible({ timeout: 5000 }).catch(() => false);
|
||
// Bounce suppression is expected but may be on a sub-tab
|
||
if (!hasBounce) {
|
||
// Look for a "Bounces" tab
|
||
const bouncesTab = page.locator('[role="tab"]:has-text(/bounce/i)');
|
||
const hasBouncesTab = await bouncesTab.isVisible({ timeout: 3000 }).catch(() => false);
|
||
if (hasBouncesTab) {
|
||
await bouncesTab.click();
|
||
await expect(page.locator('[role="tabpanel"]:visible').first()).toBeVisible({
|
||
timeout: 5000,
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
test.info().annotations.push({
|
||
type: 'skip-reason',
|
||
description: 'Email logs route not found',
|
||
});
|
||
}
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Queue admin (BullMQ)
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('BullMQ queue admin renders job management interface', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
const { getJsErrors } = attachErrorCollectors(page);
|
||
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// BullMQ board is commonly served at /admin/queues or /queues
|
||
const queueRoutes = ['/queues', '/admin/queues', '/bull'];
|
||
let foundRoute = '';
|
||
|
||
for (const route of queueRoutes) {
|
||
await page.goto(`${ADMIN_BASE}${route}`, { waitUntil: 'domcontentloaded' });
|
||
const is404 = await page
|
||
.locator('text=/404|not found/i')
|
||
.isVisible({ timeout: 3000 })
|
||
.catch(() => false);
|
||
if (!is404) {
|
||
foundRoute = route;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (foundRoute) {
|
||
// BullMQ board or custom queue management must render
|
||
const queueUi = page.locator(
|
||
'[data-testid*="queue"], .bull-master, [aria-label*="queue" i], text=/job|queue|worker/i, table',
|
||
).first();
|
||
await expect(queueUi).toBeVisible({ timeout: 15000 });
|
||
|
||
// Must not be an error page
|
||
const errorFallback = page.locator('text=/something went wrong|error boundary/i');
|
||
await expect(errorFallback).toBeHidden({ timeout: 5000 });
|
||
} else {
|
||
test.info().annotations.push({
|
||
type: 'skip-reason',
|
||
description: 'Queue admin route not found',
|
||
});
|
||
}
|
||
|
||
expect(getJsErrors()).toHaveLength(0);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Navigation sanity — sidebar / top nav
|
||
// -------------------------------------------------------------------------
|
||
|
||
test('admin navigation sidebar/topnav is present and has links', async ({
|
||
page,
|
||
loginAs,
|
||
}) => {
|
||
await loginAsAdmin(page, loginAs);
|
||
|
||
// Navigation must have more than 3 links (sanity check for not-blank)
|
||
const navLinks = page.locator('nav a, [role="navigation"] a, aside a');
|
||
const count = await navLinks.count();
|
||
expect(count).toBeGreaterThan(3);
|
||
});
|
||
});
|