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>) { 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() }) })