c76c447aa2
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-144 (#106). ## Périmètre ERP-145 Écrans **Consultation** (lecture seule) et **Modification** (édition par onglet), peuplés depuis la **seule** réponse `GET /api/providers/{id}` (embed contacts/adresses/ribs + refs comptables — pas de N+1). ### Consultation — `pages/providers/[id]/index.vue` (`/providers/{id}`) - Ouverture par défaut sur **Contacts** ; tous champs readonly ; onglets **Contacts · Adresse · Rapports · Échanges · Comptabilité** (navigation libre). Rapports/Échanges = placeholders « À venir ». - Flèche retour → répertoire. Bouton **Modifier** (si `manage` OU `accounting.manage`). Bouton **Archiver** (Admin seul, `archive`) → modal → PATCH `{isArchived:true}` ; **Restaurer** si archivé. - Comptabilité visible seulement si `accounting.view` ; banque/RIB affichés selon le type de règlement (VIREMENT/LCR). ### Modification — `pages/providers/[id]/edit.vue` (`/providers/{id}/edit`) - Pré-rempli ; **bloc principal éditable** (Nom/Catégories/Sites, PATCH `provider:write:main` via `updateMain`) ; onglets Contact/Adresse/Comptabilité en **navigation libre**, PATCH partiel par onglet (réutilise `useProviderForm` en `editMode`). - Onglets sans permission `manage` / `accounting.manage` restent **readonly** (pas de bouton Valider / suppression). Accès réservé à `manage` OU `accounting.manage`. ### Composables / helpers - **`useProvider(id)`** : charge le détail (ld+json) + archive/restore (PATCH isArchived seul, puis rechargement). - **`useProviderForm`** étendu : `updateMain()` (PATCH principal en édition) + `editMode` (completeTab ne verrouille/avance plus). - **`providerDetail.ts`** : mapping embed → brouillons + options role-indépendantes (libellés depuis l'embed) + règles d'actions (Modifier/Archiver/Restaurer). ## Conformité - `useApi()` only ; `Malio*` only ; `usePermissions()` pour boutons/onglets ; aucun texte FR en dur ; pas d'import inter-module (règle ABSOLUE n°1). ## Vérifications - Vitest : 470/470 (16 nouveaux : mapping détail, actions par permission, updateMain + editMode). - ESLint : OK · `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : **Consultation** (ACME) — bloc principal readonly + libellés catégories/sites résolus depuis l'embed, 5 onglets, Modifier+Archiver visibles (admin), Comptabilité readonly. **Modification** — bloc principal éditable pré-rempli (Site « 86 17 »), 3 onglets navigation libre, onglet Contact pré-rempli. Reviewed-on: #107 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
654 lines
24 KiB
TypeScript
654 lines
24 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())
|
|
// 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<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()
|
|
}
|
|
|
|
/** 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('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
|
|
const form = useProviderForm()
|
|
|
|
const created = await form.submitMain()
|
|
|
|
expect(created).toBe(false)
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
|
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('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
|
|
const form = useProviderForm()
|
|
form.main.companyName = ' '
|
|
form.main.categoryIris = [CAT_MAINT]
|
|
form.main.siteIris = [SITE_86]
|
|
|
|
const created = await form.submitMain()
|
|
|
|
expect(created).toBe(false)
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
|
})
|
|
|
|
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<string, unknown>
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe('useProviderForm — modification (ERP-145)', () => {
|
|
beforeEach(() => {
|
|
mockPost.mockReset()
|
|
mockPatch.mockReset()
|
|
permState.accountingView = false
|
|
permState.accountingManage = false
|
|
})
|
|
|
|
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
|
const form = useProviderForm()
|
|
form.editMode.value = true
|
|
form.activeTab.value = 'contact'
|
|
|
|
expect(form.completeTab('contact')).toBe(false)
|
|
expect(form.isValidated('contact')).toBe(false)
|
|
expect(form.activeTab.value).toBe('contact')
|
|
})
|
|
|
|
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
|
|
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
|
|
const form = useProviderForm()
|
|
form.providerId.value = 7
|
|
form.main.companyName = 'Maintenance Pro'
|
|
form.main.categoryIris = [CAT_MAINT]
|
|
form.main.siteIris = [SITE_86]
|
|
|
|
const ok = await form.updateMain()
|
|
|
|
expect(ok).toBe(true)
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
expect(mockPatch).toHaveBeenCalledWith(
|
|
'/providers/7',
|
|
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
|
|
{ toast: false },
|
|
)
|
|
// Reaffiche le nom normalise renvoye par le serveur.
|
|
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
|
})
|
|
|
|
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
|
|
const form = useProviderForm()
|
|
form.providerId.value = 7
|
|
form.main.companyName = 'X'
|
|
form.main.categoryIris = [CAT_MAINT]
|
|
|
|
const ok = await form.updateMain()
|
|
|
|
expect(ok).toBe(false)
|
|
expect(mockPatch).not.toHaveBeenCalled()
|
|
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
|
})
|
|
|
|
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
|
|
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
|
|
const form = useProviderForm()
|
|
form.providerId.value = 7
|
|
form.main.companyName = 'Doublon'
|
|
form.main.categoryIris = [CAT_MAINT]
|
|
form.main.siteIris = [SITE_86]
|
|
|
|
const ok = await form.updateMain()
|
|
|
|
expect(ok).toBe(false)
|
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
|
})
|
|
})
|