Skip to content

Commit 650db2d

Browse files
committed
test(frontend-arch): cover session race fix, workflow-state cache collapse, unsubscribe, error boundary
Add targeted tests for the four frontend-architecture refactors: - session-provider: upgrade-path ordering — fresh disableCookieCache read wins over a late-resolving stale mount query (proves the cancelQueries guard) - fetch-workflow-envelope + registry store: single shared state(id) cache entry, always-refetch (staleTime 0), request-id staleness guard - unsubscribe: query enable-gating + mutation cache reconcile - logs error boundary: renders ErrorState + reset wiring (also first ErrorState coverage)
1 parent 03f7d8c commit 650db2d

5 files changed

Lines changed: 767 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, useContext } from 'react'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockGetSession, mockSetActive, mockRequestJson } = vi.hoisted(() => ({
10+
mockGetSession: vi.fn(),
11+
mockSetActive: vi.fn(),
12+
mockRequestJson: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/auth/auth-client', () => ({
16+
client: {
17+
getSession: mockGetSession,
18+
organization: { setActive: mockSetActive },
19+
},
20+
}))
21+
22+
vi.mock('@/lib/api/client/request', () => ({
23+
requestJson: mockRequestJson,
24+
}))
25+
26+
vi.mock('posthog-js', () => ({
27+
default: {
28+
identify: vi.fn(),
29+
reset: vi.fn(),
30+
startSessionRecording: vi.fn(),
31+
sessionRecordingStarted: vi.fn(() => true),
32+
},
33+
}))
34+
35+
import {
36+
type AppSession,
37+
SessionContext,
38+
type SessionHookResult,
39+
SessionProvider,
40+
} from '@/app/_shell/providers/session-provider'
41+
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
42+
43+
/** Deferred promise: lets a test resolve a mocked async call at a chosen moment. */
44+
function defer<T>() {
45+
let resolve!: (value: T) => void
46+
let reject!: (reason?: unknown) => void
47+
const promise = new Promise<T>((res, rej) => {
48+
resolve = res
49+
reject = rej
50+
})
51+
return { promise, resolve, reject }
52+
}
53+
54+
/** Set the jsdom URL search string before rendering the provider. */
55+
function setSearch(search: string) {
56+
window.history.replaceState({}, '', `/${search}`)
57+
}
58+
59+
const STALE_SESSION: AppSession = {
60+
user: { id: 'user-1', email: 'u@x.com', name: 'Stale plan' },
61+
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
62+
}
63+
64+
const FRESH_SESSION: AppSession = {
65+
user: { id: 'user-1', email: 'u@x.com', name: 'Fresh plan' },
66+
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
67+
}
68+
69+
interface Harness {
70+
ctx: () => SessionHookResult | null
71+
queryClient: QueryClient
72+
unmount: () => void
73+
}
74+
75+
/**
76+
* Mounts SessionProvider in a real React 19 root under jsdom with a real
77+
* QueryClient, capturing the live context value via a probe consumer.
78+
*/
79+
function renderProvider(): Harness {
80+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
81+
const container = document.createElement('div')
82+
const root: Root = createRoot(container)
83+
const queryClient = new QueryClient({
84+
defaultOptions: { queries: { retry: false } },
85+
})
86+
87+
let latest: SessionHookResult | null = null
88+
function Probe() {
89+
latest = useContext(SessionContext)
90+
return null
91+
}
92+
93+
act(() => {
94+
root.render(
95+
<QueryClientProvider client={queryClient}>
96+
<SessionProvider>
97+
<Probe />
98+
</SessionProvider>
99+
</QueryClientProvider>
100+
)
101+
})
102+
103+
return {
104+
ctx: () => latest,
105+
queryClient,
106+
unmount: () => act(() => root.unmount()),
107+
}
108+
}
109+
110+
/** Flush pending microtasks inside an act() boundary. */
111+
async function flush() {
112+
await act(async () => {
113+
await Promise.resolve()
114+
await Promise.resolve()
115+
await Promise.resolve()
116+
})
117+
}
118+
119+
/** Repeatedly flush until `predicate` holds or the budget runs out. */
120+
async function flushUntil(predicate: () => boolean, attempts = 40) {
121+
for (let i = 0; i < attempts; i++) {
122+
if (predicate()) return
123+
await flush()
124+
}
125+
}
126+
127+
/** True when the getSession call is the upgrade (disableCookieCache) read. */
128+
function isUpgradeCall(arg: unknown): boolean {
129+
return Boolean(
130+
arg &&
131+
typeof arg === 'object' &&
132+
'query' in (arg as Record<string, unknown>) &&
133+
(arg as { query?: { disableCookieCache?: boolean } }).query?.disableCookieCache === true
134+
)
135+
}
136+
137+
describe('useSessionQuery', () => {
138+
it('uses an all-rooted key factory and a 5-minute staleTime', () => {
139+
expect(sessionKeys.all).toEqual(['session'])
140+
expect(sessionKeys.detail()).toEqual(['session', 'detail'])
141+
// The hook is exported and reads from the same detail key.
142+
expect(typeof useSessionQuery).toBe('function')
143+
})
144+
})
145+
146+
describe('SessionProvider', () => {
147+
beforeEach(() => {
148+
vi.clearAllMocks()
149+
setSearch('')
150+
})
151+
152+
afterEach(() => {
153+
vi.restoreAllMocks()
154+
})
155+
156+
it('exposes the contract context shape and the loaded session on a normal load', async () => {
157+
mockGetSession.mockResolvedValue({ data: STALE_SESSION })
158+
159+
const h = renderProvider()
160+
await flushUntil(() => h.ctx()?.data != null)
161+
162+
const ctx = h.ctx()
163+
expect(ctx).not.toBeNull()
164+
expect(ctx).toMatchObject({
165+
data: expect.any(Object),
166+
isPending: expect.any(Boolean),
167+
error: null,
168+
})
169+
expect(typeof ctx?.refetch).toBe('function')
170+
expect(ctx?.data).toEqual(STALE_SESSION)
171+
expect(ctx?.isPending).toBe(false)
172+
173+
h.unmount()
174+
})
175+
176+
it('upgrade path: fresh disableCookieCache read wins even when the stale mount query resolves LAST', async () => {
177+
setSearch('?upgraded=true')
178+
179+
const mount = defer<{ data: AppSession }>()
180+
const upgrade = defer<{ data: AppSession }>()
181+
182+
mockGetSession.mockImplementation((arg?: unknown) => {
183+
if (isUpgradeCall(arg)) return upgrade.promise
184+
return mount.promise
185+
})
186+
// activeOrganizationId is present, so setActive / listCreatorOrganizations are not reached.
187+
188+
const h = renderProvider()
189+
await flush()
190+
191+
// Resolve the fresh upgrade read FIRST. The cancelQueries guard runs before
192+
// setQueryData, cancelling the in-flight stale mount query.
193+
await act(async () => {
194+
upgrade.resolve({ data: FRESH_SESSION })
195+
await Promise.resolve()
196+
})
197+
await flush()
198+
199+
// Now the stale mount query resolves LATE. Because it was cancelled, its
200+
// result must NOT clobber the fresh value written to the cache.
201+
await act(async () => {
202+
mount.resolve({ data: STALE_SESSION })
203+
await Promise.resolve()
204+
})
205+
await flushUntil(() => h.ctx()?.data != null)
206+
207+
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION)
208+
expect(h.ctx()?.data).toEqual(FRESH_SESSION)
209+
expect(h.ctx()?.data).not.toEqual(STALE_SESSION)
210+
211+
h.unmount()
212+
})
213+
214+
it('strips the upgraded param from the URL', async () => {
215+
setSearch('?upgraded=true&keep=1')
216+
mockGetSession.mockResolvedValue({ data: FRESH_SESSION })
217+
218+
const h = renderProvider()
219+
await flush()
220+
221+
expect(window.location.search).not.toContain('upgraded')
222+
expect(window.location.search).toContain('keep=1')
223+
224+
h.unmount()
225+
})
226+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@/components/emcn', () => ({
9+
Button: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
10+
<button {...props}>{children}</button>
11+
),
12+
}))
13+
14+
vi.mock('@/app/workspace/[workspaceId]/components', async () => {
15+
const errorModule = await import('@/app/workspace/[workspaceId]/components/error')
16+
return errorModule
17+
})
18+
19+
import LogsError from './error'
20+
21+
let container: HTMLDivElement
22+
let root: Root
23+
24+
beforeEach(() => {
25+
container = document.createElement('div')
26+
document.body.appendChild(container)
27+
act(() => {
28+
root = createRoot(container)
29+
})
30+
})
31+
32+
afterEach(() => {
33+
act(() => {
34+
root.unmount()
35+
})
36+
container.remove()
37+
})
38+
39+
function findButtonByText(text: string): HTMLButtonElement {
40+
const button = Array.from(container.querySelectorAll('button')).find(
41+
(el) => el.textContent?.trim() === text
42+
)
43+
if (!button) throw new Error(`Button with text "${text}" not found`)
44+
return button as HTMLButtonElement
45+
}
46+
47+
describe('LogsError boundary', () => {
48+
it('renders the title and description from the shared ErrorState', () => {
49+
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
50+
51+
act(() => {
52+
root.render(<LogsError error={error} reset={vi.fn()} />)
53+
})
54+
55+
expect(container.textContent).toContain('Failed to load logs')
56+
expect(container.textContent).toContain(
57+
'Something went wrong while loading the logs. Please try again.'
58+
)
59+
})
60+
61+
it('calls reset when the refresh action is clicked', () => {
62+
const reset = vi.fn()
63+
const error = Object.assign(new Error('boom'), { digest: 'abc123' })
64+
65+
act(() => {
66+
root.render(<LogsError error={error} reset={reset} />)
67+
})
68+
69+
act(() => {
70+
findButtonByText('Refresh').click()
71+
})
72+
73+
expect(reset).toHaveBeenCalledTimes(1)
74+
})
75+
})

0 commit comments

Comments
 (0)