@sanity/sdk
Version:
209 lines (195 loc) • 6.49 kB
text/typescript
import {type MutationEvent} from '@sanity/client'
import {lastValueFrom, Observable, of} from 'rxjs'
import {toArray} from 'rxjs/operators'
import {describe, expect, it, vi} from 'vitest'
import {
DeadlineExceededError,
type ListenerEvent,
MaxBufferExceededError,
sortListenerEvents,
type SyncEvent,
} from './listen'
interface OtherEvent {
type: 'other'
payload: string
}
/**
* Create a sync event.
*
* @param rev - The revision (can be undefined)
* @param docId - Optional document id (default: "doc1")
*/
function createSyncEvent(rev: string, docId: string = 'doc1'): SyncEvent {
return {
type: 'sync',
document: {
_rev: rev,
_id: docId,
_type: 'author',
_createdAt: new Date().toISOString(),
_updatedAt: new Date().toISOString(),
},
}
}
/**
* Create a mutation event with the given properties.
*/
function createMutationEvent({
id,
previousRev,
resultRev,
transition = 'update',
docId = 'doc1',
}: {
id: string
previousRev: string | undefined
resultRev: string | undefined
transition?: 'update' | 'appear' | 'disappear'
docId?: string
}): MutationEvent {
return {
type: 'mutation',
documentId: docId,
eventId: id,
identity: 'user',
mutations: [],
timestamp: new Date().toISOString(),
transactionId: `tx-${id}`,
transactionCurrentEvent: 0,
transactionTotalEvents: 1,
previousRev,
resultRev,
transition,
visibility: 'query',
}
}
function createOtherEvent(): OtherEvent {
return {type: 'other', payload: 'test'}
}
describe('sortListenerEvents operator', () => {
it('should pass through sync events and reset state', async () => {
const sync = createSyncEvent('rev1')
const source$ = of(sync)
const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
expect(events).toEqual([sync])
})
it('should apply mutation events that chain onto the base sync event', async () => {
const sync = createSyncEvent('rev1')
const mutation = createMutationEvent({
id: 'm1',
previousRev: 'rev1',
resultRev: 'rev2',
transition: 'update',
})
const source$ = of(sync, mutation)
const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
// Expect the sync event to be output, then the mutation event.
expect(events).toEqual([sync, mutation])
})
it('should reorder out-of-order mutation events to resolve the chain', async () => {
const sync = createSyncEvent('rev1')
// Mutation A: cannot apply immediately because its previousRev is "rev2"
const mutationA = createMutationEvent({
id: 'A',
previousRev: 'rev2',
resultRev: 'rev3',
transition: 'update',
})
// Mutation B: chains immediately because its previousRev matches the base "rev1"
const mutationB = createMutationEvent({
id: 'B',
previousRev: 'rev1',
resultRev: 'rev2',
transition: 'update',
})
const source$ = of(sync, mutationA, mutationB)
const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
// The operator should first emit the sync event,
// then apply mutation B (which changes the base to "rev2"),
// then apply mutation A (which now chains on "rev2").
expect(events).toEqual([sync, mutationB, mutationA])
})
it('should process deletion events and update base to undefined', async () => {
const sync = createSyncEvent('rev1')
// A deletion event: even if resultRev is set, transition "disappear" forces base to undefined.
const deletion = createMutationEvent({
id: 'del',
previousRev: 'rev1',
resultRev: 'tx-del',
transition: 'disappear',
})
// A follow-up mutation that expects base === undefined
const followUp = createMutationEvent({
id: 'm2',
previousRev: undefined,
resultRev: 'revX',
transition: 'update',
})
const source$ = of(sync, deletion, followUp)
const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
expect(events).toEqual([sync, deletion, followUp])
})
it('should error if a mutation event arrives without a sync event first', async () => {
const mutation = createMutationEvent({
id: 'm1',
previousRev: 'rev1',
resultRev: 'rev2',
transition: 'update',
})
const source$ = of(mutation)
await expect(lastValueFrom(source$.pipe(sortListenerEvents()))).rejects.toThrow(
'Invalid state. Cannot process mutation event without a base sync event',
)
})
it('should throw MaxBufferExceededError when the buffer exceeds the max size', async () => {
// Set a very low maxBufferSize (e.g. 2)
const sync = createSyncEvent('rev1')
// Two mutation events that never chain (their previousRev do not match "rev1")
const mutation1 = createMutationEvent({
id: 'm1',
previousRev: 'x1',
resultRev: 'y1',
transition: 'update',
})
const mutation2 = createMutationEvent({
id: 'm2',
previousRev: 'x2',
resultRev: 'y2',
transition: 'update',
})
const mutation3 = createMutationEvent({
id: 'm3',
previousRev: 'x3',
resultRev: 'y3',
transition: 'update',
})
const source$ = of(sync, mutation1, mutation2, mutation3)
await expect(
lastValueFrom(source$.pipe(sortListenerEvents({maxBufferSize: 2}))),
).rejects.toThrow(MaxBufferExceededError)
})
it('should throw DeadlineExceededError when the chain does not resolve within the deadline', async () => {
vi.useFakeTimers()
const sync = createSyncEvent('rev1')
// A mutation event that does not chain to "rev1"
const mutation = createMutationEvent({
id: 'm1',
previousRev: 'x',
resultRev: 'y',
transition: 'update',
})
const source$ = of(sync, mutation)
const result$ = source$.pipe(sortListenerEvents({resolveChainDeadline: 50}))
const promise = lastValueFrom(result$)
// Advance timers to trigger the deadline
vi.advanceTimersByTime(50)
await expect(promise).rejects.toThrow(DeadlineExceededError)
vi.useRealTimers()
})
it('should pass through events of unknown type', async () => {
const other = createOtherEvent()
const source$ = of(other) as unknown as Observable<ListenerEvent>
const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
expect(events).toEqual([other])
})
})