Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ece8146c03 | |||
| 58589e93d0 | |||
| e0d59962d6 | |||
| 3ce40a707f |
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.49'
|
||||
app.version: '0.1.51'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ headerLabel }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
|
||||
// eviter de charger Pinia et la vraie store (pas necessaire ici).
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
onAuthSessionCleared: vi.fn(),
|
||||
}))
|
||||
|
||||
// Le client API est un auto-import Nuxt. On le remplace par un stub
|
||||
// global pour intercepter les appels et controler les reponses dans
|
||||
// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts).
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a
|
||||
// ce moment-la, donc le mock auth est bien actif au top-level.
|
||||
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT_A: Category = {
|
||||
id: 10,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
const CAT_B: Category = {
|
||||
id: 11,
|
||||
name: 'Boulons',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-02T10:00:00+00:00',
|
||||
updatedAt: '2026-01-02T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
return {
|
||||
totalItems: items.length,
|
||||
member: items,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useCategoriesAdmin', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// Reset systematique du state singleton entre tests : sans ca,
|
||||
// les categories chargees dans un test fuiteraient dans le suivant.
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
resetCategoriesAdmin()
|
||||
})
|
||||
|
||||
describe('fetchAll', () => {
|
||||
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple categories.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
|
||||
const { fetchAll, categories } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([CAT_A, CAT_B])
|
||||
})
|
||||
|
||||
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(queryArg).not.toHaveProperty('includeDeleted')
|
||||
})
|
||||
|
||||
it('ajoute includeDeleted=true quand demande explicitement', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
|
||||
const { fetchAll } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll(true)
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ itemsPerPage: 999, includeDeleted: 'true' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('passe loading a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchAll, loading } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchAll()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<Category>([]))
|
||||
await pending
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('peuple error.value et vide categories en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('Network down'))
|
||||
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
|
||||
// Pre-charge volontairement quelque chose pour verifier la purge.
|
||||
categories.value = [CAT_A]
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
expect(error.value).toBe('Network down')
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<Category>)
|
||||
const { fetchAll, categories } = useCategoriesAdmin()
|
||||
|
||||
await fetchAll()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchTypes', () => {
|
||||
it('appelle GET /category_types avec itemsPerPage=999', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/category_types',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple types.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
|
||||
})
|
||||
|
||||
it('peuple error.value et vide types en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('500'))
|
||||
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
|
||||
types.value = [TYPE_VENTE]
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
expect(error.value).toContain('500')
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('passe loadingTypes a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchTypes()
|
||||
expect(loadingTypes.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<CategoryType>([]))
|
||||
await pending
|
||||
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetCategoriesAdmin', () => {
|
||||
it('vide categories, types, loading, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
||||
= useCategoriesAdmin()
|
||||
// Pre-peuple le state pour verifier la purge effective.
|
||||
categories.value = [CAT_A]
|
||||
types.value = [TYPE_VENTE]
|
||||
loading.value = true
|
||||
loadingTypes.value = true
|
||||
error.value = 'oops'
|
||||
|
||||
resetCategoriesAdmin()
|
||||
|
||||
expect(categories.value).toEqual([])
|
||||
expect(types.value).toEqual([])
|
||||
expect(loading.value).toBe(false)
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||
// doivent etre rigoureusement les memes (state au niveau module).
|
||||
expect(a.categories).toBe(b.categories)
|
||||
expect(a.types).toBe(b.types)
|
||||
expect(a.loading).toBe(b.loading)
|
||||
})
|
||||
|
||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
a.categories.value = [CAT_A]
|
||||
|
||||
expect(b.categories.value).toEqual([CAT_A])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,454 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import { useCategoryForm } from '../useCategoryForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockDelete = 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: mockPatch,
|
||||
delete: mockDelete,
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
|
||||
// Quand le composable passe des params (ex: doublon), on les serialise pour
|
||||
// pouvoir verifier que l'interpolation a bien recu le bon nom.
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT: Category = {
|
||||
id: 42,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
describe('useCategoryForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
describe('loadFrom', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.name.value).toBe('Vis')
|
||||
expect(form.categoryTypeId.value).toBe(1)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
})
|
||||
|
||||
it('vide le formulaire en mode creation (null)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'old'
|
||||
form.categoryTypeId.value = 99
|
||||
|
||||
form.loadFrom(null)
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirty', () => {
|
||||
it('passe a true des qu une valeur diverge du snapshot initial', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
|
||||
form.name.value = 'Vis modifie'
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate', () => {
|
||||
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' '
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'.repeat(121)
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = null
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||
})
|
||||
|
||||
it('passe quand name et categoryType sont valides', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
})
|
||||
|
||||
it('reinitialise les erreurs avant chaque validation', () => {
|
||||
const form = useCategoryForm()
|
||||
// Erreur prealable.
|
||||
form.errors.value._global = 'erreur ancienne'
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
form.validate()
|
||||
|
||||
expect(form.errors.value._global).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitCreate', () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' Vis '
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
})
|
||||
|
||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('declenche un toast de succes en cas de creation reussie', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.created',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
|
||||
// les params i18n (stub serialise les params).
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Vis"')
|
||||
expect(mockToastError).toHaveBeenCalledTimes(1)
|
||||
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
|
||||
expect(toastArg.message).toContain('Vis')
|
||||
})
|
||||
|
||||
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
violations: [
|
||||
{ propertyPath: 'name', message: 'name should not be blank.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toBe('name should not be blank.')
|
||||
// Pas de toast quand on a mappe les violations : l erreur est
|
||||
// affichee inline sous le champ concerne.
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
'hydra:violations': [
|
||||
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value.categoryType).toBe('Type invalide.')
|
||||
})
|
||||
|
||||
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value._global).toBe('Boom server')
|
||||
expect(mockToastError).toHaveBeenCalledWith({
|
||||
title: 'Erreur',
|
||||
message: 'Boom server',
|
||||
})
|
||||
})
|
||||
|
||||
it('passe submitting a true pendant la requete et a false apres', async () => {
|
||||
let resolveRequest: (v: Category) => void = () => {}
|
||||
mockPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const pending = form.submitCreate()
|
||||
expect(form.submitting.value).toBe(true)
|
||||
|
||||
resolveRequest(CAT)
|
||||
await pending
|
||||
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitUpdate', () => {
|
||||
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.categoryTypeId.value = 2
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ categoryType: '/api/category_types/2' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('court-circuite l appel API si aucun champ n a change', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
// Aucune modification — isDirty=false, patch payload vide.
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('declenche un toast de succes au PATCH reussi', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2'
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.updated',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe le 409 sur errors.name en mode update aussi', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Doublon'
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Doublon"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitDelete', () => {
|
||||
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
|
||||
mockDelete.mockResolvedValueOnce(undefined)
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||
expect(ok).toBe(true)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.deleted',
|
||||
})
|
||||
})
|
||||
|
||||
it('retourne false et toast erreur en cas d echec', async () => {
|
||||
mockDelete.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { detail: 'down' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value._global).toBe('down')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('vide le formulaire et les erreurs', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'edit'
|
||||
form.errors.value._global = 'erreur'
|
||||
form.submitting.value = true
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation', () => {
|
||||
it('deux instances useCategoryForm() ont des states independants', () => {
|
||||
const a = useCategoryForm()
|
||||
const b = useCategoryForm()
|
||||
|
||||
a.name.value = 'A'
|
||||
b.name.value = 'B'
|
||||
|
||||
expect(a.name.value).toBe('A')
|
||||
expect(b.name.value).toBe('B')
|
||||
// Les refs sont distinctes (pas singleton — chaque drawer son state).
|
||||
expect(a.name).not.toBe(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,14 @@ import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
||||
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
||||
* toute la liste en un coup. A basculer en pagination serveur quand la
|
||||
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
||||
*/
|
||||
const HYDRA_NO_PAGINATION = 999
|
||||
|
||||
// State singleton — partage entre tous les composants qui appellent le
|
||||
// composable dans la meme session. Les refs sont declarees au niveau module
|
||||
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
||||
@@ -61,7 +69,7 @@ export function useCategoriesAdmin() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const query: Record<string, unknown> = { itemsPerPage: 999 }
|
||||
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||
if (includeDeleted) {
|
||||
query.includeDeleted = 'true'
|
||||
}
|
||||
@@ -92,7 +100,7 @@ export function useCategoriesAdmin() {
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
{ itemsPerPage: 999 },
|
||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
|
||||
@@ -19,16 +19,7 @@
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
/**
|
||||
* Forme des violations renvoyees par API Platform 4 en 422. La cle peut etre
|
||||
* `violations` ou `hydra:violations` selon la negociation de format — on
|
||||
* tente les deux.
|
||||
*/
|
||||
interface ApiViolation {
|
||||
propertyPath?: string
|
||||
message?: string
|
||||
}
|
||||
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||
@@ -136,62 +127,50 @@ export function useCategoryForm() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe une reponse 422 API Platform sur le state `errors`. API Platform 4
|
||||
* retourne `violations: [{ propertyPath, message }]` (ou
|
||||
* `hydra:violations` selon negociation). On ne mappe que les chemins
|
||||
* connus (`name`, `categoryType`) ; le reste fallback en erreur globale.
|
||||
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
|
||||
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
|
||||
* sur les futurs drawers de formulaire.
|
||||
*/
|
||||
function mapServerViolations(data: unknown): boolean {
|
||||
if (!data || typeof data !== 'object') return false
|
||||
const record = data as Record<string, unknown>
|
||||
const rawViolations = record.violations ?? record['hydra:violations']
|
||||
if (!Array.isArray(rawViolations)) return false
|
||||
|
||||
const violations = extractApiViolations(data)
|
||||
if (violations.length === 0) return false
|
||||
let mapped = false
|
||||
for (const v of rawViolations as ApiViolation[]) {
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const path = String(v.propertyPath ?? '')
|
||||
const message = String(v.message ?? '')
|
||||
if (path === 'name') {
|
||||
errors.value.name = message
|
||||
for (const v of violations) {
|
||||
if (v.propertyPath === 'name') {
|
||||
errors.value.name = v.message
|
||||
mapped = true
|
||||
} else if (path === 'categoryType') {
|
||||
errors.value.categoryType = message
|
||||
} else if (v.propertyPath === 'categoryType') {
|
||||
errors.value.categoryType = v.message
|
||||
mapped = true
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra (champs
|
||||
* `hydra:description`, `detail`, `description`).
|
||||
*/
|
||||
function extractErrorMessage(data: unknown): string {
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string)
|
||||
?? (record.detail as string)
|
||||
?? (record.description as string)
|
||||
?? ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||
* appropries. Centralise la logique entre create/update.
|
||||
*
|
||||
* Retourne true si l'erreur a ete reconnue et traitee, false sinon
|
||||
* (utile pour les tests).
|
||||
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||
* le nom soumis.
|
||||
* - 422 : tentative de mapping fin via les violations API Platform — si au
|
||||
* moins une violation est mappee, pas de toast (erreur affichee inline
|
||||
* sous le champ concerne).
|
||||
* - autre : message global + toast generique. Le toast natif d'useApi
|
||||
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
|
||||
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
|
||||
*
|
||||
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
|
||||
* false sinon (fallback generique).
|
||||
*/
|
||||
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||
const status = (e as ApiFetchError)?.response?.status
|
||||
const data = (e as ApiFetchError)?.response?._data
|
||||
|
||||
if (status === 409) {
|
||||
// RG-1.07 — doublon (name, categoryType). Toast custom +
|
||||
// erreur mappee sur le champ name (origine du conflit).
|
||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||
name: attemptedName,
|
||||
})
|
||||
@@ -204,12 +183,10 @@ export function useCategoryForm() {
|
||||
}
|
||||
|
||||
if (status === 422 && mapServerViolations(data)) {
|
||||
// Violations mappees sur les champs — pas de toast, l'utilisateur
|
||||
// voit l'erreur directement sous le champ concerne.
|
||||
return true
|
||||
}
|
||||
|
||||
const extracted = extractErrorMessage(data)
|
||||
const extracted = extractApiErrorMessage(data)
|
||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des categories : tri par defaut sur Nom ASC (RG-1.10).
|
||||
Tri serveur applique a la requete + pagination front via
|
||||
MalioDataTable (volumetrie cible <= 300, cf. spec § 4.1). -->
|
||||
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
||||
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
||||
reste cosmetique tant qu'aucun slice client n'est cable : a
|
||||
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchOptions , FetchError } from 'ofetch'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const msg = extractApiErrorMessage(data)
|
||||
if (msg) return msg
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
|
||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
return collection.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||
*/
|
||||
export interface ApiViolation {
|
||||
propertyPath: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
|
||||
* les deux formats de negociation (`violations` ou `hydra:violations`) et
|
||||
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
|
||||
*
|
||||
* Utilise par useCategoryForm et tout futur composable de formulaire qui
|
||||
* doit mapper les violations serveur sur ses champs.
|
||||
*/
|
||||
export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const record = data as Record<string, unknown>
|
||||
const raw = record.violations ?? record['hydra:violations']
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: ApiViolation[] = []
|
||||
for (const v of raw) {
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const obj = v as Record<string, unknown>
|
||||
out.push({
|
||||
propertyPath: String(obj.propertyPath ?? ''),
|
||||
message: String(obj.message ?? ''),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||
* `hydra:description` → `detail` → `description` → `message` → `error` →
|
||||
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
|
||||
*
|
||||
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
|
||||
* Symfony en text/plain ou des messages bruts).
|
||||
*/
|
||||
export function extractApiErrorMessage(data: unknown): string {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string)
|
||||
?? (record.detail as string)
|
||||
?? (record.description as string)
|
||||
?? (record.message as string)
|
||||
?? (record.error as string)
|
||||
?? (record.title as string)
|
||||
?? (record['hydra:title'] as string)
|
||||
?? ''
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user