From 8855b8b3783154e61400ce8c4a38777e31f1feb8 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Sun, 5 Apr 2026 16:52:01 +0200 Subject: [PATCH] chore: sync latest dashboard and role flow updates --- docs/Nxtgauge-End-to-End-Testing-Blueprint.md | 199 ++++++++++++++++++ package.json | 12 +- playwright.config.ts | 2 +- playwright.external.config.ts | 19 ++ playwright.storybook.config.ts | 25 +++ scripts/run-nxtgauge-e2e-suite.sh | 33 +++ scripts/visbug-storybook.sh | 10 + src/routes/[...404].tsx | 12 +- src/routes/admin.tsx | 6 +- .../external-dashboard-management/index.tsx | 113 ++++++++-- src/routes/index.tsx | 16 +- src/stories/admin/AdminPages.stories.tsx | 2 +- tests/e2e/external-role-screenshots.spec.ts | 74 +++++++ ...xternal-roles-onboarding-dashboard.spec.ts | 67 ++++++ tests/e2e/external-user-flow.spec.ts | 33 +++ tests/e2e/management-parity.spec.ts | 47 +++++ tests/e2e/storybook-admin-pages.spec.ts | 20 ++ tests/vitest/admin-auth.spec.ts | 17 ++ tests/vitest/module-access.spec.ts | 23 ++ vitest.config.ts | 13 ++ 20 files changed, 701 insertions(+), 42 deletions(-) create mode 100644 docs/Nxtgauge-End-to-End-Testing-Blueprint.md create mode 100644 playwright.external.config.ts create mode 100644 playwright.storybook.config.ts create mode 100755 scripts/run-nxtgauge-e2e-suite.sh create mode 100755 scripts/visbug-storybook.sh create mode 100644 tests/e2e/external-role-screenshots.spec.ts create mode 100644 tests/e2e/external-roles-onboarding-dashboard.spec.ts create mode 100644 tests/e2e/external-user-flow.spec.ts create mode 100644 tests/e2e/management-parity.spec.ts create mode 100644 tests/e2e/storybook-admin-pages.spec.ts create mode 100644 tests/vitest/admin-auth.spec.ts create mode 100644 tests/vitest/module-access.spec.ts create mode 100644 vitest.config.ts diff --git a/docs/Nxtgauge-End-to-End-Testing-Blueprint.md b/docs/Nxtgauge-End-to-End-Testing-Blueprint.md new file mode 100644 index 0000000..daa21b5 --- /dev/null +++ b/docs/Nxtgauge-End-to-End-Testing-Blueprint.md @@ -0,0 +1,199 @@ +# Nxtgauge End-to-End Testing Blueprint + +## 1. Scope +- Validate full lifecycle for external users from sign-up to role approval and role-specific module insertion. +- Validate admin-side verification and approval pipelines and state propagation into management modules. +- Validate runtime-config-driven onboarding/dashboard behavior and guard/redirect behavior. +- Validate notification and marketplace flows (customer requirement posting, professional discovery). +- Validate UI parity and visual regressions for management modules using Playwright + pixelmatch + Storybook + VisBug. +- Validate frontend (`vitest`, Playwright) and backend (`cargo test --workspace`) quality gates. + +## 2. Business Logic Summary +- External users can exist before any role registration and must appear in Users Management. +- Role activation is gated: verification then approval. +- Approval produces side effects: + - active role created, + - role-specific table insertion, + - notification event. +- Multi-role users are supported and role visibility must remain isolated by role/module. +- Runtime config must not silently mask missing onboarding/dashboard config. + +## 3. Environments and Preconditions +- Frontend admin app: `/Users/ashwin/workspace/nxtgauge-admin-solid` +- Rust backend workspace: `/Users/ashwin/workspace/nxtgauge-backend-rust` +- Required ports: + - Admin app: `http://localhost:3000` + - Storybook: `http://localhost:6006` +- Preconditions: + - `npm install` completed in admin repo. + - Rust toolchain + dependencies available for backend repo. + - Seed users/roles configured in backend DB (or fallback data strategy used in UI where applicable). + - Admin preview mode supported with `?_preview=1` for deterministic UI checks. + +## 4. Test Data Matrix +- External identities: + - User without role. + - User with pending role onboarding. + - User with verified/pending approval role. + - User with approved active role. + - Multi-role user (2+ roles). +- Roles to cover: + - `COMPANY` + - `JOB_SEEKER` + - `CUSTOMER` + - `DEVELOPER` + - `PHOTOGRAPHER` + - `SOCIAL_MEDIA_MANAGER` +- Marketplace entities: + - 2-3 customer accounts. + - 2 requirements per customer. + - Approved professional accounts for lead/job discovery. + +## 5. End-to-End Test Scenarios +1. Auth split: + - external identity blocked from admin login. + - internal identity allowed and redirected to admin. + - external-resolved session redirected to `/login?from=...`. +2. Onboarding: + - role-aware onboarding route loads via runtime config. + - missing config renders explicit error state. + - onboarding submissions persist and move to verification state. +3. Dashboard: + - role-specific sidebar and tabs render correctly. + - guarded routes reject wrong portal/session. +4. Verification: + - submission appears in Verification Management. + - reviewer decisions propagate to Approval Management. +5. Approval: + - approved role appears in Users Management and role-specific module. + - rejected role remains non-active but user remains visible. +6. Users Management: + - users with zero roles are visible. + - role states are visible per user. +7. Role-specific insertion: + - approval inserts into the right module only. +8. Notifications: + - approval emits and surfaces unread notification. +9. Requirements/leads: + - customer creates requirements. + - approved professionals discover and act on leads/jobs. + +## 6. Playwright Automation Plan +- Existing specs: + - `tests/e2e/admin-auth.spec.ts` + - `tests/e2e/admin-visual.spec.ts` (pixelmatch) +- Added specs: + - `tests/e2e/management-parity.spec.ts` + - asserts Department-style controls for management modules. + - asserts dashboard management pages are non-empty. + - `tests/e2e/storybook-admin-pages.spec.ts` + - Storybook admin story smoke checks. +- Execution commands: + - `npm run test:e2e` + - `npm run test:management:parity` + - `npm run test:visual` + - `npm run test:storybook:e2e` + +## 7. API and Backend Validation Plan +- Validate gateway/admin API responses and status codes for: + - `/api/gateway/api/auth/session` + - `/api/gateway/api/runtime-config` + - `/api/gateway/api/admin/users` + - `/api/gateway/api/admin/external-users` + - `/api/gateway/api/admin/dashboard-config` + - `/api/gateway/api/admin/roles` + - `/api/gateway/api/admin/companies` +- Assertions: + - lifecycle transitions emit expected payload shape/state. + - role key propagation remains consistent across services. + - no silent fallback on missing config. + +## 8. Database Validation Plan +- Validate tables/events after each phase: + - user record creation at sign-up. + - onboarding row/state creation. + - verification record creation/update. + - approval decision and role activation state. + - role-specific module table insertion on approval only. + - notification row insertion. +- Cross-check: + - rejected records never inserted into approved role module tables. + - multi-role user keeps separate role states. + +## 9. Runtime Config Validation +- Validate: + - role-specific onboarding config retrieval. + - role-specific dashboard config retrieval. + - UI behavior when config exists vs missing. + - no hidden fallback that masks configuration defects. +- Internal/External dashboard management pages must show deterministic list/form states even when backend responses are empty. + +## 10. Notification Validation +- Trigger approval and assert: + - notification is created in backend. + - notification module UI renders item. + - unread/read transitions are persisted. +- Negative: + - rejected approvals must not trigger approval-success notifications. + +## 11. Lead / Requirement Marketplace Validation +- Customer flow: + - create customer users. + - create 2 requirements per customer. + - assert requirement visibility in marketplace feeds. +- Professional flow: + - approved professionals can access leads/jobs routes. + - wrong-role users cannot access irrelevant lead pools. + - apply/contact actions persist. + +## 12. Edge Cases and Negative Cases +- User signs up but never picks role: visible in Users Management. +- Missing runtime config: explicit error/empty guard state. +- Session crossover: + - external session cannot pass admin route guards. + - admin session cannot leak into public workspace. +- Multi-role conflict: + - role A approval should not auto-activate role B. +- Approval before verification must be blocked. +- API `404`/`401` on optional module endpoints should not crash page shell. + +## 13. Release Blocking Acceptance Checklist +- Auth split tests pass. +- Verification → approval → insertion path validated for all required roles. +- Users Management shows no-role users. +- Role-specific management insertion validated. +- Notification creation/visibility validated. +- Management pages match Department-style controls and table shell. +- Visual diff (`pixelmatch`) within thresholds. +- `vitest`, existing unit tests, Playwright suites, and `cargo test --workspace` pass. + +## 14. Recommended Folder Structure For Tests +- `tests/e2e/admin-auth.spec.ts` +- `tests/e2e/admin-visual.spec.ts` +- `tests/e2e/management-parity.spec.ts` +- `tests/e2e/storybook-admin-pages.spec.ts` +- `tests/vitest/admin-auth.spec.ts` +- `tests/vitest/module-access.spec.ts` +- `tests/visual-artifacts/{actual,diff}` +- `docs/Nxtgauge-End-to-End-Testing-Blueprint.md` + +## 15. Suggested Seed Data / Factories +- User factories: + - `external_user_unregistered` + - `external_user_pending_verification` + - `external_user_pending_approval` + - `external_user_approved_role` + - `external_user_multi_role` +- Domain factories: + - `customer_requirement_factory` + - `professional_profile_factory` + - `notification_factory` +- Role fixtures for matrix roles listed in Section 4. + +## 16. Risks and Likely Failure Points +- Role-key drift between onboarding, approval, and role-specific insertion. +- Guard and redirect regressions across admin/public portals. +- Runtime config partial failures causing invisible fallback behavior. +- Backend endpoint shape drift (`users`, `external-users`, `dashboard-config`) causing UI empty states. +- Visual regressions in shared table controls/icons across management modules. + diff --git a/package.json b/package.json index 64a97cd..c4f33bc 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,17 @@ "test": "node --test --experimental-strip-types src/lib/**/*.test.ts", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:visual": "playwright test tests/e2e/admin-visual.spec.ts --reporter=list", + "test:visual": "playwright test tests/e2e/admin-visual.spec.ts --reporter=list --workers=1", + "test:management:parity": "playwright test tests/e2e/management-parity.spec.ts --reporter=list --workers=1", + "test:storybook:e2e": "playwright test -c playwright.storybook.config.ts tests/e2e/storybook-admin-pages.spec.ts --reporter=list --workers=1", + "test:external:flow": "playwright test -c playwright.external.config.ts tests/e2e/external-user-flow.spec.ts --reporter=list --workers=1", + "test:external:roles": "playwright test -c playwright.external.config.ts tests/e2e/external-roles-onboarding-dashboard.spec.ts --reporter=list --workers=1", + "test:external:screenshots": "playwright test -c playwright.external.config.ts tests/e2e/external-role-screenshots.spec.ts --reporter=list --workers=1", + "test:vitest": "vitest run", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "qa:visbug": "bash ./scripts/visbug-storybook.sh", + "qa:e2e:blueprint": "bash ./scripts/run-nxtgauge-e2e-suite.sh" }, "dependencies": { "@solidjs/meta": "^0.29.4", diff --git a/playwright.config.ts b/playwright.config.ts index 0b34c79..b8ab7f0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ fullyParallel: true, reporter: [['list']], webServer: { - command: 'npm run build && PORT=3102 npm run start', + command: 'npm run dev -- --port 3102 --host 127.0.0.1', url: 'http://127.0.0.1:3102/', reuseExistingServer: true, timeout: 300_000, diff --git a/playwright.external.config.ts b/playwright.external.config.ts new file mode 100644 index 0000000..fd0c8ef --- /dev/null +++ b/playwright.external.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + reporter: [['list']], + use: { + baseURL: process.env.EXTERNAL_BASE_URL || 'http://127.0.0.1:3001', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + viewport: { width: 1440, height: 900 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/playwright.storybook.config.ts b/playwright.storybook.config.ts new file mode 100644 index 0000000..204700a --- /dev/null +++ b/playwright.storybook.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + reporter: [['list']], + webServer: { + command: 'npm run storybook -- --ci --host 127.0.0.1 --port 6006', + url: 'http://127.0.0.1:6006/iframe.html', + reuseExistingServer: true, + timeout: 300_000, + }, + use: { + baseURL: 'http://127.0.0.1:6006', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + viewport: { width: 1440, height: 900 }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/scripts/run-nxtgauge-e2e-suite.sh b/scripts/run-nxtgauge-e2e-suite.sh new file mode 100755 index 0000000..22b4e82 --- /dev/null +++ b/scripts/run-nxtgauge-e2e-suite.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUST_ROOT="/Users/ashwin/workspace/nxtgauge-backend-rust" + +echo "[1/7] Node unit tests (existing)" +cd "$ROOT_DIR" +npm run test + +echo "[2/7] Vitest suite" +npm run test:vitest + +echo "[3/7] Storybook build smoke" +npm run build-storybook + +echo "[4/7] Playwright management parity e2e" +npm run test:management:parity + +echo "[5/7] Playwright visual pixelmatch e2e" +npm run test:visual + +echo "[6/7] Storybook Playwright smoke e2e" +npm run test:storybook:e2e + +echo "[7/7] Rust backend cargo test" +if command -v cargo >/dev/null 2>&1 && [ -f "$RUST_ROOT/Cargo.toml" ]; then + (cd "$RUST_ROOT" && cargo test --workspace) +else + echo "Skipping cargo test (cargo or backend workspace not found)." +fi + +echo "Nxtgauge E2E suite completed." diff --git a/scripts/visbug-storybook.sh b/scripts/visbug-storybook.sh new file mode 100755 index 0000000..0e8c03d --- /dev/null +++ b/scripts/visbug-storybook.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "VisBug + Storybook manual visual QA workflow" +echo "1) Start storybook: npm run storybook" +echo "2) Open: http://localhost:6006/?path=/story/admin-pages--dashboard" +echo "3) Install VisBug in browser if not installed:" +echo " https://chrome.google.com/webstore/detail/visbug/cdockenadnadldjbbgcallicgledbeoc" +echo "4) Use VisBug tools on each admin story route to inspect spacing, typography, and alignment." +echo "5) Save screenshots into tests/visual-artifacts/actual for pixelmatch comparison." diff --git a/src/routes/[...404].tsx b/src/routes/[...404].tsx index 4ea71ec..18cac60 100644 --- a/src/routes/[...404].tsx +++ b/src/routes/[...404].tsx @@ -1,19 +1,15 @@ import { Title } from "@solidjs/meta"; import { HttpStatusCode } from "@solidjs/start"; +import { A } from "@solidjs/router"; export default function NotFound() { return ( -
+
Not Found

Page Not Found

-

- Visit{" "} - - start.solidjs.com - {" "} - to learn how to build SolidStart apps. -

+

This route is not available in the cleaned admin build.

+ Go to Admin Dashboard
); } diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index e077a47..373cf8b 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,10 +1,10 @@ -import { Outlet } from '@solidjs/router'; +import type { RouteSectionProps } from '@solidjs/router'; import AdminShell from '~/components/AdminShell'; -export default function AdminLayout() { +export default function AdminLayout(props: RouteSectionProps) { return ( - + {props.children} ); } diff --git a/src/routes/admin/external-dashboard-management/index.tsx b/src/routes/admin/external-dashboard-management/index.tsx index c8ba8c9..bfbe0ef 100644 --- a/src/routes/admin/external-dashboard-management/index.tsx +++ b/src/routes/admin/external-dashboard-management/index.tsx @@ -57,7 +57,6 @@ const ROLE_BASED_SIDEBAR: Record<'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CU 'Jobs', 'My Applications', 'Saved Jobs', - 'Credits', 'Explore Nxtgauge', 'Verification', 'Help Center', @@ -127,6 +126,32 @@ function applyPortfolioRule(items: string[], persona: 'PROFESSIONAL' | 'COMPANY' return items.filter((item) => normalizeToken(item) !== normalizeToken(PORTFOLIO_SIDEBAR_ITEM)); } +function mergeSidebarForPersona( + items: string[], + persona: 'PROFESSIONAL' | 'COMPANY' | 'JOB_SEEKER' | 'CUSTOMER' | null, +): string[] { + const base = (persona && ROLE_BASED_SIDEBAR[persona]?.length) + ? ROLE_BASED_SIDEBAR[persona] + : ['My Dashboard', 'My Profile', 'Switch Services', 'Logout']; + const merged = new Map(); + for (const item of [...items, ...base]) { + const label = String(item || '').trim(); + if (!label) continue; + const key = normalizeToken(label); + if (!merged.has(key)) merged.set(key, label); + } + let next = Array.from(merged.values()); + if (persona === 'JOB_SEEKER') { + next = next.filter((item) => normalizeToken(item) !== 'credits'); + } + if (!next.some((item) => normalizeToken(item) === normalizeToken('Explore Nxtgauge'))) { + const verificationIdx = next.findIndex((item) => normalizeToken(item) === normalizeToken('Verification')); + if (verificationIdx >= 0) next.splice(verificationIdx, 0, 'Explore Nxtgauge'); + else next.push('Explore Nxtgauge'); + } + return applyPortfolioRule(next, persona); +} + function sameSidebarOrder(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; return a.every((item, idx) => normalizeToken(item) === normalizeToken(b[idx] || '')); @@ -182,6 +207,18 @@ function normalizeDashboard(item: any): ExternalDashboard { }; } +function looksLikeGateway404(payload: any): boolean { + const text = String(payload?.message || payload?.error || payload || '').toLowerCase(); + return text.includes('route not found in gateway'); +} + +function normalizeRoleNameFromKey(key: string): string { + return String(key || '') + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + function StatusBadge(props: { status: string }) { const active = () => props.status === 'ACTIVE'; return ( @@ -266,12 +303,10 @@ export default function ExternalDashboardManagementPage() { const persona = personaById ?? personaByKey; const baseItems = sidebarItems().length ? sidebarItems() - : (persona && ROLE_BASED_SIDEBAR[persona]?.length) - ? ROLE_BASED_SIDEBAR[persona] - : sidebarLooksCustomer() - ? ROLE_BASED_SIDEBAR.CUSTOMER - : ['My Dashboard', 'My Profile', 'Switch Services', 'Logout']; - return applyPortfolioRule(baseItems, persona ?? (sidebarLooksCustomer() ? 'CUSTOMER' : null)); + : sidebarLooksCustomer() + ? ROLE_BASED_SIDEBAR.CUSTOMER + : ['My Dashboard', 'My Profile', 'Switch Services', 'Logout']; + return mergeSidebarForPersona(baseItems, persona ?? (sidebarLooksCustomer() ? 'CUSTOMER' : null)); }); const previewTabs = createMemo(() => (tabs().length ? tabs() : ['overview'])); @@ -309,7 +344,7 @@ export default function ExternalDashboardManagementPage() { const persona = rolePersonaById()[selectedRoleId]; if (!persona) return; if (!force && sidebarItems().length > 0) return; - setSidebarItems(ROLE_BASED_SIDEBAR[persona]); + setSidebarItems(mergeSidebarForPersona(ROLE_BASED_SIDEBAR[persona], persona)); }; const applyPreviewPathForRole = (selectedRoleId: string, force = false) => { @@ -331,17 +366,68 @@ export default function ExternalDashboardManagementPage() { const dashData = dashRes.ok ? await dashRes.json().catch(() => []) : []; const roleData = rolesRes.ok ? await rolesRes.json().catch(() => []) : []; - const dashRows = Array.isArray(dashData) ? dashData : (dashData?.items || dashData?.configs || []); - const roleRows = Array.isArray(roleData) ? roleData : (roleData?.roles || roleData?.items || []); + const dashNeedsFallback = !dashRes.ok || looksLikeGateway404(dashData); + const rolesNeedFallback = !rolesRes.ok || looksLikeGateway404(roleData); + + let finalDashData: any = dashData; + let finalRoleData: any = roleData; + + if (dashNeedsFallback) { + const fallbackDashRes = await fetch(`${API}/api/config/dashboard`, { + headers: authHeaders(), + credentials: 'include', + }); + finalDashData = fallbackDashRes.ok ? await fallbackDashRes.json().catch(() => []) : []; + } + + if (rolesNeedFallback) { + // Gateway no longer routes /api/admin/roles in some environments. + // Build role options from dashboard configs so external dashboard table remains usable. + const rawRows = Array.isArray(finalDashData) ? finalDashData : (finalDashData?.items || finalDashData?.configs || []); + const roleMap = new Map(); + for (const row of rawRows) { + const audience = String(row?.audience || '').toUpperCase(); + if (audience !== 'EXTERNAL') continue; + const roleId = String(row?.role_id || row?.roleId || '').trim(); + const roleKey = String(row?.role_key || row?.config_json?.role_key || '').toUpperCase().trim(); + if (!roleId) continue; + const name = normalizeRoleNameFromKey(roleKey || 'EXTERNAL_ROLE'); + roleMap.set(roleId, { id: roleId, key: roleKey || 'EXTERNAL_ROLE', name }); + } + finalRoleData = Array.from(roleMap.values()); + } + + const dashRows = Array.isArray(finalDashData) ? finalDashData : (finalDashData?.items || finalDashData?.configs || []); + const roleRows = Array.isArray(finalRoleData) ? finalRoleData : (finalRoleData?.roles || finalRoleData?.items || []); setRoles(roleRows - .filter((r: any) => String(r?.audience || '').toUpperCase() === 'EXTERNAL') + .filter((r: any) => { + const hasRoleShape = Boolean(r?.id && (r?.key || r?.name)); + if (hasRoleShape && r?.audience == null) return true; + return String(r?.audience || '').toUpperCase() === 'EXTERNAL'; + }) .map((r: any) => ({ id: String(r?.id || ''), key: String(r?.key || '').toUpperCase(), name: String(r?.name || r?.key || 'External Role') })) .filter((r: RoleOption) => r.id)); + const roleKeyById = new Map(); + roleRows.forEach((r: any) => { + const id = String(r?.id || '').trim(); + const key = String(r?.key || '').toUpperCase().trim(); + if (id) roleKeyById.set(id, key); + }); + setRows(dashRows .filter((item: any) => String(item?.audience || '').toUpperCase() === 'EXTERNAL') .map(normalizeDashboard) + .map((row: ExternalDashboard) => { + const resolvedRoleKey = String(row.roleKey || roleKeyById.get(row.roleId) || '').toUpperCase(); + const persona = personaFromKey(resolvedRoleKey); + return { + ...row, + roleKey: resolvedRoleKey || row.roleKey, + sidebarItems: mergeSidebarForPersona(row.sidebarItems, persona), + }; + }) .sort((a: ExternalDashboard, b: ExternalDashboard) => b.updatedAt.localeCompare(a.updatedAt))); } catch (e: any) { setRows([]); @@ -400,7 +486,8 @@ export default function ExternalDashboardManagementPage() { setFormRoleKey(row.roleKey); setWidgets(row.widgets); setTabs(row.tabs); - setSidebarItems(row.sidebarItems); + const persona = rolePersonaById()[row.roleId] ?? personaFromKey(row.roleKey); + setSidebarItems(mergeSidebarForPersona(row.sidebarItems, persona)); setFields(row.fields); setPreviewPath(row.previewPath || rolePreviewPath(row.roleKey)); setIsActive(row.status === 'ACTIVE'); @@ -425,7 +512,7 @@ export default function ExternalDashboardManagementPage() { const persona = rolePersonaById()[roleId()] ?? personaFromKey(selectedRoleKey() || formRoleKey()); if (!persona || !sidebarItems().length) return; setSidebarItems((prev) => { - const next = applyPortfolioRule(prev, persona); + const next = mergeSidebarForPersona(prev, persona); return sameSidebarOrder(prev, next) ? prev : next; }); }); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 16286bb..1273c66 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,17 +1,5 @@ -import { A } from '@solidjs/router'; +import { Navigate } from '@solidjs/router'; export default function Home() { - return ( -
-
-
- -

Admin Access

-

Secure sign-in and runtime control center for NXTGAUGE operations.

-
- Sign In -
-
-
- ); + return ; } diff --git a/src/stories/admin/AdminPages.stories.tsx b/src/stories/admin/AdminPages.stories.tsx index ed93360..5698b8d 100644 --- a/src/stories/admin/AdminPages.stories.tsx +++ b/src/stories/admin/AdminPages.stories.tsx @@ -2,7 +2,7 @@ import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router'; import type { Meta, StoryObj } from 'storybook-solidjs-vite'; import '../../app.css'; -import AdminHomePage from '../../routes/admin'; +import AdminHomePage from '../../routes/admin/index'; import ApprovalPage from '../../routes/admin/approval'; import VerificationPage from '../../routes/admin/verification'; import DepartmentManagementPage from '../../routes/admin/department-management'; diff --git a/tests/e2e/external-role-screenshots.spec.ts b/tests/e2e/external-role-screenshots.spec.ts new file mode 100644 index 0000000..ec22b38 --- /dev/null +++ b/tests/e2e/external-role-screenshots.spec.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { test, expect } from '@playwright/test'; + +type RoleScenario = { + roleKey: string; + schemaId: string; + profession?: string; +}; + +const ROLE_SCENARIOS: RoleScenario[] = [ + { roleKey: 'COMPANY', schemaId: 'company_onboarding_v1' }, + { roleKey: 'JOB_SEEKER', schemaId: 'jobseeker_onboarding_v1' }, + { roleKey: 'CUSTOMER', schemaId: 'customer_onboarding_v1' }, + { roleKey: 'PHOTOGRAPHER', schemaId: 'photographer_onboarding_v1', profession: 'photographer' }, + { roleKey: 'MAKEUP_ARTIST', schemaId: 'makeup_artist_onboarding_v1', profession: 'makeup_artist' }, + { roleKey: 'TUTOR', schemaId: 'tutor_onboarding_v1', profession: 'tutor' }, + { roleKey: 'DEVELOPER', schemaId: 'developer_onboarding_v1', profession: 'developer' }, + { roleKey: 'VIDEO_EDITOR', schemaId: 'video_editor_onboarding_v1', profession: 'video_editor' }, + { roleKey: 'GRAPHIC_DESIGNER', schemaId: 'graphic_designer_onboarding_v1', profession: 'graphic_designer' }, + { roleKey: 'SOCIAL_MEDIA_MANAGER', schemaId: 'social_media_manager_onboarding_v1', profession: 'social_media_manager' }, + { roleKey: 'FITNESS_TRAINER', schemaId: 'fitness_trainer_onboarding_v1', profession: 'fitness_trainer' }, + { roleKey: 'CATERING_SERVICES', schemaId: 'catering_services_onboarding_v1', profession: 'catering_services' }, +]; + +const OUTPUT_ROOT = path.join(process.cwd(), 'tests', 'visual-artifacts', 'external-roles'); + +async function ensureFolders() { + await fs.mkdir(path.join(OUTPUT_ROOT, 'onboarding'), { recursive: true }); + await fs.mkdir(path.join(OUTPUT_ROOT, 'dashboard'), { recursive: true }); +} + +function roleSlug(roleKey: string) { + return roleKey.toLowerCase(); +} + +test('capture onboarding + dashboard screenshots for all external roles', async ({ page }) => { + test.setTimeout(240_000); + await ensureFolders(); + await page.setViewportSize({ width: 1440, height: 900 }); + + for (const role of ROLE_SCENARIOS) { + const params = new URLSearchParams({ + roleKey: role.roleKey, + schemaId: role.schemaId, + }); + if (role.profession) { + params.set('profession', role.profession); + params.set('intent', 'professional'); + } else if (role.roleKey === 'COMPANY') { + params.set('intent', 'company'); + } else if (role.roleKey === 'CUSTOMER') { + params.set('intent', 'customer'); + } else if (role.roleKey === 'JOB_SEEKER') { + params.set('intent', 'job_seeker'); + } + + await page.goto(`/onboarding?${params.toString()}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1400); + await expect(page.locator('body')).not.toContainText('TypeError'); + await page.screenshot({ + path: path.join(OUTPUT_ROOT, 'onboarding', `${roleSlug(role.roleKey)}.png`), + fullPage: true, + }); + + await page.goto(`/dashboard?_preview=${encodeURIComponent(role.roleKey)}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1000); + await expect(page.locator('body')).not.toContainText('TypeError'); + await page.screenshot({ + path: path.join(OUTPUT_ROOT, 'dashboard', `${roleSlug(role.roleKey)}.png`), + fullPage: true, + }); + } +}); diff --git a/tests/e2e/external-roles-onboarding-dashboard.spec.ts b/tests/e2e/external-roles-onboarding-dashboard.spec.ts new file mode 100644 index 0000000..e721805 --- /dev/null +++ b/tests/e2e/external-roles-onboarding-dashboard.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; + +type RoleScenario = { + roleKey: string; + schemaId: string; + profession?: string; + expectedBadge: string; +}; + +const ROLE_SCENARIOS: RoleScenario[] = [ + { roleKey: 'COMPANY', schemaId: 'company_onboarding_v1', expectedBadge: 'Company' }, + { roleKey: 'JOB_SEEKER', schemaId: 'jobseeker_onboarding_v1', expectedBadge: 'Job Seeker' }, + { roleKey: 'CUSTOMER', schemaId: 'customer_onboarding_v1', expectedBadge: 'Customer' }, + { roleKey: 'PHOTOGRAPHER', schemaId: 'photographer_onboarding_v1', profession: 'photographer', expectedBadge: 'Photographer' }, + { roleKey: 'MAKEUP_ARTIST', schemaId: 'makeup_artist_onboarding_v1', profession: 'makeup_artist', expectedBadge: 'Makeup Artist' }, + { roleKey: 'TUTOR', schemaId: 'tutor_onboarding_v1', profession: 'tutor', expectedBadge: 'Tutor' }, + { roleKey: 'DEVELOPER', schemaId: 'developer_onboarding_v1', profession: 'developer', expectedBadge: 'Developer' }, + { roleKey: 'VIDEO_EDITOR', schemaId: 'video_editor_onboarding_v1', profession: 'video_editor', expectedBadge: 'Video Editor' }, + { roleKey: 'GRAPHIC_DESIGNER', schemaId: 'graphic_designer_onboarding_v1', profession: 'graphic_designer', expectedBadge: 'Graphic Designer' }, + { roleKey: 'SOCIAL_MEDIA_MANAGER', schemaId: 'social_media_manager_onboarding_v1', profession: 'social_media_manager', expectedBadge: 'Social Media Manager' }, + { roleKey: 'FITNESS_TRAINER', schemaId: 'fitness_trainer_onboarding_v1', profession: 'fitness_trainer', expectedBadge: 'Fitness Trainer' }, + { roleKey: 'CATERING_SERVICES', schemaId: 'catering_services_onboarding_v1', profession: 'catering_services', expectedBadge: 'Catering Services' }, +]; + +test.describe('External roles onboarding + dashboard load checks', () => { + for (const role of ROLE_SCENARIOS) { + test(`onboarding page loads for ${role.roleKey}`, async ({ page }) => { + const params = new URLSearchParams({ + roleKey: role.roleKey, + schemaId: role.schemaId, + }); + if (role.profession) { + params.set('profession', role.profession); + params.set('intent', 'professional'); + } else if (role.roleKey === 'COMPANY') { + params.set('intent', 'company'); + } else if (role.roleKey === 'CUSTOMER') { + params.set('intent', 'customer'); + } else if (role.roleKey === 'JOB_SEEKER') { + params.set('intent', 'job_seeker'); + } + + await page.goto(`/onboarding?${params.toString()}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1200); + + await expect(page).toHaveURL(new RegExp(`/onboarding\\?`)); + await expect(page.locator('body')).not.toContainText('TypeError'); + await expect(page.locator('body')).not.toContainText('Cannot read properties'); + await expect(page.locator('body')).not.toContainText('Not Found (role='); + await expect(page.locator('body')).not.toContainText('No onboarding schema configured for role'); + + const bodyText = await page.locator('body').innerText(); + expect(bodyText.trim().length).toBeGreaterThan(20); + }); + + test(`dashboard loads for ${role.roleKey} preview`, async ({ page }) => { + await page.goto(`/dashboard?_preview=${encodeURIComponent(role.roleKey)}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(900); + + await expect(page.locator('body')).not.toContainText('TypeError'); + await expect(page.locator('body')).not.toContainText('Cannot read properties'); + await expect(page.locator('.role-badge')).toContainText(role.expectedBadge); + await expect(page.locator('.topbar-user')).toContainText('Preview User'); + await expect(page.locator('body')).toContainText('Dashboard'); + }); + } +}); diff --git a/tests/e2e/external-user-flow.spec.ts b/tests/e2e/external-user-flow.spec.ts new file mode 100644 index 0000000..ff9760c --- /dev/null +++ b/tests/e2e/external-user-flow.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; + +test.describe('External user public flow smoke', () => { + test('login page renders expected fields', async ({ page }) => { + await page.goto('/auth/login', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible(); + await expect(page.getByPlaceholder('Enter your email')).toBeVisible(); + await expect(page.getByPlaceholder('Enter your password')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible(); + }); + + test('choose-role page renders and routes to company onboarding', async ({ page }) => { + await page.goto('/users/choose-role', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'What would you like to do today?' })).toBeVisible(); + await page.waitForTimeout(1200); + + await page.getByRole('button', { name: /Company/i }).first().click(); + await expect(page).toHaveURL(/\/onboarding\?roleKey=COMPANY&schemaId=company_onboarding_v1&intent=company/, { timeout: 15000 }); + }); + + test('choose-role page routes professional subtype to professional onboarding', async ({ page }) => { + await page.goto('/users/choose-role', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1200); + await page.getByRole('button', { name: /Photographer/i }).first().click(); + await expect(page).toHaveURL(/\/onboarding\?roleKey=PHOTOGRAPHER&schemaId=photographer_onboarding_v1&profession=photographer&intent=professional/, { timeout: 15000 }); + }); + + test('dashboard route does not white-screen for guest session', async ({ page }) => { + await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('body')).not.toContainText('TypeError'); + await expect(page.locator('body')).not.toContainText('Cannot read properties'); + }); +}); diff --git a/tests/e2e/management-parity.spec.ts b/tests/e2e/management-parity.spec.ts new file mode 100644 index 0000000..3bcbc7c --- /dev/null +++ b/tests/e2e/management-parity.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; + +const MANAGEMENT_ROUTES = [ + '/admin/company?_preview=1', + '/admin/candidate?_preview=1', + '/admin/customer?_preview=1', + '/admin/photographer?_preview=1', + '/admin/makeup-artist?_preview=1', + '/admin/tutors?_preview=1', + '/admin/developers?_preview=1', + '/admin/video-editors?_preview=1', + '/admin/fitness-trainers?_preview=1', + '/admin/catering-services?_preview=1', + '/admin/graphic-designers?_preview=1', + '/admin/social-media-managers?_preview=1', + '/admin/ugc-content-creator?_preview=1', +]; + +test.describe('Department-style UI parity for management modules', () => { + for (const route of MANAGEMENT_ROUTES) { + test(`controls and table structure: ${route}`, async ({ page }) => { + await page.goto(route, { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('Sort').first()).toBeVisible(); + await expect(page.getByText('Filters').first()).toBeVisible(); + await expect(page.getByText('Export').first()).toBeVisible(); + await expect(page.getByText('REGISTERED DATE').first()).toBeVisible(); + await expect(page.getByText('ACTIONS').first()).toBeVisible(); + await expect(page.getByText('Live legacy module embedded for exact design and functionality parity during migration.')).toHaveCount(0); + }); + } +}); + +test.describe('Dashboard management modules are non-empty', () => { + test('internal dashboard management renders rows or configured table state', async ({ page }) => { + await page.goto('/admin/internal-dashboard-management?_preview=1', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Internal Dashboard Management' })).toBeVisible(); + await expect(page.getByText('No internal dashboards found.')).toHaveCount(0); + await expect(page.getByText('NAME').first()).toBeVisible(); + }); + + test('external dashboard management renders rows or configured table state', async ({ page }) => { + await page.goto('/admin/external-dashboard-management?_preview=1', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'External Dashboard Management' })).toBeVisible(); + await expect(page.getByText('No external dashboards found.')).toHaveCount(0); + await expect(page.getByText('NAME').first()).toBeVisible(); + }); +}); diff --git a/tests/e2e/storybook-admin-pages.spec.ts b/tests/e2e/storybook-admin-pages.spec.ts new file mode 100644 index 0000000..681b51c --- /dev/null +++ b/tests/e2e/storybook-admin-pages.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +const STORYBOOK_BASE_URL = process.env.STORYBOOK_BASE_URL || 'http://127.0.0.1:6006'; + +const STORIES = [ + { id: 'admin-pages--dashboard', text: 'Total Users' }, + { id: 'admin-pages--department-management', text: 'Department Management' }, + { id: 'admin-pages--internal-dashboard-management', text: 'Internal Dashboard Management' }, + { id: 'admin-pages--external-dashboard-management', text: 'External Dashboard Management' }, +]; + +test.describe('Storybook admin stories smoke', () => { + for (const story of STORIES) { + test(`renders ${story.id}`, async ({ page }) => { + const url = `${STORYBOOK_BASE_URL}/iframe.html?id=${story.id}&viewMode=story`; + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await expect(page.getByText(story.text).first()).toBeVisible({ timeout: 20_000 }); + }); + } +}); diff --git a/tests/vitest/admin-auth.spec.ts b/tests/vitest/admin-auth.spec.ts new file mode 100644 index 0000000..c92b387 --- /dev/null +++ b/tests/vitest/admin-auth.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { isExternalIdentity, pickManagementLoginError } from '../../src/lib/admin-auth'; + +describe('admin-auth helpers (vitest)', () => { + it('returns wrong portal message with highest priority', () => { + expect(pickManagementLoginError({ error_code: 'WRONG_PORTAL', message: 'Ignore me' })).toBe( + 'This login is only for internal management users. External users must use the public login page.', + ); + }); + + it('detects external identities from multiple payload shapes', () => { + expect(isExternalIdentity({ audience: 'public' })).toBe(true); + expect(isExternalIdentity({ user: { user_type: 'external_user' } })).toBe(true); + expect(isExternalIdentity({ user: { accountType: 'external' } })).toBe(true); + expect(isExternalIdentity({ audience: 'admin', user: { userType: 'employee' } })).toBe(false); + }); +}); diff --git a/tests/vitest/module-access.spec.ts b/tests/vitest/module-access.spec.ts new file mode 100644 index 0000000..1c6e601 --- /dev/null +++ b/tests/vitest/module-access.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeAllowedModules } from '../../src/lib/admin/module-access'; + +describe('module access normalization (vitest)', () => { + it('normalizes explicit enabled modules', () => { + expect( + normalizeAllowedModules({ + enabled_modules: ['employee_management', 'approval_management'], + }), + ).toEqual(['EMPLOYEE_MANAGEMENT', 'APPROVAL_MANAGEMENT']); + }); + + it('extracts module keys from permissions object', () => { + expect( + normalizeAllowedModules({ + permissions: { + 'departments.view': true, + 'external_dashboard_management.update': true, + }, + }), + ).toEqual(['DEPARTMENTS', 'EXTERNAL_DASHBOARD_MANAGEMENT']); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1687280 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/vitest/**/*.spec.ts'], + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + reportsDirectory: './test-results/vitest-coverage', + }, + }, +});