Files
familysync/apps/pwa/e2e/admin.spec.ts
T
Lucas Berger 7808426a2f feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec
- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state
- App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin
- AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating)
- BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating)
- e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar
- All 15 e2e tests pass (iphone/pixel/desktop); production build clean
2026-06-13 15:16:11 -04:00

110 lines
4.6 KiB
TypeScript
Raw 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.
/**
* admin.spec.ts — Phase 10 Plan 04 admin route gate
*
* 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.
*
* 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 <section>)
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);
});
});