import { describe, it, expect, vi, beforeEach } from 'vitest' import { mockComponentFromApi, mockLinkSKF, mockLinkFAG, mockConstructeurSKF, wrapCollection, } from '../fixtures/mockData' // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- vi.mock('~/composables/useDocuments', () => ({ useDocuments: () => ({ loadDocumentsByComponent: vi.fn().mockResolvedValue({ success: true, data: [] }), uploadDocuments: vi.fn().mockResolvedValue({ success: true, data: [] }), deleteDocument: vi.fn().mockResolvedValue({ success: true }), 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, })) // --------------------------------------------------------------------------- // Import under test (AFTER all vi.mock calls) // --------------------------------------------------------------------------- import { useComponentEdit } from '~/composables/useComponentEdit' // --------------------------------------------------------------------------- // 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) }) })