Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/web/src/features/auth/guest-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
// Verifies the temporary guest marker can be started and cleared by the welcome flow.
import { afterEach, describe, expect, it } from 'vitest';

import { clearGuestSession, startGuestSession } from './guest-session';
import {
clearGuestSession,
isGuestSession,
startGuestSession,
} from './guest-session';

afterEach(() => {
sessionStorage.clear();
});

describe('guest session', () => {
it('can clear guest mode when the welcome page loads', () => {
expect(isGuestSession()).toBe(false);

startGuestSession();
expect(sessionStorage.getItem('goforlift.guest')).toBe('true');
expect(isGuestSession()).toBe(true);

clearGuestSession();

expect(sessionStorage.getItem('goforlift.guest')).toBeNull();
expect(isGuestSession()).toBe(false);
});
});
4 changes: 4 additions & 0 deletions apps/web/src/features/auth/guest-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export function startGuestSession() {
sessionStorage.setItem(GUEST_SESSION_KEY, 'true');
}

export function isGuestSession() {
return sessionStorage.getItem(GUEST_SESSION_KEY) === 'true';
}

export function clearGuestSession() {
sessionStorage.removeItem(GUEST_SESSION_KEY);
}
15 changes: 13 additions & 2 deletions apps/web/src/features/dashboard/application-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
// Verifies the application shell exposes accessible responsive navigation and active state.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { router } from '../../router';
import { startGuestSession } from '../auth/guest-session';

afterEach(cleanup);
afterEach(() => {
cleanup();
sessionStorage.clear();
});

describe('application shell', () => {
it('renders desktop and mobile navigation with the current page selected', async () => {
await router.navigate({ to: '/dashboard' });
render(<RouterProvider router={router} />);
startGuestSession();
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);

expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Verifies dashboard access states with an isolated router and query cache for each case.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from '@tanstack/react-router';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { startGuestSession } from '../auth/guest-session';
import { DashboardAccessBoundary } from './dashboard-access-boundary';

const publicUser = {
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
email: 'lifter@example.com',
displayName: 'Go For Lifter',
avatarUrl: null,
};

afterEach(() => {
cleanup();
sessionStorage.clear();
vi.unstubAllGlobals();
});

describe('dashboard access boundary', () => {
it('allows an authenticated visitor into the dashboard', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ user: publicUser })),
);
renderDashboard();

expect(
await screen.findByRole('heading', { name: 'Dashboard' }),
).toBeVisible();
});

it('allows an active guest without waiting for the API', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => undefined)),
);
startGuestSession();
renderDashboard();

expect(
await screen.findByRole('heading', { name: 'Dashboard' }),
).toBeVisible();
});

it('redirects an anonymous visitor to the welcome page', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ user: null })),
);
renderDashboard();

expect(
await screen.findByRole('heading', { name: 'Welcome' }),
).toBeVisible();
});

it('shows a loading state while authentication is unresolved', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => undefined)),
);
renderDashboard();

expect(await screen.findByText('Checking your session…')).toBeVisible();
});

it('shows a recoverable error when authentication cannot be verified', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
);
renderDashboard();

expect(
await screen.findByText("We couldn't verify your session."),
).toBeVisible();
expect(screen.getByRole('button', { name: 'Try again' })).toBeVisible();
});
});

function renderDashboard() {
const rootRoute = createRootRoute({ component: Outlet });
const welcomeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <h1>Welcome</h1>,
});
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: () => (
<DashboardAccessBoundary>
<h1>Dashboard</h1>
</DashboardAccessBoundary>
),
});
const testRouter = createRouter({
history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
routeTree: rootRoute.addChildren([welcomeRoute, dashboardRoute]),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={testRouter} />
</QueryClientProvider>,
);
}
58 changes: 58 additions & 0 deletions apps/web/src/features/dashboard/dashboard-access-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Allows authenticated and guest visitors into application routes while handling unresolved access.
import { Navigate } from '@tanstack/react-router';
import type { ReactNode } from 'react';

import { Button } from '../../components/ui/button';
import { useCurrentUser } from '../auth/current-user.query';
import { isGuestSession } from '../auth/guest-session';

type DashboardAccessBoundaryProps = {
children: ReactNode;
};

export function DashboardAccessBoundary({
children,
}: DashboardAccessBoundaryProps) {
const currentUser = useCurrentUser();

if (isGuestSession()) {
return children;
}

if (currentUser.authenticationStatus === 'loading') {
return <AccessStatus message="Checking your session…" />;
}

if (currentUser.authenticationStatus === 'error') {
return (
<AccessStatus message="We couldn't verify your session.">
<Button onClick={() => void currentUser.refetch()} type="button">
Try again
</Button>
</AccessStatus>
);
}

if (currentUser.authenticationStatus === 'anonymous') {
return <Navigate replace to="/" />;
}

return children;
}

function AccessStatus({
children,
message,
}: {
children?: ReactNode;
message: string;
}) {
return (
<main className="grid min-h-screen place-content-center gap-4 bg-background px-6 text-center text-foreground">
<p aria-live="polite" className="text-muted-foreground">
{message}
</p>
{children}
</main>
);
}
11 changes: 4 additions & 7 deletions apps/web/src/features/dashboard/section-landing.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
// Presents a minimal content heading inside the shared application shell.
import { ApplicationShell } from './application-shell';
// Presents a minimal heading within the parent application route layout.

type SectionLandingProps = {
title: string;
};

export function SectionLanding({ title }: SectionLandingProps) {
return (
<ApplicationShell>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
{title}
</h1>
</ApplicationShell>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
{title}
</h1>
);
}
Loading