/** * admin.spec.ts — Phase 10 Plan 04 admin route gate + Phase 17 Plan 06 two-tab ARIA * * Tests: * - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry * and reaches /admin with "Admin Settings" heading + Members section. * - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is * redirected from /admin to /calendar. * - Phase 17 (D-10): Two-tab ARIA strip — tablist + named tabs visible, ArrowRight * moves selection to the Settings tab (keyboard nav). * - Phase 17 (D-08): Success toast — role=status element structure present. * * Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md). * global-setup seeds: users id=1 is_admin=true (Plan 10-01 note). * * Route-mock pattern for non-admin simulation: * page.route('/api/me', ...) → { user: { ..., isAdmin: false, needsProviderSetup: false } } * per [[dev-data-user1-no-calendars]] idiom + lists.spec page.route precedent. * * Runs on all three device profiles automatically (playwright.config.ts matrix): * iphone: iPhone 14 / WebKit / 390×844 * pixel: Pixel 7 / Chromium / 412×915 * desktop: Desktop Chrome / Chromium / 1280×720 * * Run: * pnpm --filter @familysync/pwa test:e2e * pnpm --filter @familysync/pwa exec playwright test admin.spec.ts * pnpm --filter @familysync/pwa test:e2e -- admin */ import { test, expect } from '@playwright/test'; // ── Admin user (seeded is_admin=true) ───────────────────────────────────────── test.describe('Admin user — admin nav entry + /admin route', () => { test.beforeEach(async ({ page }) => { await page.goto('/calendar'); // Wait for auth and nav to be visible before asserting await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible(); }); test('admin sees the Admin nav entry (ShieldCheck, aria-label="Admin settings")', async ({ page, }) => { // The admin nav link is rendered with aria-label="Admin settings" in both // AppNav (desktop) and BottomTabBar (mobile). const adminEntry = page.getByRole('link', { name: 'Admin settings' }); await expect(adminEntry).toBeVisible(); }); test('admin reaches /admin and sees "Admin Settings" heading', async ({ page }) => { // Navigate directly — also verifies the route guard does NOT redirect admins await page.goto('/admin'); await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible(); // The page heading is "Admin Settings" await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible(); }); test('admin /admin page renders the MEMBERS section', async ({ page }) => { await page.goto('/admin'); await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible(); // The section is labeled "Members" (aria-label on
) await expect(page.getByRole('region', { name: 'Members' })).toBeVisible(); }); }); // ── Non-admin user (route-mocked isAdmin:false) ─────────────────────────────── test.describe('Non-admin user — admin nav entry hidden + /admin redirect', () => { // Route-mock /api/me to return isAdmin:false BEFORE navigation so the PWA // never sees isAdmin:true in this test context. const mockNonAdminMe = async (page: import('@playwright/test').Page) => { await page.route('/api/me', (route) => { void route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 1, displayName: 'Dev User', color: '#4A90D9', isAdmin: false, needsProviderSetup: false, }, }), }); }); }; test('non-admin does NOT see the Admin nav entry', async ({ page }) => { await mockNonAdminMe(page); await page.goto('/calendar'); await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible(); // Admin entry must be absent const adminEntry = page.getByRole('link', { name: 'Admin settings' }); await expect(adminEntry).toHaveCount(0); }); test('non-admin navigating to /admin is redirected to /calendar', async ({ page }) => { await mockNonAdminMe(page); await page.goto('/admin'); // Wait for meQuery to resolve and redirect to fire — the Navigate component // replaces the URL once meQuery.isLoading = false + isAdmin = false. await page.waitForURL(/\/calendar/, { timeout: 10_000 }); // Should have landed on /calendar const url = new URL(page.url()); expect(url.pathname, `Expected /calendar but got ${url.pathname}`).toMatch(/^\/(calendar)?$/); // "Admin Settings" heading must NOT be present await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0); }); }); // ── Phase 17 D-10: Two-tab ARIA strip ──────────────────────────────────────── test.describe('Admin two-tab ARIA strip (D-10)', () => { test.beforeEach(async ({ page }) => { await page.goto('/admin'); // Wait for the heading to confirm /admin loaded (admin session via DEV_AUTH_BYPASS) await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible(); }); test('tab strip has correct ARIA roles — tablist and both named tabs visible', async ({ page, }) => { await expect(page.getByRole('tablist')).toBeVisible(); await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible(); }); test('Members & Accounts tab is selected by default', async ({ page }) => { const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); await expect(membersTab).toHaveAttribute('aria-selected', 'true'); }); test('ArrowRight switches selection to the Settings tab', async ({ page }) => { const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); const settingsTab = page.getByRole('tab', { name: 'Settings' }); await membersTab.focus(); await page.keyboard.press('ArrowRight'); await expect(settingsTab).toHaveAttribute('aria-selected', 'true'); }); test('ArrowLeft from Settings tab switches back to Members & Accounts tab', async ({ page }) => { const membersTab = page.getByRole('tab', { name: 'Members & Accounts' }); const settingsTab = page.getByRole('tab', { name: 'Settings' }); // Navigate to Settings first await membersTab.focus(); await page.keyboard.press('ArrowRight'); await expect(settingsTab).toHaveAttribute('aria-selected', 'true'); // Then go back await page.keyboard.press('ArrowLeft'); await expect(membersTab).toHaveAttribute('aria-selected', 'true'); }); test('both tab panels exist with correct ARIA labelledby', async ({ page }) => { // Both panels are in the DOM; the inactive one uses the HTML `hidden` attribute const membersPanel = page.locator('#admin-panel-members'); const settingsPanel = page.locator('#admin-panel-settings'); await expect(membersPanel).toHaveAttribute('aria-labelledby', 'admin-tab-members'); await expect(settingsPanel).toHaveAttribute('aria-labelledby', 'admin-tab-settings'); }); test('tab strip fits without horizontal overflow on phone viewport (390px)', async ({ page, viewport, }) => { // Only meaningful on narrow viewports; skip on desktop if ((viewport?.width ?? 1280) >= 768) return; const tablist = page.getByRole('tablist'); // Verify both tabs are visible (no overflow clipping them) await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible(); // scrollWidth <= clientWidth (no horizontal overflow) const overflows = await tablist.evaluate((el) => el.scrollWidth > el.clientWidth); expect(overflows, 'Tab strip must not overflow horizontally').toBe(false); }); }); // ── Phase 17 D-08: Success toast structure ─────────────────────────────────── test.describe('Admin success toast structure (D-08)', () => { test.beforeEach(async ({ page }) => { await page.goto('/admin'); await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible(); }); test('role=status live region is not present when no toast is active', async ({ page }) => { // On page load no toast should be showing // The toast is rendered conditionally only when toast !== null await expect(page.locator('[role="status"]')).toHaveCount(0); }); });