diff --git a/web/app/(app)/(auth)/(components)/login-with-google.tsx b/web/app/(app)/(auth)/(components)/login-with-google.tsx index 41329d3..d3d2603 100644 --- a/web/app/(app)/(auth)/(components)/login-with-google.tsx +++ b/web/app/(app)/(auth)/(components)/login-with-google.tsx @@ -2,11 +2,28 @@ import { Routes } from '@/config/routes' import { toast } from '@/hooks/use-toast' -import { CredentialResponse, GoogleLogin } from '@react-oauth/google' +import { + CredentialResponse, + GoogleLogin, + GoogleOAuthProvider, +} from '@react-oauth/google' import { signIn } from 'next-auth/react' import { useRouter, useSearchParams } from 'next/navigation' +// The provider lives here rather than in the app-wide tree so the Google +// Identity SDK is only loaded on the two pages that render this button +// (login and register) instead of on every dashboard page. export default function LoginWithGoogle() { + return ( + + + + ) +} + +function GoogleLoginButton() { const router = useRouter() const searchParams = useSearchParams() const redirect = searchParams.get('redirect') diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index 8c51fdf..e107ad6 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -117,7 +117,9 @@ export default function CheckoutPage({ planName, billingInterval: urlInterval ?? selected, }) - window.location.href = '/dashboard/account?plan-change-success=1' + // Straight to the billing tab: the /dashboard/account redirect stub + // drops query params, which silently ate the success toast. + window.location.href = '/dashboard/account/billing?plan-change-success=1' } catch (error) { // no auto-retry here: the request may have charged the card setPlanChange(null) @@ -175,7 +177,7 @@ export default function CheckoutPage({ Try again @@ -233,7 +235,7 @@ export default function CheckoutPage({ disabled={isConfirming} asChild > - Cancel + Cancel diff --git a/web/app/(app)/dashboard/(components)/billing/subscription-info.test.tsx b/web/app/(app)/dashboard/(components)/billing/subscription-info.test.tsx index 034ff81..2592f98 100644 --- a/web/app/(app)/dashboard/(components)/billing/subscription-info.test.tsx +++ b/web/app/(app)/dashboard/(components)/billing/subscription-info.test.tsx @@ -216,6 +216,21 @@ describe('SubscriptionInfo', () => { ).toHaveAttribute('href', 'https://textbee.dev/pricing') }) + // The loading state used to be a 16px spinner alone in the content column, + // which read as a blank tab while the subscription loaded. + it('shows a visible loading skeleton while the subscription loads', () => { + useSubscription.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + }) + render() + + expect(screen.getByRole('status')).toHaveTextContent( + 'Loading subscription' + ) + }) + it('renders an error state rather than an empty card', () => { useSubscription.mockReturnValue({ data: undefined, diff --git a/web/app/(app)/dashboard/(components)/billing/subscription-info.tsx b/web/app/(app)/dashboard/(components)/billing/subscription-info.tsx index 47d1f0e..b479a37 100644 --- a/web/app/(app)/dashboard/(components)/billing/subscription-info.tsx +++ b/web/app/(app)/dashboard/(components)/billing/subscription-info.tsx @@ -11,7 +11,7 @@ import { } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Spinner } from '@/components/ui/spinner' +import { Skeleton } from '@/components/ui/skeleton' import { useCurrentUser, useSubscription } from '@/lib/api' import type { SubscriptionStatus } from '@/lib/api/types' import { @@ -235,8 +235,12 @@ export default function SubscriptionInfo() { if (isLoadingSubscription) return ( -
- + // Mirrors the two cards below. The old 16px spinner in an empty column + // read as a blank page while the subscription loaded. +
+ Loading subscription + +
) diff --git a/web/app/(app)/dashboard/(components)/nav-items.test.ts b/web/app/(app)/dashboard/(components)/nav-items.test.ts new file mode 100644 index 0000000..94461b3 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/nav-items.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { isNavItemActive, navItems } from './nav-items' + +describe('isNavItemActive', () => { + it('matches the dashboard home exactly', () => { + expect(isNavItemActive({ href: '/dashboard' }, '/dashboard')).toBe(true) + expect(isNavItemActive({ href: '/dashboard' }, '/dashboard/messaging')).toBe( + false + ) + }) + + it('matches section subroutes by prefix', () => { + const messaging = { href: '/dashboard/messaging' } + expect(isNavItemActive(messaging, '/dashboard/messaging')).toBe(true) + expect(isNavItemActive(messaging, '/dashboard/messaging/bulk')).toBe(true) + expect(isNavItemActive(messaging, '/dashboard/messaging/api-guide')).toBe( + true + ) + }) + + it('does not match sibling routes that share a name prefix', () => { + expect( + isNavItemActive({ href: '/dashboard/message' }, '/dashboard/messaging') + ).toBe(false) + }) + + it('keeps Account active across the section while linking to billing', () => { + const account = navItems.find((item) => item.label === 'Account') + // Direct link skips the /dashboard/account redirect stub. + expect(account?.href).toBe('/dashboard/account/billing') + expect(isNavItemActive(account!, '/dashboard/account/billing')).toBe(true) + expect(isNavItemActive(account!, '/dashboard/account/profile')).toBe(true) + expect(isNavItemActive(account!, '/dashboard/account/security')).toBe(true) + expect(isNavItemActive(account!, '/dashboard/account')).toBe(true) + expect(isNavItemActive(account!, '/dashboard/community')).toBe(false) + }) +}) diff --git a/web/app/(app)/dashboard/(components)/nav-items.ts b/web/app/(app)/dashboard/(components)/nav-items.ts index 7b7c1b0..328fce3 100644 --- a/web/app/(app)/dashboard/(components)/nav-items.ts +++ b/web/app/(app)/dashboard/(components)/nav-items.ts @@ -11,6 +11,10 @@ export type NavItem = { href: string label: string icon: LucideIcon + // Active-state prefix when it differs from href (Account links straight to + // billing so the click skips the /dashboard/account redirect stub, but must + // stay highlighted on every account tab). + match?: string // The mobile tab bar caps at 4 items (375px width); items marked // mobileHidden appear only in the desktop sidebar and the command palette. mobileHidden?: boolean @@ -23,15 +27,24 @@ export const navItems: NavItem[] = [ { href: '/dashboard/messaging', label: 'Messaging', icon: MessageSquareText }, { href: '/dashboard/webhooks', label: 'Webhooks', icon: Webhook, mobileHidden: true }, { href: '/dashboard/community', label: 'Community', icon: Users }, - { href: '/dashboard/account', label: 'Account', icon: UserCircle }, + { + href: '/dashboard/account/billing', + label: 'Account', + icon: UserCircle, + match: '/dashboard/account', + }, ] export const mobileNavItems = navItems.filter((item) => !item.mobileHidden) // /dashboard must match exactly; deeper routes match by prefix so nested pages // keep their parent highlighted. -export function isNavItemActive(href: string, pathname: string): boolean { - return href === '/dashboard' - ? pathname === href - : pathname === href || pathname.startsWith(`${href}/`) || pathname.startsWith(href) +export function isNavItemActive( + item: Pick, + pathname: string +): boolean { + const prefix = item.match ?? item.href + return prefix === '/dashboard' + ? pathname === prefix + : pathname === prefix || pathname.startsWith(`${prefix}/`) } diff --git a/web/app/(app)/dashboard/account/loading.tsx b/web/app/(app)/dashboard/account/loading.tsx new file mode 100644 index 0000000..d7db015 --- /dev/null +++ b/web/app/(app)/dashboard/account/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton' + +// Shown in the content slot while a tab's page segment streams in; the +// section header and tabs from the layout stay mounted above it. max-w-2xl +// matches the account pages (billing, profile, security, support). +export default function Loading() { + return ( +
+ Loading + + + +
+ ) +} diff --git a/web/app/(app)/dashboard/layout.tsx b/web/app/(app)/dashboard/layout.tsx index 975447f..3f1aa8b 100644 --- a/web/app/(app)/dashboard/layout.tsx +++ b/web/app/(app)/dashboard/layout.tsx @@ -57,7 +57,7 @@ export default function DashboardLayout({ ))} @@ -114,7 +114,7 @@ export default function DashboardLayout({ ))}
diff --git a/web/app/(app)/dashboard/loading.tsx b/web/app/(app)/dashboard/loading.tsx new file mode 100644 index 0000000..27a7183 --- /dev/null +++ b/web/app/(app)/dashboard/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from '@/components/ui/skeleton' + +// Section-level fallback for dashboard routes without a nested loading file +// (home, community). Padding mirrors those pages so nothing shifts when the +// real content lands. Sections with their own layout (messaging, webhooks, +// account) use their nested loading files instead, which keep the tabs +// mounted during tab switches. +export default function Loading() { + return ( +
+ Loading +
+ + +
+ + +
+ ) +} diff --git a/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx b/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx index d008b81..0b2ab08 100644 --- a/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx +++ b/web/app/(app)/dashboard/messaging/(components)/api-guide/code-block.tsx @@ -3,11 +3,33 @@ import { useState } from 'react' import { useTheme } from 'next-themes' import { Check, Copy } from 'lucide-react' -import SyntaxHighlighter from 'react-syntax-highlighter' +import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter' +import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash' +import go from 'react-syntax-highlighter/dist/esm/languages/prism/go' +import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript' +import json from 'react-syntax-highlighter/dist/esm/languages/prism/json' +import php from 'react-syntax-highlighter/dist/esm/languages/prism/php' +import python from 'react-syntax-highlighter/dist/esm/languages/prism/python' import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +// Prism, registering only the languages the guide uses. The bare +// react-syntax-highlighter entry is the highlight.js build with every language +// compiled in, and it emits hljs-* class names that the Prism themes below do +// not style, so this both shrinks the chunk and makes the theme apply. +// The languages come from LANGUAGES in snippets.ts plus json for responses. +for (const [name, definition] of [ + ['bash', bash], + ['go', go], + ['javascript', javascript], + ['json', json], + ['php', php], + ['python', python], +] as const) { + SyntaxHighlighter.registerLanguage(name, definition) +} + // The previous guide hardcoded a dark slate background regardless of theme, so // code blocks were the only dark thing on a light page. export default function CodeBlock({ diff --git a/web/app/(app)/dashboard/messaging/loading.tsx b/web/app/(app)/dashboard/messaging/loading.tsx new file mode 100644 index 0000000..bd2c348 --- /dev/null +++ b/web/app/(app)/dashboard/messaging/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@/components/ui/skeleton' + +// Shown in the content slot while a tab's page segment streams in; the +// section header and tabs from the layout stay mounted above it. +export default function Loading() { + return ( +
+ Loading + + + +
+ ) +} diff --git a/web/app/(app)/dashboard/webhooks/loading.tsx b/web/app/(app)/dashboard/webhooks/loading.tsx new file mode 100644 index 0000000..bd2c348 --- /dev/null +++ b/web/app/(app)/dashboard/webhooks/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@/components/ui/skeleton' + +// Shown in the content slot while a tab's page segment streams in; the +// section header and tabs from the layout stay mounted above it. +export default function Loading() { + return ( +
+ Loading + + + +
+ ) +} diff --git a/web/app/(app)/providers.test.tsx b/web/app/(app)/providers.test.tsx new file mode 100644 index 0000000..304a2d2 --- /dev/null +++ b/web/app/(app)/providers.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useQueryClient } from '@tanstack/react-query' +import { http, HttpResponse } from 'msw' +import Providers from './providers' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { server } from '@/test/msw/server' +import { API_BASE_URL, TEST_ACCESS_TOKEN } from '@/test/fixtures' +import { mockSession } from '@/test/render' + +// Own getSession mock (overriding the global one in test/setup.ts) so the +// no-session-fetch guarantee below is a real call-count assertion. +const getSessionSpy = vi.hoisted(() => vi.fn()) +vi.mock('next-auth/react', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getSession: getSessionSpy } +}) + +function DefaultsProbe() { + const queries = useQueryClient().getDefaultOptions().queries + return ( +
+ {`${queries?.staleTime}|${String(queries?.refetchOnWindowFocus)}|${String( + queries?.retry + )}`} +
+ ) +} + +describe('Providers', () => { + it('sets query defaults: 60s staleTime, no focus refetch, one retry', () => { + render( + + + + ) + expect(screen.getByTestId('defaults')).toHaveTextContent('60000|false|1') + }) + + it('seeds the API token from the server session with zero session fetches', async () => { + let authHeader: string | null = null + server.use( + http.get(`${API_BASE_URL}/ping`, ({ request }) => { + authHeader = request.headers.get('authorization') + return HttpResponse.json({ ok: true }) + }) + ) + + render({null}) + await httpBrowserClient.get('/ping') + + expect(authHeader).toBe(`Bearer ${TEST_ACCESS_TOKEN}`) + expect(getSessionSpy).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/(app)/providers.tsx b/web/app/(app)/providers.tsx index 0c1d1b6..601d86e 100644 --- a/web/app/(app)/providers.tsx +++ b/web/app/(app)/providers.tsx @@ -1,33 +1,59 @@ 'use client' -import { GoogleOAuthProvider } from '@react-oauth/google' -import { SessionProvider } from 'next-auth/react' +import { SessionProvider, useSession } from 'next-auth/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useState, type PropsWithChildren } from 'react' +import { useEffect, useState, type PropsWithChildren } from 'react' import type { Session } from 'next-auth' +import { setSessionToken } from '@/lib/httpBrowserClient' + +// Keeps the API client's token in sync with the session (login, logout, tab +// broadcast). Reads from context only: the provider is seeded with the server +// session, so this never hits /api/auth/session. +function SessionTokenBridge() { + const { data: session } = useSession() + + useEffect(() => { + setSessionToken(session?.user?.accessToken ?? null) + }, [session]) + + return null +} // Client-side provider tree for the app. The QueryClient is created once via // useState so it is stable across re-renders (previously a new client was // constructed on every render, throwing away the cache). Session expiry is -// handled globally by a 401 response interceptor in httpBrowserClient, so there -// is no longer a per-navigation whoAmI check here. +// handled globally by a 401 response interceptor in httpBrowserClient, so +// SessionProvider does not refetch on window focus and there is no +// per-navigation whoAmI check here. export default function Providers({ session, children, }: PropsWithChildren<{ session: Session | null }>) { - const [queryClient] = useState(() => new QueryClient()) + const [queryClient] = useState(() => { + // Seeded during the first render, before any child mounts and queries. + setSessionToken(session?.user?.accessToken ?? null) + + // staleTime 60s: mutations invalidate their keys, so the user's own + // changes are always instant; only out-of-tab changes can lag a minute. + // The old defaults (staleTime 0, refetch on focus) refetched every query + // on every mount and window focus. + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }) + }) // ThemeProvider lives in the root layout (app/theme-provider.tsx) so its // pre-paint script is not re-rendered on client navigation. return ( - - - - {children} - - + + + {children} ) } diff --git a/web/components/shared/route-tabs.test.tsx b/web/components/shared/route-tabs.test.tsx new file mode 100644 index 0000000..d3af1b9 --- /dev/null +++ b/web/components/shared/route-tabs.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import RouteTabs from './route-tabs' + +// Capture Link props: prefetch is router behavior, invisible in the DOM. +vi.mock('next/link', () => ({ + default: ({ children, href, prefetch, ...rest }: any) => ( + + {children} + + ), +})) + +vi.mock('next/navigation', () => ({ + usePathname: () => '/dashboard/messaging', +})) + +const tabs = [ + { href: '/dashboard/messaging', label: 'Send', exact: true }, + { href: '/dashboard/messaging/bulk', label: 'Bulk Send' }, + { href: '/dashboard/messaging/history', label: 'History' }, +] + +describe('RouteTabs', () => { + it('opts every tab link into full prefetch', () => { + render() + // These routes are dynamic (session cookie in the app layout); without an + // explicit prefetch every tab click pays a full server round trip. + for (const link of screen.getAllByRole('link')) { + expect(link).toHaveAttribute('data-prefetch', 'true') + } + expect(screen.getAllByRole('link')).toHaveLength(tabs.length) + }) + + it('marks only the active tab with aria-current', () => { + render() + expect(screen.getByRole('link', { name: 'Send' })).toHaveAttribute( + 'aria-current', + 'page' + ) + expect( + screen.getByRole('link', { name: 'Bulk Send' }) + ).not.toHaveAttribute('aria-current') + }) +}) diff --git a/web/components/shared/route-tabs.tsx b/web/components/shared/route-tabs.tsx index 2b6361a..113a3e7 100644 --- a/web/components/shared/route-tabs.tsx +++ b/web/components/shared/route-tabs.tsx @@ -20,7 +20,9 @@ function isTabActive(tab: RouteTab, pathname: string): boolean { // Link-based segmented control: tabs are real routes, so the active tab // survives refresh and deep links are shareable. Mobile: horizontally -// scrollable pills; the active pill scrolls into view on load. +// scrollable pills; the active pill scrolls into view on load. Links opt into +// full prefetch because these routes are dynamic (session cookie in the app +// layout), which the router's default prefetch skips. export default function RouteTabs({ tabs, className, @@ -54,6 +56,7 @@ export default function RouteTabs({ vi.fn()) +vi.mock('next-auth/react', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useSession: useSessionMock } +}) + +const init = vi.fn() +const destroy = vi.fn() + +// A fresh object every call, the way SessionProvider hands one back after a +// refetch even when the underlying user has not changed. +const sessionWith = (overrides: Record = {}) => ({ + data: { + user: { + id: 'user_1', + name: 'Test User', + email: 'test@example.com', + phone: '', + ...overrides, + }, + }, +}) + +const scripts = () => + document.querySelectorAll('script[src*="supporthq-widget.js"]') + +// jsdom does not fetch external scripts, so loading is simulated. +const fireLoad = () => + scripts().forEach((s) => (s as HTMLScriptElement).onload?.(new Event('load'))) + +beforeEach(() => { + init.mockClear() + destroy.mockClear() + // @ts-ignore + window.SupportHQWidget = { init, destroy } +}) + +afterEach(() => { + document + .querySelectorAll('script[src*="supporthq-widget.js"]') + .forEach((s) => s.remove()) +}) + +describe('SupportHQWidget', () => { + it('injects the widget script once and initialises with the user metadata', () => { + useSessionMock.mockReturnValue(sessionWith()) + render() + + expect(scripts()).toHaveLength(1) + + fireLoad() + expect(init).toHaveBeenCalledTimes(1) + expect(init.mock.calls[0][0].metadata).toEqual({ + userId: 'user_1', + name: 'Test User', + email: 'test@example.com', + phone: '', + }) + }) + + // The reported defect: the effect depended on the session object, so a + // refetch that returned identical data still destroyed and re-injected the + // widget, closing any chat the user had open. + it('does not reinitialise when a refetch returns identical data', () => { + useSessionMock.mockReturnValue(sessionWith()) + const { rerender } = render() + fireLoad() + + useSessionMock.mockReturnValue(sessionWith()) + rerender() + + expect(scripts()).toHaveLength(1) + expect(destroy).not.toHaveBeenCalled() + expect(init).toHaveBeenCalledTimes(1) + }) + + it('reinitialises when the user metadata actually changes', () => { + useSessionMock.mockReturnValue(sessionWith()) + const { rerender } = render() + fireLoad() + + useSessionMock.mockReturnValue(sessionWith({ email: 'new@example.com' })) + rerender() + fireLoad() + + expect(destroy).toHaveBeenCalledTimes(1) + expect(init).toHaveBeenCalledTimes(2) + expect(init.mock.calls[1][0].metadata.email).toBe('new@example.com') + }) + + // destroy() tears down the widget but leaves the tag, so without an explicit + // removal every re-run left another one behind. + it('leaves no script tags behind when it unmounts', () => { + useSessionMock.mockReturnValue(sessionWith()) + const { unmount } = render() + fireLoad() + + unmount() + + expect(scripts()).toHaveLength(0) + expect(destroy).toHaveBeenCalledTimes(1) + }) + + it('does not initialise if the script loads after cleanup', () => { + useSessionMock.mockReturnValue(sessionWith()) + const { unmount } = render() + + const pending = scripts()[0] as HTMLScriptElement + unmount() + // A cached src can resolve after the effect was torn down; initialising + // then would leave a widget that nothing destroys. + pending.onload?.(new Event('load')) + + expect(init).not.toHaveBeenCalled() + }) + + it('omits metadata when nobody is signed in', () => { + useSessionMock.mockReturnValue({ data: null }) + render() + fireLoad() + + expect(init).toHaveBeenCalledTimes(1) + expect(init.mock.calls[0][0]).not.toHaveProperty('metadata') + }) +}) diff --git a/web/components/shared/support-hq-widget.tsx b/web/components/shared/support-hq-widget.tsx index 6e83bb3..203e97a 100644 --- a/web/components/shared/support-hq-widget.tsx +++ b/web/components/shared/support-hq-widget.tsx @@ -3,32 +3,49 @@ import { useSession } from 'next-auth/react' import React, { useEffect } from 'react' export default function SupportHQWidget() { + const { data: session } = useSession() - const { data: session } = useSession() - - useEffect(() => { - const script = document.createElement('script') - script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js' - script.async = true - // @ts-ignore - script.onload = () => window.SupportHQWidget?.init({ - projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID, - themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb', - ...(session?.user && { - metadata: { - userId: session.user.id || '', - name: session.user.name || '', - email: session.user.email || '', - phone: session.user.phone || '', - } - }) - }) - document.body.appendChild(script) - // @ts-ignore - return () => { window.SupportHQWidget?.destroy() } - }, [session]) + // Depended on as individual strings rather than as `session`: SessionProvider + // hands back a new object on every refetch, and comparing that by reference + // tore the widget down and re-injected its script even when nothing about the + // user had changed, closing any open chat. + const hasUser = Boolean(session?.user) + const userId = session?.user?.id ?? '' + const name = session?.user?.name ?? '' + const email = session?.user?.email ?? '' + const phone = session?.user?.phone ?? '' - return ( - <> - ) + useEffect(() => { + let cancelled = false + + const script = document.createElement('script') + script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js' + script.async = true + script.onload = () => { + // The script can finish loading after this effect was cleaned up (the + // src is cached, so a re-run resolves immediately), and initialising + // then would leave a widget behind that nothing destroys. + if (cancelled) return + // @ts-ignore + window.SupportHQWidget?.init({ + projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID, + themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb', + ...(hasUser && { + metadata: { userId, name, email, phone }, + }), + }) + } + document.body.appendChild(script) + + return () => { + cancelled = true + // @ts-ignore + window.SupportHQWidget?.destroy() + // destroy() tears down the widget but leaves this tag behind, so each + // re-run used to add another one for the life of the page. + script.remove() + } + }, [hasUser, userId, name, email, phone]) + + return <> } diff --git a/web/e2e/account.spec.ts b/web/e2e/account.spec.ts index b4ccd09..3a7852f 100644 --- a/web/e2e/account.spec.ts +++ b/web/e2e/account.spec.ts @@ -65,6 +65,33 @@ test.describe('account settings (mocked API, no real backend)', () => { ).toHaveCount(0) }) + // The sidebar used to link to /dashboard/account, whose page is a server + // redirect stub, so every click paid navigation + redirect + navigation. + test('the sidebar Account link goes straight to billing with no redirect hop', async ({ + page, + context, + }) => { + await authenticate(context) + await mockApi(page) + + const stubHits: string[] = [] + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/dashboard/account') { + stubHits.push(request.url()) + } + }) + + await page.goto('/dashboard') + await page + .getByRole('navigation', { name: 'Main' }) + .getByRole('link', { name: 'Account' }) + .click() + + await expect(page).toHaveURL(/\/dashboard\/account\/billing$/) + await expect(page.getByRole('heading', { name: 'Pro' })).toBeVisible() + expect(stubHits).toEqual([]) + }) + test('the pricing page is reachable from billing on any plan', async ({ page, context, diff --git a/web/e2e/api-guide.spec.ts b/web/e2e/api-guide.spec.ts index e0107f6..db54852 100644 --- a/web/e2e/api-guide.spec.ts +++ b/web/e2e/api-guide.spec.ts @@ -55,14 +55,49 @@ test.describe('api guide (mocked API, no real backend)', () => { await expect(page.getByText('package main').first()).toBeVisible() }) + // The guide applies Prism themes, so it must render with the Prism + // highlighter. It previously used the package's default export, which is the + // highlight.js build: that emits hljs-* classes the Prism theme never styles + // (so samples went uncoloured) and compiles in every language it supports. + test('samples are highlighted by Prism, in every language', async ({ + page, + context, + }) => { + await authenticate(context) + await mockApi(page) + await page.goto('/dashboard/messaging/api-guide') + + for (const language of ['cURL', 'Node.js', 'Python', 'PHP', 'Go']) { + await page.getByRole('tab', { name: language, exact: true }).click() + await expect( + page.locator('pre code .token').first(), + `${language} samples must carry Prism token markup` + ).toBeVisible() + } + + // A registered language yields more than one token type; a missing one + // would fall back to a single undifferentiated text run. + expect( + await page.locator('pre code .token').count() + ).toBeGreaterThan(5) + }) + test('code can be copied', async ({ page, context }) => { await authenticate(context) await mockApi(page) await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto('/dashboard/messaging/api-guide') + // navigator.clipboard.writeText rejects when the document is not focused, + // which is the state a backgrounded page sits in while workers run in + // parallel. Granting permissions alone does not cover it. + await page.bringToFront() await page.getByRole('button', { name: 'Copy code' }).first().click() + // The button relabels itself only after the async clipboard write resolves, + // so this is the signal that there is something to read back. + await expect(page.getByRole('button', { name: 'Copied' }).first()).toBeVisible() + const clipboard = await page.evaluate(() => navigator.clipboard.readText() ) @@ -74,7 +109,10 @@ test.describe('api guide (mocked API, no real backend)', () => { await authenticate(context) await mockApi(page) await page.goto('/dashboard/messaging/api-guide') - await page.waitForLoadState('networkidle') + // Not networkidle: link prefetching keeps the network busy, so that state + // can go unreached under parallel workers. A rendered sample proves the + // code blocks (the thing that would widen the page) are laid out. + await expect(page.getByText('curl -X POST').first()).toBeVisible() // Code blocks are the classic way to widen a mobile page. const { scrollWidth, innerWidth } = await page.evaluate(() => ({ diff --git a/web/e2e/bulk-send.spec.ts b/web/e2e/bulk-send.spec.ts index 57e8072..9328a3a 100644 --- a/web/e2e/bulk-send.spec.ts +++ b/web/e2e/bulk-send.spec.ts @@ -23,6 +23,20 @@ async function uploadCsv(page: import('@playwright/test').Page, csv: string) { }) } +// The section streams behind a loading.tsx boundary, so the form's HTML can +// paint before React hydrates it, and a file offered during that gap is +// silently discarded (react-dropzone binds its change handler on mount). +// The row cap in the dropzone copy is derived from useSubscription inside this +// page's own hook, so seeing it proves this component hydrated and has data. +// Waiting on the devices response would not: the dashboard layout requests the +// same query key, so it can resolve before this page has hydrated. +async function gotoBulkPage(page: import('@playwright/test').Page) { + await page.goto('/dashboard/messaging/bulk') + await expect( + page.getByText(/Up to 1 MB and [\d,]+ rows on your plan/) + ).toBeVisible() +} + // Keyboard selection rather than clicking the option: the Radix popper can // render outside the viewport on a short page, which made the click flaky. async function selectDevice(page: import('@playwright/test').Page) { @@ -51,7 +65,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { }) }) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) await uploadCsv(page, CSV_WITH_PROBLEM_ROWS) @@ -90,7 +104,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { test('offers a downloadable sample CSV', async ({ page, context }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) const link = page.getByRole('link', { name: /download a sample csv/i }) await expect(link).toBeVisible() @@ -110,7 +124,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) // The old copy printed the raw byte count ("max 1048576 bytes"). await expect(page.getByText('1048576')).toHaveCount(0) @@ -124,7 +138,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { await page.setViewportSize({ width: 375, height: 800 }) await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) // A wide table is the obvious way to reintroduce the sideways-scroll bug, // and it only exists once a file has been parsed. @@ -144,7 +158,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) await uploadCsv(page, CSV_WITH_PROBLEM_ROWS) await selectDevice(page) @@ -165,7 +179,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) await uploadCsv( page, @@ -187,7 +201,7 @@ test.describe('bulk send (mocked API, no real backend)', () => { test('explains why a non-CSV file was rejected', async ({ page, context }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging/bulk') + await gotoBulkPage(page) // Rejected drops used to return silently, so nothing happened and nothing // said why. diff --git a/web/e2e/chrome.spec.ts b/web/e2e/chrome.spec.ts index 40bea71..2c9001f 100644 --- a/web/e2e/chrome.spec.ts +++ b/web/e2e/chrome.spec.ts @@ -42,23 +42,29 @@ test.describe('app chrome (mocked API, no real backend)', () => { await mockApi(page) await page.goto('/dashboard') - // Scroll to the very bottom so the footer is in view. - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) - const footer = page.locator('footer') await expect(footer).toBeVisible() + const tabBar = page.locator('nav.fixed').first() - const footerBox = await footer.boundingBox() - const tabBarBox = await page.locator('nav.fixed').first().boundingBox() - - expect(footerBox, 'footer must have a layout box').not.toBeNull() - expect(tabBarBox, 'mobile tab bar must have a layout box').not.toBeNull() - - // The footer's last pixel must sit above the tab bar's first pixel. - expect( - footerBox!.y + footerBox!.height, - 'footer bottom must clear the fixed tab bar' - ).toBeLessThanOrEqual(tabBarBox!.y + 1) + // Scrolled again on every attempt rather than once up front: the page + // starts as a short loading skeleton and grows as each card's data lands, + // so a single scroll to the bottom can be measured from a document that is + // still shorter than it ends up, leaving the footer below the viewport. + await expect + .poll( + async () => { + await page.evaluate(() => + window.scrollTo(0, document.body.scrollHeight) + ) + const footerBox = await footer.boundingBox() + const tabBarBox = await tabBar.boundingBox() + if (!footerBox || !tabBarBox) return Number.POSITIVE_INFINITY + // The footer's last pixel must sit above the tab bar's first pixel. + return footerBox.y + footerBox.height - (tabBarBox.y + 1) + }, + { message: 'footer bottom must clear the fixed tab bar' } + ) + .toBeLessThanOrEqual(0) }) test('mobile: footer links stack vertically', async ({ page, context }) => { diff --git a/web/e2e/mobile-overflow.spec.ts b/web/e2e/mobile-overflow.spec.ts index 88b167a..72b0927 100644 --- a/web/e2e/mobile-overflow.spec.ts +++ b/web/e2e/mobile-overflow.spec.ts @@ -15,7 +15,10 @@ const AUTHED_PAGES = [ '/dashboard/messaging/api-guide', '/dashboard/webhooks', '/dashboard/webhooks/deliveries', - '/dashboard/account', + // The billing tab, not /dashboard/account: that route is a redirect stub + // with no layout of its own, and measuring it raced the redirect. The + // redirect itself is covered in account.spec.ts. + '/dashboard/account/billing', '/dashboard/account/profile', '/dashboard/account/security', '/dashboard/account/support', @@ -24,13 +27,22 @@ const AUTHED_PAGES = [ '/dashboard/community', ] +// Deterministic replacement for waitForLoadState('networkidle'), which needs a +// 500ms window with zero connections. Link prefetching keeps issuing and +// aborting RSC requests, so under parallel workers that window can be missed +// until the test times out. Waiting for the page's own heading and for its +// loading placeholders to clear proves the same thing: content is laid out. +async function waitForContent(page: import('@playwright/test').Page) { + await expect(page.locator('main#main-content h2').first()).toBeVisible() + await expect(page.locator('main#main-content [role="status"]')).toHaveCount(0) +} + async function expectNoHorizontalScroll(page: import('@playwright/test').Page) { - const { scrollWidth, innerWidth } = await page.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - innerWidth: window.innerWidth, - })) - expect(scrollWidth, 'page must not scroll sideways at 375px').toBeLessThanOrEqual( - innerWidth + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth + ) + expect(overflow, 'page must not scroll sideways at 375px').toBeLessThanOrEqual( + 0 ) } @@ -42,7 +54,7 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => { await authenticate(context) await mockApi(page) await page.goto(path) - await page.waitForLoadState('networkidle') + await waitForContent(page) await expectNoHorizontalScroll(page) }) } @@ -70,7 +82,7 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => { test('login page', async ({ page }) => { await mockApi(page) await page.goto('/login') - await page.waitForLoadState('networkidle') + await expect(page.getByText('Welcome back')).toBeVisible() await expectNoHorizontalScroll(page) }) }) diff --git a/web/e2e/send-sms.spec.ts b/web/e2e/send-sms.spec.ts index 0ccf406..054ef14 100644 --- a/web/e2e/send-sms.spec.ts +++ b/web/e2e/send-sms.spec.ts @@ -15,6 +15,16 @@ async function captureSend(page: import('@playwright/test').Page) { return payloads } +// The section streams behind a loading.tsx boundary, so the form's HTML can +// paint before React hydrates it; typing that early into the controlled To +// input is silently wiped when hydration commits. The device name renders +// only after the client-side devices fetch, so its presence proves the form +// is hydrated and its handlers are attached. +async function gotoSendPage(page: import('@playwright/test').Page) { + await page.goto('/dashboard/messaging') + await expect(page.getByLabel('Send from')).toContainText('Pixel 8') +} + test.describe('send sms (mocked API, no real backend)', () => { test('commits recipients as chips and posts exactly those numbers', async ({ page, @@ -24,7 +34,7 @@ test.describe('send sms (mocked API, no real backend)', () => { await mockApi(page) const payloads = await captureSend(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) const to = page.getByLabel('To', { exact: true }) await to.fill('+1 (415) 555-0101') @@ -57,7 +67,7 @@ test.describe('send sms (mocked API, no real backend)', () => { }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) await page .getByLabel('To', { exact: true }) @@ -77,7 +87,7 @@ test.describe('send sms (mocked API, no real backend)', () => { }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) // Spaces are formatting inside one number here, not separators. const to = page.getByLabel('To', { exact: true }) @@ -94,7 +104,7 @@ test.describe('send sms (mocked API, no real backend)', () => { test('rejects a number that cannot be dialled', async ({ page, context }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) const to = page.getByLabel('To', { exact: true }) await to.fill('not a number') @@ -113,7 +123,7 @@ test.describe('send sms (mocked API, no real backend)', () => { await mockApi(page) const payloads = await captureSend(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) // Type a number then go straight to the message without pressing Enter. await page.getByLabel('To', { exact: true }).fill('+14155550101') @@ -127,7 +137,7 @@ test.describe('send sms (mocked API, no real backend)', () => { test('preselects the only enabled device', async ({ page, context }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) // Fixtures have one enabled device (Pixel 8) and one disabled. The old // implementation computed this in defaultValues before devices loaded, so @@ -138,7 +148,7 @@ test.describe('send sms (mocked API, no real backend)', () => { test('counts SMS segments', async ({ page, context }) => { await authenticate(context) await mockApi(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) await page.getByLabel('Message').fill('a'.repeat(161)) await expect(page.getByText(/161 characters, 2 segments/)).toBeVisible() @@ -149,7 +159,7 @@ test.describe('send sms (mocked API, no real backend)', () => { await mockApi(page) await captureSend(page) - await page.goto('/dashboard/messaging') + await gotoSendPage(page) const to = page.getByLabel('To', { exact: true }) await to.fill('+14155550101') await to.press('Enter') diff --git a/web/e2e/session-calls.spec.ts b/web/e2e/session-calls.spec.ts new file mode 100644 index 0000000..87c1ad4 --- /dev/null +++ b/web/e2e/session-calls.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { authenticate } from './session' +import { mockApi } from './mock-api' + +// Serverless-cost regression guard. The API client's token is seeded from the +// server-fetched session, so browsing the dashboard must not keep calling +// /api/auth/session. Before this guard, the axios interceptor refetched the +// session every 2 minutes with no dedupe, so a burst of queries fanned out +// one session call each. +test('dashboard navigation makes at most one /api/auth/session call', async ({ + page, + context, +}) => { + await authenticate(context) + await mockApi(page) + + let sessionCalls = 0 + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/api/auth/session') { + sessionCalls += 1 + } + }) + + await page.goto('/dashboard') + await expect( + page.getByRole('heading', { name: 'Welcome back, Test', level: 2 }) + ).toBeVisible() + + const mainNav = page.getByRole('navigation', { name: 'Main' }) + const tabs = page.getByRole('navigation', { name: 'Section navigation' }) + + await mainNav.getByRole('link', { name: 'Messaging' }).click() + await expect(page).toHaveURL(/\/dashboard\/messaging$/) + await tabs.getByRole('link', { name: 'History' }).click() + await expect(page).toHaveURL(/\/dashboard\/messaging\/history$/) + + await mainNav.getByRole('link', { name: 'Webhooks' }).click() + await expect(page).toHaveURL(/\/dashboard\/webhooks$/) + await tabs.getByRole('link', { name: 'Deliveries' }).click() + await expect(page).toHaveURL(/\/dashboard\/webhooks\/deliveries$/) + + await mainNav.getByRole('link', { name: 'Account' }).click() + await expect(page).toHaveURL(/\/dashboard\/account\/billing$/) + + expect(sessionCalls).toBeLessThanOrEqual(1) +}) diff --git a/web/lib/api/hooks.ts b/web/lib/api/hooks.ts index e015cfa..f868ac9 100644 --- a/web/lib/api/hooks.ts +++ b/web/lib/api/hooks.ts @@ -358,6 +358,9 @@ export function useWebhookNotifications(filters: WebhookNotificationFilters) { `${ApiEndpoints.gateway.getWebhookNotifications()}?eventType=${eventType}&page=${page}&limit=${limit}&status=${status}&start=${start}&end=${end}&deviceId=${deviceId}&webhookSubscriptionId=${webhookSubscriptionId}` ) .then(unwrapBody), + // Deliveries arrive from outside the tab, so stay fresher than the 60s + // client-wide default. + staleTime: 15_000, }) } @@ -420,6 +423,9 @@ export function useDeviceMessages( .get(`${ApiEndpoints.gateway.getMessages(deviceId)}?${query}`) .then(unwrapBody) }, + // Inbound messages arrive from outside the tab, so stay fresher than the + // 60s client-wide default. Before ...options so callers can override. + staleTime: 15_000, ...options, }) } diff --git a/web/lib/httpBrowserClient.test.ts b/web/lib/httpBrowserClient.test.ts new file mode 100644 index 0000000..ecd132e --- /dev/null +++ b/web/lib/httpBrowserClient.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/msw/server' +import { API_BASE_URL } from '@/test/fixtures' + +// Own getSession mock (overriding the global one in test/setup.ts) so calls +// can be counted and resolved per test. +const getSessionMock = vi.hoisted(() => vi.fn()) +vi.mock('next-auth/react', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getSession: getSessionMock } +}) + +// The token lives in module state, so each test loads a fresh module copy. +async function loadClient() { + vi.resetModules() + return import('@/lib/httpBrowserClient') +} + +function captureAuthHeaders() { + const headers: (string | null)[] = [] + server.use( + http.get(`${API_BASE_URL}/ping`, ({ request }) => { + headers.push(request.headers.get('authorization')) + return HttpResponse.json({ ok: true }) + }) + ) + return headers +} + +afterEach(() => { + getSessionMock.mockReset() +}) + +describe('httpBrowserClient auth interceptor', () => { + it('attaches a seeded token without calling getSession', async () => { + const { default: client, setSessionToken } = await loadClient() + setSessionToken('abc') + const headers = captureAuthHeaders() + + await client.get('/ping') + + expect(headers).toEqual(['Bearer abc']) + expect(getSessionMock).not.toHaveBeenCalled() + }) + + it('dedupes concurrent session fetches when the token is not seeded', async () => { + getSessionMock.mockResolvedValue({ user: { accessToken: 'tok1' } }) + const { default: client } = await loadClient() + const headers = captureAuthHeaders() + + await Promise.all([client.get('/ping'), client.get('/ping')]) + + expect(getSessionMock).toHaveBeenCalledTimes(1) + expect(headers).toEqual(['Bearer tok1', 'Bearer tok1']) + }) + + it('sends no header and no session fetch when seeded signed out', async () => { + const { default: client, setSessionToken } = await loadClient() + // null means known signed out (the 401 handler and the auth pages), so + // requests must not fall back to a /api/auth/session round trip. + setSessionToken(null) + const headers = captureAuthHeaders() + + await client.get('/ping') + + expect(headers).toEqual([null]) + expect(getSessionMock).not.toHaveBeenCalled() + }) + + it('recovers with a fresh fetch after a failed session lookup', async () => { + getSessionMock.mockRejectedValueOnce(new Error('network down')) + getSessionMock.mockResolvedValueOnce({ user: { accessToken: 'tok2' } }) + const { default: client } = await loadClient() + const headers = captureAuthHeaders() + + await expect(client.get('/ping')).rejects.toThrow('network down') + await client.get('/ping') + + expect(getSessionMock).toHaveBeenCalledTimes(2) + expect(headers).toEqual(['Bearer tok2']) + }) +}) diff --git a/web/lib/httpBrowserClient.ts b/web/lib/httpBrowserClient.ts index b906cd0..250ad6e 100644 --- a/web/lib/httpBrowserClient.ts +++ b/web/lib/httpBrowserClient.ts @@ -5,32 +5,41 @@ const httpBrowserClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || '', }) -// Cache for session data to reduce API calls -let sessionCache: any = null -let cacheTimestamp = 0 -const CACHE_DURATION = 2 * 60 * 1000 // 2 minutes +// API access token, seeded from the server-fetched session by Providers and +// kept current by its SessionTokenBridge. Held in module state so the request +// interceptor attaches it synchronously instead of paying a /api/auth/session +// round trip per request (Vercel cost and added latency). undefined means not +// seeded yet; null means known signed out, so auth pages never fetch either. +let sessionToken: string | null | undefined -const getCachedSession = async () => { - const now = Date.now() - - // Return cached session if it's still valid - if (sessionCache && (now - cacheTimestamp) < CACHE_DURATION) { - return sessionCache +export function setSessionToken(token: string | null) { + sessionToken = token +} + +// Fallback for the rare request that fires before the token is seeded (deep +// hard load). One shared promise so a burst of queries costs one session call. +let sessionFetch: Promise | null = null + +function fetchSessionToken() { + if (!sessionFetch) { + sessionFetch = getSession() + .then((session) => { + sessionToken = session?.user?.accessToken ?? null + return sessionToken + }) + .finally(() => { + sessionFetch = null + }) } - - // Fetch fresh session and update cache - const session = await getSession() - sessionCache = session - cacheTimestamp = now - - return session + return sessionFetch } httpBrowserClient.interceptors.request.use(async (config) => { - const session = await getCachedSession() + const token = + sessionToken === undefined ? await fetchSessionToken() : sessionToken - if (session?.user?.accessToken) { - config.headers.Authorization = `Bearer ${session.user.accessToken}` + if (token) { + config.headers.Authorization = `Bearer ${token}` } return config }) @@ -47,7 +56,7 @@ httpBrowserClient.interceptors.response.use( ) { const { pathname } = window.location if (!pathname.includes('/logout') && !pathname.includes('/login')) { - sessionCache = null + setSessionToken(null) window.location.href = '/logout' } } diff --git a/web/test/setup.ts b/web/test/setup.ts index c280596..c5fbfbe 100644 --- a/web/test/setup.ts +++ b/web/test/setup.ts @@ -3,9 +3,11 @@ import { cleanup } from '@testing-library/react' import { afterAll, afterEach, beforeAll, vi } from 'vitest' import { server } from './msw/server' -// The axios browser client's interceptor calls next-auth's getSession(), which -// would otherwise trigger a real /api/auth/session fetch. Mock it so the -// interceptor can attach the Bearer token without any network access. +// The axios browser client's interceptor falls back to next-auth's +// getSession() when no token has been seeded (tests render components without +// the app's Providers), which would otherwise trigger a real /api/auth/session +// fetch. Mock it so the interceptor can attach the Bearer token without any +// network access. const hoisted = vi.hoisted(() => ({ accessToken: 'test-access-token' })) vi.mock('next-auth/react', async (importOriginal) => { const actual = await importOriginal()