265 lines
8.8 KiB
TypeScript
265 lines
8.8 KiB
TypeScript
import { cleanup, render, screen } from '@testing-library/react'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ROUTER_FUTURE_FLAGS } from '../router/router-future'
|
|
|
|
const mockUsePlatformAuth = vi.fn()
|
|
const mockLogout = vi.fn()
|
|
const mockToggleColorMode = vi.fn()
|
|
const mockGetAuthHealth = vi.fn()
|
|
const mockGetReleaseManifest = vi.fn()
|
|
|
|
vi.mock('../auth/platform-auth', () => ({
|
|
usePlatformAuth: () => mockUsePlatformAuth(),
|
|
}))
|
|
|
|
vi.mock('../auth/auth-api', () => ({
|
|
getAuthHealth: (...args: unknown[]) => mockGetAuthHealth(...args),
|
|
getReleaseManifest: (...args: unknown[]) => mockGetReleaseManifest(...args),
|
|
}))
|
|
|
|
import { MarketingShell } from '../components/layout/MarketingShell'
|
|
import { AppShell } from '../components/layout/AppShell'
|
|
import { ProtectedRoute } from '../components/routes/ProtectedRoute'
|
|
|
|
function LocationEcho() {
|
|
const location = useLocation()
|
|
return <div data-testid="location">{location.pathname}{location.search}{location.hash}</div>
|
|
}
|
|
|
|
function renderProtectedRoute(initialEntry: string) {
|
|
return render(
|
|
<MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE_FLAGS}>
|
|
<Routes>
|
|
<Route element={<ProtectedRoute />}>
|
|
<Route path="/app/account" element={<div>Account page</div>} />
|
|
</Route>
|
|
<Route path="/login" element={<LocationEcho />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
)
|
|
}
|
|
|
|
function renderMarketingShell(isAuthenticated = false) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
mockUsePlatformAuth.mockReturnValue({
|
|
isAuthenticated,
|
|
isLoading: false,
|
|
logout: () => mockLogout(),
|
|
toggleColorMode: () => mockToggleColorMode(),
|
|
colorMode: 'dark',
|
|
user: {
|
|
name: 'Operator',
|
|
plan: 'operator',
|
|
},
|
|
})
|
|
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter future={ROUTER_FUTURE_FLAGS}>
|
|
<MarketingShell eyebrow="Eyebrow" title="Title" lede="Lede">
|
|
<div>Body</div>
|
|
</MarketingShell>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
)
|
|
}
|
|
|
|
describe('website route and shell behavior', () => {
|
|
beforeEach(() => {
|
|
cleanup()
|
|
mockUsePlatformAuth.mockReset()
|
|
mockLogout.mockReset()
|
|
mockToggleColorMode.mockReset()
|
|
mockGetAuthHealth.mockReset()
|
|
mockGetReleaseManifest.mockReset()
|
|
mockUsePlatformAuth.mockReturnValue({
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
logout: () => mockLogout(),
|
|
toggleColorMode: () => mockToggleColorMode(),
|
|
colorMode: 'dark',
|
|
user: {
|
|
name: 'Operator',
|
|
plan: 'operator',
|
|
},
|
|
})
|
|
mockGetAuthHealth.mockResolvedValue({
|
|
ok: true,
|
|
service: 'hypertwist-auth-server',
|
|
supertokens: {
|
|
configured: true,
|
|
reachable: true,
|
|
ready: true,
|
|
apiVersion: '5.4',
|
|
error: null,
|
|
oauth: {
|
|
github: false,
|
|
google: false,
|
|
},
|
|
},
|
|
fallback: {
|
|
enabled: true,
|
|
active: false,
|
|
reason: null,
|
|
},
|
|
billing: {
|
|
statePath: '/var/lib/hypertwist/auth/hypertwist-billing-state.json',
|
|
processedEventCount: 0,
|
|
pricePlanMapConfigured: false,
|
|
productPlanMapConfigured: false,
|
|
webhookSecretConfigured: false,
|
|
},
|
|
runtime: {
|
|
mode: 'mixed',
|
|
public_origin_ready: true,
|
|
cookie_secure: true,
|
|
api_domain: 'https://hypertwist.app',
|
|
website_domain: 'https://hypertwist.app',
|
|
warnings: [],
|
|
errors: [],
|
|
},
|
|
})
|
|
mockGetReleaseManifest.mockResolvedValue({
|
|
ok: true,
|
|
manifest: {
|
|
generated_at: '2026-06-22T12:00:00.000Z',
|
|
support_email: 'hello@hypertwist.app',
|
|
public_docs_url: 'https://docs.hypertwist.app',
|
|
release_notes_url: 'https://notes.hypertwist.app',
|
|
corresponding_source_url: 'https://hypertwist.app/open-source/source.zip',
|
|
open_source_repo_url: 'https://github.com/hypertwist/hypertwist',
|
|
viewer: {
|
|
authenticated: false,
|
|
canDownload: false,
|
|
plan: null,
|
|
role: null,
|
|
accessStatus: null,
|
|
},
|
|
platforms: [],
|
|
},
|
|
})
|
|
})
|
|
|
|
it('shows the protected-route loader while auth state is still resolving', () => {
|
|
mockUsePlatformAuth.mockReturnValue({
|
|
isAuthenticated: false,
|
|
isLoading: true,
|
|
})
|
|
|
|
const { container } = renderProtectedRoute('/app/account')
|
|
|
|
expect(screen.getByText('Loading operator access...')).toBeTruthy()
|
|
expect(screen.getByText('Browser account access')).toBeTruthy()
|
|
expect(screen.getByText(/Checking your saved browser session, protected release status, and browser-to-desktop continuity/i)).toBeTruthy()
|
|
expect(container.querySelector('.general-page-loader-shell--fullscreen')).not.toBeNull()
|
|
expect(screen.getByAltText('HyperTwist')).toBeTruthy()
|
|
expect(screen.getByRole('link', { name: 'Open sign-in' }).getAttribute('href')).toBe('/login?next=%2Fapp%2Faccount')
|
|
expect(screen.getByRole('link', { name: 'Open support' }).getAttribute('href')).toBe('/support?topic=operator-access')
|
|
})
|
|
|
|
it('redirects unauthenticated protected routes to login with the full encoded next target', async () => {
|
|
renderProtectedRoute('/app/account?tab=billing#security')
|
|
|
|
expect((await screen.findByTestId('location')).textContent).toBe('/login?next=%2Fapp%2Faccount%3Ftab%3Dbilling%23security')
|
|
})
|
|
|
|
it('renders the protected outlet when authentication is present', () => {
|
|
mockUsePlatformAuth.mockReturnValue({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
})
|
|
|
|
renderProtectedRoute('/app/account')
|
|
|
|
expect(screen.getByText('Account page')).toBeTruthy()
|
|
})
|
|
|
|
it('switches the marketing-shell auth action between login and dashboard', () => {
|
|
const { rerender } = renderMarketingShell(false)
|
|
|
|
expect(screen.getByRole('link', { name: 'Log in' }).getAttribute('href')).toBe('/login')
|
|
expect(screen.getAllByRole('link', { name: 'Features' }).every((link) => link.getAttribute('href') === '/features')).toBe(true)
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
mockUsePlatformAuth.mockReturnValue({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
logout: () => mockLogout(),
|
|
toggleColorMode: () => mockToggleColorMode(),
|
|
colorMode: 'dark',
|
|
user: {
|
|
name: 'Operator',
|
|
plan: 'operator',
|
|
},
|
|
})
|
|
|
|
rerender(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter future={ROUTER_FUTURE_FLAGS}>
|
|
<MarketingShell eyebrow="Eyebrow" title="Title" lede="Lede">
|
|
<div>Body</div>
|
|
</MarketingShell>
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
)
|
|
|
|
expect(screen.getByRole('link', { name: 'Open dashboard' }).getAttribute('href')).toBe('/app')
|
|
})
|
|
|
|
it('shows operator identity in the app shell and wires theme/logout actions', async () => {
|
|
mockUsePlatformAuth.mockReturnValue({
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
logout: () => mockLogout(),
|
|
toggleColorMode: () => mockToggleColorMode(),
|
|
colorMode: 'dark',
|
|
user: {
|
|
name: 'Operator Prime',
|
|
plan: 'studio',
|
|
},
|
|
})
|
|
|
|
render(
|
|
<MemoryRouter initialEntries={['/app']} future={ROUTER_FUTURE_FLAGS}>
|
|
<Routes>
|
|
<Route path="/app" element={<AppShell />}>
|
|
<Route index element={<div>Overview content</div>} />
|
|
</Route>
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
)
|
|
|
|
expect(screen.getByText('Operator Prime')).toBeTruthy()
|
|
expect(screen.getByText('studio plan')).toBeTruthy()
|
|
expect(screen.getByText('Overview content')).toBeTruthy()
|
|
expect(screen.getByText('Desktop-first operator lane')).toBeTruthy()
|
|
expect(screen.getByRole('link', { name: 'Launch Status' }).getAttribute('href')).toBe('/app/launch-status')
|
|
expect(screen.getByRole('link', { name: 'Protected launch status' }).getAttribute('href')).toBe('/app/launch-status')
|
|
expect(screen.getByRole('link', { name: 'Public download guidance' }).getAttribute('href')).toBe('/download')
|
|
expect(screen.getByRole('link', { name: 'Public notices' }).getAttribute('href')).toBe('/open-source-notices')
|
|
expect(screen.getByRole('link', { name: 'Operator access support' }).getAttribute('href')).toBe('/support?topic=operator-access')
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: 'Switch to light mode' }))
|
|
await userEvent.click(screen.getByRole('button', { name: 'Log out' }))
|
|
|
|
expect(mockToggleColorMode).toHaveBeenCalledTimes(1)
|
|
expect(mockLogout).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|