feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
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',
})
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/composables/useApi'
import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/directoryErrors'
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
['site', 401, undefined, 'session'],
['site', 403, 'default_site', 'defaultSite'],
['site', 403, 'Only the owner can do this.', 'forbidden'],
['site', 404, 'site_not_found', 'gone'],
['site', 409, 'site_key_taken', 'keyTaken'],
['site', 409, 'site_has_articles', 'hasArticles'],
['site', 400, 'invalid_site_url', 'invalidSiteUrl'],
['site', 400, 'invalid_locale', 'invalidLocale'],
['site', 500, undefined, 'failed'],
['author', 403, 'author_rights', 'rights'],
['author', 404, 'author_not_found', 'gone'],
['author', 409, 'author_slug_taken', 'slugTaken'],
['author', 409, 'user_already_linked', 'userLinked'],
['author', 409, 'author_has_articles', 'hasArticles'],
['author', 422, 'bio_too_long', 'bioTooLong'],
['author', 422, 'invalid_url', 'invalidUrl'],
['author', 500, undefined, 'failed'],
['author', 409, 'something_else', 'failed'],
]
describe('classifyFailure', () => {
it.each(CASES)('%s %s %s is %s', (context, status, code, kind) => {
expect(classifyFailure(new ApiError(status, undefined, code), context)).toBe(kind)
})
it('treats a network error as a generic failure', () => {
expect(classifyFailure(new Error('offline'), 'site')).toBe('failed')
})
it('does not let a site code leak into the author context', () => {
expect(classifyFailure(new ApiError(409, undefined, 'site_key_taken'), 'author')).toBe(
'failed',
)
})
})
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
BIO_MAX,
authorSlugProblem,
baseUrlProblem,
bioProblem,
nameProblem,
optionalSecureUrlProblem,
secureUrlProblem,
siteKeyProblem,
} from '@/lib/directoryRules'
describe('directory validation rules', () => {
it('asks for a name', () => {
expect(nameProblem(' ')).toBe('Enter a name.')
expect(nameProblem('Zoe')).toBe('')
})
it('counts the bio in characters, not code units', () => {
expect(bioProblem('x'.repeat(BIO_MAX))).toBe('')
expect(bioProblem('x'.repeat(BIO_MAX + 1))).toBe('Shorten the bio to 280 characters.')
expect(bioProblem('😀'.repeat(BIO_MAX))).toBe('')
})
it('requires https for links and treats an empty website as fine', () => {
expect(secureUrlProblem('http://x')).toBe('Enter a full URL starting with https://.')
expect(secureUrlProblem('')).toBe('Enter a full URL starting with https://.')
expect(secureUrlProblem('https://x.example')).toBe('')
expect(optionalSecureUrlProblem('')).toBe('')
expect(optionalSecureUrlProblem('example.com')).not.toBe('')
})
it('validates author slugs and site keys', () => {
expect(authorSlugProblem('')).toBe('')
expect(authorSlugProblem('camille-roche')).toBe('')
expect(authorSlugProblem('Camille Roche')).not.toBe('')
expect(siteKeyProblem('journal')).toBe('')
expect(siteKeyProblem('j')).not.toBe('')
expect(siteKeyProblem('a'.repeat(33))).not.toBe('')
expect(siteKeyProblem('Journal')).not.toBe('')
})
it('maps a base URL to its message', () => {
expect(baseUrlProblem('https://blog.example.com')).toBe('')
expect(baseUrlProblem('http://blog.example.com')).toBe(
'Use https. For example https://blog.example.com.',
)
expect(baseUrlProblem('https://u:p@blog.example.com')).toBe(
'Enter a full URL, like https://blog.example.com.',
)
})
})