import { describe, expect, it } from 'vitest' import { AUTHOR_SLUG_MAX, SITE_KEY_MAX, SLUG_PATTERN, siteUrlProblem, slugify, suggestFreeSlug, } from '@/lib/directory' import { parseDirectoryQuery, toDirectoryRouteQuery, type DirectoryQuery, } from '@/lib/directoryQuery' const ALPHABET = 'abcXYZ019 -_.,é ñ ß æ Ł 日本語 ! /' const SAMPLES = 400 function seeded(seed: number) { let state = seed return () => { state = (state * 1664525 + 1013904223) % 4294967296 return state / 4294967296 } } function randomText(next: () => number, length: number) { return Array.from( { length }, () => [...ALPHABET][Math.floor(next() * [...ALPHABET].length)], ).join('') } describe('slugify', () => { it('never ends or starts with a dash, matches the slug pattern and respects the maximum', () => { const next = seeded(7) for (let index = 0; index < SAMPLES; index += 1) { const max = 1 + Math.floor(next() * 40) const slug = slugify(randomText(next, Math.floor(next() * 80)), max) expect(slug.length).toBeLessThanOrEqual(max) expect(slug === '' || SLUG_PATTERN.test(slug)).toBe(true) } }) it('trims the dash left by cutting at the maximum', () => { expect(slugify('Journal Renovation', 8)).toBe('journal') }) it('is idempotent', () => { const next = seeded(11) for (let index = 0; index < SAMPLES; index += 1) { const once = slugify(randomText(next, 30), AUTHOR_SLUG_MAX) expect(slugify(once, AUTHOR_SLUG_MAX)).toBe(once) } }) it('folds diacritics and ligatures like the server', () => { expect(slugify('Inès Duarte', 100)).toBe('ines-duarte') expect(slugify('Straße Œuvre', 100)).toBe('strasse-oeuvre') }) }) describe('suggestFreeSlug', () => { it('returns a free, valid candidate within the maximum for any taken set', () => { const next = seeded(3) for (let index = 0; index < SAMPLES; index += 1) { const max = 6 + Math.floor(next() * 30) const slug = slugify(randomText(next, 20), max) || 'a' const taken = new Set() const rounds = Math.floor(next() * 6) for (let round = 0; round < rounds; round += 1) { taken.add(suggestFreeSlug(slug, (candidate) => taken.has(candidate), max)) } const candidate = suggestFreeSlug(slug, (value) => taken.has(value), max) expect(taken.has(candidate)).toBe(false) expect(candidate.length).toBeLessThanOrEqual(max) expect(SLUG_PATTERN.test(candidate)).toBe(true) } }) it('keeps a full-length site key within 32 characters', () => { const key = 'a'.repeat(SITE_KEY_MAX) expect(suggestFreeSlug(key, () => false, SITE_KEY_MAX)).toBe(`${'a'.repeat(30)}-2`) }) it('replaces an existing numeric suffix', () => { expect(suggestFreeSlug('journal-2', (value) => value === 'journal-2', 32)).toBe('journal-3') }) }) describe('siteUrlProblem', () => { it.each([ 'https://blog.example.com', 'https://blog.example.com/', 'http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173', 'https://localhost', ])('accepts %s', (value) => { expect(siteUrlProblem(value)).toBeNull() }) it.each([ 'https://user@blog.example.com', 'https://user:secret@blog.example.com', 'https://:secret@blog.example.com', 'http://user@localhost:5173', ])('rejects credentials in %s', (value) => { expect(siteUrlProblem(value)).toBe('invalid') }) it('rejects credentials hidden among random paths and hosts', () => { const next = seeded(5) for (let index = 0; index < SAMPLES; index += 1) { const user = randomText(next, 1 + Math.floor(next() * 5)).replace(/[^a-z0-9]/gi, 'u') const problem = siteUrlProblem(`https://${user || 'u'}@host.example`) expect(problem).not.toBeNull() } }) it.each([ ['http://blog.example.com', 'scheme'], ['https://blog.example.com/en', 'path'], ['https://blog.example.com?x=1', 'path'], ['blog', 'invalid'], ['ftp://blog.example.com', 'invalid'], ['', 'empty'], ])('classifies %s as %s', (value, problem) => { expect(siteUrlProblem(value)).toBe(problem) }) }) describe('parseDirectoryQuery', () => { it('survives a round trip through the route query', () => { const next = seeded(13) const sizes = [5, 25, 50, 100, 30, 0, -1] for (let index = 0; index < SAMPLES; index += 1) { const raw: Record = {} if (next() > 0.3) { raw.q = randomText(next, Math.floor(next() * 12)) } if (next() > 0.3) { raw.page = String(Math.floor(next() * 12) - 2) } if (next() > 0.3) { raw.size = String(sizes[Math.floor(next() * sizes.length)]) } const parsed = parseDirectoryQuery(raw) const again = parseDirectoryQuery(toDirectoryRouteQuery(parsed)) expect(again).toEqual(parsed) expect(parsed.page).toBeGreaterThanOrEqual(1) expect([5, 25, 50, 100]).toContain(parsed.size) } }) it('keeps other parameters such as edit and drops defaults', () => { const parsed: DirectoryQuery = { q: '', page: 1, size: 25 } expect(toDirectoryRouteQuery(parsed, { edit: 'camille', page: '9' })).toEqual({ edit: 'camille', }) }) }) describe('slugify parity with the API slug generator', () => { it.each([ ['Crème Brûlée à la façon', 'creme-brulee-a-la-facon'], ['Straße œuvre Łódź', 'strasse-oeuvre-lodz'], ['Ångström', 'angstrom'], ['Tiếng Việt', 'ti-ng-vi-t'], [' --Hello, World!-- ', 'hello-world'], ['', ''], ])('turns %j into %j', (text, expected) => { expect(slugify(text, 100)).toBe(expected) }) it('cuts to the limit and drops a trailing dash', () => { expect(slugify('abcd efgh', 5)).toBe('abcd') }) })