@sanity/sdk
Version:
752 lines (700 loc) • 26.9 kB
text/typescript
import {type Reference, type SanityDocument} from '@sanity/types'
import {describe, expect, it} from 'vitest'
import {parse} from './_synchronous-groq-js.mjs'
import {type DocumentAction} from './actions'
import {ActionError, processActions} from './processActions'
import {type DocumentSet} from './processMutations'
// Helper: Create a sample document that conforms to SanityDocument.
const createDoc = (id: string, title: string, rev: string = 'initial'): SanityDocument => ({
_id: id,
_type: 'article',
_createdAt: '2025-01-01T00:00:00.000Z',
_updatedAt: '2025-01-01T00:00:00.000Z',
_rev: rev,
title,
})
// Define dummy grants using GROQ expressions
const alwaysAllow = parse('true')
const alwaysDeny = parse('false')
const defaultGrants = {
create: alwaysAllow,
update: alwaysAllow,
read: alwaysAllow,
history: alwaysAllow,
}
const transactionId = 'txn-123'
const timestamp = '2025-02-02T00:00:00.000Z'
describe('processActions', () => {
describe('document.create', () => {
it('should create a new draft document from a published document', () => {
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
type: 'document.create',
documentType: 'article',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
const draftId = 'drafts.doc1'
const draftDoc = result.working[draftId]
expect(draftDoc).toBeDefined()
expect(draftDoc?._id).toBe(draftId)
expect(draftDoc?._type).toBe('article')
expect(draftDoc?._rev).toBe(transactionId)
// Should have copied over properties from the published version:
expect(draftDoc?.['title']).toBe('Original Title')
// Outgoing actions: note that attributes keep the original _rev from base.
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.version.create',
publishedId: 'doc1',
attributes: {
...draftDoc,
_rev: 'initial',
},
},
])
// previousRevs come from initial working set
expect(result.previousRevs).toEqual({doc1: 'initial'})
})
it('should throw an error if the draft already exists', () => {
const draftDoc = createDoc('drafts.doc1', 'Draft Exists')
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {'doc1': published, 'drafts.doc1': draftDoc}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
type: 'document.create',
documentType: 'article',
},
]
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
).toThrow(ActionError)
})
it('should create a draft document using the working published version when base and working differ', () => {
const publishedBase = createDoc('doc1', 'Original Title')
const publishedWorking = createDoc('doc1', 'Working Title')
const base: DocumentSet = {doc1: publishedBase}
const working: DocumentSet = {doc1: publishedWorking}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
type: 'document.create',
documentType: 'article',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
const draftId = 'drafts.doc1'
const draftDoc = result.working[draftId]
expect(draftDoc).toBeDefined()
expect(draftDoc?._id).toBe(draftId)
// Expect that the draft is built from the working version:
expect(draftDoc?.['title']).toBe('Working Title')
})
it('should throw PermissionActionError if create permission is denied', () => {
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
type: 'document.create',
documentType: 'article',
},
]
const grants = {...defaultGrants, create: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to create a draft for document "doc1"/)
})
})
describe('document.delete', () => {
it('should delete both published and draft documents when a draft exists', () => {
const published = createDoc('doc1', 'Published Title')
const draft = createDoc('drafts.doc1', 'Draft Title')
const base: DocumentSet = {'doc1': published, 'drafts.doc1': draft}
const working: DocumentSet = {'doc1': published, 'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.delete',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
// Both published and draft should be removed (set to null)
expect(result.working['doc1']).toBeNull()
expect(result.working['drafts.doc1']).toBeNull()
expect(result.previousRevs).toEqual({'doc1': 'initial', 'drafts.doc1': 'initial'})
// Outgoing action should include the draft since it was present
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.delete',
publishedId: 'doc1',
includeDrafts: ['drafts.doc1'],
},
])
})
it('should delete the published document and not include drafts if none exist', () => {
const published = createDoc('doc1', 'Published Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.delete',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
expect(result.working['doc1']).toBeNull()
expect(result.working['drafts.doc1']).toBeNull()
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.delete',
publishedId: 'doc1',
},
])
})
it('should throw PermissionActionError if update permission is denied for deletion', () => {
const published = createDoc('doc1', 'Published Title')
const draft = createDoc('drafts.doc1', 'Draft Title')
const base: DocumentSet = {'doc1': published, 'drafts.doc1': draft}
const working: DocumentSet = {'doc1': published, 'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.delete',
},
]
const grants = {...defaultGrants, update: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to delete this document/)
})
})
describe('document.discard', () => {
it('should discard a draft document', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.discard',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
expect(result.working['drafts.doc1']).toBeNull()
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.version.discard',
versionId: 'drafts.doc1',
},
])
})
it('should throw PermissionActionError if update permission is denied for discard', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.discard',
},
]
const grants = {...defaultGrants, update: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to discard changes for document "doc1"/)
})
})
describe('document.edit', () => {
it('should edit a document when no draft exists, creating one from published', () => {
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'Edited Title'}}],
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
const draftId = 'drafts.doc1'
const editedDoc = result.working[draftId]
expect(editedDoc).toBeDefined()
expect(editedDoc?._id).toBe(draftId)
expect(editedDoc?.['title']).toBe('Edited Title')
expect(editedDoc?._rev).toBe(transactionId)
// Outgoing actions for edit should include a patch payload.
expect(result.outgoingActions[0].actionType).toBe('sanity.action.document.edit')
expect(result.outgoingActions[0]).toHaveProperty('patch')
})
it('should edit a document when a draft already exists', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'New Draft Title'}}],
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
const editedDoc = result.working['drafts.doc1']
expect(editedDoc).toBeDefined()
expect(editedDoc?.['title']).toBe('New Draft Title')
expect(result.outgoingActions[0].actionType).toBe('sanity.action.document.edit')
})
it('should edit a document when base and working diverge', () => {
const publishedBase = createDoc('doc1', 'Original Boring Title')
const publishedWorking = createDoc('doc1', 'Local Boring Title')
const base: DocumentSet = {doc1: publishedBase}
const working: DocumentSet = {doc1: publishedWorking}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'Original Cool Title'}}],
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
const draftId = 'drafts.doc1'
const editedDoc = result.working[draftId]
expect(editedDoc).toBeDefined()
expect(editedDoc?._id).toBe(draftId)
// Despite the working document originally having a different title,
// the action patch (computed from base) updates the title to 'Edited Title'
expect(editedDoc?.['title']).toBe('Local Cool Title')
})
it('should throw an error when editing a non-existent document', () => {
const base: DocumentSet = {}
const working: DocumentSet = {}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'Should Fail'}}],
},
]
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
).toThrow(ActionError)
})
it('should throw PermissionActionError if create permission is denied during edit', () => {
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'Edited Title'}}],
},
]
const grants = {...defaultGrants, create: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to create a draft for editing this document/)
})
it('should throw PermissionActionError if update permission is denied during edit', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'New Title'}}],
},
]
const grants = {...defaultGrants, update: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to edit document "doc1"/)
})
})
describe('document.publish', () => {
it('should publish a draft document', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.publish',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
// After publishing, the draft should be deleted and a published document created.
expect(result.working['drafts.doc1']).toBeNull()
const published = result.working['doc1']
expect(published).toBeDefined()
expect(published?._id).toBe('doc1')
expect(published?.['title']).toBe('Draft Title')
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.publish',
draftId: 'drafts.doc1',
publishedId: 'doc1',
},
])
})
it('should throw an error when no draft exists to publish', () => {
const published = createDoc('doc1', 'Published Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.publish',
},
]
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
).toThrow(ActionError)
})
it('should throw when base and working drafts differ', () => {
// Simulate divergence where the base and working drafts differ.
const baseDraft = createDoc('drafts.doc1', 'Base Draft', '1')
const workingDraft = createDoc('drafts.doc1', 'Working Draft', '2')
const base: DocumentSet = {'drafts.doc1': baseDraft}
const working: DocumentSet = {'drafts.doc1': workingDraft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.publish',
},
]
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
).toThrow(/Publish aborted: The document has changed elsewhere. Please try again./)
})
it('should throw PermissionActionError if update permission is denied for draft during publish', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{documentId: 'doc1', documentType: 'article', type: 'document.publish'},
]
const grants = {...defaultGrants, update: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/Publish failed: You do not have permission to update the draft for "doc1"/)
})
it('should throw PermissionActionError if update permission is denied for published during publish', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
const published = createDoc('doc1', 'Published Title', '1')
const base: DocumentSet = {'drafts.doc1': draft, 'doc1': published}
const working: DocumentSet = {'drafts.doc1': draft, 'doc1': published}
const actions: DocumentAction[] = [
{documentId: 'doc1', documentType: 'article', type: 'document.publish'},
]
const grants = {
...defaultGrants,
update: parse(`$document {"_": _id in path("drafts.**")}._`),
}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(
/Publish failed: You do not have permission to update the published version of "doc1"/,
)
})
it('should throw PermissionActionError if create permission is denied when publishing a new version', () => {
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
// simulate case where there is no published version
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{documentId: 'doc1', documentType: 'article', type: 'document.publish'},
]
const grants = {...defaultGrants, create: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/Publish failed: You do not have permission to publish a new version of "doc1"/)
})
it('should strengthen `_strengthenOnPublish` references', () => {
const _ref = crypto.randomUUID()
const referenceToStrength: Reference = {
_ref,
_type: 'reference',
_weak: true,
_strengthenOnPublish: {
type: 'author',
},
}
const strengthenedReference: Reference = {
_ref,
_type: 'reference',
}
const draft = {
...createDoc('drafts.doc1', 'Draft Title', '1'),
referenceToStrength,
nestedObject: {referenceToStrength},
items: [referenceToStrength],
}
const base: DocumentSet = {'drafts.doc1': draft}
const working: DocumentSet = {'drafts.doc1': draft}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.publish',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
// After publishing, the draft should be deleted and a published document created.
expect(result.working['drafts.doc1']).toBeNull()
const published = result.working['doc1'] as typeof draft
expect(published).toBeDefined()
expect(published?._id).toBe('doc1')
expect(published.referenceToStrength).toEqual(strengthenedReference)
expect(published.nestedObject.referenceToStrength).toEqual(strengthenedReference)
expect(published.items.at(0)).toEqual(strengthenedReference)
})
})
describe('document.unpublish', () => {
it('should unpublish a published document', () => {
const published = createDoc('doc1', 'Published Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.unpublish',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
// After unpublishing, the published document should be removed and a draft created.
expect(result.working['doc1']).toBeNull()
const draft = result.working['drafts.doc1']
expect(draft).toBeDefined()
expect(draft?._id).toBe('drafts.doc1')
expect(draft?.['title']).toBe('Published Title')
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.unpublish',
draftId: 'drafts.doc1',
publishedId: 'doc1',
},
])
})
it('should throw an error when no published document exists for unpublishing', () => {
const base: DocumentSet = {}
const working: DocumentSet = {}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.unpublish',
},
]
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
).toThrow(ActionError)
})
it('should unpublish using the working published document when base and working differ', () => {
// Simulate divergence where the published document in base and working differ.
const basePublished = createDoc('doc1', 'Base Published', '1')
const workingPublished = createDoc('doc1', 'Working Published', '2')
const base: DocumentSet = {doc1: basePublished}
const working: DocumentSet = {doc1: workingPublished}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
documentType: 'article',
type: 'document.unpublish',
},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
// Expect the published document is removed and a draft is created from the working published doc.
expect(result.working['doc1']).toBeNull()
const draft = result.working['drafts.doc1']
expect(draft).toBeDefined()
expect(draft?._id).toBe('drafts.doc1')
expect(draft?.['title']).toBe('Working Published')
expect(result.outgoingActions).toEqual([
{
actionType: 'sanity.action.document.unpublish',
draftId: 'drafts.doc1',
publishedId: 'doc1',
},
])
})
it('should throw PermissionActionError if update permission is denied during unpublish', () => {
const published = createDoc('doc1', 'Published Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{documentId: 'doc1', documentType: 'article', type: 'document.unpublish'},
]
const grants = {...defaultGrants, update: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to unpublish the document "doc1"/)
})
it('should throw PermissionActionError if create permission is denied when unpublishing', () => {
const published = createDoc('doc1', 'Published Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{documentId: 'doc1', documentType: 'article', type: 'document.unpublish'},
]
const grants = {...defaultGrants, create: alwaysDeny}
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants}),
).toThrow(/You do not have permission to create a draft from the published version of "doc1"/)
})
})
describe('Multiple actions and previousRevs', () => {
it('should handle multiple actions sequentially and compute previousRevs correctly', () => {
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{documentId: 'doc1', documentType: 'article', type: 'document.create'},
{
documentId: 'doc1',
documentType: 'article',
type: 'document.edit',
patches: [{set: {title: 'Edited Title'}}],
},
{documentId: 'doc1', documentType: 'article', type: 'document.publish'},
]
const result = processActions({
actions,
transactionId,
base,
working,
timestamp,
grants: defaultGrants,
})
expect(result.previousRevs).toEqual({doc1: 'initial'})
const publishedDoc = result.working['doc1']
expect(publishedDoc).toBeDefined()
expect(publishedDoc?.['title']).toBe('Edited Title')
})
})
describe('Unexpected action input', () => {
it('should throw if there is an unrecognized action', () => {
const published = createDoc('doc1', 'Original Title')
const base: DocumentSet = {doc1: published}
const working: DocumentSet = {doc1: published}
const actions: DocumentAction[] = [
{
documentId: 'doc1',
// @ts-expect-error testing invalid input
type: 'document.unrecognizedAction',
},
]
expect(() =>
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
).toThrow(/Unknown action type: "document.unrecognizedAction"/)
})
})
})