Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
+6
View File
@@ -0,0 +1,6 @@
# Base URL for supertest when hitting a running server
BASE_URL=http://127.0.0.1:3333
# Admin credentials
ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=1234qqqq
+6
View File
@@ -0,0 +1,6 @@
# Base URL for supertest when hitting a running server
BASE_URL=http://127.0.0.1:3333
# Admin credentials
ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=1234qqqq
+6
View File
@@ -0,0 +1,6 @@
# Base URL for supertest when hitting a running server
BASE_URL=http://127.0.0.1:3333
# Admin credentials
ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=1234qqqq
+76
View File
@@ -0,0 +1,76 @@
// apps/backend/tests/bootstrap.ts
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import type { Config } from '@japa/runner/types'
import app from '@adonisjs/core/services/app'
import testUtils from '@adonisjs/core/services/test_utils'
/**
* Japa plugins
*/
export const plugins: Config['plugins'] = [assert(), apiClient(), pluginAdonisJS(app)]
/**
* Reporters
*/
export const reporters: Config['reporters'] = {
activated: ['spec'],
}
/**
* Global setup/teardown (for ALL suites)
*
* Runs migrations against the test database (`.env.test` → adonis_test) before
* the suites and rolls them back afterwards. DB-backed specs additionally wrap
* each test in a global transaction via `testUtils.db().wrapInGlobalTransaction()`
* in their own group hooks.
*/
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
setup: [() => ensureTestDatabase(), () => testUtils.db().migrate()],
teardown: [],
}
/**
* Creates the test database on first run so a fresh checkout only needs the
* postgres container up. Guarded to `*_test` names so a misconfigured
* `.env.test` can never provision anything else.
*/
async function ensureTestDatabase() {
const { default: env } = await import('#start/env')
const database = env.get('DB_DATABASE')
if (!database.endsWith('_test')) {
throw new Error(
`Refusing to run tests against "${database}": DB_DATABASE in .env.test must end with "_test"`
)
}
const { Client } = await import('pg')
const client = new Client({
host: env.get('DB_HOST'),
port: env.get('DB_PORT'),
user: env.get('DB_USER'),
password: env.get('DB_PASSWORD'),
database: 'postgres',
})
await client.connect()
try {
const exists = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [database])
if (exists.rowCount === 0) {
await client.query(`CREATE DATABASE "${database}"`)
}
} finally {
await client.end()
}
}
/**
* Per-suite configuration (start HTTP server for HTTP tests)
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
// adjust names to your suites if needed
if (['functional', 'e2e', 'browser'].includes(suite.name)) {
suite.setup(() => testUtils.httpServer().start())
}
}
@@ -0,0 +1,41 @@
import { test } from '@japa/runner'
import type { HttpContext } from '@adonisjs/core/http'
import { FileNotFoundException } from '#exceptions/file_manager_exceptions'
import { PageRouteConflictException } from '#exceptions/web_manager_exceptions'
function captureContext() {
const captured: { status?: number; body?: unknown } = {}
const response = {
status(code: number) {
captured.status = code
return response
},
send(body: unknown) {
captured.body = body
},
}
return { captured, ctx: { response } as unknown as HttpContext }
}
test.group('API exceptions', () => {
test('page manager exceptions self-render as message and code', async ({ assert }) => {
const { captured, ctx } = captureContext()
const error = new PageRouteConflictException('en', 'about')
await error.handle(error, ctx)
assert.equal(captured.status, 409)
assert.deepEqual(captured.body, {
message: 'A page already uses /en/about',
code: 'E_PAGE_ROUTE_CONFLICT',
})
})
test('file manager exceptions self-render as message and code', async ({ assert }) => {
const { captured, ctx } = captureContext()
const error = new FileNotFoundException()
await error.handle(error, ctx)
assert.equal(captured.status, 404)
assert.deepEqual(captured.body, { message: 'File not found', code: 'E_FILE_NOT_FOUND' })
})
})
@@ -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)
})
})
@@ -0,0 +1,54 @@
import { test } from '@japa/runner'
import drive from '@adonisjs/drive/services/main'
import { FSStorageService } from '#base_modules/FileManager/services/storage_service/fs_storage_service'
const disk = drive.use('fs')
const storage = new FSStorageService()
async function cleanup() {
for (const dir of ['aa', 'aab', 'cc', 'ghostdir', 'ghostdir2']) {
await disk.deleteAll(dir)
}
}
test.group('FSStorageService directories', (group) => {
group.each.setup(() => cleanup())
group.each.teardown(() => cleanup())
test('finds a directory that has no .keep marker', async ({ assert }) => {
await disk.put('ghostdir/a.txt', 'hello')
assert.isTrue(await storage.entryExists('ghostdir'))
assert.deepInclude(await storage.getEntry('ghostdir'), { type: 'folder', name: 'ghostdir' })
})
test('moves a marker-less directory with its contents', async ({ assert }) => {
await disk.put('ghostdir/nested/a.txt', 'hello')
await storage.moveEntry('ghostdir', 'ghostdir2')
assert.isFalse(await disk.exists('ghostdir/nested/a.txt'))
assert.equal(await disk.get('ghostdir2/nested/a.txt'), 'hello')
assert.deepInclude(await storage.getEntry('ghostdir2'), { type: 'folder' })
})
test('does not touch a sibling directory sharing the same name prefix', async ({ assert }) => {
await disk.put('aa/a.txt', 'from aa')
await disk.put('aab/a.txt', 'from aab')
await storage.moveEntry('aa', 'cc')
assert.equal(await disk.get('cc/a.txt'), 'from aa')
assert.equal(await disk.get('aab/a.txt'), 'from aab')
})
test('deletes a directory without touching a prefix sibling', async ({ assert }) => {
await disk.put('aa/a.txt', 'from aa')
await disk.put('aab/a.txt', 'from aab')
await storage.deleteEntry('aa')
assert.isFalse(await disk.exists('aa/a.txt'))
assert.equal(await disk.get('aab/a.txt'), 'from aab')
})
})
@@ -0,0 +1,100 @@
import { test } from '@japa/runner'
import FormSourceBuilder from '#services/module_generator/builders/form_source_builder'
import {
ModuleFieldOption,
ModuleFieldType,
ModuleLayoutConfig,
} from '#services/module_generator/types'
const layout = (type: ModuleFieldType, options: ModuleFieldOption[]): ModuleLayoutConfig => ({
grid: [
{
id: '1',
row: 0,
col: 0,
rowSpan: 1,
colSpan: 1,
isEmpty: false,
mergeTo: null,
body: {
dbColumn: 'category',
type,
label: 'Category',
placeholder: 'Pick one',
required: false,
options,
},
},
],
rows: 1,
cols: 1,
selectedCells: [],
size: 'dialog-default',
})
test.group('FormSourceBuilder grouped options', () => {
test('emits combobox groups bucketed by group name, ungrouped options in an unlabeled group', ({
assert,
}) => {
const source = FormSourceBuilder.buildComponent(
layout(ModuleFieldType.COMBOBOX, [
{ label: 'Apple', value: 'apple', group: 'Fruits' },
{ label: 'Carrot', value: 'carrot', group: 'Veggies' },
{ label: 'Other', value: 'other' },
{ label: 'Pear', value: 'pear', group: 'Fruits' },
])
)
assert.include(
source,
'groups={[{"label":"Fruits","options":[{"label":"Apple","value":"apple"},{"label":"Pear","value":"pear"}]},{"label":"Veggies","options":[{"label":"Carrot","value":"carrot"}]},{"options":[{"label":"Other","value":"other"}]}]}'
)
assert.include(source, 'InputCombobox')
})
test('emits multiselect heading groups when any option has a group', ({ assert }) => {
const source = FormSourceBuilder.buildComponent(
layout(ModuleFieldType.MULTISELECT, [
{ label: 'Apple', value: 'apple', group: 'Fruits' },
{ label: 'Other', value: 'other' },
])
)
assert.include(
source,
'options={[{"heading":"Fruits","options":[{"label":"Apple","value":"apple"}]},{"heading":"","options":[{"label":"Other","value":"other"}]}]}'
)
})
test('keeps multiselect options flat when no groups are set', ({ assert }) => {
const source = FormSourceBuilder.buildComponent(
layout(ModuleFieldType.MULTISELECT, [
{ label: 'Apple', value: 'apple', group: '' },
{ label: 'Other', value: 'other' },
])
)
assert.include(
source,
'options={[{"label":"Apple","value":"apple"},{"label":"Other","value":"other"}]}'
)
})
test('strips group from flat-only fields', ({ assert }) => {
const source = FormSourceBuilder.buildComponent(
layout(ModuleFieldType.SELECT, [{ label: 'Apple', value: 'apple', group: 'Fruits' }])
)
assert.include(source, 'options={[{"label":"Apple","value":"apple"}]}')
assert.notInclude(source, '"group"')
})
test('imports only the composed components present in the emitted JSX', ({ assert }) => {
const source = FormSourceBuilder.buildComponent(layout(ModuleFieldType.TEXT, []))
assert.include(
source,
"import { FormElement, FormGrid, GridElement, InputText } from '#shared/forms/composed';"
)
})
})
@@ -0,0 +1,76 @@
import { test } from '@japa/runner'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { writeFrontendModule } from '#services/module_generator/writers/frontend_module_writer'
const TEMPLATE_DIR = join(process.cwd(), 'app', 'templates', 'frontend', 'module')
test.group('writeFrontendModule', (group) => {
let outDir: string
group.each.setup(() => {
outDir = mkdtempSync(join(tmpdir(), 'module-writer-'))
return () => rmSync(outDir, { recursive: true, force: true })
})
test('renders hbs files with context and strips the extension', ({ assert }) => {
writeFrontendModule({
templateDir: TEMPLATE_DIR,
outDir,
context: {
name: 'post',
fields: [{ name: 'title', type: 'string' }],
},
overrides: {},
})
assert.isFalse(existsSync(join(outDir, 'config', 'meta.ts.hbs')))
const meta = readFileSync(join(outDir, 'config', 'meta.ts'), 'utf8')
assert.include(meta, "export const MODULE_NAME = 'post'")
const entity = readFileSync(join(outDir, 'entities', 'Entity.ts'), 'utf8')
assert.include(entity, 'title: string')
})
test('copies non-hbs files verbatim, preserving nesting', ({ assert }) => {
writeFrontendModule({
templateDir: TEMPLATE_DIR,
outDir,
context: { name: 'x' },
overrides: {},
})
const template = readFileSync(join(TEMPLATE_DIR, 'view', 'widgets', 'DataTable.tsx'), 'utf8')
const copied = readFileSync(join(outDir, 'view', 'widgets', 'DataTable.tsx'), 'utf8')
assert.equal(copied, template)
})
test('writes overrides on top of the scaffold and keeps defaults for null overrides', ({
assert,
}) => {
writeFrontendModule({
templateDir: TEMPLATE_DIR,
outDir,
context: { name: 'x' },
overrides: {
'view/widgets/DataTable.tsx': '// generated',
'view/forms/MainForm/Component.tsx': null,
},
})
assert.equal(
readFileSync(join(outDir, 'view', 'widgets', 'DataTable.tsx'), 'utf8'),
'// generated'
)
const scaffoldForm = readFileSync(
join(TEMPLATE_DIR, 'view', 'forms', 'MainForm', 'Component.tsx'),
'utf8'
)
assert.equal(
readFileSync(join(outDir, 'view', 'forms', 'MainForm', 'Component.tsx'), 'utf8'),
scaffoldForm
)
})
})
+62
View File
@@ -0,0 +1,62 @@
import type { ApiClient } from '@japa/api-client'
import db from '@adonisjs/lucid/services/db'
import Admins from '#base_modules/Admin/model/admin'
import Role from '#base_modules/Role/model/role'
let sequence = 0
/**
* Creates an active admin holding exactly the given permission slugs (via a
* dedicated role) and returns a Bearer token for the access-tokens guard.
*
* DB-backed: call from specs that run inside a migrated test database (see
* `tests/bootstrap.ts`), typically with a global-transaction group hook.
* Pass an empty array for an admin with no permissions at all.
*/
export async function createAdminWithToken(permissions: string[]) {
sequence += 1
const suffix = `${Date.now()}-${sequence}`
const admin = await Admins.create({
email: `spec-admin-${suffix}@test.local`,
password: 'secret-password',
status: 'active',
})
if (permissions.length > 0) {
const role = await Role.create({ name: `spec-role-${suffix}` })
await db.table('admin_roles').insert({ admin_id: admin.id, role_id: role.id })
await db
.table('role_permissions')
.multiInsert(permissions.map((slug) => ({ role_id: role.id, permission_slug: slug })))
}
const accessToken = await Admins.accessTokens.create(admin)
return { admin, token: accessToken.value!.release() }
}
/**
* Logs in as Admin using the test HTTP client.
* Returns a Bearer token string.
*/
export async function loginAsAdmin(client: ApiClient): Promise<string> {
const email = process.env.ADMIN_EMAIL || 'admin@admin.com'
const password = process.env.ADMIN_PASSWORD || '1234qqqq'
const res = await client.post('/auth/login').json({ email, password })
res.assertStatus(200)
const body = res.body()
// returns: { type, token, user }
const token = body?.token?.release?.() || body?.token || body?.access_token
if (!token) {
console.error('Login response body:', body)
throw new Error('Could not acquire admin token from /auth/login response')
}
return token
}
+50
View File
@@ -0,0 +1,50 @@
import type { PageInput } from '#base_modules/WebManager/types/web_manager'
let sequence = 0
/**
* A minimal valid page input; override per test.
*
* `hero` defaults to a minimal object rather than null: the HTTP validator
* (`hero: jsonObjectSchema`) requires an object, so null only works when
* calling WebManagerService directly.
*/
export function pageInput(overrides: Partial<PageInput> = {}): PageInput {
sequence += 1
return {
locale: 'en',
path: `spec-page-${sequence}`,
groupKey: `spec-page-${sequence}`,
pageType: 'landing',
title: 'Spec page',
hero: { type: 'none', ctaItems: [] },
seoTitle: '',
seoDescription: '',
components: [],
...overrides,
} as PageInput
}
/** A minimal valid `content` component. */
export function contentComponent(text = 'Hello') {
return {
type: 'content',
data: {
// Non-empty on purpose: the JSON bodyparser nulls empty strings, which the
// strict block validator then rejects; only the multipart `payload` JSON
// string transports '' intact. See the "empty strings survive" spec.
title: 'Section',
subtitle: 'Sub',
columns: [{ content: { content: `<p>${text}</p>` }, ctaItems: [] }],
},
}
}
/** A minimal valid `banner` component. */
export function bannerComponent(text = 'Banner') {
return {
type: 'banner',
data: { style: 'info', content: { content: `<p>${text}</p>` }, ctaItems: [] },
}
}