diff --git a/docs/todo.md b/docs/todo.md index 23e93e5..0498cc1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -30,20 +30,6 @@ prefixes: ## Todo -- [ ] #TEST-004 [P2] Trois helpers publics sans aucun test #tests #effort-s - - **Source** : audit 2026-08-24 - **Zone** : `tests/unit/helpers.test.ts` - - `getSeriesCover`, `serializeSeries` et `getParentCollection` sont exportés par l'API publique et n'apparaissent **nulle part** dans `tests/` — vérifié par recherche du symbole sur tout le répertoire, zéro occurrence pour les trois. Le ratio global est pourtant bon (1,38 ligne de test par ligne de code) : c'est un trou ponctuel, pas une négligence de fond. - - `serializeSeries` est le plus exposé — il façonne ce qu'un site passe à ses îlots client. - - **Checklist** : - - [ ] `getSeriesCover` — cover déclarée, cover absente, série introuvable - - [ ] `serializeSeries` — forme du retour, champs optionnels absents - - [ ] `getParentCollection` — slug imbriqué, slug racine - ## In Progress ## Blocked @@ -52,6 +38,11 @@ prefixes: ## Done +- [x] #TEST-004 [P2] Trois helpers publics sans aucun test #tests #effort-s + > ✅ **Terminé** le 2026-08-24 — audit `docs/reports/audit-summary-2026-08-24.md` + **Zone** : `tests/unit/helpers-uncovered.test.ts` (nouveau) + **Résumé** : `getSeriesCover`, `serializeSeries` et `getParentCollection` étaient exportés par l'API publique et n'apparaissaient **nulle part** dans `tests/` — zéro occurrence du symbole sur tout le répertoire. Trou ponctuel et non négligence de fond : le ratio du dépôt est bon par ailleurs. 19 tests ajoutés, **293 verts** au total, et plus aucun helper public sans couverture. **Validés par mutation, cinq fois** : `indexOf`→`lastIndexOf` (2 tombent), retour du suffixe au lieu du préfixe (3), `Date` non convertie en ISO (1), `date` non retirée de `data` (1), dernière image au lieu de la première (2). Aucune mutation ne passe inaperçue. Trois tests méritent d'être signalés parce qu'ils fixent un contrat plutôt qu'ils ne vérifient un calcul : `render` doit être **omis** — c'est la raison d'être de `serializeSeries`, une île React recevrait la fonction comme `undefined` — l'ordre de `images[]` fait foi contre tout tri introduit en amont, et `getParentCollection('/x')` rend une chaîne vide, cas dégénéré documenté pour que sa modification soit délibérée. **Aucune entrée au CHANGELOG** : rien ne change pour un consommateur, et le gonfler d'ajouts internes lui ferait perdre sa valeur de signal. + - [x] #SEC-002 [P1] `embeds[].id` pouvait détourner l'URL de lecture #sécurité #effort-xs > ✅ **Terminé** le 2026-08-24 — audit `docs/reports/audit-summary-2026-08-24.md` **Zone** : `src/components/SeriesEmbeds.astro`, `tests/e2e/routes.test.ts`, fixture `garde-injection` diff --git a/tests/unit/helpers-uncovered.test.ts b/tests/unit/helpers-uncovered.test.ts new file mode 100644 index 0000000..3d6ce5c --- /dev/null +++ b/tests/unit/helpers-uncovered.test.ts @@ -0,0 +1,162 @@ +/** + * Couverture de trois helpers publics restés sans aucun test (#TEST-004). + * + * Repérés par l'audit du 2026-08-24 : `getSeriesCover`, `serializeSeries` et + * `getParentCollection` sont exportés par l'API publique et n'apparaissaient + * nulle part dans `tests/`. Le ratio global du dépôt est pourtant bon — c'était + * un trou ponctuel, pas une négligence de fond. + */ + +import { describe, it, expect } from 'vitest'; +import { + getSeriesCover, + getParentCollection, + serializeSeries, + resetSeriesCache, +} from '../../src/helpers/index.js'; +import type { Series } from '../../src/helpers/index.js'; + +/** Entrée `series` minimale — même fabrique que les autres suites unitaires. */ +function makeSeries(data: Record, extra: Record = {}): Series { + return { + id: 'demo', + collection: 'series', + data: { title: 'Demo', ...data }, + body: '', + ...extra, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +// ─── getParentCollection (spec §1.7) ───────────────────────────────────────── + +describe('getParentCollection', () => { + it('extrait le premier segment d\'un slug hiérarchique', () => { + expect(getParentCollection('voyages/asie/tokyo-2024')).toBe('voyages'); + }); + + it('extrait le parent d\'un slug à deux niveaux', () => { + expect(getParentCollection('music/festival-2024')).toBe('music'); + }); + + it('retourne null pour un slug plat', () => { + expect(getParentCollection('bretagne-2024')).toBeNull(); + }); + + it('retourne null pour une chaîne vide', () => { + expect(getParentCollection('')).toBeNull(); + }); + + it('retourne une chaîne vide sur un slash en tête — le segment est vide, pas absent', () => { + // Cas dégénéré : `/x` a bien un parent au sens de la fonction, il est vide. + // Documenté ici pour que le jour où ce comportement change, il change + // délibérément. + expect(getParentCollection('/tokyo-2024')).toBe(''); + }); + + it('ne retient que le premier segment, jamais le dernier', () => { + // Garde contre une inversion : `lastIndexOf` au lieu d'`indexOf` rendrait + // 'voyages/asie' et passerait les trois premiers tests. + expect(getParentCollection('voyages/asie/tokyo')).not.toBe('voyages/asie'); + }); +}); + +// ─── serializeSeries (#MVP-005) ────────────────────────────────────────────── + +describe('serializeSeries', () => { + it('convertit la date en chaîne ISO', () => { + const series = makeSeries({ date: new Date('2024-06-15T00:00:00.000Z') }); + const out = serializeSeries(series); + expect(out.data['date']).toBe('2024-06-15T00:00:00.000Z'); + expect(typeof out.data['date']).toBe('string'); + }); + + it('reporte id et collection', () => { + const out = serializeSeries(makeSeries({})); + expect(out.id).toBe('demo'); + expect(out.collection).toBe('series'); + }); + + it('préserve les autres champs de data', () => { + const out = serializeSeries(makeSeries({ title: 'Bretagne', location: 'Finistère' })); + expect(out.data['title']).toBe('Bretagne'); + expect(out.data['location']).toBe('Finistère'); + }); + + it('omet la clé date quand elle est absente', () => { + const out = serializeSeries(makeSeries({})); + expect('date' in out.data).toBe(false); + }); + + it('omet la clé date quand la valeur n\'est pas une Date', () => { + // Le schéma garantit une `Date`, mais `serializeSeries` accepte n'importe + // quelle `Series` — dont une construite à la main par un consommateur. + const out = serializeSeries(makeSeries({ date: '2024-06-15' })); + expect('date' in out.data).toBe(false); + }); + + it('omet la méthode render — c\'est la raison d\'être de la fonction', () => { + // Une `Series` brute n'est pas sérialisable en JSON : `render` est une + // fonction, et une île React la recevrait comme `undefined` après passage + // de props. Le test garde ce contrat. + const series = makeSeries({}, { render: () => Promise.resolve({ Content: null }) }); + const out = serializeSeries(series); + expect('render' in out).toBe(false); + expect(() => JSON.stringify(out)).not.toThrow(); + }); + + it('reporte le body quand il est présent', () => { + expect(serializeSeries(makeSeries({}, { body: 'texte' })).body).toBe('texte'); + }); + + it('omet la clé body quand elle est absente', () => { + const series = { id: 'demo', collection: 'series', data: { title: 'Demo' } } as unknown as Series; + expect('body' in serializeSeries(series)).toBe(false); + }); + + it('ne mute pas la série passée en argument', () => { + const date = new Date('2024-06-15T00:00:00.000Z'); + const series = makeSeries({ date }); + serializeSeries(series); + expect(series.data['date']).toBeInstanceOf(Date); + }); +}); + +// ─── getSeriesCover (spec §1.6) ────────────────────────────────────────────── + +describe('getSeriesCover', () => { + it('retourne la première image de la série', async () => { + resetSeriesCache(); + const series = makeSeries({ + images: [ + { url: 'https://cdn.test/01.jpg', alt: 'un', width: 100, height: 50 }, + { url: 'https://cdn.test/02.jpg', alt: 'deux', width: 100, height: 50 }, + ], + }); + const cover = await getSeriesCover('demo', series); + expect(cover?.src).toBe('https://cdn.test/01.jpg'); + expect(cover?.alt).toBe('un'); + }); + + it('suit l\'ordre du frontmatter, pas l\'alphabétique', async () => { + // Garde contre un tri introduit en amont : c'est `images[]` qui fait foi. + resetSeriesCache(); + const series = makeSeries({ + images: [ + { url: 'https://cdn.test/zzz.jpg', width: 1, height: 1 }, + { url: 'https://cdn.test/aaa.jpg', width: 1, height: 1 }, + ], + }); + expect((await getSeriesCover('demo', series))?.src).toBe('https://cdn.test/zzz.jpg'); + }); + + it('retourne undefined quand la série n\'a aucune image', async () => { + resetSeriesCache(); + expect(await getSeriesCover('demo', makeSeries({}))).toBeUndefined(); + }); + + it('retourne undefined sur un tableau images vide', async () => { + resetSeriesCache(); + expect(await getSeriesCover('demo', makeSeries({ images: [] }))).toBeUndefined(); + }); +});