platform-codebase/e2e/mvp0/admin.spec.ts

545 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
});