test(16-05): Journal layer util tests

- node --test over routes, client (fake fetcher, include_empty=1),
  pagination, htmlGate (Go FormatHTML parity cases), sitemap, seo and
  pl/en i18n parity
- package script test and a Testing line in the README
This commit is contained in:
2026-10-09 20:03:46 +02:00
parent 783426c5d8
commit a4dca42d77
9 changed files with 658 additions and 0 deletions

View File

@@ -143,6 +143,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
## Testing
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML gate (the Journal plugin's FormatHTML cases), sitemap entries, SEO values, and pl/en key and placeholder parity. They need no build, network or database.
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.

View File

@@ -7,6 +7,7 @@
"scripts": {
"dev": "nuxi dev .playground",
"build:playground": "nuxt build .playground",
"test": "node --test --test-reporter=tap tests/*.test.ts",
"dev:prepare": "nuxt prepare .playground",
"check:git-install": "bash scripts/check-git-install.sh"
},

185
tests/client.test.ts Normal file
View File

@@ -0,0 +1,185 @@
// The Journal client against a fake fetcher: query building, include_empty,
// category lookup and 404 handling. No network.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
JOURNAL_DEFAULT_PER_PAGE,
JOURNAL_MAX_PER_PAGE,
buildPostListQuery,
clampPerPage,
createJournalClient,
encodeSlugPath,
flattenCategories,
isNotFound,
} from '../app/utils/client.ts'
import type { JournalCategory } from '../app/utils/types.ts'
type Call = { path: string; query?: Record<string, string | number> }
// A fetcher that records each call and answers from `routes` by path; a
// function value may throw to simulate an HTTP error.
function fakeFetcher(routes: Record<string, unknown | (() => unknown)>) {
const calls: Call[] = []
const fetcher = async (path: string, query?: Record<string, string | number>) => {
calls.push(query === undefined ? { path } : { path, query })
if (!(path in routes)) throw Object.assign(new Error(`no route ${path}`), { status: 404 })
const value = routes[path]
return typeof value === 'function' ? (value as () => unknown)() : value
}
return { fetcher, calls }
}
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
const row: JournalCategory = {
id,
name: slug,
slug,
description: null,
content: null,
parent_id: null,
nest_depth: 0,
post_count: 0,
}
if (children) row.children = children
return row
}
const TREE = [category(1, 'grzyby', [category(2, 'cordyceps', [category(3, 'militaris')])]), category(4, 'chaga')]
test('the per_page limits match the API', () => {
assert.equal(JOURNAL_MAX_PER_PAGE, 30)
assert.equal(JOURNAL_DEFAULT_PER_PAGE, 9)
})
test('clampPerPage clamps to 1..30 and defaults to 9', () => {
assert.equal(clampPerPage(0), 1)
assert.equal(clampPerPage(-5), 1)
assert.equal(clampPerPage(31), 30)
assert.equal(clampPerPage(12), 12)
assert.equal(clampPerPage(12.7), 12)
assert.equal(clampPerPage('12'), 12)
assert.equal(clampPerPage('abc'), 9)
assert.equal(clampPerPage(''), 9)
assert.equal(clampPerPage(undefined), 9)
assert.equal(clampPerPage(Number.NaN), 9)
})
test('buildPostListQuery always sends per_page and the rest only when set', () => {
assert.deepEqual(buildPostListQuery(), { per_page: 9 })
assert.deepEqual(
buildPostListQuery({ page: 2, perPage: 12, sort: 'published_at desc', category: 'cordyceps', tag: 'zdrowie' }),
{ per_page: 12, page: 2, sort: 'published_at desc', category: 'cordyceps', tag: 'zdrowie' },
)
})
test('buildPostListQuery drops an invalid page and empty strings', () => {
assert.deepEqual(buildPostListQuery({ page: 0, sort: '', category: '', tag: '' }), { per_page: 9 })
assert.deepEqual(buildPostListQuery({ page: 1.5 }), { per_page: 9 })
assert.deepEqual(buildPostListQuery({ perPage: 100 }), { per_page: 30 })
})
test('encodeSlugPath keeps / as a separator', () => {
assert.equal(encodeSlugPath('a b/c?d'), 'a%20b/c%3Fd')
})
test('flattenCategories walks parents before children', () => {
assert.deepEqual(flattenCategories(TREE).map((c) => c.slug), ['grzyby', 'cordyceps', 'militaris', 'chaga'])
assert.deepEqual(flattenCategories(null), [])
})
test('isNotFound reads status, statusCode and response.status', () => {
assert.equal(isNotFound({ status: 404 }), true)
assert.equal(isNotFound({ statusCode: 404 }), true)
assert.equal(isNotFound({ response: { status: 404 } }), true)
assert.equal(isNotFound({ status: 500 }), false)
assert.equal(isNotFound(new Error('boom')), false)
assert.equal(isNotFound(null), false)
assert.equal(isNotFound('404'), false)
})
test('listCategories always sends include_empty=1', async () => {
const { fetcher, calls } = fakeFetcher({ '/categories': { data: TREE } })
const client = createJournalClient(fetcher)
assert.deepEqual(await client.listCategories(), TREE)
await client.findCategory('chaga')
assert.equal(calls.length, 2)
for (const call of calls) {
assert.equal(call.path, '/categories')
assert.deepEqual(call.query, { include_empty: 1 })
}
})
test('listCategories gives [] for a malformed body', async () => {
const { fetcher } = fakeFetcher({ '/categories': { data: 'nope' } })
assert.deepEqual(await createJournalClient(fetcher).listCategories(), [])
})
test('findCategory finds a nested child and null for an unknown slug', async () => {
const { fetcher } = fakeFetcher({ '/categories': { data: TREE } })
const client = createJournalClient(fetcher)
assert.equal((await client.findCategory('militaris'))?.id, 3)
assert.equal(await client.findCategory('reishi'), null)
})
test('listPosts sends the built query', async () => {
const page = { data: [], meta: { current_page: 2, last_page: 2, per_page: 12, total: 13 } }
const { fetcher, calls } = fakeFetcher({ '/posts': page })
const res = await createJournalClient(fetcher).listPosts({ page: 2, perPage: 12, category: 'cordyceps' })
assert.deepEqual(res, page)
assert.deepEqual(calls, [{ path: '/posts', query: { per_page: 12, page: 2, category: 'cordyceps' } }])
})
test('getPost returns the data and encodes the slug', async () => {
const { fetcher, calls } = fakeFetcher({ '/posts/a%20b': { data: { id: 1, slug: 'a b' } } })
const post = await createJournalClient(fetcher).getPost('a b')
assert.equal(post?.id, 1)
assert.equal(calls[0]?.path, '/posts/a%20b')
})
test('getPost returns null for status 404 and statusCode 404', async () => {
const byStatus = fakeFetcher({
'/posts/gone': () => {
throw Object.assign(new Error('Not Found'), { status: 404 })
},
})
assert.equal(await createJournalClient(byStatus.fetcher).getPost('gone'), null)
const byStatusCode = fakeFetcher({
'/posts/gone': () => {
throw Object.assign(new Error('Not Found'), { statusCode: 404 })
},
})
assert.equal(await createJournalClient(byStatusCode.fetcher).getPost('gone'), null)
})
test('getPost rethrows a 500', async () => {
const { fetcher } = fakeFetcher({
'/posts/boom': () => {
throw Object.assign(new Error('Server Error'), { status: 500 })
},
})
await assert.rejects(createJournalClient(fetcher).getPost('boom'), /Server Error/)
})
test('listTags returns the data or []', async () => {
const tags = [{ id: 1, name: 'zdrowie', slug: 'zdrowie' }]
assert.deepEqual(await createJournalClient(fakeFetcher({ '/tags': { data: tags } }).fetcher).listTags(), tags)
assert.deepEqual(await createJournalClient(fakeFetcher({ '/tags': {} }).fetcher).listTags(), [])
})
test('getAuthor and listAuthorPosts encode the author and map 404 to null', async () => {
const author = { id: 7, slug: 'jan kowalski', first_name: 'Jan', last_name: 'Kowalski' }
const page = { data: [], meta: { current_page: 1, last_page: 1, per_page: 10, total: 0 } }
const { fetcher, calls } = fakeFetcher({
'/authors/jan%20kowalski': { data: author },
'/authors/jan%20kowalski/posts': page,
})
const client = createJournalClient(fetcher)
assert.deepEqual(await client.getAuthor('jan kowalski'), author)
assert.deepEqual(await client.listAuthorPosts('jan kowalski', { page: 2, perPage: 10, sort: 'published_at desc' }), page)
assert.deepEqual(calls[1], {
path: '/authors/jan%20kowalski/posts',
query: { per_page: 10, page: 2, sort: 'published_at desc' },
})
assert.equal(await client.getAuthor('nobody'), null)
assert.equal(await client.listAuthorPosts('nobody'), null)
})

65
tests/htmlGate.test.ts Normal file
View File

@@ -0,0 +1,65 @@
// The HTML gate mirrors the Journal plugin's FormatHTML / CheckHTML gate.
// The unsafe cases are the six of TestFormatHTMLRejectsUnsafeHTML in
// sm-journal-plugin classes/format_html_test.go, as rendered HTML.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { DANGEROUS_URL, EVENT_HANDLER, UNSAFE_TAG, gateJournalHtml, isSafeJournalHtml } from '../app/utils/htmlGate.ts'
const GO_CASES: Array<[string, string]> = [
['script', '<script>alert(1)</script>'],
['iframe', '<iframe src="https://evil.test"></iframe>'],
['event', '<img src=x onerror="alert(1)">'],
['javascript', '<a href="javascript:alert(1)">x</a>'],
['vbscript', '<a href="vbscript:msgbox(1)">x</a>'],
['data', '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>'],
]
test('the patterns are the Go regex sources, case-insensitive', () => {
assert.equal(UNSAFE_TAG.source, '<(?:script|iframe|object|embed)\\b')
assert.equal(EVENT_HANDLER.source, '\\son[a-z]+\\s*=')
assert.equal(DANGEROUS_URL.source, '(?:javascript|vbscript|data):')
for (const re of [UNSAFE_TAG, EVENT_HANDLER, DANGEROUS_URL]) assert.ok(re.flags.includes('i'))
})
for (const [name, html] of GO_CASES) {
test(`Go case ${name} is unsafe`, () => {
assert.equal(isSafeJournalHtml(html), false)
assert.deepEqual(gateJournalHtml(html), { safe: false, html: '' })
})
test(`Go case ${name} in upper case is unsafe`, () => {
const upper = html.toUpperCase()
assert.equal(isSafeJournalHtml(upper), false)
assert.deepEqual(gateJournalHtml(upper), { safe: false, html: '' })
})
}
test('object and embed tags and spaced handlers are unsafe', () => {
assert.equal(isSafeJournalHtml('<object data="x"></object>'), false)
assert.equal(isSafeJournalHtml('<embed src="x">'), false)
assert.equal(isSafeJournalHtml('<div\nonclick = "x">'), false)
})
test('headings, tables, footnotes and links are safe and pass unchanged', () => {
const safe = [
'<h1>Hello</h1>',
'<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>',
'<p>See footnote.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref">1</a></sup></p>',
'<p><a href="https://grzybyfunkcjonalne.pl/blog">Artykuły</a> <img src="/storage/app/media/las.webp" alt="las"></p>',
]
for (const html of safe) {
assert.equal(isSafeJournalHtml(html), true, html)
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
}
})
test('words starting with "on" are not handlers without an =', () => {
assert.equal(isSafeJournalHtml('<p class="content">one of the onions</p>'), true)
})
test('empty input is safe and empty', () => {
for (const html of [null, undefined, '']) {
assert.equal(isSafeJournalHtml(html), true)
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '' })
}
})

56
tests/i18n.test.ts Normal file
View File

@@ -0,0 +1,56 @@
// pl and en strings have the same keys and the same {name} placeholders,
// so a string added to one locale fails until the other has it too.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'i18n', 'locales')
type Messages = { [key: string]: string | Messages }
function load(code: string): Messages {
return JSON.parse(readFileSync(join(LOCALES, `${code}.json`), 'utf8')) as Messages
}
// Every leaf string of the message tree, keyed by its dotted path.
function leaves(tree: Messages, prefix = ''): Array<[string, string]> {
const out: Array<[string, string]> = []
for (const [key, value] of Object.entries(tree)) {
const path = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') out.push([path, value])
else out.push(...leaves(value, path))
}
return out
}
function placeholders(value: string): string[] {
return [...value.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]!).sort()
}
const pl = new Map(leaves(load('pl')))
const en = new Map(leaves(load('en')))
test('every string lives under the journal namespace', () => {
for (const key of [...pl.keys(), ...en.keys()]) assert.ok(key.startsWith('journal.'), key)
})
test('pl and en have identical leaf key sets', () => {
assert.deepEqual([...pl.keys()].sort(), [...en.keys()].sort())
assert.ok(pl.size > 0)
})
test('no leaf is empty', () => {
for (const [key, value] of [...pl, ...en]) assert.ok(value.trim() !== '', key)
})
test('pl and en use the same placeholders per key', () => {
for (const [key, value] of pl) {
assert.deepEqual(placeholders(value), placeholders(en.get(key) ?? ''), key)
}
})
test('the pagination title carries {page}', () => {
assert.deepEqual(placeholders(pl.get('journal.pagination.pageTitle') ?? ''), ['page'])
})

47
tests/pagination.test.ts Normal file
View File

@@ -0,0 +1,47 @@
// ?page=N parsing, out-of-range detection and the page window.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { pageOutOfRange, paginationWindow, parsePageParam } from '../app/utils/pagination.ts'
test('a missing page is page 1', () => {
assert.equal(parsePageParam(undefined), 1)
assert.equal(parsePageParam(null), 1)
})
test('a string of digits above 0 is that page', () => {
assert.equal(parsePageParam('1'), 1)
assert.equal(parsePageParam('2'), 2)
assert.equal(parsePageParam('017'), 17)
})
test('anything else is a 404 (null)', () => {
for (const raw of ['0', '-1', '1.5', 'abc', '', ' 2', '2 ', '1e3', '0x2', '99999999999999999999']) {
assert.equal(parsePageParam(raw), null, JSON.stringify(raw))
}
assert.equal(parsePageParam(['2']), null)
assert.equal(parsePageParam(2), null)
})
test('pageOutOfRange: past the last page only, page 1 never', () => {
assert.equal(pageOutOfRange(3, 2), true)
assert.equal(pageOutOfRange(2, 2), false)
assert.equal(pageOutOfRange(1, 0), false)
assert.equal(pageOutOfRange(1, 1), false)
assert.equal(pageOutOfRange(2, 0), true)
})
test('paginationWindow centres on the current page', () => {
assert.deepEqual(paginationWindow(5, 10), [3, 4, 5, 6, 7])
})
test('paginationWindow stays full width near either end', () => {
assert.deepEqual(paginationWindow(1, 10), [1, 2, 3, 4, 5])
assert.deepEqual(paginationWindow(10, 10), [6, 7, 8, 9, 10])
assert.deepEqual(paginationWindow(9, 10, 3), [8, 9, 10])
})
test('paginationWindow clips to the page count and handles empty input', () => {
assert.deepEqual(paginationWindow(1, 2), [1, 2])
assert.deepEqual(paginationWindow(1, 0), [])
assert.deepEqual(paginationWindow(1, 5, 0), [])
})

111
tests/routes.test.ts Normal file
View File

@@ -0,0 +1,111 @@
// journal.routes: defaults, overrides, disabling, validation and path filling.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_JOURNAL_ROUTES,
JOURNAL_ROUTE_NAMES,
fillJournalPath,
normalizeJournalRoutes,
} from '../app/utils/routes.ts'
test('no input gives the defaults', () => {
assert.deepEqual(normalizeJournalRoutes(), { ...DEFAULT_JOURNAL_ROUTES })
assert.deepEqual(normalizeJournalRoutes(null), { ...DEFAULT_JOURNAL_ROUTES })
assert.deepEqual(normalizeJournalRoutes({}), { ...DEFAULT_JOURNAL_ROUTES })
})
test('the defaults round-trip through normalization', () => {
const once = normalizeJournalRoutes({ ...DEFAULT_JOURNAL_ROUTES })
assert.deepEqual(normalizeJournalRoutes(once), once)
})
test('the result is a copy, never the frozen defaults', () => {
const routes = normalizeJournalRoutes()
assert.notEqual(routes, DEFAULT_JOURNAL_ROUTES)
assert.ok(Object.isFrozen(DEFAULT_JOURNAL_ROUTES))
})
test('an override replaces one key and keeps the others', () => {
const routes = normalizeJournalRoutes({ category: '/:category' })
assert.equal(routes.category, '/:category')
assert.equal(routes.index, DEFAULT_JOURNAL_ROUTES.index)
assert.equal(routes.post, DEFAULT_JOURNAL_ROUTES.post)
assert.equal(routes.tag, DEFAULT_JOURNAL_ROUTES.tag)
assert.equal(routes.author, DEFAULT_JOURNAL_ROUTES.author)
})
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
assert.deepEqual(normalizeJournalRoutes(input), input)
})
test('false disables a route; null and undefined keep the default', () => {
const routes = normalizeJournalRoutes({ tag: false, author: null, post: undefined })
assert.equal(routes.tag, false)
assert.equal(routes.author, DEFAULT_JOURNAL_ROUTES.author)
assert.equal(routes.post, DEFAULT_JOURNAL_ROUTES.post)
})
test('unknown keys are ignored', () => {
assert.deepEqual(normalizeJournalRoutes({ archive: '/archive' }), { ...DEFAULT_JOURNAL_ROUTES })
})
test('a post path without :slug throws naming journal.routes.post', () => {
assert.throws(() => normalizeJournalRoutes({ post: '/blog/:id' }), /journal\.routes\.post/)
})
test('a param prefix is not the param (:slugs is not :slug)', () => {
assert.throws(() => normalizeJournalRoutes({ post: '/blog/:slugs' }), /journal\.routes\.post/)
assert.equal(normalizeJournalRoutes({ post: '/blog/:slug(.*)' }).post, '/blog/:slug(.*)')
})
test('a category path without :category throws naming journal.routes.category', () => {
assert.throws(() => normalizeJournalRoutes({ category: '/kategoria' }), /journal\.routes\.category/)
})
test('tag and author paths need their params', () => {
assert.throws(() => normalizeJournalRoutes({ tag: '/tags/:slug' }), /journal\.routes\.tag/)
assert.throws(() => normalizeJournalRoutes({ author: '/autor' }), /journal\.routes\.author/)
})
test('a path without a leading / throws naming the key', () => {
assert.throws(() => normalizeJournalRoutes({ index: 'blog' }), /journal\.routes\.index/)
assert.throws(() => normalizeJournalRoutes({ post: 'blog/:slug' }), /journal\.routes\.post/)
})
test('true and non-string values throw', () => {
assert.throws(() => normalizeJournalRoutes({ index: true }), /journal\.routes\.index/)
assert.throws(() => normalizeJournalRoutes({ index: 42 }), /journal\.routes\.index/)
})
test('route names are stable', () => {
assert.deepEqual({ ...JOURNAL_ROUTE_NAMES }, {
index: 'journal-index',
post: 'journal-post',
category: 'journal-category',
tag: 'journal-tag',
author: 'journal-author',
})
})
test('fillJournalPath encodes a value', () => {
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
assert.equal(fillJournalPath('/blog/:slug', { slug: 'zażółć' }), '/blog/za%C5%BC%C3%B3%C5%82%C4%87')
})
test('fillJournalPath encodes a slug with / per segment', () => {
assert.equal(fillJournalPath('/blog/:slug', { slug: '2024/a b?' }), '/blog/2024/a%20b%3F')
})
test('fillJournalPath fills several params and numbers', () => {
assert.equal(fillJournalPath('/:category/:slug', { category: 'cordyceps', slug: 7 }), '/cordyceps/7')
})
test('fillJournalPath drops a custom matcher and a modifier with the param', () => {
assert.equal(fillJournalPath('/blog/:slug(.*)', { slug: 'a/b' }), '/blog/a/b')
assert.equal(fillJournalPath('/blog/:slug?', { slug: 'x' }), '/blog/x')
})
test('fillJournalPath leaves unknown params empty', () => {
assert.equal(fillJournalPath('/autor/:author', {}), '/autor/')
})

94
tests/seo.test.ts Normal file
View File

@@ -0,0 +1,94 @@
// SEO values for Journal pages: description fallback, absolute og:image and
// list titles.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { absoluteUrl, htmlToText, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import type { JournalPost } from '../app/utils/types.ts'
function post(extra: Partial<JournalPost> = {}): JournalPost {
return {
id: 1,
title: 'Najnowsze Badania nad Cordycepsem',
slug: 'blogbadania-nad-cordycepsem',
excerpt: null,
content: null,
content_html: null,
reading_time: 1,
published: true,
published_at: '2024-06-12T06:00:00+00:00',
is_pinned: false,
metadata: null,
sources: null,
author: null,
categories: [],
tags: [],
featured_images: [],
featured_image_url: null,
translations: null,
created_at: '2024-06-12T06:00:00+00:00',
updated_at: '2024-06-13T06:00:00+00:00',
...extra,
}
}
test('htmlToText drops tags, scripts and styles and decodes entities', () => {
assert.equal(
htmlToText('<p>A&nbsp;&amp;&lt;b&gt; &quot;c&quot; &#39;d&#39;</p><script>x()</script><style>p{}</style>\n<p>e</p>'),
'A &<b> "c" \'d\' e',
)
assert.equal(htmlToText(null), '')
})
test('truncateText keeps short text and cuts long text at a word with an ellipsis', () => {
assert.equal(truncateText('krótki', 160), 'krótki')
const long = 'słowo '.repeat(40).trim()
const cut = truncateText(long, 160)
assert.ok(cut.length <= 160)
assert.ok(cut.endsWith('…'))
assert.ok(!cut.endsWith(' …'))
assert.equal(truncateText('x'.repeat(200), 10), `${'x'.repeat(9)}…`)
})
test('the excerpt is the description when set', () => {
const meta = postSeoMeta(post({ excerpt: 'Co mówi nauka.', content_html: '<p>Treść</p>' }))
assert.equal(meta.description, 'Co mówi nauka.')
assert.equal(meta.ogDescription, 'Co mówi nauka.')
assert.equal(meta.title, 'Najnowsze Badania nad Cordycepsem')
assert.equal(meta.ogTitle, meta.title)
assert.equal(meta.ogType, 'article')
})
test('without an excerpt the description is the first 160 characters of the body text', () => {
const body = `<p>${'Grzyby funkcjonalne wspierają zdrowie. '.repeat(10)}</p>`
const meta = postSeoMeta(post({ excerpt: '', content_html: body }))
assert.ok(meta.description.length <= 160)
assert.ok(meta.description.startsWith('Grzyby funkcjonalne wspierają zdrowie.'))
assert.ok(!meta.description.includes('<'))
})
test('og:image becomes absolute against the site URL', () => {
const url = '/storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png'
assert.equal(postSeoMeta(post({ featured_image_url: url }), 'https://grzybyfunkcjonalne.pl/').ogImage, `https://grzybyfunkcjonalne.pl${url}`)
assert.equal(postSeoMeta(post({ featured_image_url: 'https://cdn.test/a.png' }), 'https://x.test').ogImage, 'https://cdn.test/a.png')
assert.equal(postSeoMeta(post()).ogImage, undefined)
})
test('article times come from published_at and updated_at', () => {
const meta = postSeoMeta(post())
assert.equal(meta.articlePublishedTime, '2024-06-12T06:00:00+00:00')
assert.equal(meta.articleModifiedTime, '2024-06-13T06:00:00+00:00')
const bare = postSeoMeta(post({ published_at: null, updated_at: null }))
assert.equal(bare.articlePublishedTime, undefined)
assert.equal(bare.articleModifiedTime, undefined)
})
test('absoluteUrl passes through absolute URLs and a missing site URL', () => {
assert.equal(absoluteUrl('a.png', 'https://x.test'), 'https://x.test/a.png')
assert.equal(absoluteUrl('HTTPS://y.test/a.png', 'https://x.test'), 'HTTPS://y.test/a.png')
assert.equal(absoluteUrl('/a.png', null), '/a.png')
})
test('listTitle adds the page title only after page 1', () => {
assert.equal(listTitle('Artykuły', 1, 'Strona 1'), 'Artykuły')
assert.equal(listTitle('Artykuły', 2, 'Strona 2'), 'Artykuły · Strona 2')
})

98
tests/sitemap.test.ts Normal file
View File

@@ -0,0 +1,98 @@
// Sitemap entries are built from journal.routes, never from fixed paths.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildJournalSitemapEntries } from '../app/utils/sitemap.ts'
import { DEFAULT_JOURNAL_ROUTES, normalizeJournalRoutes } from '../app/utils/routes.ts'
import type { JournalCategory, JournalPost } from '../app/utils/types.ts'
function post(id: number, slug: string, extra: Partial<JournalPost> = {}): JournalPost {
return {
id,
title: slug,
slug,
excerpt: null,
content: null,
content_html: null,
reading_time: 1,
published: true,
published_at: '2024-06-12T06:00:00+00:00',
is_pinned: false,
metadata: null,
sources: null,
author: null,
categories: [],
tags: [],
featured_images: [],
featured_image_url: null,
translations: null,
created_at: '2024-06-12T06:00:00+00:00',
updated_at: '2024-06-13T06:00:00+00:00',
...extra,
}
}
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
const row: JournalCategory = {
id,
name: slug,
slug,
description: null,
content: null,
parent_id: null,
nest_depth: 0,
post_count: 0,
}
if (children) row.children = children
return row
}
const POSTS = [post(1, 'blogbadania-nad-cordycepsem'), post(2, 'bez-daty', { updated_at: null })]
const CATEGORIES = [category(1, 'cordyceps', [category(2, 'militaris')]), category(3, 'amanita-muscaria')]
test('default routes give /blog/:slug and /blog/category/:category', () => {
assert.deepEqual(buildJournalSitemapEntries(POSTS, CATEGORIES, { ...DEFAULT_JOURNAL_ROUTES }), [
{ loc: '/blog/blogbadania-nad-cordycepsem', lastmod: '2024-06-13T06:00:00+00:00' },
{ loc: '/blog/bez-daty' },
{ loc: '/blog/category/cordyceps' },
{ loc: '/blog/category/militaris' },
{ loc: '/blog/category/amanita-muscaria' },
])
})
test('a custom root category route gives /cordyceps', () => {
const routes = normalizeJournalRoutes({ category: '/:category', tag: false })
const locs = buildJournalSitemapEntries(POSTS, CATEGORIES, routes).map((e) => e.loc)
assert.ok(locs.includes('/cordyceps'))
assert.ok(locs.includes('/militaris'), 'nested categories are flattened')
assert.ok(locs.includes('/amanita-muscaria'), 'empty categories are listed')
})
test('a disabled route contributes nothing', () => {
const noCategories = normalizeJournalRoutes({ category: false })
assert.deepEqual(
buildJournalSitemapEntries(POSTS, CATEGORIES, noCategories).map((e) => e.loc),
['/blog/blogbadania-nad-cordycepsem', '/blog/bez-daty'],
)
const noPosts = normalizeJournalRoutes({ post: false })
assert.equal(buildJournalSitemapEntries(POSTS, CATEGORIES, noPosts).filter((e) => e.loc.startsWith('/blog/b')).length, 0)
})
test('unpublished posts and empty slugs are skipped', () => {
const rows = [post(1, 'draft', { published: false }), post(2, ''), post(3, 'live')]
assert.deepEqual(
buildJournalSitemapEntries(rows, [category(9, '')], { ...DEFAULT_JOURNAL_ROUTES }).map((e) => e.loc),
['/blog/live'],
)
})
test('lastmod comes from updated_at', () => {
const [entry] = buildJournalSitemapEntries([post(1, 'x', { updated_at: '2025-01-02T03:04:05+00:00' })], [], {
...DEFAULT_JOURNAL_ROUTES,
})
assert.equal(entry?.lastmod, '2025-01-02T03:04:05+00:00')
})
test('slugs are encoded per segment', () => {
const [entry] = buildJournalSitemapEntries([post(1, 'a b/c')], [], { ...DEFAULT_JOURNAL_ROUTES })
assert.equal(entry?.loc, '/blog/a%20b/c')
})