hydrogen-sanity
Version:
Sanity.io toolkit for Hydrogen
696 lines (573 loc) • 20.1 kB
text/typescript
import {createClient, SanityClient} from '@sanity/client'
import type {QueryStore} from '@sanity/react-loader'
import {CacheShort, type WithCache} from '@shopify/hydrogen'
import groq from 'groq'
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {createSanityContext} from './context'
import {PreviewSession} from './fixtures'
import type {SanityProviderValue} from './provider'
import {hashQuery} from './utils'
// Mock the global caches object
const cache = vi.hoisted<Cache>(() => ({
add: vi.fn().mockResolvedValue(undefined),
addAll: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(true),
keys: vi.fn().mockResolvedValue([]),
match: vi.fn().mockResolvedValue(undefined),
matchAll: vi.fn().mockResolvedValue([]),
put: vi.fn().mockResolvedValue(undefined),
}))
const loadQuery = vi.hoisted<QueryStore['loadQuery']>(() => vi.fn().mockResolvedValue(null))
const setServerClient = vi.hoisted(() => vi.fn())
let withCache = vi.hoisted<WithCache | null>(() => null)
vi.mock('@shopify/hydrogen', async (importOriginal) => {
const module = await importOriginal<typeof import('@shopify/hydrogen')>()
withCache = module.createWithCache({
cache,
waitUntil: () => Promise.resolve(),
request: new Request('https://example.com'),
})
return {
...module,
createWithCache: vi.fn().mockReturnValue(withCache),
}
})
vi.mock('@sanity/react-loader', async (importOriginal) => {
const module = await importOriginal<typeof import('@sanity/react-loader')>()
return {
...module,
loadQuery,
setServerClient,
}
})
const runWithCache = vi.spyOn(withCache!, 'run')
const projectId = 'my-project-id'
const client = createClient({projectId, dataset: 'my-dataset'})
const query = groq`true`
const params = {}
const hashedQuery = await hashQuery(query, params)
beforeEach(() => {
vi.clearAllMocks()
})
describe('the Sanity request context', () => {
const request = new Request('https://example.com')
let sanity: Awaited<ReturnType<typeof createSanityContext>>
beforeEach(async () => {
sanity = await createSanityContext({request, cache, client})
})
it('should return a client', () => {
expect(sanity.client).toSatisfy((contextClient) => contextClient instanceof SanityClient)
})
it('queries should get cached using the default caching strategy', async () => {
const defaultStrategy = CacheShort()
const contextWithDefaultStrategy = await createSanityContext({
request,
cache,
client,
defaultStrategy,
})
await contextWithDefaultStrategy.loadQuery<boolean>(query, params)
expect(runWithCache).toHaveBeenNthCalledWith(
1,
expect.objectContaining({cacheKey: hashedQuery, cacheStrategy: defaultStrategy}),
expect.any(Function),
)
expect(cache.put).toHaveBeenCalledOnce()
})
it('queries should use the cache strategy passed in `loadQuery`', async () => {
const strategy = CacheShort()
await sanity.loadQuery<boolean>(query, params, {
hydrogen: {cache: strategy},
})
expect(runWithCache).toHaveBeenNthCalledWith(
1,
expect.objectContaining({cacheKey: hashedQuery, cacheStrategy: strategy}),
expect.any(Function),
)
expect(cache.put).toHaveBeenCalledOnce()
})
})
describe('when configured for preview', () => {
const request = new Request('https://example.com')
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
let sanity: Awaited<ReturnType<typeof createSanityContext>>
beforeEach(async () => {
sanity = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
})
it('should throw if a token is not provided', async () => {
await expect(
// @ts-expect-error meant to test invalid configuration
createSanityContext({client, preview: {enabled: true}}),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Enabling preview mode requires a token.]`)
})
it.todo(`shouldn't use API CDN`, () => {
expect(sanity.client.config().useCdn).toBe(false)
})
it.todo('should use the `previewDrafts` perspective', () => {
expect(sanity.client.config().perspective).toBe('previewDrafts')
})
it('should enable preview mode', () => {
expect(sanity.preview?.enabled).toBe(true)
})
it(`shouldn't cache queries`, async () => {
await sanity.loadQuery<boolean>(query)
expect(loadQuery).toHaveBeenCalledOnce()
expect(cache.put).not.toHaveBeenCalledOnce()
})
it(`shouldn't cache fetch calls`, async () => {
const spy = vi.spyOn(sanity.client, 'fetch').mockResolvedValue({
result: true,
resultSourceMap: undefined,
ms: 100,
})
await sanity.fetch<boolean>(query)
expect(spy).toHaveBeenCalledOnce()
expect(cache.put).not.toHaveBeenCalledOnce()
})
})
describe('session-based preview detection', () => {
const request = new Request('https://example.com')
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
beforeEach(() => {
vi.clearAllMocks()
})
it('should enable preview when provided session contains matching project ID', async () => {
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
expect(context.preview?.enabled).toBe(true)
})
it('should disable preview when provided session contains different project ID', async () => {
previewSession.set('projectId', 'different-project-id')
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
expect(context.preview?.enabled).toBe(false)
})
it('should disable preview when provided session contains no project ID', async () => {
previewSession.unset('projectId')
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
expect(context.preview?.enabled).toBe(false)
})
})
describe('stegaEnabled serialization', () => {
const request = new Request('https://example.com')
beforeEach(() => {
vi.clearAllMocks()
})
it('should set stegaEnabled to false when preview is enabled but stega not configured', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
expect(providerProps.value.stegaEnabled).toBe(false) // stega is opt-in, not automatic
expect(providerProps.value.previewEnabled).toBe(true)
})
it('should set stegaEnabled to false when preview is disabled', async () => {
const context = await createSanityContext({
request,
cache,
client,
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
expect(providerProps.value.stegaEnabled).toBe(false)
expect(providerProps.value.previewEnabled).toBe(false)
})
it('should set stegaEnabled to false when preview session contains different project ID', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', 'different-project-id')
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
expect(providerProps.value.stegaEnabled).toBe(false)
expect(providerProps.value.previewEnabled).toBe(false)
})
it('should set stegaEnabled to false when preview session contains no project ID', async () => {
const previewSession = new PreviewSession()
// Don't set projectId
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
expect(providerProps.value.stegaEnabled).toBe(false)
expect(providerProps.value.previewEnabled).toBe(false)
})
it('should work with Hydrogen session for stegaEnabled', async () => {
const hydrogenSession = {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'projectId') return projectId
return undefined
}),
set: vi.fn(),
unset: vi.fn(),
has: vi.fn(),
commit: vi.fn(),
destroy: vi.fn(),
}
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: hydrogenSession as unknown as PreviewSession,
},
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
expect(providerProps.value.stegaEnabled).toBe(false) // stega is opt-in, not automatic
expect(providerProps.value.previewEnabled).toBe(true)
})
it('should include stegaEnabled in provider value interface', async () => {
const context = await createSanityContext({
request,
cache,
client,
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
const providerValue = providerProps.value
// Ensure all expected properties are present
expect(providerValue).toHaveProperty('projectId')
expect(providerValue).toHaveProperty('dataset')
expect(providerValue).toHaveProperty('apiHost')
expect(providerValue).toHaveProperty('apiVersion')
expect(providerValue).toHaveProperty('previewEnabled')
expect(providerValue).toHaveProperty('perspective')
expect(providerValue).toHaveProperty('stegaEnabled')
// Type assertion to ensure stegaEnabled is boolean
expect(typeof providerValue.stegaEnabled).toBe('boolean')
})
it('should enable stegaEnabled when explicitly configured in client', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const clientWithStega = createClient({
projectId,
dataset: 'production',
stega: {
enabled: true,
studioUrl: 'https://test.sanity.studio',
},
})
const context = await createSanityContext({
request,
cache,
client: clientWithStega,
preview: {
token: 'my-token',
session: previewSession,
},
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
expect(providerProps.value.stegaEnabled).toBe(true)
expect(providerProps.value.previewEnabled).toBe(true)
})
it('should maintain independence between preview and stegaEnabled flags', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const contextWithPreview = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
const contextWithoutPreview = await createSanityContext({
request,
cache,
client,
})
const providerPropsWithPreview = (
contextWithPreview.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
const providerPropsWithoutPreview = (
contextWithoutPreview.SanityProvider({children: null}) as {
props: {value: SanityProviderValue}
}
).props
// Preview can be true while stegaEnabled remains false (stega is opt-in)
expect(providerPropsWithPreview.value.previewEnabled).toBe(true)
expect(providerPropsWithPreview.value.stegaEnabled).toBe(false)
// Both should be false when preview is disabled
expect(providerPropsWithoutPreview.value.previewEnabled).toBe(false)
expect(providerPropsWithoutPreview.value.stegaEnabled).toBe(false)
})
it('should freeze provider value object', async () => {
const context = await createSanityContext({
request,
cache,
client,
})
const providerProps = (
context.SanityProvider({children: null}) as {props: {value: SanityProviderValue}}
).props
const providerValue = providerProps.value
expect(Object.isFrozen(providerValue)).toBe(true)
})
})
describe('perspective resolution priority', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should use URL param perspective over session value', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
previewSession.set('perspective', 'drafts')
const request = new Request('https://example.com/?sanity-preview-perspective=releaseId,drafts')
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
expect(context.client.config().perspective).toEqual(['releaseId', 'drafts'])
})
it('should fall back to session perspective when URL param is absent', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
previewSession.set('perspective', 'drafts')
const request = new Request('https://example.com/')
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
expect(context.client.config().perspective).toEqual(['drafts'])
})
it('should pass perspective explicitly to loadQuery in preview mode', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
previewSession.set('perspective', 'drafts')
const request = new Request('https://example.com/?sanity-preview-perspective=releaseId,drafts')
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
await context.loadQuery<boolean>(query, params)
expect(loadQuery).toHaveBeenCalledWith(
query,
params,
expect.objectContaining({
perspective: ['releaseId', 'drafts'],
}),
)
})
it('should ignore URL param perspective stack when API version is too old', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const oldClient = createClient({
projectId,
dataset: 'my-dataset',
apiVersion: '2024-01-01',
})
const request = new Request('https://example.com/?sanity-preview-perspective=releaseId,drafts')
const context = await createSanityContext({
request,
cache,
client: oldClient,
preview: {
token: 'my-token',
session: previewSession,
},
})
// Should fall back to previewDrafts since API version doesn't support stacks
expect(context.client.config().perspective).toBe('previewDrafts')
})
it('should accept single URL param perspective even with old API version', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const oldClient = createClient({
projectId,
dataset: 'my-dataset',
apiVersion: '2024-01-01',
})
const request = new Request('https://example.com/?sanity-preview-perspective=drafts')
const context = await createSanityContext({
request,
cache,
client: oldClient,
preview: {
token: 'my-token',
session: previewSession,
},
})
expect(context.client.config().perspective).toBe('drafts')
})
})
describe('lazy-initialize loaders', () => {
const request = new Request('https://example.com')
beforeEach(() => {
vi.clearAllMocks()
})
it("shouldn't call `setServerClient` during context creation", async () => {
await createSanityContext({
request,
cache,
client,
})
expect(setServerClient).not.toHaveBeenCalled()
})
it('should call `setServerClient` on every `loadQuery` invocation', async () => {
const context = await createSanityContext({
request,
cache,
client,
})
await context.loadQuery<boolean>(query, params)
expect(setServerClient).toHaveBeenCalledTimes(1)
await context.loadQuery<boolean>(query, params)
expect(setServerClient).toHaveBeenCalledTimes(2)
})
it('should call `setServerClient` with the preview-configured client', async () => {
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
await context.loadQuery<boolean>(query, params)
const calledWithClient = setServerClient.mock.calls[0][0]
expect(calledWithClient.config().useCdn).toBe(false)
expect(calledWithClient.config().token).toBe('my-token')
})
it('should display warning when `loadQuery` called outside preview mode in development', async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const context = await createSanityContext({
request,
cache,
client,
})
const callsBefore = warnSpy.mock.calls.length
await context.loadQuery<boolean>(query, params)
// Should have warned about loadQuery usage (may have other warnings too)
expect(warnSpy.mock.calls.length).toBeGreaterThan(callsBefore)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('`loadQuery` is being called outside of preview mode'),
)
const callsAfterFirst = warnSpy.mock.calls.length
// Second call should not warn again about loadQuery
await context.loadQuery<boolean>(query, params)
expect(warnSpy.mock.calls.length).toBe(callsAfterFirst)
warnSpy.mockRestore()
process.env.NODE_ENV = originalNodeEnv
})
it("shouldn't display warning in production", async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const context = await createSanityContext({
request,
cache,
client,
})
await context.loadQuery<boolean>(query, params)
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
process.env.NODE_ENV = originalNodeEnv
})
it("shouldn't display warning when in preview mode", async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const previewSession = new PreviewSession()
previewSession.set('projectId', projectId)
const context = await createSanityContext({
request,
cache,
client,
preview: {
token: 'my-token',
session: previewSession,
},
})
await context.loadQuery<boolean>(query, params)
// Should not warn because we're in preview mode
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
process.env.NODE_ENV = originalNodeEnv
})
})