Files
simstudioai--sim/apps/sim/app/_shell/providers/session-provider.test.tsx
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

279 lines
8.7 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { act, useContext } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockSetActive, mockRequestJson } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockSetActive: vi.fn(),
mockRequestJson: vi.fn(),
}))
vi.mock('@/lib/auth/auth-client', () => ({
client: {
getSession: mockGetSession,
organization: { setActive: mockSetActive },
},
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: mockRequestJson,
}))
vi.mock('posthog-js', () => ({
default: {
identify: vi.fn(),
reset: vi.fn(),
startSessionRecording: vi.fn(),
sessionRecordingStarted: vi.fn(() => true),
},
}))
import type { AppSession } from '@/lib/auth/session-response'
import {
SessionContext,
type SessionHookResult,
SessionProvider,
} from '@/app/_shell/providers/session-provider'
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
/** Deferred promise: lets a test resolve a mocked async call at a chosen moment. */
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
/** Set the jsdom URL search string before rendering the provider. */
function setSearch(search: string) {
window.history.replaceState({}, '', `/${search}`)
}
const STALE_SESSION: AppSession = {
user: { id: 'user-1', email: 'u@x.com', name: 'Stale plan' },
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
}
const FRESH_SESSION: AppSession = {
user: { id: 'user-1', email: 'u@x.com', name: 'Fresh plan' },
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
}
interface Harness {
ctx: () => SessionHookResult | null
queryClient: QueryClient
unmount: () => void
}
/**
* Mounts SessionProvider in a real React 19 root under jsdom with a real
* QueryClient, capturing the live context value via a probe consumer.
*/
function renderProvider(): Harness {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root: Root = createRoot(container)
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
let latest: SessionHookResult | null = null
function Probe() {
latest = useContext(SessionContext)
return null
}
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SessionProvider>
<Probe />
</SessionProvider>
</QueryClientProvider>
)
})
return {
ctx: () => latest,
queryClient,
unmount: () => act(() => root.unmount()),
}
}
/**
* Flush pending work inside an act() boundary. Drains the microtask queue and
* then yields one macrotask tick, so React Query's notifyManager (which can
* schedule observer notifications on a timer) and any deferred renders settle
* deterministically — microtask-only flushing raced the query→render update.
*/
async function flush() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
})
}
/** Repeatedly flush until `predicate` holds or the budget runs out. */
async function flushUntil(predicate: () => boolean, attempts = 40) {
for (let i = 0; i < attempts; i++) {
if (predicate()) return
await flush()
}
}
/** True when the getSession call is the upgrade (disableCookieCache) read. */
function isUpgradeCall(arg: unknown): boolean {
return Boolean(
arg &&
typeof arg === 'object' &&
'query' in (arg as Record<string, unknown>) &&
(arg as { query?: { disableCookieCache?: boolean } }).query?.disableCookieCache === true
)
}
describe('useSessionQuery', () => {
it('uses an all-rooted key factory and a 5-minute staleTime', () => {
expect(sessionKeys.all).toEqual(['session'])
expect(sessionKeys.detail()).toEqual(['session', 'detail'])
// The hook is exported and reads from the same detail key.
expect(typeof useSessionQuery).toBe('function')
})
})
describe('SessionProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
setSearch('')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes the contract context shape and the loaded session on a normal load', async () => {
mockGetSession.mockResolvedValue({ data: STALE_SESSION })
const h = renderProvider()
await flushUntil(() => h.ctx()?.data != null)
const ctx = h.ctx()
expect(ctx).not.toBeNull()
expect(ctx).toMatchObject({
data: expect.any(Object),
isPending: expect.any(Boolean),
error: null,
})
expect(typeof ctx?.refetch).toBe('function')
expect(ctx?.data).toEqual(STALE_SESSION)
expect(ctx?.isPending).toBe(false)
h.unmount()
})
it('upgrade path: fresh disableCookieCache read wins even when the stale mount query resolves LAST', async () => {
setSearch('?upgraded=true')
const mount = defer<{ data: AppSession }>()
const upgrade = defer<{ data: AppSession }>()
mockGetSession.mockImplementation((arg?: unknown) => {
if (isUpgradeCall(arg)) return upgrade.promise
// Honor the abort signal like the real fetch-backed client: cancelQueries
// aborts the in-flight mount read, so it rejects rather than resolving late.
const signal = (arg as { fetchOptions?: { signal?: AbortSignal } })?.fetchOptions?.signal
signal?.addEventListener('abort', () =>
mount.reject(new DOMException('Aborted', 'AbortError'))
)
return mount.promise
})
// activeOrganizationId is present, so setActive / listCreatorOrganizations are not reached.
const h = renderProvider()
await flush()
// Resolve the fresh upgrade read FIRST. The cancelQueries guard runs before
// setQueryData, cancelling (aborting) the in-flight stale mount query.
await act(async () => {
upgrade.resolve({ data: FRESH_SESSION })
await Promise.resolve()
})
await flushUntil(() => h.queryClient.getQueryData(sessionKeys.detail()) != null)
// Assert on the cache — the contended state cancelQueries + setQueryData
// govern. The fresh value wins; the aborted stale mount read never clobbers it.
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION)
expect(h.queryClient.getQueryData(sessionKeys.detail())).not.toEqual(STALE_SESSION)
h.unmount()
})
it('upgrade path: a failed fresh read keeps the user signed in and still reconciles plan surfaces', async () => {
setSearch('?upgraded=true')
const mount = defer<{ data: AppSession }>()
const upgrade = defer<{ data: AppSession }>()
mockGetSession.mockImplementation((arg?: unknown) =>
isUpgradeCall(arg) ? upgrade.promise : mount.promise
)
const invalidateSpy = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
const invalidatedKeys = () =>
invalidateSpy.mock.calls.map(([arg]) => (arg as { queryKey?: unknown[] })?.queryKey)
const h = renderProvider()
await flush()
// The fresh disableCookieCache read fails.
await act(async () => {
upgrade.reject(new Error('refresh failed'))
await Promise.resolve()
})
await flush()
// The normal cookie-cached mount query lands AFTER the failure.
await act(async () => {
mount.resolve({ data: STALE_SESSION })
await Promise.resolve()
})
await flushUntil(
() =>
h.queryClient.getQueryData(sessionKeys.detail()) != null &&
invalidatedKeys().some((k) => Array.isArray(k) && k[0] === 'subscription')
)
// The valid cookie-cached session is still cached — a failed upgrade refresh
// must not sign the user out, and it must not surface as a session error.
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(STALE_SESSION)
expect(h.queryClient.getQueryState(sessionKeys.detail())?.error ?? null).toBeNull()
// Plan surfaces read server truth, so they still reconcile after the failure.
expect(invalidatedKeys()).toContainEqual(['organizations'])
expect(invalidatedKeys()).toContainEqual(['subscription'])
invalidateSpy.mockRestore()
h.unmount()
})
it('strips the upgraded param from the URL', async () => {
setSearch('?upgraded=true&keep=1')
mockGetSession.mockResolvedValue({ data: FRESH_SESSION })
const h = renderProvider()
await flush()
expect(window.location.search).not.toContain('upgraded')
expect(window.location.search).toContain('keep=1')
h.unmount()
})
})