mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
- prefetch route-tab links so tab clicks reuse a cached payload instead of paying an uncached server round trip each time - add loading.tsx boundaries (dashboard root and each section) so clicks paint within a frame; section-level files keep the header and tabs mounted while only the content area swaps - link the sidebar Account item and the checkout page straight to /dashboard/account/billing. The /dashboard/account redirect stub stays for old links, but no internal link pays the extra hop anymore. The stub also dropped query params, which silently ate the plan-change success toast. - set QueryClient defaults (staleTime 60s, no focus refetch, retry 1); mutations already invalidate their keys, so the user's own changes stay instant. Device messages and webhook deliveries get a 15s staleTime since they change from outside the tab. - replace the axios getCachedSession TTL cache with a token seeded from the server session in Providers and kept in sync by a session bridge. Requests attach the token synchronously; /api/auth/session is only a deduped fallback, instead of a refetch every 2 minutes with a thundering herd on expiry. - swap the billing card's 16px loading spinner for a card-shaped skeleton so the tab no longer looks blank while loading Tests: interceptor seeding/dedupe/signed-out behavior, provider defaults and token seeding, nav active-state matching, tab prefetch, billing loading state, plus e2e coverage for direct-to-billing navigation and an at-most-one-session-call budget guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
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<typeof import('next-auth/react')>()
|
|
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'])
|
|
})
|
|
})
|