diff --git a/backend/scripts/seed_test_users.py b/backend/scripts/seed_test_users.py index 030c1101..e0037fc2 100644 --- a/backend/scripts/seed_test_users.py +++ b/backend/scripts/seed_test_users.py @@ -2,11 +2,13 @@ """ Create test user accounts for local development. -Creates 4 accounts: - 1. Super Admin – platform-wide admin (manages everything) - 2. Pro Solo User – single user on a "pro" plan - 3. Team Admin – admin of a team account ("team" plan) - 4. Team Engineer – regular engineer on the same team account +Creates 6 accounts: + 1. Super Admin – platform-wide admin (manages everything) + 2. Pro Solo User – single user on a "pro" plan + 3. Team Admin – admin of a team account ("team" plan) + 4. Team Engineer – regular engineer on the same team account + 5. L1 Tech – l1_tech role on the Acme MSP team (E2E: L1 happy path) + 6. Coverage Engineer – engineer with can_cover_l1=True (E2E: coverage banner) Usage: cd backend @@ -71,6 +73,29 @@ USERS = [ "account_name": "Acme MSP", # same shared account "account_role": "engineer", "plan": None, # uses the team_admin's account & subscription + "can_cover_l1": False, + }, + { + "key": "l1_tech", + "name": "Lee L1Tech", + "email": "l1@resolutionflow.example.com", + "is_super_admin": False, + "is_team_admin": False, + "account_name": "Acme MSP", # same shared account as team_admin + "account_role": "l1_tech", + "plan": None, # uses the team_admin's account & subscription + "can_cover_l1": False, + }, + { + "key": "coverage_engineer", + "name": "Casey Coverage", + "email": "engineer-coverage@resolutionflow.example.com", + "is_super_admin": False, + "is_team_admin": False, + "account_name": "Acme MSP", # same shared account as team_admin + "account_role": "engineer", + "plan": None, # uses the team_admin's account & subscription + "can_cover_l1": True, }, ] @@ -114,7 +139,9 @@ async def main() -> None: continue # ---- Create or reuse Account ---- - if cfg["key"] == "team_engineer": + # Users that share the Acme MSP account (no own account to create) + _acme_members = {"team_engineer", "l1_tech", "coverage_engineer"} + if cfg["key"] in _acme_members: if team_account_id is None: result = await conn.execute( text("SELECT id FROM accounts WHERE name = :name"), @@ -145,13 +172,14 @@ async def main() -> None: # 7-day verification grace immediately. Without this, fixtures hit # require_verified_email_after_grace once their created_at ages past # 7 days and get walled out of protected routes. + can_cover_l1 = cfg.get("can_cover_l1", False) await conn.execute( text(""" INSERT INTO users (id, email, password_hash, name, role, is_super_admin, is_team_admin, is_active, account_id, account_role, - created_at, email_verified_at) + can_cover_l1, created_at, email_verified_at) VALUES (:id, :email, :pw, :name, 'engineer', :is_sa, :is_ta, true, - :account_id, :account_role, :now, :now) + :account_id, :account_role, :can_cover_l1, :now, :now) """), { "id": user_id, @@ -162,12 +190,13 @@ async def main() -> None: "is_ta": cfg["is_team_admin"], "account_id": account_id, "account_role": cfg["account_role"], + "can_cover_l1": can_cover_l1, "now": now, }, ) - # Set account owner (skip for team_engineer — they don't own the account) - if cfg["key"] != "team_engineer": + # Set account owner (skip for shared-account members — they don't own the account) + if cfg["key"] not in _acme_members: await conn.execute( text("UPDATE accounts SET owner_id = :uid WHERE id = :aid"), {"uid": user_id, "aid": account_id}, @@ -183,7 +212,8 @@ async def main() -> None: {"id": uuid.uuid4(), "aid": account_id, "plan": cfg["plan"], "now": now}, ) - print(f" [OK] {cfg['email']:40s} account_role={cfg['account_role']:<10s} plan={cfg['plan'] or '(shared)'}") + cover_flag = " [can_cover_l1]" if can_cover_l1 else "" + print(f" [OK] {cfg['email']:40s} account_role={cfg['account_role']:<12s} plan={cfg['plan'] or '(shared)'}{cover_flag}") await engine.dispose() @@ -194,10 +224,12 @@ async def main() -> None: print("=" * 60) print() print(" Accounts:") - print(f" Super Admin : admin@resolutionflow.example.com") - print(f" Pro Solo : pro@resolutionflow.example.com") - print(f" Team Admin : teamadmin@resolutionflow.example.com") - print(f" Team Engineer: engineer@resolutionflow.example.com") + print(f" Super Admin : admin@resolutionflow.example.com") + print(f" Pro Solo : pro@resolutionflow.example.com") + print(f" Team Admin : teamadmin@resolutionflow.example.com") + print(f" Team Engineer : engineer@resolutionflow.example.com") + print(f" L1 Tech : l1@resolutionflow.example.com") + print(f" Coverage Engineer : engineer-coverage@resolutionflow.example.com") print() diff --git a/frontend/e2e/l1-workspace.spec.ts b/frontend/e2e/l1-workspace.spec.ts new file mode 100644 index 00000000..fdf7248d --- /dev/null +++ b/frontend/e2e/l1-workspace.spec.ts @@ -0,0 +1,189 @@ +/** + * E2E tests for the L1 Workspace surface (Phase 1). + * + * Covers: + * 1. L1 user lands on /l1 after login and can start an ad-hoc walk, take + * notes (autosave), and resolve the session. + * 2. L1 user cannot access /pilot, /trees/new, or /escalations — route + * guards bounce them back to /. + * 3. Engineer with can_cover_l1=true sees the "L1 Workspace" nav entry and + * the "You're covering L1" banner. + * 4. escalate-without-walk API endpoint returns an escalated adhoc session + * when called from an authenticated L1 user. + * + * Seed users (added by seed_test_users.py): + * l1@resolutionflow.example.com — account_role=l1_tech + * engineer-coverage@resolutionflow.example.com — engineer + can_cover_l1 + */ + +import { test, expect, type Page } from '@playwright/test' + +// These tests always log in fresh — no shared storageState from auth.setup.ts. +test.use({ storageState: { cookies: [], origins: [] } }) + +const L1_EMAIL = 'l1@resolutionflow.example.com' +const COVERAGE_EMAIL = 'engineer-coverage@resolutionflow.example.com' +const PASSWORD = 'TestPass123!' + +const apiOrigin = process.env.PLAYWRIGHT_API_ORIGIN || 'http://127.0.0.1:8000' + +/** + * Log in via the login form using exact test-IDs / labels that LoginPage uses. + * Uses data-testid="login-form", getByLabel('Email address'), getByLabel('Password'), + * and data-testid="login-submit" — matching the actual LoginPage.tsx markup. + */ +async function login(page: Page, email: string): Promise { + await page.goto('/login') + await expect(page.getByTestId('login-form')).toBeVisible() + await page.getByLabel('Email address').fill(email) + await page.getByLabel('Password').fill(PASSWORD) + await page.getByTestId('login-submit').click() +} + +/** + * Obtain a bearer token for the given email via the JSON login endpoint. + * Used for direct API assertions without going through the browser. + */ +async function getToken( + page: Page, + email: string, +): Promise { + const response = await page.request.post(`${apiOrigin}/api/v1/auth/login/json`, { + data: { email, password: PASSWORD }, + }) + expect(response.ok()).toBeTruthy() + const body = (await response.json()) as { access_token: string } + return body.access_token +} + +test.describe('L1 Workspace', () => { + // ------------------------------------------------------------------------- + // Test 1: Happy path — login → /l1 → start walk → notes → resolve + // ------------------------------------------------------------------------- + test('L1 user lands on /l1 after login and can intake, take notes, and resolve', async ({ page }) => { + await login(page, L1_EMAIL) + + // ProtectedRoute redirects l1_tech from / → /l1 + await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 }) + + // Greeting heading: "Good morning|afternoon|evening, ." + await expect( + page.getByRole('heading', { name: /Good (morning|afternoon|evening)/i }), + ).toBeVisible() + + // Fill in problem statement textarea + const problemTextarea = page.getByPlaceholder("What's the user calling about?") + await expect(problemTextarea).toBeVisible() + await problemTextarea.fill('Customer says Outlook is broken after the latest update') + + // Click "Start walk →" button + await page.getByRole('button', { name: /Start walk/i }).click() + + // Should navigate to /l1/walk/ + await expect(page).toHaveURL(/\/l1\/walk\//, { timeout: 10_000 }) + + // The header badge shows "Ad-hoc walk" + await expect(page.getByText('Ad-hoc walk')).toBeVisible() + + // Take notes in the walk textarea + const notesTextarea = page.getByPlaceholder( + 'What did the customer say? What did you check? What did you try?', + ) + await expect(notesTextarea).toBeVisible() + await notesTextarea.fill('Walked customer through closing and reopening Outlook — issue resolved') + + // Autosave fires after 300ms debounce; wait up to 5s for the "Saved Xs ago" indicator + await expect( + page.getByText(/Saved \d+s ago|Saving…/i), + ).toBeVisible({ timeout: 5_000 }) + + // Open the Resolve modal + await page.getByRole('button', { name: /Resolve/i }).click() + + // Modal heading: "Did this resolve it?" + await expect( + page.getByRole('heading', { name: 'Did this resolve it?' }), + ).toBeVisible() + + // Click "Yes" + await page.getByRole('button', { name: 'Yes' }).click() + + // Fill resolution notes + await page.getByPlaceholder('Resolution notes…').fill('Fixed via restarting Outlook') + + // Confirm + await page.getByRole('button', { name: 'Confirm' }).click() + + // After resolution, onDone() navigates back to /l1 + await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 }) + }) + + // ------------------------------------------------------------------------- + // Test 2: Route guard — L1 user cannot access engineer-only routes + // ------------------------------------------------------------------------- + test('L1 user cannot access /pilot, /trees/new, or /escalations', async ({ page }) => { + await login(page, L1_EMAIL) + await expect(page).toHaveURL(/\/l1$/, { timeout: 10_000 }) + + // /pilot — ProtectedRoute requires at least engineer rank; l1_tech gets bounced + await page.goto('/pilot') + await expect(page).not.toHaveURL(/\/pilot/, { timeout: 5_000 }) + + // /trees/new — same guard + await page.goto('/trees/new') + await expect(page).not.toHaveURL(/\/trees\/new/, { timeout: 5_000 }) + + // /escalations — if this route exists with a role guard it should bounce too + await page.goto('/escalations') + await expect(page).not.toHaveURL(/\/escalations/, { timeout: 5_000 }) + }) + + // ------------------------------------------------------------------------- + // Test 3: Coverage engineer sees the L1 nav link and the coverage banner + // ------------------------------------------------------------------------- + test('Engineer with can_cover_l1 sees the L1 Workspace nav and coverage banner', async ({ page }) => { + await login(page, COVERAGE_EMAIL) + + // Coverage engineer is not l1_tech — they land on the normal workspace root + await expect(page.getByTestId('app-shell')).toBeVisible({ timeout: 10_000 }) + + // Sidebar should show "L1 Workspace" link + const l1NavLink = page.getByRole('link', { name: /L1 Workspace/i }) + await expect(l1NavLink).toBeVisible({ timeout: 10_000 }) + + // Navigate to /l1 + await l1NavLink.click() + await expect(page).toHaveURL(/\/l1/, { timeout: 10_000 }) + + // L1CoverageBanner renders: "You're covering L1. Actions logged as coverage." + await expect( + page.getByText(/You're covering L1/i), + ).toBeVisible({ timeout: 5_000 }) + }) + + // ------------------------------------------------------------------------- + // Test 4: escalate-without-walk endpoint — direct API assertion + // ------------------------------------------------------------------------- + test('escalate-without-walk returns an escalated adhoc session', async ({ page }) => { + const token = await getToken(page, L1_EMAIL) + + const response = await page.request.post( + `${apiOrigin}/api/v1/l1/escalate-without-walk`, + { + data: { + problem_statement: 'Customer issue with no KB content available', + reason_category: 'No KB available', + }, + headers: { Authorization: `Bearer ${token}` }, + }, + ) + + expect(response.status()).toBe(200) + const body = (await response.json()) as { + status: string + session_kind: string + } + expect(body.status).toBe('escalated') + expect(body.session_kind).toBe('adhoc') + }) +})