import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import WebManagerService from '#base_modules/WebManager/services/web_manager_service' import WebPage from '#base_modules/WebManager/model/web_page' import WebComponent from '#base_modules/WebManager/model/web_component' import { WEB_MANAGER_CACHE_TAG } from '#base_modules/WebManager/services/web_revalidation_service' import { PageNotPublishedException, PageRouteConflictException, } from '#exceptions/web_manager_exceptions' import { bannerComponent, contentComponent, pageInput } from '#tests/utils/web_manager' /** * Lifecycle specs for the draft → published → unpublished state machine. * * `.env.test` leaves WEB_REVALIDATE_SECRET empty, so revalidation reports * `disabled` but still returns the tags it would have purged — the assertions * below use those tags to tell a full-site purge from a targeted one. */ test.group('WebManager page lifecycle', (group) => { group.each.setup(() => testUtils.db().wrapInGlobalTransaction()) const service = () => new WebManagerService() test('createDraft stores a root draft with components in submitted order', async ({ assert }) => { const { page } = await service().createDraft( pageInput({ path: 'about-us', components: [contentComponent('first'), bannerComponent('second')], }) ) const row = await WebPage.findOrFail(page.id) assert.equal(row.status, 'draft') assert.isNull(row.draftOfId) assert.isNull(row.publishedAt) const components = await WebComponent.query().where('page_id', row.id).orderBy('sort_order') assert.deepEqual( components.map((component) => [component.type, component.sortOrder]), [ ['content', 0], ['banner', 1], ] ) }) test('createDraft normalizes the path before storing it', async ({ assert }) => { const { page } = await service().createDraft(pageInput({ path: '/about//team/' })) assert.equal(page.path, 'about/team') }) test('createDraft rejects a route held by any root row, whatever its status', async ({ assert, }) => { await service().createDraft(pageInput({ path: 'taken', groupKey: 'taken-one' })) await assert.rejects( () => service().createDraft(pageInput({ path: 'taken', groupKey: 'taken-two' })), PageRouteConflictException ) }) test('saveDraft edits a draft in place and replaces components wholesale', async ({ assert }) => { const { page } = await service().createDraft( pageInput({ components: [contentComponent(), bannerComponent()] }) ) const { page: saved } = await service().saveDraft( page.id, pageInput({ path: page.path, groupKey: page.groupKey, title: 'Renamed', components: [bannerComponent('only one now')], }) ) assert.equal(saved.id, page.id) assert.equal(saved.title, 'Renamed') assert.lengthOf(await WebPage.query(), 1, 'no clone was created for an unpublished draft') const components = await WebComponent.query().where('page_id', page.id) assert.lengthOf(components, 1) assert.equal(components[0].type, 'banner') assert.equal(components[0].sortOrder, 0) }) test('saveDraft on a published page creates one clone and reuses it', async ({ assert }) => { const { page } = await service().createDraft(pageInput({ components: [contentComponent()] })) await service().publish(page.id) const input = pageInput({ path: page.path, groupKey: page.groupKey, title: 'Edited copy', components: [bannerComponent()], }) const { page: firstClone } = await service().saveDraft(page.id, input) assert.notEqual(firstClone.id, page.id) assert.equal(firstClone.draftOfId, page.id) assert.equal(firstClone.status, 'draft') const original = await WebPage.findOrFail(page.id) assert.equal(original.status, 'published') assert.equal(original.title, 'Spec page') const { page: secondClone } = await service().saveDraft(page.id, input) assert.equal(secondClone.id, firstClone.id) const clones = await WebPage.query().where('draft_of_id', page.id) assert.lengthOf(clones, 1) }) test('publish flips a root draft live and purges the whole site', async ({ assert }) => { const { page } = await service().createDraft(pageInput()) const result = await service().publish(page.id) assert.equal(result.page.page.status, 'published') assert.isNotNull(result.page.page.publishedAt) assert.equal(result.revalidation.status, 'disabled') assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG]) }) test('publish merges a pending clone; a content-only change purges only page tags', async ({ assert, }) => { const { page } = await service().createDraft(pageInput({ components: [contentComponent()] })) await service().publish(page.id) const { page: clone } = await service().saveDraft( page.id, pageInput({ path: page.path, groupKey: page.groupKey, title: page.title, components: [bannerComponent('new content')], }) ) const result = await service().publish(page.id) assert.notInclude(result.revalidation.tags, WEB_MANAGER_CACHE_TAG) assert.include(result.revalidation.tags, `${WEB_MANAGER_CACHE_TAG}:page:en:${page.path}`) assert.isNull(await WebPage.find(clone.id)) const components = await WebComponent.query().where('page_id', page.id) assert.lengthOf(components, 1) assert.equal(components[0].type, 'banner') }) test('publish treats a changed title as structural and purges the whole site', async ({ assert, }) => { const { page } = await service().createDraft(pageInput()) await service().publish(page.id) await service().saveDraft( page.id, pageInput({ path: page.path, groupKey: page.groupKey, title: 'New title' }) ) const result = await service().publish(page.id) assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG]) assert.equal(result.page.page.title, 'New title') }) test('publishing an already-published page with no clone only re-purges its cache', async ({ assert, }) => { const { page } = await service().createDraft(pageInput()) const first = await service().publish(page.id) const again = await service().publish(page.id) assert.equal(again.page.page.publishedAt, first.page.page.publishedAt) assert.notInclude(again.revalidation.tags, WEB_MANAGER_CACHE_TAG) assert.include(again.revalidation.tags, `${WEB_MANAGER_CACHE_TAG}:page:en:${page.path}`) }) test('publish refuses when another published page holds the address', async ({ assert }) => { // Two root rows on the same route can only exist when one is not published // (assertNewRouteAvailable blocks the service path), so build them directly. await WebPage.create({ status: 'published', draftOfId: null, locale: 'en', path: 'clash', groupKey: 'clash-live', pageType: 'landing', title: 'Live page', hero: null, seoTitle: '', seoDescription: '', }) const contender = await WebPage.create({ status: 'draft', draftOfId: null, locale: 'en', path: 'clash', groupKey: 'clash-draft', pageType: 'landing', title: 'Contender', hero: null, seoTitle: '', seoDescription: '', }) await assert.rejects(() => service().publish(contender.id), PageRouteConflictException) }) test('unpublish takes the page down but keeps content, date, and address', async ({ assert }) => { const { page } = await service().createDraft(pageInput({ components: [contentComponent()] })) const published = await service().publish(page.id) const result = await service().unpublish(page.id) assert.equal(result.page.page.status, 'unpublished') assert.equal(result.page.page.publishedAt, published.page.page.publishedAt) assert.deepEqual(result.revalidation.tags, [WEB_MANAGER_CACHE_TAG]) assert.lengthOf(await WebComponent.query().where('page_id', page.id), 1) // the address stays reserved for the unpublished page await assert.rejects( () => service().createDraft(pageInput({ path: page.path, groupKey: 'squatter' })), PageRouteConflictException ) }) test('unpublish rejects anything that is not currently published', async ({ assert }) => { const { page } = await service().createDraft(pageInput()) await assert.rejects(() => service().unpublish(page.id), PageNotPublishedException) }) test('an unpublished page is edited in place and can be republished', async ({ assert }) => { const { page } = await service().createDraft(pageInput()) await service().publish(page.id) await service().unpublish(page.id) const { page: edited } = await service().saveDraft( page.id, pageInput({ path: page.path, groupKey: page.groupKey, title: 'Back soon' }) ) assert.equal(edited.id, page.id, 'no clone for a page that is not serving') const republished = await service().publish(page.id) assert.equal(republished.page.page.status, 'published') assert.deepEqual(republished.revalidation.tags, [WEB_MANAGER_CACHE_TAG]) }) test('discarding a clone leaves the published original untouched', async ({ assert }) => { const { page } = await service().createDraft(pageInput({ components: [contentComponent()] })) await service().publish(page.id) const { page: clone } = await service().saveDraft( page.id, pageInput({ path: page.path, groupKey: page.groupKey, title: 'Scrapped edit' }) ) await service().discard(clone.id) assert.isNull(await WebPage.find(clone.id)) const original = await WebPage.findOrFail(page.id) assert.equal(original.status, 'published') assert.lengthOf(await WebComponent.query().where('page_id', page.id), 1) }) test('deleting a published page cascades its clone and components', async ({ assert }) => { const { page } = await service().createDraft(pageInput({ components: [contentComponent()] })) await service().publish(page.id) const { page: clone } = await service().saveDraft( page.id, pageInput({ path: page.path, groupKey: page.groupKey, title: 'Open draft' }) ) await service().discard(page.id) assert.isNull(await WebPage.find(page.id)) assert.isNull(await WebPage.find(clone.id)) assert.lengthOf(await WebComponent.query().whereIn('page_id', [page.id, clone.id]), 0) }) test('resolvePublicPage serves published pages only and normalizes the path', async ({ assert, }) => { const { page } = await service().createDraft(pageInput({ title: 'Public page' })) assert.isNull(await service().resolvePublicPage('en', page.path)) await service().publish(page.id) const resolved = await service().resolvePublicPage('en', `/${page.path}/`) assert.isNotNull(resolved) assert.equal(resolved!.page.title, 'Public page') assert.equal(resolved!.page.seo.title, 'Public page', 'seo title falls back to the page title') }) })