feat(transport) : écran ajout transporteur — layout + formulaire principal (ERP-165)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m11s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

This commit is contained in:
2026-06-16 17:05:21 +02:00
parent 597c63bb2e
commit 5734aaef54
5 changed files with 607 additions and 1 deletions
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
*
* `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter)
* et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à
* la création :
* - pré-check front : nom requis → POST bloqué, erreur inline, aucun appel réseau ;
* - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json +
* toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat +
* réaffichage du nom normalisé ;
* - 409 doublon (RG-4.12) → erreur inline dédiée sur `name` ;
* - 422 → mapping inline par champ (propertyPath) ;
* - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab
* déverrouille/avance et signale le dernier onglet ;
* - patchCarrier : PATCH partiel, no-op avant création.
*/
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
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(),
}))
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
describe('useCarrierForm', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
})
it('front : nom vide → erreur inline sur name, pas de POST', async () => {
const form = useCarrierForm()
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
expect(form.mainLocked.value).toBe(false)
})
it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => {
const form = useCarrierForm()
form.main.name = ' '
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
})
it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => {
mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
const form = useCarrierForm()
form.main.name = 'Transports Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
const created = await form.submitMain()
expect(created).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/carriers')
expect(body).toEqual({
name: 'Transports Acme',
certificationType: 'GMP_PLUS',
isChartered: true,
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.carrierId.value).toBe(42)
// RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur.
expect(form.main.name).toBe('TRANSPORTS ACME')
expect(form.mainLocked.value).toBe(true)
expect(form.activeTab.value).toBe('qualimat')
expect(form.unlockedIndex.value).toBe(0)
})
it('payload : omet name et certificationType vides, garde isChartered', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [] } } })
const form = useCarrierForm()
form.main.name = 'X' // nom présent pour passer le pré-check front
// certificationType laissé null → omis pour que la 422 « obligatoire » porte.
await form.submitMain()
const body = mockPost.mock.calls[0]?.[1] as Record<string, unknown>
expect(body).toEqual({ name: 'X', isChartered: false })
expect('certificationType' in body).toBe(false)
})
it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
const form = useCarrierForm()
form.main.name = 'Doublon'
form.main.certificationType = 'AUTRE'
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName')
expect(form.mainLocked.value).toBe(false)
})
it('422 : mappe les violations serveur inline par champ', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'certificationType', message: 'Le type de certification est obligatoire.' }] },
},
})
const form = useCarrierForm()
form.main.name = 'Sans Certif'
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.certificationType).toBe('Le type de certification est obligatoire.')
expect(form.mainLocked.value).toBe(false)
})
it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => {
expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
const form = useCarrierForm()
expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
// Tous verrouillés tant que le formulaire principal n'est pas validé.
expect(form.unlockedIndex.value).toBe(-1)
})
it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => {
const form = useCarrierForm()
// Qualimat → Adresses (pas le dernier).
expect(form.completeTab('qualimat')).toBe(false)
expect(form.isValidated('qualimat')).toBe(true)
expect(form.activeTab.value).toBe('addresses')
expect(form.unlockedIndex.value).toBe(1)
expect(form.completeTab('addresses')).toBe(false)
expect(form.activeTab.value).toBe('contacts')
expect(form.completeTab('contacts')).toBe(false)
expect(form.activeTab.value).toBe('prices')
// Prix = dernier onglet → true (création terminée).
expect(form.completeTab('prices')).toBe(true)
expect(form.isValidated('prices')).toBe(true)
})
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
const form = useCarrierForm()
form.editMode.value = true
form.activeTab.value = 'qualimat'
expect(form.completeTab('qualimat')).toBe(false)
expect(form.isValidated('qualimat')).toBe(false)
expect(form.activeTab.value).toBe('qualimat')
})
it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => {
const form = useCarrierForm()
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
expect(mockPatch).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' })
form.main.name = 'Acme'
form.main.certificationType = 'OVOCOM'
await form.submitMain()
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false })
})
})