TASKS-1823 WebManager module
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
import { assert } from '@japa/assert'
|
||||
import { apiClient } from '@japa/api-client'
|
||||
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
|
||||
import { specReporter } from '@japa/spec-reporter'
|
||||
import type { Config } from '@japa/runner/types'
|
||||
|
||||
import app from '@adonisjs/core/services/app'
|
||||
@@ -18,22 +17,54 @@ export const plugins: Config['plugins'] = [assert(), apiClient(), pluginAdonisJS
|
||||
*/
|
||||
export const reporters: Config['reporters'] = {
|
||||
activated: ['spec'],
|
||||
list: [
|
||||
{
|
||||
name: 'spec',
|
||||
handler: specReporter(),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [],
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -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="javascript: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="�">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,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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,40 @@
|
||||
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.
|
||||
|
||||
@@ -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: [] },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user