Files
payloadcms/test/trash/int.spec.ts
Patrik 3b13867aee fix: skip validation when trashing documents with empty required fields (#13807)
### What?

Skip field validation when trashing documents with empty required
fields.

### Why?

When trashing a document that was saved as a draft with empty required
fields, Payload would run full validation and fail with "The following
fields are invalid" errors. This happened because trash operations were
treated as regular updates that require full field validation, even
though trashing is just a metadata change (setting `deletedAt`) and
shouldn't be blocked by content validation issues.
   
### How?

- Modified `skipValidation` logic in `updateDocument()` to skip
validation when `deletedAt` is being set in the update data

Fixes #13706
2025-09-16 07:09:39 -07:00

1774 lines
54 KiB
TypeScript

import type { CollectionSlug, Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type { Post, RestrictedCollection } from './payload-types.js'
import { regularUser } from '../credentials.js'
import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { postsSlug } from './collections/Posts/index.js'
import { restrictedCollectionSlug } from './collections/RestrictedCollection/index.js'
import { usersSlug } from './collections/Users/index.js'
let restClient: NextRESTClient
let payload: Payload
let user: any
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('trash', () => {
beforeAll(async () => {
const initResult = await initPayloadInt(dirname)
payload = initResult.payload
restClient = initResult.restClient
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
let restrictedCollectionDoc: RestrictedCollection
let postsDocOne: Post
let postsDocTwo: Post
beforeEach(async () => {
await restClient.login({
slug: usersSlug,
credentials: regularUser,
})
user = await payload.login({
collection: usersSlug,
data: {
email: regularUser.email,
password: regularUser.password,
},
})
restrictedCollectionDoc = await payload.create({
collection: restrictedCollectionSlug as CollectionSlug,
data: {
title: 'With Access Control one',
},
})
postsDocOne = await payload.create({
collection: postsSlug,
data: {
title: 'Doc one',
},
})
postsDocTwo = await payload.create({
collection: postsSlug,
data: {
title: 'Doc two',
deletedAt: new Date().toISOString(),
},
})
})
afterEach(async () => {
await payload.delete({
collection: postsSlug,
trash: true,
where: {
title: {
exists: true,
},
},
})
})
// Access control tests use the Pages collection because it has delete access control enabled.
// The Post collection does not have any access restrictions and is used for general CRUD tests.
describe('Access control', () => {
it('should not allow bulk soft-deleting documents when restricted by delete access', async () => {
await expect(
payload.update({
collection: restrictedCollectionSlug as CollectionSlug,
data: {
deletedAt: new Date().toISOString(),
},
user, // Regular user does not have delete access
where: {
// Using where to target multiple documents
title: {
equals: restrictedCollectionDoc.title,
},
},
overrideAccess: false, // Override access to false to test access control
}),
).rejects.toMatchObject({
status: 403,
name: 'Forbidden',
message: expect.stringContaining('You are not allowed'),
})
})
it('should not allow soft-deleting a document when restricted by delete access', async () => {
await expect(
payload.update({
collection: restrictedCollectionSlug as CollectionSlug,
data: {
deletedAt: new Date().toISOString(),
},
id: restrictedCollectionDoc.id, // Using ID to target specific document
user, // Regular user does not have delete access
overrideAccess: false, // Override access to false to test access control
}),
).rejects.toMatchObject({
status: 403,
name: 'Forbidden',
message: expect.stringContaining('You are not allowed'),
})
})
})
describe('LOCAL API', () => {
describe('find', () => {
it('should return all docs including soft-deleted docs in find with trash: true', async () => {
const allDocs = await payload.find({
collection: postsSlug,
trash: true,
})
expect(allDocs.totalDocs).toEqual(2)
})
it('should return only soft-deleted docs in find with trash: true', async () => {
const trashedDocs = await payload.find({
collection: postsSlug,
where: {
deletedAt: {
exists: true,
},
},
trash: true,
})
expect(trashedDocs.totalDocs).toEqual(1)
expect(trashedDocs.docs[0]?.id).toEqual(postsDocTwo.id)
})
it('should return only non-soft-deleted docs in find with trash: false', async () => {
const normalDocs = await payload.find({
collection: postsSlug,
trash: false,
})
expect(normalDocs.totalDocs).toEqual(1)
expect(normalDocs.docs[0]?.id).toEqual(postsDocOne.id)
})
it('should find restored documents after setting deletedAt to null', async () => {
await payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: {
deletedAt: null,
},
trash: true,
})
const result = await payload.find({
collection: postsSlug,
trash: false, // Normal query should return it now
})
const restored = result.docs.find(
(doc) => (doc.id as number | string) === (postsDocTwo.id as number | string),
)
expect(restored).toBeDefined()
expect(restored?.deletedAt).toBeNull()
})
})
describe('findDistinct', () => {
it('should return all unique values for a field (excluding soft-deleted docs by default)', async () => {
// Add a duplicate title
await payload.create({
collection: postsSlug,
data: { title: 'Doc one' },
})
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
})
const titles = result.values.map((v) => v.title)
// Expect only distinct titles of non-trashed docs
expect(titles).toContain('Doc one')
expect(titles).not.toContain('Doc two') // because it's soft-deleted
expect(titles).toHaveLength(1)
})
it('should include soft-deleted docs when trash: true', async () => {
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
trash: true,
})
const titles = result.values.map((v) => v.title)
expect(titles).toContain('Doc one')
expect(titles).toContain('Doc two') // soft-deleted doc
})
it('should return only distinct values from soft-deleted docs when where[deletedAt][exists]=true', async () => {
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
trash: true,
where: {
deletedAt: { exists: true },
},
})
const titles = result.values.map((v) => v.title)
expect(titles).toEqual(['Doc two']) // Only the soft-deleted doc
})
it('should respect where filters when retrieving distinct values', async () => {
const result = await payload.findDistinct({
collection: postsSlug,
field: 'title',
trash: true,
where: {
title: { equals: 'Doc two' },
},
})
const titles = result.values.map((v) => v.title)
expect(titles).toEqual(['Doc two'])
})
})
describe('findByID operation', () => {
it('should return a soft-deleted document when trash: true', async () => {
const trashedPostDoc: Post = await payload.findByID({
collection: postsSlug,
id: postsDocTwo.id,
trash: true,
})
expect(trashedPostDoc).toBeDefined()
expect(trashedPostDoc?.id).toEqual(postsDocTwo.id)
expect(trashedPostDoc?.deletedAt).toBeDefined()
expect(trashedPostDoc?.deletedAt).toEqual(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to find a soft-deleted document w/o trash: true', async () => {
await expect(
payload.findByID({
collection: postsSlug,
id: postsDocTwo.id,
}),
).rejects.toThrow('Not Found')
await expect(
payload.findByID({
collection: postsSlug,
id: postsDocTwo.id,
trash: false,
}),
).rejects.toThrow('Not Found')
})
})
describe('findVersions operation', () => {
beforeAll(async () => {
await payload.update({
collection: postsSlug,
data: {
title: 'Some updated title',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
})
it('should return all versions including soft-deleted docs in findVersions with trash: true', async () => {
const allVersions = await payload.findVersions({
collection: postsSlug,
trash: true,
})
expect(allVersions.totalDocs).toEqual(2)
expect(allVersions.docs[0]?.parent).toEqual(postsDocTwo.id)
expect(allVersions.docs[1]?.parent).toEqual(postsDocOne.id)
})
it('should return only soft-deleted docs in findVersions with trash: true', async () => {
const trashedVersions = await payload.findVersions({
collection: postsSlug,
where: {
'version.deletedAt': {
exists: true,
},
},
trash: true,
})
expect(trashedVersions.totalDocs).toEqual(1)
expect(trashedVersions.docs[0]?.parent).toEqual(postsDocTwo.id)
})
it('should return only non-soft-deleted docs in findVersions with trash: false', async () => {
const normalVersions = await payload.findVersions({
collection: postsSlug,
trash: false,
})
expect(normalVersions.totalDocs).toEqual(1)
expect(normalVersions.docs[0]?.parent).toEqual(postsDocOne.id)
})
it('should find versions where version.deletedAt is null after restore', async () => {
await payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: {
deletedAt: null,
},
trash: true,
})
const versions = await payload.findVersions({
collection: postsSlug,
trash: true,
where: {
'version.deletedAt': {
equals: null,
},
},
})
expect(versions.docs.some((v) => v.parent === postsDocTwo.id)).toBe(true)
})
})
describe('findVersionByID operation', () => {
beforeAll(async () => {
await payload.update({
collection: postsSlug,
data: {
title: 'Some updated title',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
})
it('should return a soft-deleted version document when trash: true', async () => {
const trashedVersions = await payload.findVersions({
collection: postsSlug,
where: {
'version.deletedAt': {
exists: true,
},
},
trash: true,
})
expect(trashedVersions.docs).toHaveLength(1)
const version = trashedVersions.docs[0]
const trashedVersionDoc = await payload.findVersionByID({
collection: postsSlug,
id: version!.id,
trash: true,
})
expect(trashedVersionDoc).toBeDefined()
expect(trashedVersionDoc?.parent).toEqual(postsDocTwo.id)
expect(trashedVersionDoc?.version?.deletedAt).toBeDefined()
expect(trashedVersionDoc?.version?.deletedAt).toEqual(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to find a soft-deleted version document w/o trash: true', async () => {
const trashedVersions = await payload.findVersions({
collection: postsSlug,
where: {
'version.deletedAt': {
exists: true,
},
},
trash: true,
})
expect(trashedVersions.docs).toHaveLength(1)
const version = trashedVersions.docs[0]
await expect(
payload.findVersionByID({
collection: postsSlug,
id: version!.id,
}),
).rejects.toThrow('Not Found')
await expect(
payload.findVersionByID({
collection: postsSlug,
id: version!.id,
trash: false,
}),
).rejects.toThrow('Not Found')
})
})
describe('updateByID operation', () => {
it('should update a single soft-deleted document when trash: true', async () => {
const updatedPostDoc: Post = await payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: {
title: 'Updated Doc Two',
},
trash: true,
})
expect(updatedPostDoc).toBeDefined()
expect(updatedPostDoc.id).toEqual(postsDocTwo.id)
expect(updatedPostDoc.title).toEqual('Updated Doc Two')
expect(updatedPostDoc.deletedAt).toBeDefined()
expect(updatedPostDoc.deletedAt).toEqual(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => {
await expect(
payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: {
title: 'Updated Doc Two',
},
}),
).rejects.toThrow('Not Found')
await expect(
payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: {
title: 'Updated Doc Two',
},
trash: false,
}),
).rejects.toThrow('Not Found')
})
it('should update a single normal document when trash: false', async () => {
const updatedPostDoc: Post = await payload.update({
collection: postsSlug,
id: postsDocOne.id,
data: {
title: 'Updated Doc One',
},
})
expect(updatedPostDoc).toBeDefined()
expect(updatedPostDoc.id).toEqual(postsDocOne.id)
expect(updatedPostDoc.title).toEqual('Updated Doc One')
expect(updatedPostDoc.deletedAt).toBeFalsy()
})
it('should restore a soft-deleted document by setting deletedAt to null', async () => {
const restored = await payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: {
deletedAt: null,
},
trash: true,
})
expect(restored.deletedAt).toBeNull()
// Should now show up in trash: false queries
const result = await payload.find({
collection: postsSlug,
trash: false,
})
const found = result.docs.find((doc) => doc.id === postsDocTwo.id)
expect(found).toBeDefined()
expect(found?.deletedAt).toBeNull()
})
})
describe('update operation', () => {
it('should update only normal document when trash: false', async () => {
const result = await payload.update({
collection: postsSlug,
data: {
title: 'Updated Doc',
},
trash: false,
where: {
title: {
exists: true,
},
},
})
expect(result.docs).toBeDefined()
expect(result.docs.length).toBeGreaterThan(0)
const updatedDoc = result.docs[0]
expect(updatedDoc?.id).toEqual(postsDocOne.id)
expect(updatedDoc?.title).toEqual('Updated Doc')
expect(updatedDoc?.deletedAt).toBeFalsy()
})
it('should update all documents including soft-deleted documents when trash: true', async () => {
const result = await payload.update({
collection: postsSlug,
data: {
title: 'A New Updated Doc',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
expect(result.docs).toBeDefined()
expect(result.docs.length).toBeGreaterThan(0)
const updatedPostdDocOne = result.docs.find((doc) => doc.id === postsDocOne.id)
const updatedPostdDocTwo = result.docs.find((doc) => doc.id === postsDocTwo.id)
expect(updatedPostdDocOne?.title).toEqual('A New Updated Doc')
expect(updatedPostdDocOne?.deletedAt).toBeFalsy()
expect(updatedPostdDocTwo?.title).toEqual('A New Updated Doc')
expect(updatedPostdDocTwo?.deletedAt).toBeDefined()
})
it('should only update soft-deleted documents when trash: true and where[deletedAt][exists]=true', async () => {
const docThree = await payload.create({
collection: postsSlug,
data: {
title: 'Doc three',
deletedAt: new Date().toISOString(),
},
})
const result = await payload.update({
collection: postsSlug,
data: {
title: 'Updated Soft Deleted Doc',
},
trash: true,
where: {
deletedAt: {
exists: true,
},
},
})
expect(result.docs).toBeDefined()
expect(result.docs[0]?.id).toEqual(docThree.id)
expect(result.docs[0]?.title).toEqual('Updated Soft Deleted Doc')
expect(result.docs[0]?.deletedAt).toEqual(docThree.deletedAt)
expect(result.docs[1]?.id).toEqual(postsDocTwo.id)
expect(result.docs[1]?.title).toEqual('Updated Soft Deleted Doc')
expect(result.docs[1]?.deletedAt).toEqual(postsDocTwo.deletedAt)
// Clean up
await payload.delete({
collection: postsSlug,
id: docThree.id,
trash: true,
})
})
})
describe('delete operation', () => {
it('should perma delete all docs including soft-deleted documents when trash: true', async () => {
await payload.delete({
collection: postsSlug,
trash: true,
where: {
title: {
exists: true,
},
},
})
const allDocs = await payload.find({
collection: postsSlug,
trash: true,
})
expect(allDocs.totalDocs).toEqual(0)
})
it('should only perma delete normal docs when trash: false', async () => {
await payload.delete({
collection: postsSlug,
trash: false,
where: {
title: {
exists: true,
},
},
})
const allDocs = await payload.find({
collection: postsSlug,
trash: true,
})
expect(allDocs.totalDocs).toEqual(1)
expect(allDocs.docs[0]?.id).toEqual(postsDocTwo.id)
})
})
describe('trashing documents with validation issues', () => {
it('should allow trashing documents with empty required fields (draft scenario)', async () => {
// Create a draft document with empty required field
const draftDoc = await payload.create({
collection: postsSlug,
data: {
title: '', // Empty required field
_status: 'draft',
},
draft: true,
})
expect(draftDoc.title).toBe('')
expect(draftDoc._status).toBe('draft')
// Should be able to trash the document even with empty required field
const trashedDoc = await payload.update({
collection: postsSlug,
id: draftDoc.id,
data: {
deletedAt: new Date().toISOString(),
},
})
expect(trashedDoc.deletedAt).toBeDefined()
expect(trashedDoc.title).toBe('') // Title should still be empty
expect(trashedDoc._status).toBe('draft')
// Clean up
await payload.delete({
collection: postsSlug,
id: draftDoc.id,
trash: true,
})
})
})
describe('deleteByID operation', () => {
it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => {
await expect(
payload.delete({
collection: postsSlug,
id: postsDocTwo.id,
}),
).rejects.toThrow('Not Found')
await expect(
payload.delete({
collection: postsSlug,
id: postsDocTwo.id,
trash: false,
}),
).rejects.toThrow('Not Found')
})
it('should delete a soft-deleted document when trash: true', async () => {
await payload.delete({
collection: postsSlug,
id: postsDocTwo.id,
trash: true,
})
const allDocs = await payload.find({
collection: postsSlug,
trash: true,
})
expect(allDocs.totalDocs).toEqual(1)
expect(allDocs.docs[0]?.id).toEqual(postsDocOne.id)
})
})
describe('restoreVersion operation', () => {
it('should throw error when restoring a version of a trashed document', async () => {
// Create a version of postsDocTwo (which is soft-deleted)
await payload.update({
collection: postsSlug,
id: postsDocTwo.id,
data: { title: 'Updated Before Restore Attempt' },
trash: true,
})
const { docs: versions } = await payload.findVersions({
collection: postsSlug,
trash: true,
})
const version = versions.find((v) => v.parent === postsDocTwo.id)
expect(version).toBeDefined()
await expect(
payload.restoreVersion({
collection: postsSlug,
id: version!.id,
}),
).rejects.toThrow(/Cannot restore a version of a trashed document/i)
})
})
describe('count operation', () => {
it('should return total count of non-soft-deleted documents by default (trash: false)', async () => {
const result = await payload.count({
collection: postsSlug,
})
expect(result.totalDocs).toEqual(1) // Only postsDocOne
})
it('should return total count of all documents including soft-deleted when trash: true', async () => {
const result = await payload.count({
collection: postsSlug,
trash: true,
})
expect(result.totalDocs).toEqual(2)
})
it('should return count of only soft-deleted documents when where[deletedAt][exists]=true', async () => {
const result = await payload.count({
collection: postsSlug,
trash: true,
where: { deletedAt: { exists: true } },
})
expect(result.totalDocs).toEqual(1) // Only postsDocTwo
})
})
})
describe('REST API', () => {
describe('find endpoint', () => {
it('should return all docs including soft-deleted docs in find with trash=true', async () => {
const res = await restClient.GET(`/${postsSlug}?trash=true`)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.docs).toHaveLength(2)
})
it('should return only soft-deleted docs with trash=true and where[deletedAt][exists]=true', async () => {
const res = await restClient.GET(`/${postsSlug}?trash=true&where[deletedAt][exists]=true`)
const data = await res.json()
expect(data.docs).toHaveLength(1)
expect(data.docs[0]?.id).toEqual(postsDocTwo.id)
})
it('should return only normal docs when trash=false', async () => {
const res = await restClient.GET(`/${postsSlug}?trash=false`)
const data = await res.json()
expect(data.docs).toHaveLength(1)
expect(data.docs[0]?.id).toEqual(postsDocOne.id)
})
it('should find restored documents after setting deletedAt to null', async () => {
await restClient.PATCH(`/${postsSlug}/${postsDocTwo.id}?trash=true`, {
body: JSON.stringify({
deletedAt: null,
}),
})
const res = await restClient.GET(`/${postsSlug}?trash=false`)
const data = await res.json()
const restored = data.docs.find((doc: Post) => doc.id === postsDocTwo.id)
expect(restored).toBeDefined()
expect(restored.deletedAt).toBeNull()
})
})
describe('findByID endpoint', () => {
it('should return a soft-deleted doc by ID with trash=true', async () => {
const res = await restClient.GET(`/${postsSlug}/${postsDocTwo.id}?trash=true`)
const data = await res.json()
expect(data?.id).toEqual(postsDocTwo.id)
expect(data?.deletedAt).toEqual(postsDocTwo.deletedAt)
})
it('should 404 when trying to get a soft-deleted doc without trash=true', async () => {
const res = await restClient.GET(`/${postsSlug}/${postsDocTwo.id}`)
expect(res.status).toBe(404)
})
})
describe('find versions endpoint', () => {
beforeAll(async () => {
await payload.update({
collection: postsSlug,
data: {
title: 'Some updated title',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
})
it('should return all versions including soft-deleted docs in findVersions with trash: true', async () => {
const res = await restClient.GET(`/${postsSlug}/versions?trash=true`)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.docs).toHaveLength(2)
})
it('should return only soft-deleted docs in findVersions with trash: true', async () => {
const res = await restClient.GET(
`/${postsSlug}/versions?trash=true&where[version.deletedAt][exists]=true`,
)
const data = await res.json()
expect(data.docs).toHaveLength(1)
expect(data.docs[0]?.parent).toEqual(postsDocTwo.id)
})
it('should return only non-soft-deleted docs in findVersions with trash: false', async () => {
const res = await restClient.GET(`/${postsSlug}/versions?trash=false`)
const data = await res.json()
expect(data.docs).toHaveLength(1)
expect(data.docs[0]?.parent).toEqual(postsDocOne.id)
})
it('should find versions where version.deletedAt is null after restore via REST', async () => {
await restClient.PATCH(`/${postsSlug}/${postsDocTwo.id}?trash=true`, {
body: JSON.stringify({
deletedAt: null,
}),
})
const res = await restClient.GET(
`/${postsSlug}/versions?trash=true&where[version.deletedAt][equals]=null`,
)
const data = await res.json()
const version = data.docs.find((v: any) => v.parent === postsDocTwo.id)
expect(version).toBeDefined()
expect(version.version.deletedAt).toBeNull()
})
})
describe('findVersionByID endpoint', () => {
beforeAll(async () => {
await payload.update({
collection: postsSlug,
data: {
title: 'Some updated title',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
})
it('should return a soft-deleted version document when trash: true', async () => {
const trashedVersions = await restClient.GET(
`/${postsSlug}/versions?trash=true&where[version.deletedAt][exists]=true`,
)
const trashedVersionsData = await trashedVersions.json()
expect(trashedVersionsData.docs).toHaveLength(1)
const version = trashedVersionsData.docs[0]
const versionDoc = await restClient.GET(`/${postsSlug}/versions/${version!.id}?trash=true`)
const trashedVersionDoc = await versionDoc.json()
expect(trashedVersionDoc).toBeDefined()
expect(trashedVersionDoc?.parent).toEqual(postsDocTwo.id)
expect(trashedVersionDoc?.version?.deletedAt).toBeDefined()
expect(trashedVersionDoc?.version?.deletedAt).toEqual(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to find a soft-deleted version document w/o trash: true', async () => {
const trashedVersions = await restClient.GET(
`/${postsSlug}/versions?trash=true&where[version.deletedAt][exists]=true`,
)
const trashedVersionsData = await trashedVersions.json()
expect(trashedVersionsData.docs).toHaveLength(1)
const version = trashedVersionsData.docs[0]
const withoutTrash = await restClient.GET(`/${postsSlug}/versions/${version!.id}`)
expect(withoutTrash.status).toBe(404)
const withTrashFalse = await restClient.GET(
`/${postsSlug}/versions/${version!.id}?trash=false`,
)
expect(withTrashFalse.status).toBe(404)
})
})
describe('updateByID endpoint', () => {
it('should update a single soft-deleted doc when trash=true', async () => {
const res = await restClient.PATCH(`/${postsSlug}/${postsDocTwo.id}?trash=true`, {
body: JSON.stringify({
title: 'Updated via REST',
}),
})
const result = await res.json()
expect(result.doc.title).toBe('Updated via REST')
expect(result.doc.deletedAt).toEqual(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => {
const res = await restClient.PATCH(`/${postsSlug}/${postsDocTwo.id}`, {
body: JSON.stringify({ title: 'Fail Update' }),
})
expect(res.status).toBe(404)
})
it('should update a single normal document when trash: false', async () => {
const res = await restClient.PATCH(`/${postsSlug}/${postsDocOne.id}?trash=false`, {
body: JSON.stringify({ title: 'Updated Normal via REST' }),
})
const result = await res.json()
expect(result.doc.title).toBe('Updated Normal via REST')
expect(result.doc.deletedAt).toBeFalsy()
})
it('should restore a soft-deleted document by setting deletedAt to null', async () => {
const res = await restClient.PATCH(`/${postsSlug}/${postsDocTwo.id}?trash=true`, {
body: JSON.stringify({
deletedAt: null,
}),
})
const result = await res.json()
expect(result.doc.deletedAt).toBeNull()
const check = await restClient.GET(`/${postsSlug}?trash=false`)
const data = await check.json()
const restored = data.docs.find((doc: Post) => doc.id === postsDocTwo.id)
expect(restored).toBeDefined()
expect(restored.deletedAt).toBeNull()
})
})
describe('update endpoint', () => {
it('should update only normal document when trash: false', async () => {
const query = `?trash=false&where[id][equals]=${postsDocOne.id}`
const res = await restClient.PATCH(`/${postsSlug}${query}`, {
body: JSON.stringify({ title: 'Updated Normal via REST' }),
})
const result = await res.json()
expect(result.docs).toHaveLength(1)
expect(result.docs[0].id).toBe(postsDocOne.id)
expect(result.docs[0].title).toBe('Updated Normal via REST')
expect(result.docs[0].deletedAt).toBeFalsy()
})
it('should update all documents including soft-deleted documents when trash: true', async () => {
const query = `?trash=true&where[title][exists]=true`
const res = await restClient.PATCH(`/${postsSlug}${query}`, {
body: JSON.stringify({ title: 'Bulk Updated All' }),
})
const result = await res.json()
expect(result.docs).toHaveLength(2)
expect(result.docs.every((doc: Post) => doc.title === 'Bulk Updated All')).toBe(true)
})
it('should only update soft-deleted documents when trash: true and where[deletedAt][exists]=true', async () => {
const query = `?trash=true&where[deletedAt][exists]=true`
const docThree = await payload.create({
collection: postsSlug,
data: {
title: 'Doc three',
deletedAt: new Date().toISOString(),
},
})
const res = await restClient.PATCH(`/${postsSlug}${query}`, {
body: JSON.stringify({ title: 'Updated Soft Deleted Doc' }),
})
const result = await res.json()
expect(result.docs).toHaveLength(2)
expect(result.docs).toBeDefined()
expect(result.docs[0]?.id).toEqual(docThree.id)
expect(result.docs[0]?.title).toEqual('Updated Soft Deleted Doc')
expect(result.docs[0]?.deletedAt).toEqual(docThree.deletedAt)
expect(result.docs[1]?.id).toEqual(postsDocTwo.id)
expect(result.docs[1]?.title).toEqual('Updated Soft Deleted Doc')
expect(result.docs[1]?.deletedAt).toEqual(postsDocTwo.deletedAt)
// Clean up
await payload.delete({
collection: postsSlug,
id: docThree.id,
trash: true,
})
})
})
describe('delete endpoint', () => {
it('should perma delete all docs including soft-deleted documents when trash: true', async () => {
const query = `?trash=true&where[title][exists]=true`
const res = await restClient.DELETE(`/${postsSlug}${query}`)
expect(res.status).toBe(200)
const result = await res.json()
expect(result.docs).toHaveLength(2)
const check = await restClient.GET(`/${postsSlug}?trash=true`)
const checkData = await check.json()
expect(checkData.docs).toHaveLength(0)
})
it('should only perma delete normal docs when trash: false', async () => {
const query = `?trash=false&where[title][exists]=true`
const res = await restClient.DELETE(`/${postsSlug}${query}`)
expect(res.status).toBe(200)
const result = await res.json()
expect(result.docs).toHaveLength(1)
expect(result.docs[0]?.id).toBe(postsDocOne.id)
const check = await restClient.GET(`/${postsSlug}?trash=true`)
const checkData = await check.json()
// Make sure postsDocTwo (soft-deleted) is still there
expect(checkData.docs.some((doc: Post) => doc.id === postsDocTwo.id)).toBe(true)
})
})
describe('deleteByID endpoint', () => {
it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => {
const res = await restClient.DELETE(`/${postsSlug}/${postsDocTwo.id}`)
expect(res.status).toBe(404)
})
it('should delete a soft-deleted document when trash: true', async () => {
const res = await restClient.DELETE(`/${postsSlug}/${postsDocTwo.id}?trash=true`)
expect(res.status).toBe(200)
const result = await res.json()
expect(result.doc.id).toBe(postsDocTwo.id)
})
})
describe('restoreVersion operation', () => {
it('should throw error when restoring a version of a trashed document', async () => {
const updateRes = await restClient.PATCH(`/${postsSlug}/${postsDocTwo.id}?trash=true`, {
body: JSON.stringify({ title: 'Updated Soft Deleted for Restore Test' }),
})
expect(updateRes.status).toBe(200)
const { docs: versions } = await payload.findVersions({
collection: postsSlug,
trash: true,
})
const version = versions.find((v) => v.parent === postsDocTwo.id)
const res = await restClient.POST(`/${postsSlug}/versions/${version!.id}`)
const body = await res.json()
expect(res.status).toBe(403)
expect(body.message ?? body.errors?.[0]?.message).toMatch(
'Cannot restore a version of a trashed document',
)
})
})
describe('count endpoint', () => {
it('should return count of non-soft-deleted docs by default (trash=false)', async () => {
const res = await restClient.GET(`/${postsSlug}/count`)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.totalDocs).toEqual(1)
})
it('should return count of all docs including soft-deleted when trash=true', async () => {
const res = await restClient.GET(`/${postsSlug}/count?trash=true`)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.totalDocs).toEqual(2)
})
it('should return count of only soft-deleted docs with trash=true & where[deletedAt][exists]=true', async () => {
const res = await restClient.GET(
`/${postsSlug}/count?trash=true&where[deletedAt][exists]=true`,
)
const data = await res.json()
expect(data.totalDocs).toEqual(1)
})
})
})
describe('GRAPHQL API', () => {
describe('find query', () => {
it('should return all docs including soft-deleted docs in find with trash=true', async () => {
const query = `
query {
Posts(trash: true) {
docs {
id
title
deletedAt
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.Posts.docs).toHaveLength(2)
})
it('should return only soft-deleted docs with trash=true and where[deletedAt][exists]=true', async () => {
const query = `
query {
Posts(
trash: true
where: { deletedAt: { exists: true } }
) {
docs {
id
deletedAt
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.Posts.docs).toHaveLength(1)
expect(res.data.Posts.docs[0].id).toEqual(postsDocTwo.id)
})
it('should return only normal docs when trash=false', async () => {
const query = `
query {
Posts(trash: false) {
docs {
id
deletedAt
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.Posts.docs).toHaveLength(1)
expect(res.data.Posts.docs[0].id).toEqual(postsDocOne.id)
expect(res.data.Posts.docs[0].deletedAt).toBeNull()
})
it('should find restored documents after setting deletedAt to null', async () => {
const mutation = `
mutation {
updatePost(id: ${idToString(postsDocTwo.id, payload)}, trash: true, data: {
deletedAt: null
}) {
id
}
}
`
await restClient.GRAPHQL_POST({ body: JSON.stringify({ query: mutation }) })
const query = `
query {
Posts(trash: false) {
docs {
id
deletedAt
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
const restored = res.data.Posts.docs.find((doc: Post) => doc.id === postsDocTwo.id)
expect(restored).toBeDefined()
expect(restored.deletedAt).toBeNull()
})
})
describe('findByID query', () => {
it('should return a soft-deleted doc by ID with trash=true', async () => {
const query = `
query {
Post(id: ${idToString(postsDocTwo.id, payload)}, trash: true) {
id
deletedAt
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.Post.id).toBe(postsDocTwo.id)
expect(res.data.Post.deletedAt).toBe(postsDocTwo.deletedAt)
})
it('should 404 when trying to get a soft-deleted doc without trash=true', async () => {
const query = `
query {
Post(id: ${idToString(postsDocTwo.id, payload)}) {
id
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.errors?.[0]?.message).toMatch(/not found/i)
})
})
describe('find versions query', () => {
beforeAll(async () => {
await payload.update({
collection: postsSlug,
data: {
title: 'Some updated title',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
})
it('should return all versions including soft-deleted docs in findVersions with trash: true', async () => {
const query = `
query {
versionsPosts(trash: true) {
docs {
id
version {
title
deletedAt
}
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.versionsPosts.docs).toHaveLength(2)
})
it('should return only soft-deleted docs in findVersions with trash: true', async () => {
const query = `
query {
versionsPosts(
trash: true,
where: {
version__deletedAt: {
exists: true
}
}
) {
docs {
id
version {
title
deletedAt
}
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
const { docs } = res.data.versionsPosts
// Should only include soft-deleted versions
expect(docs).toHaveLength(1)
for (const doc of docs) {
expect(doc.version.deletedAt).toBeDefined()
}
})
it('should return only non-soft-deleted docs in findVersions with trash: false', async () => {
const query = `
query {
versionsPosts(trash: false) {
docs {
id
version {
title
deletedAt
}
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
const { docs } = res.data.versionsPosts
// All versions returned should NOT have deletedAt set
for (const doc of docs) {
expect(doc.version.deletedAt).toBeNull()
}
})
it('should find versions where version.deletedAt is null after restore', async () => {
const mutation = `
mutation {
updatePost(id: ${idToString(postsDocTwo.id, payload)}, trash: true, data: { deletedAt: null }) {
id
title
deletedAt
}
}
`
await restClient.GRAPHQL_POST({ body: JSON.stringify({ query: mutation }) })
const query = `
query {
versionsPosts(
trash: true,
where: {
version__deletedAt: {
equals: null
}
}
) {
docs {
id
parent {
id
}
version {
deletedAt
}
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
const version = res.data.versionsPosts.docs.find(
(v: any) => String(v.parent.id) === String(postsDocTwo.id),
)
expect(version).toBeDefined()
expect(version.version.deletedAt).toBeNull()
})
})
describe('findVersionByID endpoint', () => {
beforeAll(async () => {
await payload.update({
collection: postsSlug,
data: {
title: 'Some updated title',
},
trash: true,
where: {
title: {
exists: true,
},
},
})
})
it('should return a soft-deleted document when trash: true', async () => {
// First, get the version ID of the soft-deleted trash enabled doc
const listQuery = `
query {
versionsPosts(
trash: true,
where: {
version__deletedAt: {
exists: true
}
}
) {
docs {
id
version {
deletedAt
}
}
}
}
`
const listRes = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: listQuery }) })
.then((r) => r.json())
const trashedVersion = listRes.data.versionsPosts.docs[0]
const detailQuery = `
query {
versionPost(id: ${idToString(trashedVersion.id, payload)}, trash: true) {
id
version {
deletedAt
}
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: detailQuery }) })
.then((r) => r.json())
expect(res.data.versionPost.id).toBe(trashedVersion.id)
expect(res.data.versionPost.version.deletedAt).toBe(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to find a soft-deleted version document w/o trash: true', async () => {
// First, get the version ID of the soft-deleted trash enabled doc
const listQuery = `
query {
versionsPosts(
trash: true,
where: {
version__deletedAt: {
exists: true
}
}
) {
docs {
id
}
}
}
`
const listRes = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: listQuery }) })
.then((r) => r.json())
const trashedVersion = listRes.data.versionsPosts.docs[0]
const detailQuery = `
query {
versionPost(id: ${idToString(trashedVersion.id, payload)}) {
id
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: detailQuery }) })
.then((r) => r.json())
expect(res.errors?.[0]?.message).toMatch(/not found/i)
})
})
describe('updateByID query', () => {
it('should update a single soft-deleted doc when trash=true', async () => {
const query = `
mutation {
updatePost(id: ${idToString(postsDocTwo.id, payload)}, trash: true, data: { title: "Updated Soft Deleted via GQL" }) {
id
title
deletedAt
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.updatePost.id).toBe(postsDocTwo.id)
expect(res.data.updatePost.title).toBe('Updated Soft Deleted via GQL')
expect(res.data.updatePost.deletedAt).toBe(postsDocTwo.deletedAt)
})
it('should throw NotFound error when trying to update a soft-deleted document w/o trash: true', async () => {
const query = `
mutation {
updatePost(id: ${idToString(postsDocTwo.id, payload)}, data: { title: "Should Fail" }) {
id
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.errors?.[0]?.message).toMatch(/not found/i)
})
it('should update a single normal document when trash: false', async () => {
const query = `
mutation {
updatePost(id: ${idToString(postsDocOne.id, payload)}, trash: false, data: { title: "Updated Normal via GQL" }) {
id
title
deletedAt
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.updatePost.id).toBe(postsDocOne.id)
expect(res.data.updatePost.title).toBe('Updated Normal via GQL')
expect(res.data.updatePost.deletedAt).toBeNull()
})
it('should restore a soft-deleted document by setting deletedAt to null', async () => {
const mutation = `
mutation {
updatePost(id: ${idToString(postsDocTwo.id, payload)}, trash: true, data: {
deletedAt: null
}) {
id
deletedAt
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: mutation }) })
.then((r) => r.json())
expect(res.data.updatePost.deletedAt).toBeNull()
const query = `
query {
Posts(trash: false) {
docs {
id
deletedAt
}
}
}
`
const restored = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
const match = restored.data.Posts.docs.find((doc: Post) => doc.id === postsDocTwo.id)
expect(match).toBeDefined()
expect(match.deletedAt).toBeNull()
})
})
// describe('update endpoint', () => {
// it.todo('should update only normal document when trash: false')
// it.todo('should update all documents including soft-deleted documents when trash: true')
// it.todo(
// 'should only update soft-deleted documents when trash: true and where[deletedAt][exists]=true',
// )
// })
// describe('delete endpoint', () => {
// it.todo('should perma delete all docs including soft-deleted documents when trash: true')
// it.todo('should only perma delete normal docs when trash: false')
// })
describe('deleteByID query', () => {
it('should throw NotFound error when trying to delete a soft-deleted document w/o trash: true', async () => {
const query = `
mutation {
deletePost(id: ${idToString(postsDocTwo.id, payload)}) {
id
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.errors?.[0]?.message).toMatch(/not found/i)
})
it('should delete a soft-deleted document when trash: true', async () => {
const query = `
mutation {
deletePost(id: ${idToString(postsDocTwo.id, payload)}, trash: true) {
id
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.deletePost.id).toBe(postsDocTwo.id)
})
})
describe('restoreVersion operation', () => {
it('should throw error when restoring a version of a trashed document', async () => {
const updateMutation = `
mutation {
updatePost(id: ${idToString(postsDocTwo.id, payload)}, trash: true, data: {
title: "Soft Deleted Version"
}) {
id
}
}
`
await restClient.GRAPHQL_POST({ body: JSON.stringify({ query: updateMutation }) })
const versionQuery = `
query {
versionsPosts(
trash: true,
where: {
version__deletedAt: { exists: true }
}
) {
docs {
id
parent {
id
}
version {
deletedAt
}
}
}
}
`
const versionRes = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: versionQuery }) })
.then((r) => r.json())
const version = versionRes.data.versionsPosts.docs.find((v: any) => v?.version?.deletedAt)
expect(version?.id).toBeDefined()
expect(version).toBeDefined()
const restoreMutation = `
mutation {
restoreVersionPost(id: ${idToString(version.id, payload)}) {
id
}
}
`
const restoreRes = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query: restoreMutation }) })
.then((r) => r.json())
expect(restoreRes.errors?.[0]?.message).toMatch(
/Cannot restore a version of a trashed document/i,
)
})
})
describe('count query', () => {
it('should return count of non-soft-deleted documents by default (trash=false)', async () => {
const query = `
query {
countPosts {
totalDocs
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.countPosts.totalDocs).toBe(1)
})
it('should return count of all documents including soft-deleted when trash=true', async () => {
const query = `
query {
countPosts(trash: true) {
totalDocs
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.countPosts.totalDocs).toBe(2)
})
it('should return count of only soft-deleted docs with where[deletedAt][exists]=true', async () => {
const query = `
query {
countPosts(trash: true, where: { deletedAt: { exists: true } }) {
totalDocs
}
}
`
const res = await restClient
.GRAPHQL_POST({ body: JSON.stringify({ query }) })
.then((r) => r.json())
expect(res.data.countPosts.totalDocs).toBe(1)
})
})
})
})