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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user