feat(catalog) : M7 — écran Ajouter un stockage /admin/storages/new (ERP-217)
- Formulaire de création à plat (pas d'onglets, HP-M7-06), gate catalog.storages.manage - Champs Site, Type de stockage, Numéro, État (multi ≥1) en composants Malio, validation inline 422 par champ via useFormErrors - 409 doublon (site, type, numéro) RG-7.01 → erreur inline sous Numéro + toast explicite - Composable useStorageForm (POST /storages, payload relations en IRI), libellés i18n - Référentiel des types PLAT : pas de cascade Site→Type (RG-7.03 non portée côté back, StorageType sans relation Site — à reclarifier spec) - Tests Vitest de useStorageForm (référentiel plat, submit, 409/422)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useStorageForm } from '../useStorageForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = 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: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
|
||||
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */
|
||||
const STORAGE_TYPES = {
|
||||
member: [
|
||||
{ '@id': '/api/storage_types/9', label: 'Cellule' },
|
||||
{ '@id': '/api/storage_types/5', label: 'Tas' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('useStorageForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
|
||||
// Routage des GET par url (referentiels). Le type de stockage est un
|
||||
// referentiel plat : meme reponse quelle que soit la requete.
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
}
|
||||
if (url === '/storage_types') {
|
||||
return Promise.resolve(STORAGE_TYPES)
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('referentiel plat — pas de cascade Site->Type (RG-7.03 non portee back)', () => {
|
||||
it('loadReferentials charge les sites et TOUS les types, sans filtre site', async () => {
|
||||
const { siteOptions, storageTypeOptions, loadReferentials } = useStorageForm()
|
||||
await loadReferentials()
|
||||
|
||||
const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types')
|
||||
expect(storageCall).toBeDefined()
|
||||
// Aucun filtre siteId envoye (referentiel plat).
|
||||
expect(storageCall?.[1]).not.toHaveProperty('siteId[]')
|
||||
|
||||
expect(siteOptions.value.map(o => o.value)).toEqual(['/api/sites/1'])
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
|
||||
'/api/storage_types/9',
|
||||
'/api/storage_types/5',
|
||||
])
|
||||
})
|
||||
|
||||
it('changer de site ne recharge pas les types ni ne purge la selection', async () => {
|
||||
const { form, setSite, setStorageType, loadReferentials } = useStorageForm()
|
||||
await loadReferentials()
|
||||
const callsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
|
||||
|
||||
setStorageType('/api/storage_types/9')
|
||||
setSite('/api/sites/1')
|
||||
|
||||
expect(form.siteIri).toBe('/api/sites/1')
|
||||
// Selection conservee : pas de cascade ni de purge par site.
|
||||
expect(form.storageTypeIri).toBe('/api/storage_types/9')
|
||||
const callsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
|
||||
expect(callsAfter).toBe(callsBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit — POST /storages', () => {
|
||||
function fillValidForm(form: ReturnType<typeof useStorageForm>['form']): void {
|
||||
form.siteIri = '/api/sites/1'
|
||||
form.storageTypeIri = '/api/storage_types/9'
|
||||
form.numero = '12'
|
||||
form.states = ['RECEPTION', 'PRODUCTION']
|
||||
}
|
||||
|
||||
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42 })
|
||||
const { form, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/storages',
|
||||
{
|
||||
numero: '12',
|
||||
states: ['RECEPTION', 'PRODUCTION'],
|
||||
site: '/api/sites/1',
|
||||
storageType: '/api/storage_types/9',
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omet `site` / `storageType` du payload quand la relation n\'est pas choisie', async () => {
|
||||
// Envoyer null casserait la denormalisation back (IRI attendu) et
|
||||
// court-circuiterait les autres violations -> on omet la cle.
|
||||
mockPost.mockResolvedValueOnce({ id: 43 })
|
||||
const { form, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
form.siteIri = null
|
||||
form.storageTypeIri = null
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = mockPost.mock.calls[0][1]
|
||||
expect(payload).not.toHaveProperty('site')
|
||||
expect(payload).not.toHaveProperty('storageType')
|
||||
// numero envoye en chaine vide si non saisi (NotBlank cote back).
|
||||
expect(payload).toHaveProperty('numero')
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon (site, type, numero) sur errors.numero + toast explicite', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { form, errors, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.numero).toBe('admin.storages.form.duplicateNumero')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 inline par champ (errors.numero) sans toast', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'numero', message: 'Le numéro du stockage est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
form.numero = null
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.numero).toBe('Le numéro du stockage est obligatoire.')
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 sur site / storageType / states (NotNull / Count)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [
|
||||
{ propertyPath: 'site', message: 'Le site est obligatoire.' },
|
||||
{ propertyPath: 'storageType', message: 'Le type de stockage est obligatoire.' },
|
||||
{ propertyPath: 'states', message: 'Sélectionnez au moins un état.' },
|
||||
] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
|
||||
await submit()
|
||||
|
||||
expect(errors.site).toBe('Le site est obligatoire.')
|
||||
expect(errors.storageType).toBe('Le type de stockage est obligatoire.')
|
||||
expect(errors.states).toBe('Sélectionnez au moins un état.')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user