All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N) - Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write - Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone - Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories - MAJ MCP tools / MachineStructureController / audit subscriber / tests - Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
891 lines
30 KiB
TypeScript
891 lines
30 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
import {
|
|
mockComponentFromApi,
|
|
mockLinkSKF,
|
|
mockLinkFAG,
|
|
mockConstructeurSKF,
|
|
wrapCollection,
|
|
} from '../fixtures/mockData'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import under test (AFTER all vi.mock calls)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { useComponentEdit } from '~/composables/useComponentEdit'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — API layer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockGet = vi.fn()
|
|
const mockPost = vi.fn()
|
|
const mockPatch = vi.fn()
|
|
const mockDel = vi.fn()
|
|
const mockPostFormData = vi.fn()
|
|
|
|
vi.mock('~/composables/useApi', () => ({
|
|
useApi: () => ({
|
|
get: mockGet,
|
|
post: mockPost,
|
|
patch: mockPatch,
|
|
put: vi.fn(),
|
|
delete: mockDel,
|
|
postFormData: mockPostFormData,
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — Toast
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockShowSuccess = vi.fn()
|
|
const mockShowError = vi.fn()
|
|
|
|
vi.mock('~/composables/useToast', () => ({
|
|
useToast: () => ({
|
|
showSuccess: mockShowSuccess,
|
|
showError: mockShowError,
|
|
showInfo: vi.fn(),
|
|
showToast: vi.fn(),
|
|
toasts: { value: [] },
|
|
clearAll: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useComposants (updateComposant)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockUpdateComposant = vi.fn()
|
|
|
|
vi.mock('~/composables/useComposants', () => ({
|
|
useComposants: () => ({
|
|
updateComposant: mockUpdateComposant,
|
|
composants: { value: [] },
|
|
loading: { value: false },
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — usePieces, useProducts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('~/composables/usePieces', () => ({
|
|
usePieces: () => ({
|
|
pieces: { value: [] },
|
|
loading: { value: false },
|
|
}),
|
|
}))
|
|
|
|
vi.mock('~/composables/useProducts', () => ({
|
|
useProducts: () => ({
|
|
products: { value: [] },
|
|
loading: { value: false },
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
|
|
const mockComponentTypes = { value: [] as any[] }
|
|
|
|
vi.mock('~/composables/useComponentTypes', () => ({
|
|
useComponentTypes: () => ({
|
|
componentTypes: mockComponentTypes,
|
|
loadComponentTypes: mockLoadComponentTypes,
|
|
loadingComponentTypes: { value: false },
|
|
}),
|
|
}))
|
|
|
|
vi.mock('~/composables/usePieceTypes', () => ({
|
|
usePieceTypes: () => ({
|
|
pieceTypes: { value: [] },
|
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('~/composables/useProductTypes', () => ({
|
|
useProductTypes: () => ({
|
|
productTypes: { value: [] },
|
|
loadProductTypes: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useDocuments
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockLoadDocumentsByComponent = vi.fn().mockResolvedValue({ success: true, data: [] })
|
|
const mockUploadDocuments = vi.fn().mockResolvedValue({ success: true, data: [] })
|
|
const mockDeleteDocument = vi.fn().mockResolvedValue({ success: true })
|
|
|
|
vi.mock('~/composables/useDocuments', () => ({
|
|
useDocuments: () => ({
|
|
loadDocumentsByComponent: mockLoadDocumentsByComponent,
|
|
uploadDocuments: mockUploadDocuments,
|
|
deleteDocument: mockDeleteDocument,
|
|
documents: { value: [] },
|
|
loading: { value: false },
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useConstructeurLinks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockFetchLinks = vi.fn().mockResolvedValue([])
|
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
|
|
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
|
useConstructeurLinks: () => ({
|
|
fetchLinks: mockFetchLinks,
|
|
syncLinks: mockSyncLinks,
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useCustomFieldInputs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
|
const mockRefreshCF = vi.fn()
|
|
|
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
|
useCustomFieldInputs: () => ({
|
|
fields: { value: [] },
|
|
requiredFilled: { value: true },
|
|
saveAll: mockSaveAll,
|
|
refresh: mockRefreshCF,
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.stubGlobal('usePermissions', () => ({
|
|
canEdit: { value: true },
|
|
canManage: { value: true },
|
|
isAdmin: { value: false },
|
|
isGranted: () => true,
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useConstructeurs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('~/composables/useConstructeurs', () => ({
|
|
useConstructeurs: () => ({
|
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — useEntityHistory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('~/composables/useEntityHistory', () => ({
|
|
useEntityHistory: () => ({
|
|
history: { value: [] },
|
|
loading: { value: false },
|
|
error: { value: null },
|
|
loadHistory: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — shared utils
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
|
|
getStructurePieces: (s: any) => Array.isArray(s?.pieces) ? s.pieces : [],
|
|
getStructureProducts: (s: any) => Array.isArray(s?.products) ? s.products : [],
|
|
resolvePieceLabel: (p: any) => p?.name ?? '',
|
|
resolveProductLabel: (p: any) => p?.name ?? '',
|
|
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
|
|
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
|
|
buildTypeLabelMap: () => ({}),
|
|
}))
|
|
|
|
vi.mock('~/shared/modelUtils', () => ({
|
|
formatStructurePreview: () => '',
|
|
normalizeStructureForEditor: (s: any) => s,
|
|
}))
|
|
|
|
vi.mock('~/shared/constructeurUtils', () => ({
|
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
|
}))
|
|
|
|
vi.mock('~/shared/utils/structureSelectionUtils', () => ({
|
|
collectStructureSelections: () => ({ pieces: [], products: [], components: [] }),
|
|
}))
|
|
|
|
vi.mock('~/utils/documentPreview', () => ({
|
|
canPreviewDocument: () => false,
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test data — component with structure containing slots
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const COMPONENT_ID = 'cl-comp-1'
|
|
|
|
function buildComponentWithStructure() {
|
|
return {
|
|
...mockComponentFromApi,
|
|
id: COMPONENT_ID,
|
|
'@id': `/api/composants/${COMPONENT_ID}`,
|
|
description: 'Un moteur triphas\u00e9 haute performance',
|
|
prix: '1500.00',
|
|
typeComposantId: 'tc-moteur',
|
|
structure: {
|
|
pieces: [
|
|
{
|
|
slotId: 'ps-001',
|
|
typePieceId: 'tp-bearing-001',
|
|
selectedPieceId: 'piece-001',
|
|
selectedPieceName: 'Roulement 6205',
|
|
quantity: 2,
|
|
position: 0,
|
|
},
|
|
{
|
|
slotId: 'ps-002',
|
|
typePieceId: 'tp-seal-002',
|
|
selectedPieceId: 'piece-002',
|
|
selectedPieceName: 'Joint torique',
|
|
quantity: 1,
|
|
position: 1,
|
|
},
|
|
],
|
|
products: [
|
|
{
|
|
slotId: 'prs-001',
|
|
typeProductId: 'tprod-grease-001',
|
|
selectedProductId: 'prod-001',
|
|
selectedProductName: 'Graisse LGMT2',
|
|
familyCode: 'LUB',
|
|
position: 0,
|
|
},
|
|
],
|
|
subcomponents: [
|
|
{
|
|
slotId: 'scs-001',
|
|
typeComposantId: 'tc-sub-001',
|
|
selectedComponentId: 'comp-sub-001',
|
|
selectedComponentName: 'Palier avant',
|
|
alias: 'Palier avant',
|
|
familyCode: 'PAL',
|
|
position: 0,
|
|
},
|
|
],
|
|
customFields: [],
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Wait for next tick + micro-tasks so watchers fire. */
|
|
const tick = () => new Promise(r => setTimeout(r, 0))
|
|
|
|
/**
|
|
* Create the composable AND hydrate it by resolving the mocked get.
|
|
* Returns the composable instance after fetch + watcher hydration.
|
|
*/
|
|
async function createAndHydrate(overrides?: Partial<ReturnType<typeof buildComponentWithStructure>>) {
|
|
const comp = { ...buildComponentWithStructure(), ...overrides }
|
|
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url.includes(`/composants/${COMPONENT_ID}`)) {
|
|
return Promise.resolve({ success: true, data: structuredClone(comp) })
|
|
}
|
|
return Promise.resolve({ success: true, data: wrapCollection([]) })
|
|
})
|
|
|
|
mockFetchLinks.mockResolvedValue([
|
|
{ ...mockLinkSKF },
|
|
])
|
|
|
|
const composable = useComponentEdit(COMPONENT_ID)
|
|
|
|
// fetchComponent is called, then the watcher hydrates editionForm
|
|
await composable.fetchComponent()
|
|
await tick()
|
|
|
|
return composable
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// beforeEach
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockComponentTypes.value = [
|
|
{ id: 'tc-moteur', name: 'Moteur \u00e9lectrique', category: 'COMPONENT', structure: null },
|
|
]
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchComponent — hydration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('fetchComponent — hydration', () => {
|
|
it('loads simple fields into editionForm (name, reference, description, prix)', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
expect(composable.editionForm.name).toBe('Moteur principal')
|
|
expect(composable.editionForm.reference).toBe('COMP-MOT-001')
|
|
expect(composable.editionForm.description).toBe('Un moteur triphas\u00e9 haute performance')
|
|
expect(composable.editionForm.prix).toBe('1500.00')
|
|
})
|
|
|
|
it('loads component object with structure containing slots', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
expect(composable.component.value).not.toBeNull()
|
|
expect(composable.component.value.structure).toBeDefined()
|
|
expect(composable.component.value.structure.pieces).toHaveLength(2)
|
|
expect(composable.component.value.structure.products).toHaveLength(1)
|
|
expect(composable.component.value.structure.subcomponents).toHaveLength(1)
|
|
expect(composable.component.value.customFieldValues).toBeDefined()
|
|
expect(Array.isArray(composable.component.value.customFieldValues)).toBe(true)
|
|
})
|
|
|
|
it('loads constructeur links via fetchLinks', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
expect(mockFetchLinks).toHaveBeenCalledWith('composant', COMPONENT_ID)
|
|
expect(composable.constructeurLinks.value).toHaveLength(1)
|
|
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Slot operations — no data loss
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('slot operations — no data loss', () => {
|
|
it('setting piece slot selection preserves product and subcomponent slots', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
// Record initial product and subcomponent slot entries
|
|
const initialProductSlots = composable.productSlotEntries.value
|
|
const initialSubSlots = composable.subcomponentSlotEntries.value
|
|
|
|
expect(initialProductSlots).toHaveLength(1)
|
|
expect(initialSubSlots).toHaveLength(1)
|
|
|
|
// Change a piece slot selection
|
|
composable.setPieceSlotSelection('ps-001', 'piece-999')
|
|
await tick()
|
|
|
|
// Piece slot changed
|
|
const pieceSlots = composable.pieceSlotEntries.value
|
|
expect(pieceSlots.find(s => s.slotId === 'ps-001')?.selectedPieceId).toBe('piece-999')
|
|
|
|
// Product and subcomponent slots untouched
|
|
expect(composable.productSlotEntries.value).toHaveLength(1)
|
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
|
|
expect(composable.subcomponentSlotEntries.value).toHaveLength(1)
|
|
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-sub-001')
|
|
})
|
|
|
|
it('setting product slot selection preserves piece slots', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
// Change a product slot
|
|
composable.setProductSlotSelection('prs-001', 'prod-new-001')
|
|
await tick()
|
|
|
|
// Product changed
|
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-new-001')
|
|
|
|
// Piece slots untouched
|
|
expect(composable.pieceSlotEntries.value).toHaveLength(2)
|
|
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
|
|
expect(composable.pieceSlotEntries.value[1].selectedPieceId).toBe('piece-002')
|
|
})
|
|
|
|
it('setting subcomponent slot selection preserves piece and product slots', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
// Change a subcomponent slot
|
|
composable.setSubcomponentSlotSelection('scs-001', 'comp-new-sub')
|
|
await tick()
|
|
|
|
// Subcomponent changed
|
|
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-new-sub')
|
|
|
|
// Piece and product slots untouched
|
|
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
|
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
|
|
})
|
|
|
|
it('setting slot quantity preserves selectedPieceId', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
// Set a piece selection first
|
|
composable.setPieceSlotSelection('ps-001', 'piece-special')
|
|
await tick()
|
|
|
|
// Now change quantity on the same slot
|
|
composable.setSlotQuantity('ps-001', 5)
|
|
await tick()
|
|
|
|
const slot = composable.pieceSlotEntries.value.find(s => s.slotId === 'ps-001')
|
|
expect(slot?.selectedPieceId).toBe('piece-special')
|
|
expect(slot?.quantity).toBe(5)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// submitEdition — no data loss
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('submitEdition — no data loss', () => {
|
|
it('sends all form fields in PATCH payload via updateComposant', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
// Modify form fields
|
|
composable.editionForm.name = 'Moteur modifi\u00e9'
|
|
composable.editionForm.description = 'Nouvelle description'
|
|
composable.editionForm.reference = 'REF-MOD-001'
|
|
composable.editionForm.prix = '2500'
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload).toMatchObject({
|
|
name: 'Moteur modifi\u00e9',
|
|
description: 'Nouvelle description',
|
|
reference: 'REF-MOD-001',
|
|
prix: '2500',
|
|
})
|
|
})
|
|
|
|
it('saves custom fields after patch', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('patches slot edits to correct endpoints', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
mockPatch.mockResolvedValue({ success: true, data: {} })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
// Make slot edits
|
|
composable.setPieceSlotSelection('ps-001', 'piece-new')
|
|
composable.setSlotQuantity('ps-002', 3)
|
|
composable.setProductSlotSelection('prs-001', 'prod-new')
|
|
composable.setSubcomponentSlotSelection('scs-001', 'comp-new')
|
|
|
|
await composable.submitEdition()
|
|
|
|
// Verify piece slot patches
|
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-new' })
|
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-002', { quantity: 3 })
|
|
|
|
// Verify product slot patch
|
|
expect(mockPatch).toHaveBeenCalledWith('/composant-product-slots/prs-001', { selectedProductId: 'prod-new' })
|
|
|
|
// Verify subcomponent slot patch
|
|
expect(mockPatch).toHaveBeenCalledWith('/composant-subcomponent-slots/scs-001', { selectedComposantId: 'comp-new' })
|
|
})
|
|
|
|
it('syncs constructeur links with diff', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
// Add a second constructeur link
|
|
composable.constructeurLinks.value = [
|
|
{ ...mockLinkSKF },
|
|
{ ...mockLinkFAG },
|
|
]
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
|
// originalConstructeurLinks was set to [mockLinkSKF] from fetchLinks
|
|
// formLinks is now [mockLinkSKF, mockLinkFAG]
|
|
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
|
expect(entityType).toBe('composant')
|
|
expect(entityId).toBe(COMPONENT_ID)
|
|
expect(origLinks).toHaveLength(1)
|
|
expect(formLinks).toHaveLength(2)
|
|
})
|
|
|
|
it('editing name does not lose constructeur links', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
// Only edit name
|
|
composable.editionForm.name = 'Nouveau nom moteur'
|
|
|
|
await composable.submitEdition()
|
|
|
|
// updateComposant was called with name change
|
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.name).toBe('Nouveau nom moteur')
|
|
|
|
// syncLinks was still called (preserving links)
|
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
|
// Both should contain the original SKF link
|
|
expect(origLinks).toHaveLength(1)
|
|
expect(formLinks).toHaveLength(1)
|
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Document operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('document operations', () => {
|
|
it('populates componentDocuments from fetchComponent response', async () => {
|
|
const docFixtures = [
|
|
{ id: 'doc-1', name: 'photo.jpg', type: 'photo' },
|
|
{ id: 'doc-2', name: 'schema.pdf', type: 'schema' },
|
|
]
|
|
const composable = await createAndHydrate({ documents: docFixtures } as any)
|
|
|
|
expect(composable.componentDocuments.value).toHaveLength(2)
|
|
expect(composable.componentDocuments.value[0].id).toBe('doc-1')
|
|
expect(composable.componentDocuments.value[1].id).toBe('doc-2')
|
|
})
|
|
|
|
it('sets componentDocuments to empty array when response has no documents', async () => {
|
|
const composable = await createAndHydrate({ documents: undefined } as any)
|
|
|
|
expect(composable.componentDocuments.value).toEqual([])
|
|
})
|
|
|
|
it('handleFilesAdded calls uploadDocuments with composantId context', async () => {
|
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
|
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: [] })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
const files = [new File(['content'], 'test.pdf', { type: 'application/pdf' })]
|
|
await composable.handleFilesAdded(files)
|
|
|
|
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
|
|
const callArgs = mockUploadDocuments.mock.calls[0]![0]
|
|
expect(callArgs.files).toBe(files)
|
|
expect(callArgs.context.composantId).toBe(COMPONENT_ID)
|
|
})
|
|
|
|
it('handleFilesAdded does nothing when files array is empty', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.handleFilesAdded([])
|
|
|
|
expect(mockUploadDocuments).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handleFilesAdded refreshes documents after successful upload', async () => {
|
|
const refreshedDocs = [{ id: 'doc-new', name: 'uploaded.pdf' }]
|
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
|
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: refreshedDocs })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
const files = [new File(['data'], 'uploaded.pdf')]
|
|
await composable.handleFilesAdded(files)
|
|
|
|
expect(mockLoadDocumentsByComponent).toHaveBeenCalledWith(COMPONENT_ID, { updateStore: false })
|
|
expect(composable.componentDocuments.value).toHaveLength(1)
|
|
expect(composable.componentDocuments.value[0].id).toBe('doc-new')
|
|
})
|
|
|
|
it('removeDocument calls deleteDocument and removes from local list', async () => {
|
|
mockDeleteDocument.mockResolvedValue({ success: true })
|
|
|
|
const composable = await createAndHydrate({
|
|
documents: [
|
|
{ id: 'doc-a', name: 'a.pdf' },
|
|
{ id: 'doc-b', name: 'b.pdf' },
|
|
],
|
|
} as any)
|
|
|
|
expect(composable.componentDocuments.value).toHaveLength(2)
|
|
|
|
await composable.removeDocument('doc-a')
|
|
|
|
expect(mockDeleteDocument).toHaveBeenCalledWith('doc-a', { updateStore: false })
|
|
expect(composable.componentDocuments.value).toHaveLength(1)
|
|
expect(composable.componentDocuments.value[0].id).toBe('doc-b')
|
|
})
|
|
|
|
it('removeDocument does nothing when documentId is falsy', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.removeDocument(null)
|
|
await composable.removeDocument(undefined)
|
|
|
|
expect(mockDeleteDocument).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Null field handling in PATCH payload
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('null field handling in PATCH payload', () => {
|
|
it('empty prix string sends null in payload', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.prix = ''
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.prix).toBeNull()
|
|
})
|
|
|
|
it('whitespace-only prix string sends null in payload', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.prix = ' '
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.prix).toBeNull()
|
|
})
|
|
|
|
it('valid prix string sends stringified number', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.prix = '42.50'
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.prix).toBe('42.5')
|
|
})
|
|
|
|
it('empty reference string sends null in payload', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.reference = ''
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.reference).toBeNull()
|
|
})
|
|
|
|
it('empty description string sends null in payload', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.description = ''
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.description).toBeNull()
|
|
})
|
|
|
|
it('whitespace-only description sends null in payload', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.description = ' '
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.description).toBeNull()
|
|
})
|
|
|
|
it('name is trimmed but never null', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.name = ' Moteur '
|
|
|
|
await composable.submitEdition()
|
|
|
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
|
expect(payload.name).toBe('Moteur')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('error paths', () => {
|
|
it('does not save custom fields when updateComposant returns { success: false }', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.editionForm.name = 'Test'
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
|
expect(mockPatch).not.toHaveBeenCalled()
|
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not patch slots when updateComposant returns { success: false }', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.setPieceSlotSelection('ps-001', 'piece-new')
|
|
composable.setProductSlotSelection('prs-001', 'prod-new')
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockPatch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not sync constructeur links when updateComposant fails', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows error toast when saveAllCustomFields returns failed fields', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
mockSaveAll.mockResolvedValue(['Tension nominale', 'Indice de protection'])
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
|
expect(mockShowError.mock.calls[0]![0]).toContain('Tension nominale')
|
|
expect(mockShowError.mock.calls[0]![0]).toContain('Indice de protection')
|
|
})
|
|
|
|
it('still saves slots and syncs links even when custom fields fail', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
mockSaveAll.mockResolvedValue(['Tension nominale'])
|
|
mockPatch.mockResolvedValue({ success: true, data: {} })
|
|
|
|
const composable = await createAndHydrate()
|
|
composable.setPieceSlotSelection('ps-001', 'piece-after-cf-fail')
|
|
|
|
await composable.submitEdition()
|
|
|
|
// Slots still patched despite custom field failure
|
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-after-cf-fail' })
|
|
// Links still synced
|
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
|
// Success toast still shown (alongside the error toast for CF)
|
|
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('shows error toast when submitEdition throws', async () => {
|
|
mockUpdateComposant.mockRejectedValue(new Error('Network failure'))
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
|
expect(mockShowError.mock.calls[0]![0]).toContain('Network failure')
|
|
expect(composable.saving.value).toBe(false)
|
|
})
|
|
|
|
it('resets saving flag even when updateComposant throws', async () => {
|
|
mockUpdateComposant.mockRejectedValue(new Error('Server error'))
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(composable.saving.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom field save verification
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('custom field save verification', () => {
|
|
it('saveAllCustomFields is called after successful updateComposant', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
mockSaveAll.mockResolvedValue([])
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not show error toast when saveAll returns empty array (no failures)', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
mockSaveAll.mockResolvedValue([])
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
// showError should NOT have been called (only showSuccess)
|
|
expect(mockShowError).not.toHaveBeenCalled()
|
|
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('shows error with all failed field names joined', async () => {
|
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
|
mockSaveAll.mockResolvedValue(['Champ A', 'Champ B', 'Champ C'])
|
|
|
|
const composable = await createAndHydrate()
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
|
const errorMsg = mockShowError.mock.calls[0]![0] as string
|
|
expect(errorMsg).toContain('Champ A')
|
|
expect(errorMsg).toContain('Champ B')
|
|
expect(errorMsg).toContain('Champ C')
|
|
})
|
|
|
|
it('submitEdition does nothing when component is null', async () => {
|
|
const composable = await createAndHydrate()
|
|
|
|
// Force component to null
|
|
composable.component.value = null
|
|
|
|
await composable.submitEdition()
|
|
|
|
expect(mockUpdateComposant).not.toHaveBeenCalled()
|
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
|
})
|
|
})
|