Files
Starseed/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts
T
tristan c1e45cd582
Auto Tag Develop / tag (push) Successful in 8s
feat(front) : onglet contact prestataire (ERP-142) (#104)
Empilée sur ERP-141 (#103).

## Périmètre ERP-142
Onglet **Contact** de l'écran `/providers/new` — saisie multi-contacts (blocs ajoutables) via la sous-ressource contacts.

- **`ProviderContactBlock.vue`** (miroir `SupplierContactBlock`) : Nom / Prénom / Fonction / Email / Téléphone (x1, +1 révélable, **max 2**), erreurs 422 par champ (prop `:errors`).
- **`useProviderForm`** étendu : état `contacts`, `canAddContact` (RG-3.04), `addContact`/`removeContact`, `submitContacts` (POST `/providers/{id}/contacts` pour les nouveaux, PATCH `/provider_contacts/{id}` pour les existants, groupe `provider:write:contacts`), `submitRows` (erreurs collectées **par ligne**, non bloquant).
- **RG-3.04** : « + Nouveau contact » désactivé tant que le bloc courant est vide (≥1 champ parmi prénom/nom/fonction/tél/email — aligné back).
- **RG-3.12** : onglet non validable vide ; une amorce vide est soumise pour déclencher la 422 `firstName` inline.
- Suppression d'un bloc → modal de confirmation.
- Helpers purs `utils/forms/providerContact.ts` (`isProviderContactBlank`, `buildProviderContactPayload`).
- i18n `technique.providers.form.contact/confirmDelete` + `toast.updateSuccess`.

## Vérifications
- Vitest : 418/418 (16 nouveaux : helpers, bloc, workflow contacts).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : bloc Contact rendu, « Nouveau contact » désactivé tant que vide puis activé après saisie, révélation du 2e téléphone (max 2).

Reviewed-on: #104
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:05:07 +00:00

307 lines
12 KiB
TypeScript

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())
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
const permState = vi.hoisted(() => ({ accountingView: 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) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
}))
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
const { emptyProviderContact } = await import('~/modules/technique/types/providerForm')
type ProviderForm = ReturnType<typeof useProviderForm>
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()
}
describe('useProviderForm', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = 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<string, unknown>
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
})
/** 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.')
})
})