import { beforeEach, describe, expect, it, vi } from 'vitest' /** * Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141). * * `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et * l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la * creation : * - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie * -> POST bloque, erreurs inline, aucun appel reseau. * - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json * + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact + * reaffichage du nom normalise. * - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName. * - 422 -> mapping inline par champ (propertyPath). * - Onglets : « Comptabilite » present uniquement avec accounting.view ; * completeTab deverrouille/avance et signale le dernier onglet. */ const mockPost = vi.hoisted(() => vi.fn()) const mockPatch = vi.hoisted(() => vi.fn()) // Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite). const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false })) vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, put: vi.fn(), patch: mockPatch, delete: vi.fn(), })) vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) vi.stubGlobal('useToast', () => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), })) vi.stubGlobal('usePermissions', () => ({ can: (perm: string) => { if (perm === 'technique.providers.accounting.view') return permState.accountingView if (perm === 'technique.providers.accounting.manage') return permState.accountingManage return true }, })) const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm') type ProviderForm = ReturnType const SITE_86 = '/api/sites/1' const CAT_MAINT = '/api/categories/7' /** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */ function contactAt(form: ProviderForm, index = 0) { return form.contacts.value[index] ?? emptyProviderContact() } /** Accede a un bloc adresse (idem). */ function addressAt(form: ProviderForm, index = 0) { return form.addresses.value[index] ?? emptyProviderAddress() } describe('useProviderForm', () => { beforeEach(() => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = false permState.accountingManage = false }) it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => { const form = useProviderForm() form.main.companyName = 'Maintenance Pro' const created = await form.submitMain() expect(created).toBe(false) expect(mockPost).not.toHaveBeenCalled() expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') expect(form.mainLocked.value).toBe(false) }) it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => { const form = useProviderForm() form.main.companyName = 'Maintenance Pro' form.main.siteIris = [SITE_86] await form.submitMain() expect(mockPost).not.toHaveBeenCalled() expect(form.mainErrors.errors.sites).toBeUndefined() expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') }) it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => { mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' }) const form = useProviderForm() form.main.companyName = 'Maintenance Pro' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] const created = await form.submitMain() expect(created).toBe(true) expect(mockPost).toHaveBeenCalledTimes(1) const [url, body, opts] = mockPost.mock.calls[0] ?? [] expect(url).toBe('/providers') expect(body).toEqual({ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86], }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(form.providerId.value).toBe(42) // RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur. expect(form.main.companyName).toBe('MAINTENANCE PRO') expect(form.mainLocked.value).toBe(true) expect(form.activeTab.value).toBe('contact') expect(form.unlockedIndex.value).toBe(0) }) it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => { mockPost.mockResolvedValueOnce({ id: 1, companyName: null }) const form = useProviderForm() form.main.companyName = ' ' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] await form.submitMain() const body = (mockPost.mock.calls[0] ?? [])[1] as Record expect(body).not.toHaveProperty('companyName') expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] }) }) it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => { mockPost.mockRejectedValueOnce({ response: { status: 409 } }) const form = useProviderForm() form.main.companyName = 'Doublon' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] const created = await form.submitMain() expect(created).toBe(false) expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany') expect(form.mainLocked.value).toBe(false) }) it('422 : mappe les violations serveur inline par champ', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] }, }, }) const form = useProviderForm() form.main.companyName = 'X' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] const created = await form.submitMain() expect(created).toBe(false) expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.') }) it('onglet Comptabilite : absent sans accounting.view, present avec', () => { expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address']) expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting']) permState.accountingView = true const form = useProviderForm() expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting']) }) it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => { const form = useProviderForm() // Contact -> Adresse (pas le dernier). expect(form.completeTab('contact')).toBe(false) expect(form.isValidated('contact')).toBe(true) expect(form.activeTab.value).toBe('address') expect(form.unlockedIndex.value).toBe(1) // Adresse = dernier onglet remplissable (sans accounting.view) -> true. expect(form.completeTab('address')).toBe(true) expect(form.isValidated('address')).toBe(true) }) it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => { const form = useProviderForm() await form.patchProvider({ siren: '123456789' }) expect(mockPatch).not.toHaveBeenCalled() mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' }) form.main.companyName = 'Acme' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] await form.submitMain() await form.patchProvider({ siren: '123456789' }) expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false }) }) }) describe('useProviderForm — onglet Contact (ERP-142)', () => { beforeEach(() => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = false permState.accountingManage = false }) /** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */ function createdForm() { const form = useProviderForm() form.providerId.value = 7 return form } it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => { const form = createdForm() expect(form.canAddContact.value).toBe(false) // addContact est un no-op tant que le bloc est vide. form.addContact() expect(form.contacts.value).toHaveLength(1) contactAt(form).lastName = 'Doe' expect(form.canAddContact.value).toBe(true) form.addContact() expect(form.contacts.value).toHaveLength(2) }) it('removeContact retire le bloc et son erreur de ligne', () => { const form = createdForm() contactAt(form).lastName = 'Doe' form.addContact() form.contactErrors.value = [{}, { lastName: 'x' }] form.removeContact(1) expect(form.contacts.value).toHaveLength(1) expect(form.contactErrors.value).toHaveLength(1) }) it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => { mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 }) const form = createdForm() contactAt(form).lastName = 'Doe' const ok = await form.submitContacts(vi.fn()) expect(ok).toBe(true) const [url, body, opts] = mockPost.mock.calls[0] ?? [] expect(url).toBe('/providers/7/contacts') expect(body).toMatchObject({ lastName: 'Doe' }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(contactAt(form).id).toBe(55) expect(contactAt(form).iri).toBe('/api/provider_contacts/55') expect(form.isValidated('contact')).toBe(true) }) it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() contactAt(form).id = 55 contactAt(form).lastName = 'Doe' await form.submitContacts(vi.fn()) expect(mockPost).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) }) it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] }, }, }) const form = createdForm() const ok = await form.submitContacts(vi.fn()) expect(ok).toBe(false) expect(mockPost).toHaveBeenCalledTimes(1) expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.') expect(form.isValidated('contact')).toBe(false) }) it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => { mockPost .mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 }) .mockRejectedValueOnce({ response: { status: 422, _data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] }, }, }) const form = createdForm() contactAt(form).lastName = 'Doe' form.addContact() contactAt(form, 1).email = 'invalide' const ok = await form.submitContacts(vi.fn()) expect(ok).toBe(false) expect(form.contactErrors.value[0]).toBeUndefined() expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.') }) }) describe('useProviderForm — onglet Adresse (ERP-143)', () => { beforeEach(() => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = false permState.accountingManage = false }) /** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */ function createdForm() { const form = useProviderForm() form.providerId.value = 7 return form } /** Remplit un bloc adresse valide (site + categorie + scalaires requis). */ function fillValidAddress(form: ProviderForm, index = 0): void { const a = addressAt(form, index) a.siteIris = [SITE_86] a.categoryIris = [CAT_MAINT] a.postalCode = '86100' a.city = 'Châtellerault' a.street = '1 rue du Test' } it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => { const form = createdForm() expect(form.canAddAddress.value).toBe(false) // no-op tant que l'adresse n'est pas valide. form.addAddress() expect(form.addresses.value).toHaveLength(1) addressAt(form).siteIris = [SITE_86] expect(form.canAddAddress.value).toBe(false) // categorie manquante addressAt(form).categoryIris = [CAT_MAINT] expect(form.canAddAddress.value).toBe(true) form.addAddress() expect(form.addresses.value).toHaveLength(2) }) it('removeAddress retire le bloc et son erreur de ligne', () => { const form = createdForm() fillValidAddress(form) form.addAddress() form.addressErrors.value = [{}, { city: 'x' }] form.removeAddress(1) expect(form.addresses.value).toHaveLength(1) expect(form.addressErrors.value).toHaveLength(1) }) it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => { mockPost.mockResolvedValueOnce({ id: 88 }) const form = createdForm() fillValidAddress(form) const ok = await form.submitAddresses(vi.fn()) expect(ok).toBe(true) const [url, body, opts] = mockPost.mock.calls[0] ?? [] expect(url).toBe('/providers/7/addresses') expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(addressAt(form).id).toBe(88) expect(form.isValidated('address')).toBe(true) }) it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() fillValidAddress(form) addressAt(form).id = 88 await form.submitAddresses(vi.fn()) expect(mockPost).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false }) }) it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] }, }, }) const form = createdForm() fillValidAddress(form) const ok = await form.submitAddresses(vi.fn()) expect(ok).toBe(false) expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.') expect(form.isValidated('address')).toBe(false) }) }) describe('useProviderForm — onglet Comptabilite (ERP-144)', () => { const TVA = '/api/tva_modes/1' const DELAY = '/api/payment_delays/1' const TYPE = '/api/payment_types/3' const BANK = '/api/banks/2' beforeEach(() => { mockPost.mockReset() mockPatch.mockReset() permState.accountingView = true permState.accountingManage = true }) /** Prestataire cree, onglet Comptabilite editable (view + manage). */ function createdForm() { const form = useProviderForm() form.providerId.value = 7 return form } /** Remplit les scalaires comptables communs. */ function fillScalars(form: ProviderForm): void { form.accounting.siren = '123456789' form.accounting.accountNumber = '4010' form.accounting.tvaModeIri = TVA form.accounting.nTva = 'FR123' form.accounting.paymentDelayIri = DELAY form.accounting.paymentTypeIri = TYPE } it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => { permState.accountingManage = false const form = createdForm() expect(form.accountingReadonly.value).toBe(true) permState.accountingManage = true const form2 = createdForm() expect(form2.accountingReadonly.value).toBe(false) }) it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => { const form = createdForm() form.accounting.bankIri = BANK // Type VIREMENT -> banque requise, conservee. form.setPaymentType(TYPE, true, false) expect(form.accounting.bankIri).toBe(BANK) // Type non-VIREMENT -> banque videe (sans objet). form.setPaymentType(TYPE, false, false) expect(form.accounting.bankIri).toBeNull() }) it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => { const form = createdForm() expect(form.ribs.value).toHaveLength(0) form.setPaymentType(TYPE, false, true) expect(form.ribs.value).toHaveLength(1) }) it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => { const form = createdForm() form.setPaymentType(TYPE, false, true) expect(form.canAddRib.value).toBe(false) const rib = form.ribs.value[0] if (rib) { rib.label = 'Compte' rib.bic = 'BNPAFRPP' rib.iban = 'FR76...' } expect(form.canAddRib.value).toBe(true) }) it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() fillScalars(form) form.accounting.bankIri = BANK const ok = await form.submitAccounting(true, false, vi.fn()) expect(ok).toBe(true) expect(mockPost).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith( '/providers/7', expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }), { toast: false }, ) expect(form.isValidated('accounting')).toBe(true) }) it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() fillScalars(form) form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false) await form.submitAccounting(false, false, vi.fn()) const body = mockPatch.mock.calls[0]?.[1] as Record expect(body.bank).toBeNull() }) it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => { mockPost.mockResolvedValueOnce({ id: 50 }) mockPatch.mockResolvedValueOnce({}) const form = createdForm() fillScalars(form) form.setPaymentType(TYPE, false, true) const rib = form.ribs.value[0] if (rib) { rib.label = 'Compte' rib.bic = 'BNPAFRPP' rib.iban = 'FR76...' } const ok = await form.submitAccounting(false, true, vi.fn()) expect(ok).toBe(true) expect(mockPost).toHaveBeenCalledWith( '/providers/7/ribs', expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }), { headers: { Accept: 'application/ld+json' }, toast: false }, ) expect(form.ribs.value[0]?.id).toBe(50) // Le PATCH des scalaires intervient APRES la creation du RIB. expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false }) }) it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => { mockPatch.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] }, }, }) const form = createdForm() fillScalars(form) const ok = await form.submitAccounting(true, false, vi.fn()) expect(ok).toBe(false) expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.') expect(form.isValidated('accounting')).toBe(false) }) it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] }, }, }) const form = createdForm() fillScalars(form) form.setPaymentType(TYPE, false, true) const rib = form.ribs.value[0] if (rib) { rib.label = 'Compte' rib.bic = 'BNPAFRPP' } const ok = await form.submitAccounting(false, true, vi.fn()) expect(ok).toBe(false) expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.') expect(mockPatch).not.toHaveBeenCalled() }) })