test: tests unitaires Vitest — format, version-utils, rate-limit

- Installe vitest@4 + @vitest/coverage-v8 (40 tests, 0 échec)
- lib/__tests__/format.test.js        : 14 tests (formatKonstitisyon, formatDate, hasRestrictedChar)
- lib/__tests__/version-utils.test.js : 17 tests (filterVersions par texte/auteur/date, getFilterStats)
- lib/__tests__/rate-limit.test.js    : 9 tests avec fake timers (limite, reset, retryAfter, keys indépendantes)
- vitest.config.mjs : environnement node, imports explicites (pas de globals)
- package.json : scripts test / test:watch / test:coverage + override XO pour les fichiers de test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 06:30:10 +04:00
parent 170c3c5e90
commit 7b831d5bc4
7 changed files with 12342 additions and 3 deletions
+111
View File
@@ -0,0 +1,111 @@
import {describe, it, expect} from 'vitest'
import {formatKonstitisyon, formatDate, hasRestrictedChar} from '../format.js'
describe('formatKonstitisyon', () => {
it('renvoie un tableau vide si aucun titre', () => {
expect(formatKonstitisyon([], [])).toEqual([])
})
it('regroupe les articles sous leur titre', () => {
const titres = [{id: 1, contenu: 'Titre I'}]
const articles = [
{id: 10, titre: 1, contenu: 'Art. 1'},
{id: 20, titre: 2, contenu: 'Art. 2'}, // autre titre — exclu
]
expect(formatKonstitisyon(titres, articles)).toEqual([
{titre: 'Titre I', titreId: 1, articles: [{id: 10, titre: 1, contenu: 'Art. 1'}]},
])
})
it('produit un article vide si aucun article pour ce titre', () => {
const titres = [{id: 1, contenu: 'Titre I'}]
expect(formatKonstitisyon(titres, [])).toEqual([
{titre: 'Titre I', titreId: 1, articles: []},
])
})
it('gère plusieurs titres et plusieurs articles', () => {
const titres = [
{id: 1, contenu: 'Titre I'},
{id: 2, contenu: 'Titre II'},
]
const articles = [
{id: 10, titre: 1},
{id: 11, titre: 1},
{id: 20, titre: 2},
]
const result = formatKonstitisyon(titres, articles)
expect(result).toHaveLength(2)
expect(result[0].articles).toHaveLength(2)
expect(result[1].articles).toHaveLength(1)
})
})
describe('formatDate', () => {
const fixedDate = new Date('2024-03-15T10:30:00Z')
it('renvoie une chaîne non vide par défaut', () => {
const result = formatDate(fixedDate)
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('ne contient pas de timezone par défaut', () => {
const result = formatDate(fixedDate)
expect(result).not.toMatch(/\(.+\)$/)
})
it('ajoute la timezone entre parenthèses quand withTimezone: true', () => {
const result = formatDate(fixedDate, 'PP', {withTimezone: true})
expect(result).toMatch(/\(.+\)$/)
})
it('utilise la locale française (contient un mois en français)', () => {
// date-fns format 'MMMM' en fr → mars / janvier / etc.
const result = formatDate(new Date('2024-01-15'), 'MMMM')
expect(result).toBe('janvier')
})
it('respecte le format personnalisé', () => {
const result = formatDate(new Date('2024-03-15'), 'dd/MM/yyyy')
expect(result).toBe('15/03/2024')
})
})
describe('hasRestrictedChar', () => {
it('détecte le caractère <', () => {
expect(hasRestrictedChar('<script>')).toBe(true)
})
it('détecte le caractère >', () => {
expect(hasRestrictedChar('a > b')).toBe(true)
})
it('détecte le caractère &', () => {
expect(hasRestrictedChar('a & b')).toBe(true)
})
it('détecte le caractère "', () => {
expect(hasRestrictedChar('"texte"')).toBe(true)
})
it('renvoie false pour un texte sans caractères restreints', () => {
expect(hasRestrictedChar('hello world')).toBe(false)
})
it('renvoie false pour une chaîne vide', () => {
expect(hasRestrictedChar('')).toBe(false)
})
it('renvoie false pour des apostrophes droites', () => {
expect(hasRestrictedChar('l\'article')).toBe(false)
})
})
+108
View File
@@ -0,0 +1,108 @@
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
} from 'vitest'
import {createRateLimiter} from '../rate-limit.js'
describe('createRateLimiter', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0) // Temps fixe et prévisible
})
afterEach(() => {
vi.useRealTimers()
})
it('autorise les requêtes dans la limite', () => {
const check = createRateLimiter({windowMs: 60_000, max: 3})
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
})
it('bloque quand la limite est atteinte', () => {
const check = createRateLimiter({windowMs: 60_000, max: 2})
check('ip1')
check('ip1')
const result = check('ip1')
expect(result.success).toBe(false)
})
it('renvoie retryAfter en secondes quand bloqué', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1') // start = 0
const result = check('ip1') // now = 0, retryAfter = ceil((0 + 60000 - 0) / 1000)
expect(result.success).toBe(false)
expect(result.retryAfter).toBe(60)
})
it('retryAfter décroît avec le temps écoulé', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1') // start = 0
vi.advanceTimersByTime(30_000) // 30s plus tard
const result = check('ip1') // retryAfter = ceil((0 + 60000 - 30000) / 1000) = 30
expect(result.success).toBe(false)
expect(result.retryAfter).toBe(30)
})
it('réinitialise le compteur après expiration de la fenêtre', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1')
expect(check('ip1').success).toBe(false)
vi.advanceTimersByTime(61_000) // Fenêtre expirée
expect(check('ip1').success).toBe(true)
})
it('suit les clés indépendamment', () => {
const check = createRateLimiter({windowMs: 60_000, max: 1})
check('ip1')
expect(check('ip1').success).toBe(false)
expect(check('ip2').success).toBe(true) // ip2 non affectée
})
it('réinitialise proprement à la requête suivante après expiration', () => {
const check = createRateLimiter({windowMs: 60_000, max: 2})
check('ip1')
check('ip1')
expect(check('ip1').success).toBe(false)
vi.advanceTimersByTime(61_000)
// Nouvelle fenêtre : 2 requêtes autorisées à nouveau
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(true)
expect(check('ip1').success).toBe(false)
})
it('purge les entrées expirées lors du cleanup', () => {
const check = createRateLimiter({windowMs: 60_000, max: 5})
// Plusieurs IPs remplissent le store
check('ip1')
check('ip2')
check('ip3')
// Avancer d'une fenêtre complète pour déclencher le cleanup
vi.advanceTimersByTime(61_000)
// ip1 repart de zéro (entrée purgée)
expect(check('ip1').success).toBe(true)
})
})
+147
View File
@@ -0,0 +1,147 @@
import {describe, it, expect} from 'vitest'
import {filterVersions, getFilterStats} from '../version-utils.js'
// Usine à versions pour les tests
const makeVersion = (id, overrides = {}) => ({
id,
name: `Version ${id}`,
date_created: '2024-03-15T10:00:00Z',
user_created: `user-${id}-suffix`,
delta: {contenu: `Contenu de la version ${id}`},
...overrides,
})
describe('filterVersions', () => {
it('renvoie un tableau vide si versions est vide', () => {
expect(filterVersions([], '', {})).toEqual([])
})
it('renvoie un tableau vide si versions est null', () => {
expect(filterVersions(null, '', {})).toEqual([])
})
it('renvoie toutes les versions sans filtre ni recherche', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(filterVersions(versions, '', {})).toHaveLength(2)
})
describe('recherche textuelle (searchTerm)', () => {
it('filtre par contenu', () => {
const versions = [
makeVersion(1, {delta: {contenu: 'liberté égalité fraternité'}}),
makeVersion(2, {delta: {contenu: 'droits fondamentaux'}}),
]
const result = filterVersions(versions, 'liberté', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('filtre par nom de version', () => {
const versions = [
makeVersion(1, {name: 'Proposition Dupont'}),
makeVersion(2, {name: 'Version Martin'}),
]
const result = filterVersions(versions, 'dupont', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('filtre par auteur (préfixe du user_created)', () => {
const versions = [
makeVersion(1, {user_created: 'alice-uuid'}),
makeVersion(2, {user_created: 'bob-uuid'}),
]
const result = filterVersions(versions, 'alice', {})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('est insensible à la casse', () => {
const versions = [makeVersion(1, {name: 'Proposition Alpha'})]
expect(filterVersions(versions, 'ALPHA', {})).toHaveLength(1)
})
it('renvoie vide si aucune correspondance', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(filterVersions(versions, 'zzz-inexistant', {})).toHaveLength(0)
})
})
describe('filtre par auteur (filters.author)', () => {
it('ne retient que les versions de l\'auteur indiqué', () => {
const versions = [
makeVersion(1, {user_created: 'alice-uuid'}),
makeVersion(2, {user_created: 'bob-uuid'}),
]
const result = filterVersions(versions, '', {author: 'alice'})
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('renvoie vide si aucune version de cet auteur', () => {
const versions = [makeVersion(1, {user_created: 'alice-uuid'})]
expect(filterVersions(versions, '', {author: 'bob'})).toHaveLength(0)
})
})
describe('filtre par plage de dates', () => {
const versions = [
makeVersion(1, {date_created: '2024-01-10T00:00:00Z'}),
makeVersion(2, {date_created: '2024-03-15T00:00:00Z'}),
makeVersion(3, {date_created: '2024-06-20T00:00:00Z'}),
]
it('filtre les versions antérieures à dateFrom', () => {
const result = filterVersions(versions, '', {dateFrom: '2024-03-01'})
expect(result.map(v => v.id)).toEqual([2, 3])
})
it('filtre les versions postérieures à dateTo', () => {
const result = filterVersions(versions, '', {dateTo: '2024-04-01'})
expect(result.map(v => v.id)).toEqual([1, 2])
})
it('combine dateFrom et dateTo', () => {
const result = filterVersions(versions, '', {dateFrom: '2024-02-01', dateTo: '2024-05-01'})
expect(result.map(v => v.id)).toEqual([2])
})
})
})
describe('getFilterStats', () => {
it('calcule les totaux correctement', () => {
const original = [makeVersion(1), makeVersion(2), makeVersion(3)]
const filtered = [makeVersion(1)]
expect(getFilterStats(original, filtered)).toEqual({
total: 3,
filtered: 1,
hidden: 2,
})
})
it('renvoie hidden: 0 quand rien n\'est filtré', () => {
const versions = [makeVersion(1), makeVersion(2)]
expect(getFilterStats(versions, versions)).toEqual({
total: 2,
filtered: 2,
hidden: 0,
})
})
it('gère les tableaux vides', () => {
expect(getFilterStats([], [])).toEqual({total: 0, filtered: 0, hidden: 0})
})
})
+11944
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -3,7 +3,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "xo"
"lint": "xo",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@directus/sdk": "^20.3.0",
@@ -28,7 +31,9 @@
"use-debounce": "^10.0.5"
},
"devDependencies": {
"@vitest/coverage-v8": "^4.1.4",
"eslint-config-xo-nextjs": "^6.0.0",
"vitest": "^4.1.4",
"xo": "^0.58.0"
},
"xo": {
@@ -49,6 +54,16 @@
"n/prefer-global/process": "off",
"comma-dangle": "off",
"unicorn/prevent-abbreviations": "off"
}
},
"overrides": [
{
"files": "lib/__tests__/**/*.js",
"envs": ["node", "es2020"],
"rules": {
"camelcase": ["error", {"properties": "never"}],
"capitalized-comments": "off"
}
}
]
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
## Améliorations hautes (P2)
- [x] **Headers CSP**`next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
- [ ] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
- [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
- [ ] **Tests extensions Directus** — mocks VersionsService
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
+14
View File
@@ -0,0 +1,14 @@
import {defineConfig} from 'vitest/config'
export default defineConfig({
test: {
// Imports explicites (pas de globals) — cohérent avec le style XO du projet
globals: false,
environment: 'node',
coverage: {
provider: 'v8',
include: ['lib/**/*.js'],
exclude: ['lib/__tests__/**'],
},
},
})