From 53e19d61acb2dfdc20141e3e20a33558837aac87 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 29 May 2026 09:23:41 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-51]=20=C3=89crire=20les=20tests=20Vitest?= =?UTF-8?q?=20des=20composables=20Catalog=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Résumé Couvre les deux composables Catalog extraits du refactor ERP-50 avec **42 tests Vitest unitaires** (happy-dom, sans dépendance backend). - 14 tests sur \`useCategoriesAdmin\` (fetchAll/fetchTypes, includeDeleted, loading, error, reset, singleton) - 28 tests sur \`useCategoryForm\` (validation RG-1.02/1.04/1.05 + trim, POST/PATCH/DELETE, mapping 409 RG-1.07 + 422 violations, isDirty, loadFrom, reset, isolation) Mocks via \`vi.stubGlobal\` (useApi / useI18n / useToast) et \`vi.mock\` (\`~/shared/stores/auth\` pour neutraliser l'auto-enregistrement \`onAuthSessionCleared\`). La suite tourne en **~1.2s**. Ticket Lesstime : #51 ## Tests automatisés - \`make nuxt-test\` ✓ 85 tests (dont 42 nouveaux), 0 échec, 1.2s ## Reviewer @matthieu ## À tester en local - [ ] \`make nuxt-test\` passe - [ ] Mock \`useApi\` reste stable si le pattern d'auto-import Nuxt évolue - [ ] Couverture jugée suffisante des cas back miroir Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/26 Co-authored-by: tristan Co-committed-by: tristan --- .../__tests__/useCategoriesAdmin.spec.ts | 250 ++++++++++ .../__tests__/useCategoryForm.spec.ts | 454 ++++++++++++++++++ 2 files changed, 704 insertions(+) create mode 100644 frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts create mode 100644 frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts diff --git a/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts new file mode 100644 index 0000000..a018863 --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Category, CategoryType } from '~/modules/catalog/types/category' +import type { HydraCollection } from '~/shared/utils/api' + +// Mock du store auth : useCategoriesAdmin s'auto-enregistre via +// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour +// eviter de charger Pinia et la vraie store (pas necessaire ici). +vi.mock('~/shared/stores/auth', () => ({ + onAuthSessionCleared: vi.fn(), +})) + +// Le client API est un auto-import Nuxt. On le remplace par un stub +// global pour intercepter les appels et controler les reponses dans +// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts). +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a +// ce moment-la, donc le mock auth est bien actif au top-level. +const { useCategoriesAdmin } = await import('../useCategoriesAdmin') + +const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' } +const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } + +const CAT_A: Category = { + id: 10, + name: 'Vis', + categoryType: TYPE_VENTE, + deletedAt: null, + createdAt: '2026-01-01T10:00:00+00:00', + updatedAt: '2026-01-01T10:00:00+00:00', + createdBy: null, + updatedBy: null, +} +const CAT_B: Category = { + id: 11, + name: 'Boulons', + categoryType: TYPE_VENTE, + deletedAt: null, + createdAt: '2026-01-02T10:00:00+00:00', + updatedAt: '2026-01-02T10:00:00+00:00', + createdBy: null, + updatedBy: null, +} + +function makeHydra(items: T[]): HydraCollection { + return { + totalItems: items.length, + member: items, + } +} + +describe('useCategoriesAdmin', () => { + beforeEach(() => { + mockGet.mockReset() + // Reset systematique du state singleton entre tests : sans ca, + // les categories chargees dans un test fuiteraient dans le suivant. + const { resetCategoriesAdmin } = useCategoriesAdmin() + resetCategoriesAdmin() + }) + + describe('fetchAll', () => { + it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll() + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith( + '/categories', + { itemsPerPage: 999 }, + { toast: false }, + ) + }) + + it('peuple categories.value depuis le champ Hydra member', async () => { + mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B])) + const { fetchAll, categories } = useCategoriesAdmin() + + await fetchAll() + + expect(categories.value).toEqual([CAT_A, CAT_B]) + }) + + it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll() + + const queryArg = mockGet.mock.calls[0]?.[1] as Record + expect(queryArg).not.toHaveProperty('includeDeleted') + }) + + it('ajoute includeDeleted=true quand demande explicitement', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll(true) + + expect(mockGet).toHaveBeenCalledWith( + '/categories', + { itemsPerPage: 999, includeDeleted: 'true' }, + { toast: false }, + ) + }) + + it('passe loading a true pendant la requete et false apres', async () => { + let resolveRequest: (v: HydraCollection) => void = () => {} + mockGet.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const { fetchAll, loading } = useCategoriesAdmin() + + const pending = fetchAll() + expect(loading.value).toBe(true) + + resolveRequest(makeHydra([])) + await pending + + expect(loading.value).toBe(false) + }) + + it('peuple error.value et vide categories en cas d echec', async () => { + mockGet.mockRejectedValueOnce(new Error('Network down')) + const { fetchAll, categories, error, loading } = useCategoriesAdmin() + // Pre-charge volontairement quelque chose pour verifier la purge. + categories.value = [CAT_A] + + await fetchAll() + + expect(categories.value).toEqual([]) + expect(error.value).toBe('Network down') + expect(loading.value).toBe(false) + }) + + it('gere une reponse sans champ member (fallback tableau vide)', async () => { + mockGet.mockResolvedValueOnce({ + totalItems: 0, + } as unknown as HydraCollection) + const { fetchAll, categories } = useCategoriesAdmin() + + await fetchAll() + + expect(categories.value).toEqual([]) + }) + }) + + describe('fetchTypes', () => { + it('appelle GET /category_types avec itemsPerPage=999', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + const { fetchTypes } = useCategoriesAdmin() + + await fetchTypes() + + expect(mockGet).toHaveBeenCalledWith( + '/category_types', + { itemsPerPage: 999 }, + { toast: false }, + ) + }) + + it('peuple types.value depuis le champ Hydra member', async () => { + mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT])) + const { fetchTypes, types } = useCategoriesAdmin() + + await fetchTypes() + + expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT]) + }) + + it('peuple error.value et vide types en cas d echec', async () => { + mockGet.mockRejectedValueOnce(new Error('500')) + const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin() + types.value = [TYPE_VENTE] + + await fetchTypes() + + expect(types.value).toEqual([]) + expect(error.value).toContain('500') + expect(loadingTypes.value).toBe(false) + }) + + it('passe loadingTypes a true pendant la requete et false apres', async () => { + let resolveRequest: (v: HydraCollection) => void = () => {} + mockGet.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const { fetchTypes, loadingTypes } = useCategoriesAdmin() + + const pending = fetchTypes() + expect(loadingTypes.value).toBe(true) + + resolveRequest(makeHydra([])) + await pending + + expect(loadingTypes.value).toBe(false) + }) + }) + + describe('resetCategoriesAdmin', () => { + it('vide categories, types, loading, loadingTypes et error', () => { + const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error } + = useCategoriesAdmin() + // Pre-peuple le state pour verifier la purge effective. + categories.value = [CAT_A] + types.value = [TYPE_VENTE] + loading.value = true + loadingTypes.value = true + error.value = 'oops' + + resetCategoriesAdmin() + + expect(categories.value).toEqual([]) + expect(types.value).toEqual([]) + expect(loading.value).toBe(false) + expect(loadingTypes.value).toBe(false) + expect(error.value).toBeNull() + }) + }) + + describe('singleton', () => { + it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => { + const a = useCategoriesAdmin() + const b = useCategoriesAdmin() + + // Les fonctions sont reinstanciees a chaque appel mais les refs + // doivent etre rigoureusement les memes (state au niveau module). + expect(a.categories).toBe(b.categories) + expect(a.types).toBe(b.types) + expect(a.loading).toBe(b.loading) + }) + + it('une mutation via une instance est visible depuis une autre instance', () => { + const a = useCategoriesAdmin() + const b = useCategoriesAdmin() + + a.categories.value = [CAT_A] + + expect(b.categories.value).toEqual([CAT_A]) + }) + }) +}) diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts new file mode 100644 index 0000000..c3500cd --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Category, CategoryType } from '~/modules/catalog/types/category' +import { useCategoryForm } from '../useCategoryForm' + +// Stubs des auto-imports Nuxt consommes par le composable. +const mockGet = vi.hoisted(() => vi.fn()) +const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) +const mockDelete = vi.hoisted(() => vi.fn()) +const mockToastSuccess = vi.hoisted(() => vi.fn()) +const mockToastError = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: mockPost, + put: vi.fn(), + patch: mockPatch, + delete: mockDelete, +})) +vi.stubGlobal('useToast', () => ({ + success: mockToastSuccess, + error: mockToastError, +})) +// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus). +// Quand le composable passe des params (ex: doublon), on les serialise pour +// pouvoir verifier que l'interpolation a bien recu le bon nom. +vi.stubGlobal('useI18n', () => ({ + t: (key: string, params?: Record) => + params ? `${key}::${JSON.stringify(params)}` : key, +})) + +const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' } +const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } + +const CAT: Category = { + id: 42, + name: 'Vis', + categoryType: TYPE_VENTE, + deletedAt: null, + createdAt: '2026-01-01T10:00:00+00:00', + updatedAt: '2026-01-01T10:00:00+00:00', + createdBy: null, + updatedBy: null, +} + +describe('useCategoryForm', () => { + beforeEach(() => { + mockGet.mockReset() + mockPost.mockReset() + mockPatch.mockReset() + mockDelete.mockReset() + mockToastSuccess.mockReset() + mockToastError.mockReset() + }) + + describe('loadFrom', () => { + it('pre-remplit le formulaire depuis une categorie existante', () => { + const form = useCategoryForm() + + form.loadFrom(CAT) + + expect(form.name.value).toBe('Vis') + expect(form.categoryTypeId.value).toBe(1) + expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) + }) + + it('vide le formulaire en mode creation (null)', () => { + const form = useCategoryForm() + form.name.value = 'old' + form.categoryTypeId.value = 99 + + form.loadFrom(null) + + expect(form.name.value).toBe('') + expect(form.categoryTypeId.value).toBeNull() + }) + + it('reinitialise le snapshot initial → isDirty=false juste apres', () => { + const form = useCategoryForm() + + form.loadFrom(CAT) + + expect(form.isDirty.value).toBe(false) + }) + }) + + describe('isDirty', () => { + it('passe a true des qu une valeur diverge du snapshot initial', () => { + const form = useCategoryForm() + form.loadFrom(CAT) + expect(form.isDirty.value).toBe(false) + + form.name.value = 'Vis modifie' + + expect(form.isDirty.value).toBe(true) + }) + }) + + describe('validate', () => { + it('signale une erreur si name est vide (RG-1.02)', () => { + const form = useCategoryForm() + form.name.value = '' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') + }) + + it('signale erreur si name est whitespace-only (trim → vide)', () => { + const form = useCategoryForm() + form.name.value = ' ' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') + }) + + it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { + const form = useCategoryForm() + form.name.value = 'A' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') + }) + + it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { + const form = useCategoryForm() + form.name.value = 'A'.repeat(121) + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') + }) + + it('signale erreur si categoryTypeId est null (RG-1.05)', () => { + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = null + + const ok = form.validate() + + expect(ok).toBe(false) + expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired') + }) + + it('passe quand name et categoryType sont valides', () => { + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const ok = form.validate() + + expect(ok).toBe(true) + expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) + }) + + it('reinitialise les erreurs avant chaque validation', () => { + const form = useCategoryForm() + // Erreur prealable. + form.errors.value._global = 'erreur ancienne' + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + form.validate() + + expect(form.errors.value._global).toBe('') + }) + }) + + describe('submitCreate', () => { + it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => { + mockPost.mockResolvedValueOnce(CAT) + const form = useCategoryForm() + form.name.value = ' Vis ' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(mockPost).toHaveBeenCalledWith( + '/categories', + { name: 'Vis', categoryType: '/api/category_types/1' }, + { toast: false }, + ) + expect(result).toEqual(CAT) + }) + + it('ne declenche aucun appel API si la validation client echoue', async () => { + const form = useCategoryForm() + form.name.value = '' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(mockPost).not.toHaveBeenCalled() + expect(result).toBeNull() + }) + + it('declenche un toast de succes en cas de creation reussie', async () => { + mockPost.mockResolvedValueOnce(CAT) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + await form.submitCreate() + + expect(mockToastSuccess).toHaveBeenCalledWith({ + title: 'Succès', + message: 'admin.categories.toast.created', + }) + }) + + it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => { + mockPost.mockRejectedValueOnce({ + response: { status: 409, _data: {} }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(result).toBeNull() + // La cle est interpolee avec le nom soumis : on retrouve "Vis" dans + // les params i18n (stub serialise les params). + expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') + expect(form.errors.value.name).toContain('"name":"Vis"') + expect(mockToastError).toHaveBeenCalledTimes(1) + const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string } + expect(toastArg.message).toContain('Vis') + }) + + it('mappe un 422 violations sur les champs concernes (errors.name)', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { + violations: [ + { propertyPath: 'name', message: 'name should not be blank.' }, + ], + }, + }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const result = await form.submitCreate() + + expect(result).toBeNull() + expect(form.errors.value.name).toBe('name should not be blank.') + // Pas de toast quand on a mappe les violations : l erreur est + // affichee inline sous le champ concerne. + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('mappe aussi hydra:violations (negociation de format alternative)', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { + 'hydra:violations': [ + { propertyPath: 'categoryType', message: 'Type invalide.' }, + ], + }, + }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + await form.submitCreate() + + expect(form.errors.value.categoryType).toBe('Type invalide.') + }) + + it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => { + mockPost.mockRejectedValueOnce({ + response: { status: 500, _data: { 'hydra:description': 'Boom server' } }, + }) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + await form.submitCreate() + + expect(form.errors.value._global).toBe('Boom server') + expect(mockToastError).toHaveBeenCalledWith({ + title: 'Erreur', + message: 'Boom server', + }) + }) + + it('passe submitting a true pendant la requete et a false apres', async () => { + let resolveRequest: (v: Category) => void = () => {} + mockPost.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const form = useCategoryForm() + form.name.value = 'Vis' + form.categoryTypeId.value = 1 + + const pending = form.submitCreate() + expect(form.submitting.value).toBe(true) + + resolveRequest(CAT) + await pending + + expect(form.submitting.value).toBe(false) + }) + }) + + describe('submitUpdate', () => { + it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'Vis V2' // categoryTypeId inchange + + await form.submitUpdate(42) + + expect(mockPatch).toHaveBeenCalledWith( + '/categories/42', + { name: 'Vis V2' }, // pas de categoryType car non modifie + { toast: false }, + ) + }) + + it('envoie categoryType en IRI quand seul le type a change', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.categoryTypeId.value = 2 + + await form.submitUpdate(42) + + expect(mockPatch).toHaveBeenCalledWith( + '/categories/42', + { categoryType: '/api/category_types/2' }, + { toast: false }, + ) + }) + + it('court-circuite l appel API si aucun champ n a change', async () => { + const form = useCategoryForm() + form.loadFrom(CAT) + // Aucune modification — isDirty=false, patch payload vide. + + const result = await form.submitUpdate(42) + + expect(mockPatch).not.toHaveBeenCalled() + expect(result).toBeNull() + expect(form.submitting.value).toBe(false) + }) + + it('declenche un toast de succes au PATCH reussi', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'Vis V2' + + await form.submitUpdate(42) + + expect(mockToastSuccess).toHaveBeenCalledWith({ + title: 'Succès', + message: 'admin.categories.toast.updated', + }) + }) + + it('mappe le 409 sur errors.name en mode update aussi', async () => { + mockPatch.mockRejectedValueOnce({ + response: { status: 409, _data: {} }, + }) + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'Doublon' + + const result = await form.submitUpdate(42) + + expect(result).toBeNull() + expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') + expect(form.errors.value.name).toContain('"name":"Doublon"') + }) + }) + + describe('submitDelete', () => { + it('appelle DELETE /categories/{id} et declenche un toast succes', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const form = useCategoryForm() + + const ok = await form.submitDelete(42) + + expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false }) + expect(ok).toBe(true) + expect(mockToastSuccess).toHaveBeenCalledWith({ + title: 'Succès', + message: 'admin.categories.toast.deleted', + }) + }) + + it('retourne false et toast erreur en cas d echec', async () => { + mockDelete.mockRejectedValueOnce({ + response: { status: 500, _data: { detail: 'down' } }, + }) + const form = useCategoryForm() + + const ok = await form.submitDelete(42) + + expect(ok).toBe(false) + expect(form.errors.value._global).toBe('down') + expect(mockToastError).toHaveBeenCalled() + }) + }) + + describe('reset', () => { + it('vide le formulaire et les erreurs', () => { + const form = useCategoryForm() + form.loadFrom(CAT) + form.name.value = 'edit' + form.errors.value._global = 'erreur' + form.submitting.value = true + + form.reset() + + expect(form.name.value).toBe('') + expect(form.categoryTypeId.value).toBeNull() + expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) + expect(form.submitting.value).toBe(false) + }) + }) + + describe('isolation', () => { + it('deux instances useCategoryForm() ont des states independants', () => { + const a = useCategoryForm() + const b = useCategoryForm() + + a.name.value = 'A' + b.name.value = 'B' + + expect(a.name.value).toBe('A') + expect(b.name.value).toBe('B') + // Les refs sont distinctes (pas singleton — chaque drawer son state). + expect(a.name).not.toBe(b.name) + }) + }) +})