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:
@@ -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/`.
|
||||
|
||||
@@ -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
185
tests/client.test.ts
Normal 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
65
tests/htmlGate.test.ts
Normal 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
56
tests/i18n.test.ts
Normal 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
47
tests/pagination.test.ts
Normal 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
111
tests/routes.test.ts
Normal 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
94
tests/seo.test.ts
Normal 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 &<b> "c" 'd'</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
98
tests/sitemap.test.ts
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user