Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions packages/plugin-nested-docs/src/hooks/resaveChildren.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { CollectionAfterChangeHook, JsonObject, SanitizedCollectionConfig } from 'payload'

import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('../utilities/populateBreadcrumbs.js', () => ({
populateBreadcrumbs: vi.fn(),
}))

import type { NestedDocsPluginConfig } from '../types.js'

import { populateBreadcrumbs } from '../utilities/populateBreadcrumbs.js'
import { resaveChildren } from './resaveChildren.js'

const mockPopulateBreadcrumbs = vi.mocked(populateBreadcrumbs)

describe('resaveChildren', () => {
const mockUpdate = vi.fn()
const mockFind = vi.fn()
const mockLogger = { error: vi.fn() }

const collection = {
slug: 'pages',
versions: { drafts: true },
} as unknown as SanitizedCollectionConfig

const pluginConfig: NestedDocsPluginConfig = {
collections: ['pages'],
generateLabel: (_, doc) => doc.title as string,
generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug}`, ''),
}

beforeEach(() => {
vi.clearAllMocks()
})

it('should await populateBreadcrumbs before passing data to payload.update', async () => {
const resolvedData = {
id: 'child-1',
title: 'Child',
slug: 'child',
parent: 'parent-1',
_status: 'published',
breadcrumbs: [
{ doc: 'parent-1', label: 'Parent', url: '/parent' },
{ doc: 'child-1', label: 'Child', url: '/parent/child' },
],
}

mockPopulateBreadcrumbs.mockResolvedValue(resolvedData)

const child: JsonObject = {
id: 'child-1',
title: 'Child',
slug: 'child',
parent: 'parent-1',
_status: 'published',
updatedAt: '2026-01-01',
}

mockFind
.mockResolvedValueOnce({ docs: [] }) // draft children query
.mockResolvedValueOnce({ docs: [child] }) // published children query

const hook = resaveChildren(pluginConfig) as CollectionAfterChangeHook

await hook({
collection,
context: {},
doc: { id: 'parent-1', _status: 'published' },
operation: 'update',
previousDoc: {},
req: {
locale: 'en',
payload: {
find: mockFind,
logger: mockLogger,
update: mockUpdate,
},
} as any,
})

expect(mockUpdate).toHaveBeenCalledTimes(1)

const updateArgs = mockUpdate.mock.calls[0][0]

// The critical assertion: data must be the resolved object, not a Promise
expect(updateArgs.data).toBe(resolvedData)
expect(updateArgs.data).not.toBeInstanceOf(Promise)
expect(updateArgs.data.breadcrumbs).toHaveLength(2)
expect(updateArgs.data.breadcrumbs[0].label).toBe('Parent')
})
})
2 changes: 1 addition & 1 deletion packages/plugin-nested-docs/src/hooks/resaveChildren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const resaveChildren =
await req.payload.update({
id: child.id,
collection: collection.slug,
data: populateBreadcrumbs({
data: await populateBreadcrumbs({
collection,
data: child,
generateLabel: pluginConfig.generateLabel,
Expand Down
90 changes: 90 additions & 0 deletions test/plugin-nested-docs/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,96 @@ describe('@payloadcms/plugin-nested-docs', () => {
})
})

describe('resaveChildren', () => {
it('should keep parent published after resaving children breadcrumbs', async () => {
const parentDoc = await payload.create({
collection: 'pages',
data: {
title: 'publish-parent',
slug: 'publish-parent',
_status: 'published',
},
})

await payload.create({
collection: 'pages',
data: {
title: 'publish-child',
slug: 'publish-child',
parent: parentDoc.id,
_status: 'published',
},
})

// Re-publish the parent with an updated slug — this triggers resaveChildren
const updatedParent = await payload.update({
collection: 'pages',
id: parentDoc.id,
data: {
title: 'publish-parent-updated',
slug: 'publish-parent-updated',
_status: 'published',
},
})

// Re-read the parent to confirm the transaction was not rolled back
const refetchedParent = await payload.findByID({
collection: 'pages',
id: parentDoc.id,
})

expect(updatedParent._status).toStrictEqual('published')
expect(refetchedParent._status).toStrictEqual('published')
expect(refetchedParent.slug).toStrictEqual('publish-parent-updated')
})

it('should update child breadcrumbs when parent is re-published with new slug', async () => {
const parentDoc = await payload.create({
collection: 'pages',
data: {
title: 'breadcrumb-parent',
slug: 'breadcrumb-parent',
_status: 'published',
},
})

const childDoc = await payload.create({
collection: 'pages',
data: {
title: 'breadcrumb-child',
slug: 'breadcrumb-child',
parent: parentDoc.id,
_status: 'published',
},
})

// Update parent to trigger resaveChildren on the child
await payload.update({
collection: 'pages',
id: parentDoc.id,
data: {
title: 'breadcrumb-parent-v2',
slug: 'breadcrumb-parent-v2',
_status: 'published',
},
})

const updatedChild = await payload.findByID({
collection: 'pages',
id: childDoc.id,
})

// Breadcrumbs must be resolved values, not Promise objects or undefined
expect(updatedChild.breadcrumbs).toHaveLength(2)
expect(typeof updatedChild.breadcrumbs![0]!.label).toBe('string')
expect(typeof updatedChild.breadcrumbs![0]!.url).toBe('string')
expect(updatedChild.breadcrumbs![0]!.url).toStrictEqual('/breadcrumb-parent-v2')
expect(updatedChild.breadcrumbs![1]!.url).toStrictEqual(
'/breadcrumb-parent-v2/breadcrumb-child',
)
})
})

describe('overrides', () => {
let collection
beforeAll(() => {
Expand Down
Loading