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
This commit is contained in:
Lucas Berger
2026-06-13 15:16:11 -04:00
parent 2c2c71e7cc
commit 7808426a2f
5 changed files with 717 additions and 4 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* 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);
});
});