@sanity/sdk
Version:
251 lines (206 loc) • 9.26 kB
text/typescript
import {
type ListenEvent,
type MutationEvent,
type ReconnectEvent,
type SanityClient,
type WelcomeEvent,
} from '@sanity/client'
import {createDocumentLoaderFromClient} from '@sanity/mutate/_unstable_store'
import {type SanityDocument} from '@sanity/types'
import {forkJoin, lastValueFrom, of, Subject, throwError} from 'rxjs'
import {bufferTime, catchError, toArray} from 'rxjs/operators'
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
import {getClientState} from '../client/clientStore'
import {createSanityInstance} from '../store/createSanityInstance'
import {type StateSource} from '../store/createStateSourceAction'
import {createFetchDocument, createSharedListener} from './sharedListener'
const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
vi.mock('../client/clientStore.ts', () => ({getClientState: vi.fn()}))
vi.mock('@sanity/mutate/_unstable_store', () => ({createDocumentLoaderFromClient: vi.fn()}))
describe('createSharedListener', () => {
let fakeListenSubject: Subject<ListenEvent<SanityDocument>>
let fakeClient: SanityClient
beforeEach(() => {
// Create a subject to simulate the events coming from client.listen.
fakeListenSubject = new Subject()
// Create a fake client whose listen() method returns our subject's observable.
fakeClient = {
listen: vi.fn(() => fakeListenSubject.asObservable()),
} as unknown as SanityClient
// Make getSubscribableClient return an observable that immediately emits fakeClient.
vi.mocked(getClientState).mockReturnValue({
observable: of(fakeClient),
} as StateSource<SanityClient>)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should call client.listen with the expected parameters', () => {
createSharedListener(instance).events.subscribe()
expect(fakeClient.listen).toHaveBeenCalledTimes(1)
expect(fakeClient.listen).toHaveBeenCalledWith(
'*',
{},
{
events: ['mutation', 'welcome', 'reconnect'],
includeResult: false,
tag: 'document-listener',
},
)
})
it('should merge welcome and other events (mutation/reconnect) in order', async () => {
// Prepare some fake events.
const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'listener'}
const mutationEvent: MutationEvent = {type: 'mutation'} as MutationEvent
const reconnectEvent: ReconnectEvent = {type: 'reconnect'} as ReconnectEvent
const sharedListener = createSharedListener(instance)
// Start collecting the emitted events.
const eventsPromise = lastValueFrom(sharedListener.events.pipe(toArray()))
// Emit events in order.
fakeListenSubject.next(welcomeEvent)
fakeListenSubject.next(mutationEvent)
fakeListenSubject.next(reconnectEvent)
fakeListenSubject.complete()
const events = await eventsPromise
// Expect the merged stream to emit welcome, then mutation and reconnect.
expect(events).toEqual([welcomeEvent, mutationEvent, reconnectEvent])
})
it('should replay the welcome event for new subscribers', async () => {
const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'listener'}
const sharedListener = createSharedListener(instance)
// First subscription: emit welcome and complete.
const firstPromise = lastValueFrom(sharedListener.events.pipe(toArray()))
fakeListenSubject.next(welcomeEvent)
fakeListenSubject.complete()
const firstEvents = await firstPromise
expect(firstEvents).toEqual([welcomeEvent])
// New subscriber should immediately receive the replayed welcome event.
const secondEvents = await lastValueFrom(sharedListener.events.pipe(toArray()))
expect(secondEvents).toEqual([welcomeEvent])
})
it('should propagate non-welcome events (e.g. mutation and reconnect) without replay', async () => {
const mutationEvent = {type: 'mutation'} as MutationEvent
const reconnectEvent = {type: 'reconnect'} as ReconnectEvent
const sharedListener = createSharedListener(instance)
const eventsPromise = lastValueFrom(sharedListener.events.pipe(toArray()))
fakeListenSubject.next(mutationEvent)
fakeListenSubject.next(reconnectEvent)
fakeListenSubject.complete()
const events = await eventsPromise
expect(events).toEqual([mutationEvent, reconnectEvent])
})
it('should multicast events to multiple subscribers concurrently', async () => {
const welcomeEvent = {type: 'welcome'} as WelcomeEvent
const mutationEvent = {type: 'mutation'} as MutationEvent
const sharedListener = createSharedListener(instance)
// Subscribe two observers concurrently.
const subscriber1 = sharedListener.events.pipe(bufferTime(0))
const subscriber2 = sharedListener.events.pipe(bufferTime(0))
const combined = forkJoin([subscriber1, subscriber2])
const result = lastValueFrom(combined)
// Emit events.
fakeListenSubject.next(welcomeEvent)
fakeListenSubject.next(mutationEvent)
fakeListenSubject.complete()
const [results1, results2] = await result
// Both subscribers should receive the same sequence.
expect(results1).toEqual([welcomeEvent, mutationEvent])
expect(results2).toEqual([welcomeEvent, mutationEvent])
})
it('should propagate errors from the underlying client.listen observable', async () => {
const errorMessage = 'Test error'
const sharedListener = createSharedListener(instance)
const error$ = sharedListener.events.pipe(
toArray(),
catchError((err) => of(err)),
)
fakeListenSubject.error(new Error(errorMessage))
const result = await lastValueFrom(error$)
expect(result).toBeInstanceOf(Error)
expect(result.message).toBe(errorMessage)
})
it('should stop emitting events after calling dispose', async () => {
const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'listener'}
const mutationEvent: MutationEvent = {type: 'mutation'} as MutationEvent
const sharedListener = createSharedListener(instance)
const events: ListenEvent<SanityDocument>[] = []
const subscription = sharedListener.events.subscribe((event) => {
events.push(event)
})
fakeListenSubject.next(welcomeEvent)
sharedListener.dispose()
fakeListenSubject.next(mutationEvent)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(events).toEqual([welcomeEvent])
subscription.unsubscribe()
})
})
describe('createFetchDocument', () => {
let fakeClient: SanityClient
let fakeLoadDocument: Mock
beforeEach(() => {
// Create a fake client.
fakeClient = {fetch: vi.fn()} as unknown as SanityClient
vi.mocked(getClientState).mockReturnValue({
observable: of(fakeClient),
} as StateSource<SanityClient>)
// createDocumentLoaderFromClient returns a function (the "loader")
fakeLoadDocument = vi.fn()
vi.mocked(createDocumentLoaderFromClient).mockReturnValue(fakeLoadDocument)
})
afterEach(() => {
vi.clearAllMocks()
})
it('should call createDocumentLoaderFromClient with the fetched client', async () => {
const fetchDocument = createFetchDocument(instance)
const accessibleResult = {
id: 'doc1',
document: {_id: 'doc1', _rev: 'rev1'},
accessible: true,
}
fakeLoadDocument.mockReturnValue(of(accessibleResult))
await lastValueFrom(fetchDocument('doc1'))
expect(createDocumentLoaderFromClient).toHaveBeenCalledTimes(1)
expect(createDocumentLoaderFromClient).toHaveBeenCalledWith(fakeClient)
})
it('should return the document when it is accessible', async () => {
const fetchDocument = createFetchDocument(instance)
const accessibleResult = {
id: 'doc1',
document: {_id: 'doc1', _rev: 'rev1', title: 'Test Document'},
accessible: true,
}
fakeLoadDocument.mockReturnValue(of(accessibleResult))
const doc = await lastValueFrom(fetchDocument('doc1'))
expect(doc).toEqual(accessibleResult.document)
})
it('should return null when the document is inaccessible due to existence', async () => {
const fetchDocument = createFetchDocument(instance)
const inaccessibleResult = {
accessible: false,
id: 'doc1',
reason: 'existence',
}
fakeLoadDocument.mockReturnValue(of(inaccessibleResult))
const doc = await lastValueFrom(fetchDocument('doc1'))
expect(doc).toBeNull()
})
it('should throw an error when the document is inaccessible due to permissions', async () => {
const fetchDocument = createFetchDocument(instance)
const inaccessibleResult = {
accessible: false,
id: 'doc1',
reason: 'permissions',
}
fakeLoadDocument.mockReturnValue(of(inaccessibleResult))
await expect(lastValueFrom(fetchDocument('doc1'))).rejects.toThrow(
'Document with ID `doc1` is inaccessible due to permissions.',
)
})
it('should propagate errors from the loadDocument observable', async () => {
const fetchDocument = createFetchDocument(instance)
const errorMessage = 'Load document failed'
fakeLoadDocument.mockReturnValue(throwError(() => new Error(errorMessage)))
await expect(lastValueFrom(fetchDocument('doc1'))).rejects.toThrow(errorMessage)
})
})