187 lines
5.4 KiB
TypeScript
187 lines
5.4 KiB
TypeScript
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<string>()
|
|
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<string, string> = {}
|
|
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')
|
|
})
|
|
})
|