From a4dca42d77b0f928fee439c4a4c6bd8c2b736695 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 20:03:46 +0200 Subject: [PATCH] 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 --- README.md | 1 + package.json | 1 + tests/client.test.ts | 185 +++++++++++++++++++++++++++++++++++++++ tests/htmlGate.test.ts | 65 ++++++++++++++ tests/i18n.test.ts | 56 ++++++++++++ tests/pagination.test.ts | 47 ++++++++++ tests/routes.test.ts | 111 +++++++++++++++++++++++ tests/seo.test.ts | 94 ++++++++++++++++++++ tests/sitemap.test.ts | 98 +++++++++++++++++++++ 9 files changed, 658 insertions(+) create mode 100644 tests/client.test.ts create mode 100644 tests/htmlGate.test.ts create mode 100644 tests/i18n.test.ts create mode 100644 tests/pagination.test.ts create mode 100644 tests/routes.test.ts create mode 100644 tests/seo.test.ts create mode 100644 tests/sitemap.test.ts diff --git a/README.md b/README.md index b3d813f..b684a0f 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 --expect ` 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/`. diff --git a/package.json b/package.json index a239dcf..70e2bde 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..adb03f2 --- /dev/null +++ b/tests/client.test.ts @@ -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 } + +// 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 unknown)>) { + const calls: Call[] = [] + const fetcher = async (path: string, query?: Record) => { + 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) +}) diff --git a/tests/htmlGate.test.ts b/tests/htmlGate.test.ts new file mode 100644 index 0000000..f83da4a --- /dev/null +++ b/tests/htmlGate.test.ts @@ -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', ''], + ['iframe', ''], + ['event', ''], + ['javascript', 'x'], + ['vbscript', 'x'], + ['data', 'x'], +] + +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(''), false) + assert.equal(isSafeJournalHtml(''), false) + assert.equal(isSafeJournalHtml(''), false) +}) + +test('headings, tables, footnotes and links are safe and pass unchanged', () => { + const safe = [ + '

Hello

', + '
ab
12
', + '

See footnote.1

', + '

Artykuły las

', + ] + 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('

one of the onions

'), 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: '' }) + } +}) diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts new file mode 100644 index 0000000..e5006e2 --- /dev/null +++ b/tests/i18n.test.ts @@ -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']) +}) diff --git a/tests/pagination.test.ts b/tests/pagination.test.ts new file mode 100644 index 0000000..067e94f --- /dev/null +++ b/tests/pagination.test.ts @@ -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), []) +}) diff --git a/tests/routes.test.ts b/tests/routes.test.ts new file mode 100644 index 0000000..21a85e2 --- /dev/null +++ b/tests/routes.test.ts @@ -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/') +}) diff --git a/tests/seo.test.ts b/tests/seo.test.ts new file mode 100644 index 0000000..7caa8a4 --- /dev/null +++ b/tests/seo.test.ts @@ -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 { + 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('

A &<b> "c" 'd'

\n

e

'), + 'A & "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: '

Treść

' })) + 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 = `

${'Grzyby funkcjonalne wspierają zdrowie. '.repeat(10)}

` + 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') +}) diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts new file mode 100644 index 0000000..e3bc353 --- /dev/null +++ b/tests/sitemap.test.ts @@ -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 { + 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') +})