The dashboard and residential interest smoke tests were intermittently
failing with the page rendering empty/skeleton state. Root causes:
1. ui-store persisted currentPortId/Slug, but those are URL-derived state.
After login lands on /<first-port-by-name>/dashboard, localStorage holds
that port. Hard-navigating to /port-nimara/... rehydrated the store with
the stale id, and useQuery fired with the wrong port before
PortProvider's URL-sync useEffect could correct it. Drop both fields
from partialize — PortProvider re-derives them from the route every
navigation.
2. apiFetch's slug-to-port fallback fired N parallel /api/v1/admin/ports
calls when N components mounted simultaneously with an empty store.
Dedupe in-flight lookups so a stampede collapses into one round-trip.
Also tightened four flaky smoke tests that depended on a fixed 3s wait or
non-waiting isVisible({timeout}) — replaced with expect(...).toBeVisible
or expect.poll so they handle dev-mode JIT cold-start delays cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { login, navigateTo } from './helpers';
|
|
|
|
test.describe('Admin Features', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await login(page, 'super_admin');
|
|
});
|
|
|
|
// ── Webhooks ─────────────────────────────────────────────────────────────
|
|
|
|
test('webhook admin page loads with heading and add button', async ({ page }) => {
|
|
await navigateTo(page, '/admin/webhooks');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const heading = page.getByText(/webhook/i).first();
|
|
await expect(heading).toBeVisible({ timeout: 10_000 });
|
|
|
|
// "Add Webhook" button on the page (not the topbar "+ New")
|
|
const addBtn = page.getByRole('button', { name: 'Add Webhook' }).first();
|
|
await expect(addBtn).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('webhook page shows list or empty state', async ({ page }) => {
|
|
await navigateTo(page, '/admin/webhooks');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
// Should show either existing webhooks (table or cards) or an empty-state message
|
|
const hasTable = await page
|
|
.locator('table')
|
|
.first()
|
|
.isVisible({ timeout: 3_000 })
|
|
.catch(() => false);
|
|
const hasWebhookUrl = await page
|
|
.getByText(/^https?:\/\//i)
|
|
.first()
|
|
.isVisible({ timeout: 3_000 })
|
|
.catch(() => false);
|
|
const hasEmptyState = await page
|
|
.getByText(/no webhooks|add your first|get started/i)
|
|
.first()
|
|
.isVisible({ timeout: 3_000 })
|
|
.catch(() => false);
|
|
|
|
expect(hasTable || hasWebhookUrl || hasEmptyState).toBeTruthy();
|
|
});
|
|
|
|
// ── Custom Fields ─────────────────────────────────────────────────────────
|
|
|
|
test('custom fields admin page loads with entity tabs', async ({ page }) => {
|
|
await navigateTo(page, '/admin/custom-fields');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const heading = page.getByRole('heading', { name: /custom fields/i }).first();
|
|
await expect(heading).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Should have tabs for each entity type
|
|
const clientsTab = page.getByRole('tab', { name: /client/i }).first();
|
|
await expect(clientsTab).toBeVisible({ timeout: 5_000 });
|
|
|
|
const interestsTab = page.getByRole('tab', { name: /interest/i }).first();
|
|
const berthsTab = page.getByRole('tab', { name: /berth/i }).first();
|
|
|
|
const hasInterests = await interestsTab.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
const hasBerths = await berthsTab.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
|
|
// At least two entity tabs should be visible
|
|
const tabCount = [true, hasInterests, hasBerths].filter(Boolean).length;
|
|
expect(tabCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('custom fields page shows New Field button', async ({ page }) => {
|
|
await navigateTo(page, '/admin/custom-fields');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const newFieldBtn = page.getByRole('button', { name: 'New Field' }).first();
|
|
await expect(newFieldBtn).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('custom fields tabs are clickable and switch content', async ({ page }) => {
|
|
await navigateTo(page, '/admin/custom-fields');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
// Click through available tabs
|
|
const tabs = page.getByRole('tab');
|
|
const tabCount = await tabs.count();
|
|
|
|
if (tabCount >= 2) {
|
|
// Click the second tab
|
|
await tabs.nth(1).click();
|
|
await page.waitForTimeout(1_000);
|
|
|
|
// Page should not crash
|
|
const body = await page
|
|
.locator('body')
|
|
.textContent()
|
|
.catch(() => '');
|
|
expect(body && body.length > 10).toBeTruthy();
|
|
|
|
// Click back to first tab
|
|
await tabs.first().click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
expect(true).toBeTruthy();
|
|
});
|
|
|
|
// ── Document Templates ────────────────────────────────────────────────────
|
|
|
|
test('document templates page loads', async ({ page }) => {
|
|
await navigateTo(page, '/admin/templates');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const heading = page.getByText(/template/i).first();
|
|
await expect(heading).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('document templates page shows list or empty state', async ({ page }) => {
|
|
await navigateTo(page, '/admin/templates');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const hasTable = await page
|
|
.locator('table')
|
|
.isVisible({ timeout: 5_000 })
|
|
.catch(() => false);
|
|
const hasCards = await page
|
|
.locator('[class*="card"], [class*="template"]')
|
|
.first()
|
|
.isVisible({ timeout: 3_000 })
|
|
.catch(() => false);
|
|
const hasEmptyState = await page
|
|
.getByText(/no templates|create your first|get started/i)
|
|
.isVisible({ timeout: 5_000 })
|
|
.catch(() => false);
|
|
|
|
expect(hasTable || hasCards || hasEmptyState).toBeTruthy();
|
|
});
|
|
|
|
test('document templates new/create button is visible', async ({ page }) => {
|
|
await navigateTo(page, '/admin/templates');
|
|
await page.waitForTimeout(2_000);
|
|
|
|
const createBtn = page.getByRole('button', { name: 'New Template' }).first();
|
|
await expect(createBtn).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
// ── System Monitoring ─────────────────────────────────────────────────────
|
|
|
|
test('monitoring dashboard shows PostgreSQL and Redis status cards', async ({ page }) => {
|
|
await navigateTo(page, '/admin/monitoring');
|
|
await page.waitForTimeout(3_000);
|
|
|
|
const pgStatus = page.getByText(/postgres/i).first();
|
|
await expect(pgStatus).toBeVisible({ timeout: 10_000 });
|
|
|
|
const redisStatus = page.getByText(/redis/i).first();
|
|
await expect(redisStatus).toBeVisible({ timeout: 5_000 });
|
|
|
|
// At least one health indicator should show a status
|
|
const healthIndicator = page.getByText(/healthy|degraded|down|ok/i).first();
|
|
await expect(healthIndicator).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test('monitoring dashboard shows queue overview with expected queues', async ({ page }) => {
|
|
await navigateTo(page, '/admin/monitoring');
|
|
|
|
// Wait for the queue overview section to render. The queue cards expose
|
|
// names that don't appear in the sidebar (e.g. webhooks, maintenance) so
|
|
// we anchor on one of those to confirm the panel has loaded.
|
|
await expect(page.getByText('webhooks', { exact: false }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// All 10 expected queue names from QUEUE_CONFIGS
|
|
const expectedQueues = [
|
|
'email',
|
|
'documents',
|
|
'notifications',
|
|
'import',
|
|
'export',
|
|
'reports',
|
|
'webhooks',
|
|
'maintenance',
|
|
'ai',
|
|
'bulk',
|
|
];
|
|
|
|
let foundCount = 0;
|
|
for (const queueName of expectedQueues) {
|
|
const queueEl = page.getByText(queueName, { exact: false }).first();
|
|
const visible = await queueEl.isVisible().catch(() => false);
|
|
if (visible) foundCount++;
|
|
}
|
|
|
|
// Should find at least 8 out of 10 queues
|
|
expect(foundCount).toBeGreaterThanOrEqual(8);
|
|
});
|
|
|
|
test('monitoring page auto-refreshes or has refresh control', async ({ page }) => {
|
|
await navigateTo(page, '/admin/monitoring');
|
|
await page.waitForTimeout(3_000);
|
|
|
|
// Look for a refresh button or auto-refresh indicator
|
|
const refreshBtn = page.getByRole('button', { name: /refresh|reload/i }).first();
|
|
|
|
const autoRefreshToggle = page.getByText(/auto.?refresh|live|polling/i).first();
|
|
|
|
const hasRefresh = await refreshBtn.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
const hasAutoRefresh = await autoRefreshToggle.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
|
|
// Either a manual refresh button or auto-refresh label is acceptable
|
|
// (Some monitoring UIs auto-refresh silently without UI controls)
|
|
expect(hasRefresh || hasAutoRefresh || true).toBeTruthy();
|
|
});
|
|
|
|
test('monitoring shows queue stats with numeric values', async ({ page }) => {
|
|
await navigateTo(page, '/admin/monitoring');
|
|
await page.waitForTimeout(3_000);
|
|
|
|
// Queue stats should display numeric counts (even if all zeros)
|
|
const numericStats = page.locator('text=/^\\d+$/').first();
|
|
const hasStats = await numericStats.isVisible({ timeout: 5_000 }).catch(() => false);
|
|
|
|
if (!hasStats) {
|
|
// Broader search: any element containing just a number
|
|
const anyNumber = page
|
|
.locator('[class*="count"], [class*="stat"], [class*="badge"]')
|
|
.filter({ hasText: /^\d+$/ })
|
|
.first();
|
|
const hasAnyNumber = await anyNumber.isVisible({ timeout: 3_000 }).catch(() => false);
|
|
expect(hasAnyNumber || true).toBeTruthy();
|
|
}
|
|
|
|
expect(true).toBeTruthy();
|
|
});
|
|
});
|