import type { Payload } from 'payload' import path from 'path' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' import { devUser } from '../credentials.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { pagesSlug, postsSlug } from './shared.js' let payload: Payload let restClient: NextRESTClient let token: string const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) describe('@payloadcms/plugin-search', () => { beforeAll(async () => { ;({ payload, restClient } = await initPayloadInt(dirname)) const data = await restClient .POST('/users/login', { body: JSON.stringify({ email: devUser.email, password: devUser.password, }), }) .then((res) => res.json()) token = data.token }) beforeEach(async () => { await payload.delete({ collection: 'search', depth: 0, where: { id: { exists: true, }, }, }) await Promise.all([ payload.delete({ collection: postsSlug, depth: 0, where: { id: { exists: true, }, }, }), payload.delete({ collection: pagesSlug, depth: 0, where: { id: { exists: true, }, }, }), ]) }) afterAll(async () => { if (typeof payload.db.destroy === 'function') { await payload.db.destroy() } }) it('should add a search collection', async () => { const search = await payload.find({ collection: 'search', depth: 0, limit: 1, }) expect(search).toBeTruthy() }) it('should sync published pages to the search collection', async () => { const pageToSync = await payload.create({ collection: 'pages', data: { _status: 'published', excerpt: 'This is a test page', title: 'Hello, world!', }, }) const { docs: results } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: pageToSync.id, }, }, }) expect(results).toHaveLength(1) expect(results[0].doc.value).toBe(pageToSync.id) expect(results[0].title).toBe('Hello, world!') expect(results[0].excerpt).toBe('This is a test page') }) it('should not sync drafts pages to the search collection', async () => { const draftPage = await payload.create({ collection: 'pages', data: { _status: 'draft', excerpt: 'This is a test page', title: 'Hello, world!', }, }) // wait for the search document to be potentially created // we do not await this within the `syncToSearch` hook await wait(200) const { docs: results } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: draftPage.id, }, }, }) expect(results).toHaveLength(0) }) it('should not delete a search doc if a published item has a new draft but remains published', async () => { const publishedPage = await payload.create({ collection: 'pages', data: { _status: 'published', title: 'Published title!', }, }) // wait for the search document to be potentially created // we do not await this within the `syncToSearch` hook await wait(200) const { docs: results } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: publishedPage.id, }, }, }) expect(results).toHaveLength(1) // Create a new draft await payload.update({ collection: 'pages', id: publishedPage.id, draft: true, data: { _status: 'draft', title: 'Draft title!', }, }) // This should remain with the published content const { docs: updatedResults } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: publishedPage.id, }, }, }) expect(updatedResults).toHaveLength(1) await payload.update({ collection: 'pages', id: publishedPage.id, data: { _status: 'draft', title: 'Drafted again', }, }) // Should now be deleted given we've unpublished the page const { docs: deletedResults } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: publishedPage.id, }, }, }) expect(deletedResults).toHaveLength(0) }) it('should sync changes made to an existing search document', async () => { const pageToReceiveUpdates = await payload.create({ collection: 'pages', data: { _status: 'published', excerpt: 'This is a test page', title: 'Hello, world!', }, }) const { docs: results } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: pageToReceiveUpdates.id, }, }, }) expect(results).toHaveLength(1) expect(results[0].doc.value).toBe(pageToReceiveUpdates.id) expect(results[0].title).toBe('Hello, world!') expect(results[0].excerpt).toBe('This is a test page') await payload.update({ id: pageToReceiveUpdates.id, collection: 'pages', data: { excerpt: 'This is a test page (updated)', title: 'Hello, world! (updated)', }, }) // wait for the search document to be potentially updated // we do not await this within the `syncToSearch` hook await wait(200) // Do not add `limit` to this query, this way we can test if multiple documents were created const { docs: updatedResults } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: pageToReceiveUpdates.id, }, }, }) expect(updatedResults).toHaveLength(1) expect(updatedResults[0].doc.value).toBe(pageToReceiveUpdates.id) expect(updatedResults[0].title).toBe('Hello, world! (updated)') expect(updatedResults[0].excerpt).toBe('This is a test page (updated)') }) it('should clear the search document when the original document is deleted', async () => { const page = await payload.create({ collection: 'pages', data: { _status: 'published', excerpt: 'This is a test page', title: 'Hello, world!', }, }) // wait for the search document to be created // we do not await this within the `syncToSearch` hook await wait(200) const { docs: results } = await payload.find({ collection: 'search', depth: 0, where: { 'doc.value': { equals: page.id, }, }, }) expect(results).toHaveLength(1) expect(results[0].doc.value).toBe(page.id) await payload.delete({ id: page.id, collection: 'pages', }) // wait for the search document to be potentially deleted // we do not await this within the `syncToSearch` hook await wait(200) const { docs: deletedResults } = await payload.find({ collection: 'search', depth: 0, where: { id: { equals: results[0].id, }, }, }) expect(deletedResults).toHaveLength(0) }) it('should clear the proper search document when having the same doc.value but different doc.relationTo', async () => { const custom_id_1 = await payload.create({ collection: 'custom-ids-1', data: { id: 'custom_id' }, }) await payload.create({ collection: 'custom-ids-2', data: { id: 'custom_id' }, }) await wait(200) const { docs: [docBefore], } = await payload.find({ collection: 'search', where: { 'doc.value': { equals: 'custom_id' } }, limit: 1, sort: 'createdAt', }) expect(docBefore.doc.relationTo).toBe('custom-ids-1') await payload.delete({ collection: 'custom-ids-1', id: custom_id_1.id }) await wait(200) const { docs: [docAfter], } = await payload.find({ collection: 'search', where: { 'doc.value': { equals: 'custom_id' } }, limit: 1, sort: 'createdAt', }) expect(docAfter.doc.relationTo).toBe('custom-ids-2') }) it('should sync localized data', async () => { const createdDoc = await payload.create({ collection: 'posts', data: { _status: 'published', title: 'test title', slug: 'es', }, locale: 'es', }) await payload.update({ collection: 'posts', id: createdDoc.id, data: { _status: 'published', title: 'test title', slug: 'en', }, locale: 'en', }) const syncedSearchData = await payload.find({ collection: 'search', locale: 'es', where: { and: [ { 'doc.value': { equals: createdDoc.id, }, }, ], }, }) expect(syncedSearchData.docs[0].slug).toEqual('es') }) it('should respond with 401 when invalid permissions on user before reindex', async () => { const testCreds = { email: 'test@payloadcms.com', password: 'test', } await payload.create({ collection: 'users', data: testCreds, }) const testUserRes = await restClient.POST(`/users/login`, { body: JSON.stringify(testCreds), }) const testUser = await testUserRes.json() const endpointRes = await restClient.POST(`/search/reindex`, { body: JSON.stringify({ collections: [postsSlug], }), headers: { Authorization: `JWT ${testUser.token}`, }, }) expect(endpointRes.status).toEqual(401) }) it('should respond with 400 when invalid collection args passed to reindex', async () => { const endpointNoArgsRes = await restClient.POST(`/search/reindex`, { body: JSON.stringify({}), headers: { Authorization: `JWT ${token}`, }, }) const endpointEmptyArrRes = await restClient.POST(`/search/reindex`, { body: JSON.stringify({ collections: [], }), headers: { Authorization: `JWT ${token}`, }, }) const endpointInvalidArrRes = await restClient.POST(`/search/reindex`, { body: JSON.stringify({ collections: ['users'], }), headers: { Authorization: `JWT ${token}`, }, }) expect(endpointNoArgsRes.status).toBe(400) expect(endpointEmptyArrRes.status).toBe(400) expect(endpointInvalidArrRes.status).toBe(400) }) it('should delete existing search indexes before reindexing', async () => { await payload.create({ collection: postsSlug, data: { title: 'post_1', _status: 'published', }, }) await wait(200) await payload.create({ collection: postsSlug, data: { title: 'post_2', _status: 'published', }, }) const { docs } = await payload.find({ collection: 'search' }) await wait(200) const endpointRes = await restClient.POST('/search/reindex', { body: JSON.stringify({ collections: [postsSlug, pagesSlug], }), }) expect(endpointRes.status).toBe(200) const { docs: results } = await payload.find({ collection: 'search', depth: 0, where: { id: { in: docs.map((doc) => doc.id), }, }, }) // Should have no docs with these ID // after reindex since it deletes indexes and recreates them expect(results).toHaveLength(0) }) it('should reindex whole collections', async () => { await payload.create({ collection: pagesSlug, data: { title: 'Test page title', _status: 'published', }, }) await payload.create({ collection: postsSlug, data: { title: 'Test page title', _status: 'published', }, }) await wait(200) const { totalDocs: totalBeforeReindex } = await payload.count({ collection: 'search', }) const endpointRes = await restClient.POST(`/search/reindex`, { body: JSON.stringify({ collections: [postsSlug, pagesSlug], }), headers: { Authorization: `JWT ${token}`, }, }) expect(endpointRes.status).toBe(200) const { totalDocs: totalAfterReindex } = await payload.count({ collection: 'search', }) expect(totalAfterReindex).toBe(totalBeforeReindex) }) })