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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "xo"
|
"lint": "xo",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^20.3.0",
|
"@directus/sdk": "^20.3.0",
|
||||||
@@ -28,7 +31,9 @@
|
|||||||
"use-debounce": "^10.0.5"
|
"use-debounce": "^10.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"eslint-config-xo-nextjs": "^6.0.0",
|
"eslint-config-xo-nextjs": "^6.0.0",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"xo": "^0.58.0"
|
"xo": "^0.58.0"
|
||||||
},
|
},
|
||||||
"xo": {
|
"xo": {
|
||||||
@@ -49,6 +54,16 @@
|
|||||||
"n/prefer-global/process": "off",
|
"n/prefer-global/process": "off",
|
||||||
"comma-dangle": "off",
|
"comma-dangle": "off",
|
||||||
"unicorn/prevent-abbreviations": "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)
|
## Améliorations hautes (P2)
|
||||||
|
|
||||||
- [x] **Headers CSP** — `next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
|
- [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
|
- [ ] **Tests extensions Directus** — mocks VersionsService
|
||||||
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
|
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
|
||||||
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
|
- [ ] **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