Files
vue-journal-layer/tests/client.test.ts
Jakub Zych 102da43f76 feat: opt-in Journal search page and JournalSearchBox
- journal.routes.search (off by default) registers journal-search, the
  results page for ?q= in the API's relevance order (no sort is sent),
  server-rendered with a Polish-aware result count.
- JournalSearchBox: an icon button and a WAI-ARIA combobox with debounced
  ?fields=suggest suggestions, stale answers dropped, query words marked
  ignoring diacritics; a plain GET form without JS.
- The module keeps the search path out of the route cache and the sitemap
  and marks it noindex, follow.
- Client: suggestPosts and the fields option; useJournalLinks.searchLink.
2026-10-30 18:14:05 +01:00

221 lines
8.9 KiB
TypeScript

// 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)
})
test('buildPostListQuery passes fields through only when set', () => {
assert.deepEqual(buildPostListQuery({ search: 'kurki', fields: 'suggest', perPage: 5 }), {
per_page: 5,
search: 'kurki',
fields: 'suggest',
})
assert.equal('fields' in buildPostListQuery({ search: 'kurki' }), false)
})
test('suggestPosts sends search, fields=suggest and per_page, no sort, with the signal', async () => {
const page = { data: [{ id: 1, title: 'Kurki', slug: 'kurki', excerpt: 'x', featured_image_url: null, published_at: null }], meta: { current_page: 1, last_page: 1, per_page: 5, total: 1 } }
const inits: unknown[] = []
const calls: Call[] = []
const fetcher = async (path: string, query?: Record<string, string | number>, init?: { signal?: AbortSignal }) => {
calls.push({ path, query })
inits.push(init)
return page
}
const signal = new AbortController().signal
const client = createJournalClient(fetcher)
assert.deepEqual(await client.suggestPosts(' kurki ', { signal }), page)
assert.deepEqual(calls[0], { path: '/posts', query: { per_page: 5, search: 'kurki', fields: 'suggest' } })
assert.equal((inits[0] as { signal: AbortSignal }).signal, signal)
await client.suggestPosts('kurki', { perPage: 99 })
assert.equal(calls[1]!.query!.per_page, JOURNAL_MAX_PER_PAGE)
})
test('suggestPosts sends nothing for a term under 3 characters', async () => {
const { fetcher, calls } = fakeFetcher({})
const res = await createJournalClient(fetcher).suggestPosts('ąę')
assert.equal(calls.length, 0)
assert.deepEqual(res.data, [])
assert.equal(res.meta.total, 0)
})