TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
@@ -0,0 +1,59 @@
import { test } from '@japa/runner'
import {
ancestorPaths,
ancestorsAlreadyLoaded,
BREADCRUMB_PARENT_GROUP_KEYS,
} from '#base_modules/WebManager/services/breadcrumb_paths'
import { BREADCRUMB_PAGE_TYPE_PARENTS } from '#base_modules/WebManager/config/general'
/**
* These decide which rows the breadcrumb query asks for. Miss a path here and the trail
* silently loses a level, because the walk can only choose from what was loaded.
*/
test.group('Web Manager breadcrumb candidate paths', () => {
test('climbs every shorter prefix, longest first, then home', ({ assert }) => {
assert.deepEqual(ancestorPaths('blog/post/hello'), ['blog/post', 'blog', ''])
})
test('never offers the page itself as its own parent', ({ assert }) => {
assert.notInclude(ancestorPaths('about'), 'about')
assert.notInclude(ancestorPaths('blog/post'), 'blog/post')
})
test('a top-level page and the home page both fall back to home', ({ assert }) => {
assert.deepEqual(ancestorPaths('about'), [''])
assert.deepEqual(ancestorPaths(''), [''])
})
test('tolerates stray slashes in a stored path', ({ assert }) => {
assert.deepEqual(ancestorPaths('/blog/post/'), ancestorPaths('blog/post'))
assert.deepEqual(ancestorPaths('/blog/post/'), ['blog', ''])
})
test('covers the group keys a page type can be re-parented onto', ({ assert }) => {
assert.includeMembers(BREADCRUMB_PARENT_GROUP_KEYS, Object.values(BREADCRUMB_PAGE_TYPE_PARENTS))
assert.equal(
BREADCRUMB_PARENT_GROUP_KEYS.length,
new Set(BREADCRUMB_PARENT_GROUP_KEYS).size,
'duplicate group keys would widen the query for nothing'
)
})
})
test.group('Web Manager breadcrumb candidate reuse', () => {
test('walking up a prefix reuses the rows already loaded', ({ assert }) => {
assert.isTrue(ancestorsAlreadyLoaded('blog/post', ['blog/post/hello']))
assert.isTrue(ancestorsAlreadyLoaded('blog', ['blog/post/hello']))
assert.isTrue(ancestorsAlreadyLoaded('', ['blog/post/hello']))
assert.isTrue(ancestorsAlreadyLoaded('blog/post/hello', ['blog/post/hello']))
})
test('a jump to an unrelated branch needs its own query', ({ assert }) => {
assert.isFalse(ancestorsAlreadyLoaded('company/blog', ['news/hello']))
assert.isFalse(ancestorsAlreadyLoaded('blog', []))
})
test('does not mistake a shared name prefix for an ancestor', ({ assert }) => {
assert.isFalse(ancestorsAlreadyLoaded('blogging', ['blog/post']))
})
})
@@ -0,0 +1,56 @@
import { test } from '@japa/runner'
import {
sanitizePayload,
sanitizeRichText,
sanitizeUrl,
} from '#base_modules/WebManager/services/content_sanitizer'
test.group('Web Manager content sanitizer', () => {
test('rich text keeps allowed markup and drops event handlers', ({ assert }) => {
assert.equal(
sanitizeRichText('<p class="lead" onclick="alert(1)">Hi <em>there</em></p>'),
'<p class="lead">Hi <em>there</em></p>'
)
})
test('rich text drops dangerous subtrees entirely', ({ assert }) => {
assert.equal(
sanitizeRichText('before<script>alert(1)</script><svg><a onload="alert(1)"></a></svg>after'),
'beforeafter'
)
})
test('rich text rejects unsafe link schemes and normalizes rel', ({ assert }) => {
assert.equal(
sanitizeRichText('<a href="javascript:alert(1)" target="_blank" rel="me nofollow">x</a>'),
'<a target="_blank" rel="noopener noreferrer">x</a>'
)
assert.equal(
sanitizeRichText('<a href="https://example.com" target="_top" rel="me nofollow">x</a>'),
'<a href="https://example.com" rel="nofollow">x</a>'
)
})
test('url fields allow relative and https urls, reject script schemes', ({ assert }) => {
assert.equal(sanitizeUrl('/en/about'), '/en/about')
assert.equal(sanitizeUrl('https://example.com/?a=1&b=2'), 'https://example.com/?a=1&b=2')
assert.equal(sanitizeUrl('mailto:hi@example.com'), 'mailto:hi@example.com')
assert.equal(sanitizeUrl('javascript:alert(1)'), '')
assert.equal(sanitizeUrl(' jav\tascript:alert(1)'), '')
})
test('payload pass applies the url policy to known url keys only', ({ assert }) => {
assert.deepEqual(
sanitizePayload({
title: '<b>Hello</b>',
externalUrl: 'javascript:alert(1)',
items: [{ url: 'https://example.com/?a=1&b=2' }],
}),
{
title: '<b>Hello</b>',
externalUrl: '',
items: [{ url: 'https://example.com/?a=1&b=2' }],
}
)
})
})
@@ -0,0 +1,311 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import WebManagerService from '#base_modules/WebManager/services/web_manager_service'
import { createAdminWithToken } from '#tests/utils/auth'
import { contentComponent, pageInput } from '#tests/utils/web_manager'
const ALL_PERMISSIONS = [
'web_manager.view',
'web_manager.create',
'web_manager.update',
'web_manager.publish',
'web_manager.delete',
'web_manager.settings',
]
/** [method, path, permission the action authorizes against] */
const ADMIN_ROUTES = [
['get', '/web-manager/pages', 'web_manager.view'],
['get', '/web-manager/pages/group-keys', 'web_manager.view'],
['post', '/web-manager/pages', 'web_manager.create'],
['get', '/web-manager/pages/1', 'web_manager.view'],
['put', '/web-manager/pages/1/draft', 'web_manager.update'],
['post', '/web-manager/pages/1/publish', 'web_manager.publish'],
['post', '/web-manager/pages/1/unpublish', 'web_manager.publish'],
['post', '/web-manager/pages/1/preview-token', 'web_manager.view'],
['delete', '/web-manager/pages/1', 'web_manager.delete'],
['get', '/web-manager/settings', 'web_manager.view'],
['put', '/web-manager/settings', 'web_manager.settings'],
] as const
test.group('WebManager admin API auth', (group) => {
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('every admin route rejects an unauthenticated request', async ({ client }) => {
for (const [method, url] of ADMIN_ROUTES) {
const response = await client[method](url)
response.assertStatus(401)
}
})
test('every action rejects a token that lacks its permission', async ({ client }) => {
// holds every permission except the one each action checks
for (const [method, url, permission] of ADMIN_ROUTES) {
const { token } = await createAdminWithToken(
ALL_PERMISSIONS.filter((slug) => slug !== permission)
)
const response = await client[method](url).bearerToken(token)
response.assertStatus(403)
}
})
test('a viewer can read but not mutate', async ({ client }) => {
const { token } = await createAdminWithToken(['web_manager.view'])
const list = await client.get('/web-manager/pages').bearerToken(token)
list.assertStatus(200)
const create = await client.post('/web-manager/pages').bearerToken(token).json(pageInput())
create.assertStatus(403)
})
})
test.group('WebManager admin API', (group) => {
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('create → publish → unpublish round trip reports revalidation', async ({
assert,
client,
}) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const created = await client
.post('/web-manager/pages')
.bearerToken(token)
.json(pageInput({ components: [contentComponent()] }))
created.assertStatus(201)
const pageId = created.body().data.page.id
assert.equal(created.body().data.page.status, 'draft')
const published = await client.post(`/web-manager/pages/${pageId}/publish`).bearerToken(token)
published.assertStatus(200)
assert.equal(published.body().data.page.status, 'published')
assert.equal(published.body().meta.revalidation.status, 'disabled')
assert.isNotEmpty(published.body().meta.revalidation.tags)
const unpublished = await client
.post(`/web-manager/pages/${pageId}/unpublish`)
.bearerToken(token)
unpublished.assertStatus(200)
assert.equal(unpublished.body().data.page.status, 'unpublished')
const again = await client.post(`/web-manager/pages/${pageId}/unpublish`).bearerToken(token)
again.assertStatus(409)
assert.equal(again.body().code, 'E_PAGE_NOT_PUBLISHED')
})
test('page list filters by status and search', async ({ assert, client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const service = new WebManagerService()
const { page: draft } = await service.createDraft(pageInput({ title: 'Needle draft' }))
const { page: live } = await service.createDraft(pageInput({ title: 'Other page' }))
await service.publish(live.id)
const drafts = await client
.get('/web-manager/pages')
.qs({ status: 'draft', search: 'needle' })
.bearerToken(token)
drafts.assertStatus(200)
assert.deepEqual(
drafts.body().data.map((row: { id: number }) => row.id),
[draft.id]
)
})
test('validation rejects a malformed path with 422', async ({ client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const response = await client
.post('/web-manager/pages')
.bearerToken(token)
.json(pageInput({ path: 'About/Us' }))
response.assertStatus(422)
})
test('an unknown block type is rejected with 422', async ({ assert, client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const response = await client
.post('/web-manager/pages')
.bearerToken(token)
.json(pageInput({ components: [{ type: 'carousel', data: {} }] }))
response.assertStatus(422)
assert.equal(response.body().code, 'E_INVALID_PAGE_PAYLOAD')
})
test('a multipart payload field is parsed as JSON', async ({ assert, client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const response = await client
.post('/web-manager/pages')
.bearerToken(token)
.field('payload', JSON.stringify(pageInput({ components: [contentComponent()] })))
response.assertStatus(201)
assert.equal(response.body().data.components.length, 1)
})
test('empty strings survive the multipart payload but not a JSON body', async ({
assert,
client,
}) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const input = pageInput({
components: [
{
type: 'content',
data: { title: 'Section', subtitle: '', columns: [] },
},
],
})
// The JSON bodyparser converts '' to null, so the strict block validator
// rejects it — this is why the admin sends the page as one JSON string.
const asJson = await client.post('/web-manager/pages').bearerToken(token).json(input)
asJson.assertStatus(422)
assert.equal(asJson.body().code, 'E_INVALID_PAGE_PAYLOAD')
const asPayload = await client
.post('/web-manager/pages')
.bearerToken(token)
.field('payload', JSON.stringify(input))
asPayload.assertStatus(201)
})
test('a malformed multipart payload is rejected with 422', async ({ assert, client }) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const response = await client
.post('/web-manager/pages')
.bearerToken(token)
.field('payload', 'not-json')
response.assertStatus(422)
assert.equal(response.body().code, 'E_INVALID_PAGE_PAYLOAD')
})
test('a media value referencing a missing upload is rejected with 422', async ({
assert,
client,
}) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const input = pageInput({
hero: { type: 'middle', ctaItems: [], media: { name: 'x.png', fileKey: 'file-0' } },
})
const response = await client
.post('/web-manager/pages')
.bearerToken(token)
.field('payload', JSON.stringify(input))
response.assertStatus(422)
assert.equal(response.body().code, 'E_PAGE_MEDIA_FILE_MISSING')
})
test('settings upsert requires its own permission and reports revalidation', async ({
assert,
client,
}) => {
const settings = {
locale: 'en',
header: { menus: [], ctaItems: [] },
footer: { copyright: '© Spec', menus: [] },
legal: { privacyPath: '/privacy', termsPath: '/terms' },
}
const { token: viewer } = await createAdminWithToken(['web_manager.view'])
const denied = await client.put('/web-manager/settings').bearerToken(viewer).json(settings)
denied.assertStatus(403)
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const saved = await client.put('/web-manager/settings').bearerToken(token).json(settings)
saved.assertStatus(200)
assert.equal(saved.body().meta.revalidation.status, 'disabled')
assert.deepEqual(saved.body().meta.revalidation.tags, ['web-manager:settings:en'])
})
})
test.group('WebManager public API', (group) => {
group.each.setup(() => testUtils.db().wrapInGlobalTransaction())
test('resolve serves published pages only', async ({ assert, client }) => {
const service = new WebManagerService()
const { page } = await service.createDraft(pageInput({ title: 'Public page' }))
const asDraft = await client
.get('/web-manager/public/pages/resolve')
.qs({ locale: 'en', path: page.path })
asDraft.assertStatus(404)
await service.publish(page.id)
const asPublished = await client
.get('/web-manager/public/pages/resolve')
.qs({ locale: 'en', path: page.path })
asPublished.assertStatus(200)
assert.equal(asPublished.body().page.title, 'Public page')
assert.equal(asPublished.body().page.status, 'published')
assert.isArray(asPublished.body().breadcrumbs)
assert.isArray(asPublished.body().languageAlternatives)
})
test('preview serves a draft through a signed token and rejects tampering', async ({
assert,
client,
}) => {
const { token } = await createAdminWithToken(ALL_PERMISSIONS)
const service = new WebManagerService()
const { page } = await service.createDraft(pageInput({ title: 'Draft preview' }))
const minted = await client
.post(`/web-manager/pages/${page.id}/preview-token`)
.bearerToken(token)
.json({})
minted.assertStatus(201)
const previewToken: string = minted.body().data.token
const preview = await client
.get('/web-manager/public/pages/preview')
.qs({ token: previewToken })
preview.assertStatus(200)
assert.equal(preview.body().page.title, 'Draft preview')
assert.equal(preview.body().page.status, 'draft')
const tampered = await client
.get('/web-manager/public/pages/preview')
.qs({ token: `${previewToken}x` })
tampered.assertStatus(404)
})
test('sitemap lists published root pages with pagination', async ({ assert, client }) => {
const service = new WebManagerService()
const { page } = await service.createDraft(pageInput())
await service.publish(page.id)
await service.createDraft(pageInput()) // stays draft, must not appear
const response = await client.get('/web-manager/public/pages/sitemap')
response.assertStatus(200)
const items = response.body().items as Array<{ id: number }>
assert.deepEqual(
items.map((item) => item.id),
[page.id]
)
assert.properties(response.body().pagination, ['page', 'perPage', 'total', 'lastPage'])
})
test('blog listing returns published blog items, newest first', async ({ assert, client }) => {
const service = new WebManagerService()
const { page: older } = await service.createDraft(
pageInput({ pageType: 'blog-item', title: 'Older post' })
)
await service.publish(older.id)
const { page: newer } = await service.createDraft(
pageInput({ pageType: 'blog-item', title: 'Newer post' })
)
await service.publish(newer.id)
const { page: landing } = await service.createDraft(pageInput({ title: 'Not a post' }))
await service.publish(landing.id)
const response = await client.get('/web-manager/public/blog/pages').qs({ locale: 'en' })
response.assertStatus(200)
const titles = (response.body().items as Array<{ title: string }>).map((item) => item.title)
assert.deepEqual(titles, ['Newer post', 'Older post'])
})
})
@@ -0,0 +1,19 @@
import { test } from '@japa/runner'
import PreviewTokenService from '#base_modules/WebManager/services/preview_token_service'
test.group('Web Manager preview tokens', () => {
test('creates a verifiable token and rejects tampering', ({ assert }) => {
const service = new PreviewTokenService()
const { token } = service.create(42, 900)
assert.equal(service.verify(token)?.pageId, 42)
assert.isNull(service.verify(`${token}tampered`))
})
test('rejects expired tokens', ({ assert }) => {
const service = new PreviewTokenService()
const { token } = service.create(42, -1)
assert.isNull(service.verify(token))
})
})
@@ -0,0 +1,88 @@
import { test } from '@japa/runner'
import PublicContentService from '#base_modules/WebManager/services/public_content_service'
test.group('Web Manager public content', () => {
test('keeps safe rich text and removes executable markup', async ({ assert }) => {
const service = new PublicContentService()
const content = await service.prepare(
{
content:
'<p class="lead" onclick="alert(1)">Hello <strong>world</strong></p><script>alert(1)</script><svg><a onload="alert(1)"></a></svg>',
},
new Map()
)
assert.deepEqual(content, {
content: '<p class="lead">Hello <strong>world</strong></p>',
})
})
test('rejects encoded unsafe links and resolves internal page links', async ({ assert }) => {
const service = new PublicContentService()
const content = await service.prepare(
{
html: '<a href="jav&#x61;script:alert(1)" target="_blank">Unsafe</a>',
ctaItems: [{ linkType: 'internal', pageId: 42, label: 'About' }],
externalUrl: 'javascript:alert(1)',
},
new Map([['42', '/en/about']])
)
assert.deepEqual(content, {
html: '<a target="_blank" rel="noopener noreferrer">Unsafe</a>',
ctaItems: [{ linkType: 'internal', pageId: 42, label: 'About', href: '/en/about' }],
externalUrl: '',
})
})
test('survives out-of-range numeric character references', async ({ assert }) => {
const service = new PublicContentService()
const content = await service.prepare({ html: '<a href="&#4294967296;">Link</a>' }, new Map())
assert.deepEqual(content, { html: '<a href="�">Link</a>' })
})
})
/**
* The link map is fetched by id, so anything this collector misses renders as an empty href
* on the public site.
*/
test.group('Web Manager internal link collection', () => {
test('finds internal page ids at any depth and normalizes them to strings', ({ assert }) => {
const service = new PublicContentService()
const ids = service.collectInternalPageIds({
menus: [
{
items: [
{ linkType: 'internal', pageId: 13 },
{ linkType: 'external', url: '/x' },
],
},
{ items: [{ linkType: 'internal', pageId: '16' }] },
],
nested: { deep: { cta: { linkType: 'internal', pageId: 7 } } },
})
assert.deepEqual([...ids].sort(), ['13', '16', '7'])
})
test('ignores links that are not internal and empty content', ({ assert }) => {
const service = new PublicContentService()
assert.deepEqual([...service.collectInternalPageIds({ linkType: 'external', pageId: 5 })], [])
assert.deepEqual([...service.collectInternalPageIds(null)], [])
assert.deepEqual([...service.collectInternalPageIds({ title: 'No links here' })], [])
})
test('accumulates across several content sections into one set', ({ assert }) => {
const service = new PublicContentService()
const ids = new Set<string>()
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 3 } }, ids)
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 3 } }, ids)
service.collectInternalPageIds({ cta: { linkType: 'internal', pageId: 4 } }, ids)
assert.deepEqual([...ids].sort(), ['3', '4'])
})
})
@@ -0,0 +1,297 @@
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')
})
})
@@ -0,0 +1,170 @@
import { test } from '@japa/runner'
import WebRevalidationService, {
blogCacheTag,
flushPendingRevalidations,
pageCacheTag,
REVALIDATION_MAX_ATTEMPTS,
settingsCacheTag,
WEB_MANAGER_CACHE_TAG,
type RevalidationResult,
} from '#base_modules/WebManager/services/web_revalidation_service'
/** Captures the tags instead of posting them to the web app. */
class RecordingRevalidationService extends WebRevalidationService {
readonly purged: string[][] = []
override async revalidate(tags: string[]): Promise<RevalidationResult> {
this.purged.push(tags)
return { status: 'purged', tags }
}
get lastPurge() {
return this.purged.at(-1) ?? []
}
}
/**
* Exercises the delivery path itself: counts attempts and fails the first `failures` of
* them, with the backoff collapsed so the retries do not slow the suite down.
*/
class FlakyRevalidationService extends WebRevalidationService {
attempts = 0
constructor(private failures: number) {
super()
}
protected override resolveTarget() {
return { webAppUrl: 'http://web.test', secret: 'secret' }
}
protected override async attempt() {
this.attempts++
return this.attempts <= this.failures ? 'connection refused' : null
}
protected override backoffFor() {
return 0
}
}
test.group('Web Manager revalidation tags', () => {
/**
* These literals are duplicated in `apps/web/web-runtime/api/cache-tags.ts`; a change
* here without a matching change there silently stops purging cached content.
*/
test('builds the tag names the web app labels its fetches with', ({ assert }) => {
assert.equal(pageCacheTag('en', 'pricing'), 'web-manager:page:en:pricing')
assert.equal(settingsCacheTag('uk'), 'web-manager:settings:uk')
assert.equal(blogCacheTag('de'), 'web-manager:blog:de')
})
test('gives the home page a tag that cannot collide with a real path', ({ assert }) => {
assert.equal(pageCacheTag('en', ''), 'web-manager:page:en:/')
assert.notEqual(pageCacheTag('en', ''), pageCacheTag('en', 'index'))
})
test('purges the page and its sibling translations', async ({ assert }) => {
const service = new RecordingRevalidationService()
await service.revalidatePage({
locale: 'en',
path: 'pricing',
pageType: 'landing',
translations: [
{ locale: 'uk', path: 'tsiny' },
{ locale: 'de', path: 'preise' },
],
})
assert.deepEqual(service.lastPurge, [
'web-manager:page:en:pricing',
'web-manager:page:uk:tsiny',
'web-manager:page:de:preise',
])
assert.notInclude(service.lastPurge, WEB_MANAGER_CACHE_TAG)
})
test('purges the blog listing only when a post changed', async ({ assert }) => {
const service = new RecordingRevalidationService()
const target = { locale: 'en', path: 'blog/hello', translations: [] }
await service.revalidatePage({ ...target, pageType: 'blog-item' })
assert.include(service.lastPurge, 'web-manager:blog:en')
await service.revalidatePage({ ...target, pageType: 'landing' })
assert.notInclude(service.lastPurge, 'web-manager:blog:en')
})
test('deduplicates when a translation repeats the page itself', async ({ assert }) => {
const service = new RecordingRevalidationService()
await service.revalidatePage({
locale: 'en',
path: 'pricing',
pageType: 'landing',
translations: [{ locale: 'en', path: 'pricing' }],
})
assert.deepEqual(service.lastPurge, ['web-manager:page:en:pricing'])
})
test('settings purge is scoped to its locale', async ({ assert }) => {
const service = new RecordingRevalidationService()
await service.revalidateSettings('uk')
assert.deepEqual(service.lastPurge, ['web-manager:settings:uk'])
})
test('structural changes still purge everything', async ({ assert }) => {
const service = new RecordingRevalidationService()
await service.revalidatePublicContent()
assert.deepEqual(service.lastPurge, [WEB_MANAGER_CACHE_TAG])
})
})
test.group('Web Manager revalidation delivery', (group) => {
group.each.teardown(() => flushPendingRevalidations())
test('reports a purge the web app confirmed', async ({ assert }) => {
const service = new FlakyRevalidationService(0)
const result = await service.revalidate([WEB_MANAGER_CACHE_TAG])
assert.equal(result.status, 'purged')
assert.equal(service.attempts, 1)
})
test('does not keep the caller waiting when the first attempt fails', async ({ assert }) => {
const service = new FlakyRevalidationService(1)
const result = await service.revalidate([WEB_MANAGER_CACHE_TAG])
assert.equal(result.status, 'retrying')
assert.equal(service.attempts, 1, 'retries must not run on the request path')
await flushPendingRevalidations()
assert.equal(service.attempts, 2, 'the background retry recovers the purge')
})
test('gives up after the attempt budget', async ({ assert }) => {
const service = new FlakyRevalidationService(Number.POSITIVE_INFINITY)
await service.revalidate([WEB_MANAGER_CACHE_TAG])
await flushPendingRevalidations()
assert.equal(service.attempts, REVALIDATION_MAX_ATTEMPTS)
})
test('the last retry still counts', async ({ assert }) => {
const service = new FlakyRevalidationService(REVALIDATION_MAX_ATTEMPTS - 1)
await service.revalidate([WEB_MANAGER_CACHE_TAG])
await flushPendingRevalidations()
assert.equal(service.attempts, REVALIDATION_MAX_ATTEMPTS)
})
})