From 07f593e4513573af5afaabd119452523709f3fc5 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Wed, 28 Oct 2026 13:25:13 -0700 Subject: [PATCH 1/2] fix(SEL-615): show beta unavailable for SEL-614 frontend loads --- .../pages/sel615-beta-unavailable.test.tsx | 62 +++++++++++++++++++ frontend/src/app/dashboard/audit-log/page.tsx | 8 ++- .../src/app/dashboard/notifications/page.tsx | 8 ++- frontend/src/app/dashboard/products/page.tsx | 8 ++- frontend/src/app/dashboard/settings/page.tsx | 17 +++-- .../components/ui/beta-unavailable-state.tsx | 14 +++++ frontend/src/lib/dashboard-load-failure.ts | 11 ++++ 7 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 frontend/src/__tests__/pages/sel615-beta-unavailable.test.tsx create mode 100644 frontend/src/components/ui/beta-unavailable-state.tsx create mode 100644 frontend/src/lib/dashboard-load-failure.ts diff --git a/frontend/src/__tests__/pages/sel615-beta-unavailable.test.tsx b/frontend/src/__tests__/pages/sel615-beta-unavailable.test.tsx new file mode 100644 index 000000000..11afe9167 --- /dev/null +++ b/frontend/src/__tests__/pages/sel615-beta-unavailable.test.tsx @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import SettingsPage from '@/app/dashboard/settings/page'; +import ProductsPage from '@/app/dashboard/products/page'; +import AuditLogPage from '@/app/dashboard/audit-log/page'; +import NotificationsPage from '@/app/dashboard/notifications/page'; + +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/fetch-with-csrf', () => ({ fetchWithCsrf: (...args: unknown[]) => fetchMock(...args) })); +vi.mock('@/lib/api-config', () => ({ getServiceURL: () => '/api/ingestion' })); +vi.mock('@/lib/auth-context', () => ({ useAuth: () => ({ isAuthenticated: true, user: null }) })); +vi.mock('@/lib/tenant-context', () => ({ useTenant: () => ({ tenantId: 't', isSystemTenant: false }) })); +vi.mock('@/hooks/use-billing', () => ({ useCurrentSubscription: () => ({ data: null, isLoading: false, isError: false }) })); +vi.mock('@/hooks/use-dashboard-refresh', () => ({ useDashboardRefresh: vi.fn() })); + +beforeEach(() => fetchMock.mockReset()); + +for (const [name, Page, primaryPath] of [ + ['Settings', SettingsPage, '/api/v1/settings/t'], + ['Products', ProductsPage, '/api/v1/products/t'], + ['Audit Log', AuditLogPage, '/api/v1/audit-log/t'], + ['Notifications', NotificationsPage, '/api/v1/notifications/t/preferences'], +] as const) { + describe(name, () => { + function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } }); + render(); + } + + function expectProfileBlocked() { + if (name === 'Settings') { + expect(screen.getByRole('button', { name: 'Save Changes' })).toBeDisabled(); + expect(screen.queryByLabelText('Company Type')).not.toBeInTheDocument(); + } + } + + it.each([404, 403])('shows Beta unavailable for HTTP %s', async status => { + fetchMock.mockResolvedValue(new Response(null, { status })); + renderPage(); + expect(await screen.findByText(/not yet available in RegEngine Beta/)).toBeVisible(); + expect(screen.queryByText(/Couldn't load|We could not load/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + expectProfileBlocked(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining(primaryPath), expect.anything()); + }); + + it.each(['server', 'network'] as const)('keeps ErrorState and working retry for %s failure', async failure => { + if (failure === 'server') fetchMock.mockResolvedValue(new Response(null, { status: 503 })); + else fetchMock.mockResolvedValue(Response.error()); + renderPage(); + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this"); + expect(screen.queryByText(/not yet available in RegEngine Beta/)).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Contact support' })).toBeVisible(); + expectProfileBlocked(); + const callsBeforeRetry = fetchMock.mock.calls.filter(([url]) => url.includes(primaryPath)).length; + await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(fetchMock.mock.calls.filter(([url]) => url.includes(primaryPath)).length).toBeGreaterThan(callsBeforeRetry)); + }); + }); +} diff --git a/frontend/src/app/dashboard/audit-log/page.tsx b/frontend/src/app/dashboard/audit-log/page.tsx index 19e5c0571..2fb656c79 100644 --- a/frontend/src/app/dashboard/audit-log/page.tsx +++ b/frontend/src/app/dashboard/audit-log/page.tsx @@ -9,6 +9,8 @@ import { Badge, type BadgeTone } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Callout } from '@/components/ui/callout'; import { ErrorState } from '@/components/ui/error-state'; +import { BetaUnavailableState } from '@/components/ui/beta-unavailable-state'; +import { isBetaUnavailableError } from '@/lib/dashboard-load-failure'; import { EmptyState } from '@/components/ui/empty-state'; import { Input } from '@/components/ui/input'; import { StatTile } from '@/components/ui/stat-tile'; @@ -37,7 +39,7 @@ import { import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; import { fetchWithCsrf } from '@/lib/fetch-with-csrf'; -import { getErrorRequestId, withRequestId } from '@/lib/api-error'; +import { apiError, getErrorRequestId, withRequestId } from '@/lib/api-error'; import { useTenant } from '@/lib/tenant-context'; import { useDashboardRefresh } from '@/hooks/use-dashboard-refresh'; @@ -117,7 +119,7 @@ async function fetchAuditLog(tenantId: string, page = 1, pageSize = 50): Promise }); if (!res.ok) { console.error(`Audit log fetch failed: ${res.status} ${res.statusText}`); - throw withRequestId(new Error("Couldn't load the audit log."), res); + throw withRequestId(apiError(res.status, res.statusText), res); } return res.json(); } @@ -245,7 +247,7 @@ export default function AuditLogPage() { )} {/* Error */} - {error && loadLog()} requestId={getErrorRequestId(error)} />} + {error && (isBetaUnavailableError(error) ? : loadLog()} requestId={getErrorRequestId(error)} />)} {/* Event list shell — toolbar, rows, pagination */} {isLoggedIn && !error && entries.length > 0 && ( diff --git a/frontend/src/app/dashboard/notifications/page.tsx b/frontend/src/app/dashboard/notifications/page.tsx index 095acdf57..e89a06e03 100644 --- a/frontend/src/app/dashboard/notifications/page.tsx +++ b/frontend/src/app/dashboard/notifications/page.tsx @@ -10,6 +10,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Callout } from '@/components/ui/callout'; import { ErrorState } from '@/components/ui/error-state'; +import { BetaUnavailableState } from '@/components/ui/beta-unavailable-state'; +import { isBetaUnavailableError } from '@/lib/dashboard-load-failure'; import { Spinner } from '@/components/ui/spinner'; import { fieldControlClasses } from '@/components/ui/input'; import { cn } from '@/lib/utils'; @@ -24,7 +26,7 @@ import { import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; -import { getErrorRequestId, withRequestId } from '@/lib/api-error'; +import { apiError, getErrorRequestId, withRequestId } from '@/lib/api-error'; /* ── Types matching NotificationPreferences ── */ @@ -81,7 +83,7 @@ async function apiFetchPrefs(tenantId: string): Promise credentials: 'include', headers: { 'Content-Type': 'application/json' }, }); - if (!res.ok) throw withRequestId(new Error(`API error: ${res.status} ${res.statusText}`), res); + if (!res.ok) throw withRequestId(apiError(res.status, res.statusText), res); return res.json(); } @@ -244,7 +246,7 @@ export default function NotificationPrefsPage() {
)} - {fetchFailed && refetchPrefs()} retrying={isFetching} requestId={getErrorRequestId(prefsError)} />} + {fetchFailed && (isBetaUnavailableError(prefsError) ? : refetchPrefs()} retrying={isFetching} requestId={getErrorRequestId(prefsError)} />)} {saveError && ( diff --git a/frontend/src/app/dashboard/products/page.tsx b/frontend/src/app/dashboard/products/page.tsx index 76d3ef920..9e13b356b 100644 --- a/frontend/src/app/dashboard/products/page.tsx +++ b/frontend/src/app/dashboard/products/page.tsx @@ -10,6 +10,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Callout } from '@/components/ui/callout'; import { ErrorState } from '@/components/ui/error-state'; +import { BetaUnavailableState } from '@/components/ui/beta-unavailable-state'; +import { isBetaUnavailableError } from '@/lib/dashboard-load-failure'; import { EmptyState } from '@/components/ui/empty-state'; import { Field } from '@/components/ui/field'; import { Input, fieldControlClasses } from '@/components/ui/input'; @@ -33,7 +35,7 @@ import { import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; -import { getErrorRequestId, withRequestId } from '@/lib/api-error'; +import { apiError, getErrorRequestId, withRequestId } from '@/lib/api-error'; import { useDashboardRefresh } from '@/hooks/use-dashboard-refresh'; /* ── Types matching ProductCatalogResponse ── */ @@ -79,7 +81,7 @@ async function apiFetchProducts(tenantId: string): Promise )} - {error && loadProducts()} requestId={getErrorRequestId(error)} />} + {error && (isBetaUnavailableError(error) ? : loadProducts()} requestId={getErrorRequestId(error)} />)} {isLoggedIn && !error && products.length > 0 && ( <> diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx index e9077e16f..106049172 100644 --- a/frontend/src/app/dashboard/settings/page.tsx +++ b/frontend/src/app/dashboard/settings/page.tsx @@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Callout } from '@/components/ui/callout'; import { ErrorState } from '@/components/ui/error-state'; +import { BetaUnavailableState } from '@/components/ui/beta-unavailable-state'; +import { isBetaUnavailableError } from '@/lib/dashboard-load-failure'; import { Field } from '@/components/ui/field'; import { Input, fieldControlClasses } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; @@ -20,7 +22,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useAuth } from '@/lib/auth-context'; import { useTenant } from '@/lib/tenant-context'; import { useCurrentSubscription } from '@/hooks/use-billing'; -import { getErrorRequestId } from '@/lib/api-error'; +import { apiError, getErrorRequestId, withRequestId } from '@/lib/api-error'; import { Building2, Key, Database, Plug, Save, CheckCircle2, CreditCard, @@ -121,7 +123,7 @@ export default function SettingsPage() { fei_number: '', }); - const { data: settingsData } = useQuery({ + const { data: settingsData, error: settingsError, isSuccess: settingsLoaded, refetch: refetchSettings, isFetching: settingsFetching } = useQuery({ queryKey: ['settings', tenantId], queryFn: async (): Promise => { const { getServiceURL } = await import('@/lib/api-config'); @@ -130,7 +132,7 @@ export default function SettingsPage() { credentials: 'include', headers: { 'Content-Type': 'application/json' }, }); - if (!res.ok) return null; + if (!res.ok) throw withRequestId(apiError(res.status, res.statusText), res); return res.json(); }, enabled: !!effectiveTenantId && isAuthenticated, @@ -237,7 +239,7 @@ export default function SettingsPage() { primaryAction={