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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
Generated
+11944
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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__/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user