import { describe, it, expect, vi, beforeEach } from 'vitest' import { mockPieceFromApi, mockLinkSKF, mockLinkFAG, mockConstructeurSKF, mockConstructeurFAG, 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 — usePieces (updatePiece) // --------------------------------------------------------------------------- const mockUpdatePiece = vi.fn() vi.mock('~/composables/usePieces', () => ({ usePieces: () => ({ updatePiece: mockUpdatePiece, pieces: { value: [] }, loading: { value: false }, }), })) // --------------------------------------------------------------------------- // Mocks — usePieceTypes // --------------------------------------------------------------------------- const mockPieceTypes = { value: [] as any[] } const mockLoadPieceTypes = vi.fn().mockResolvedValue(undefined) vi.mock('~/composables/usePieceTypes', () => ({ usePieceTypes: () => ({ pieceTypes: mockPieceTypes, loadPieceTypes: mockLoadPieceTypes, }), })) // --------------------------------------------------------------------------- // Mocks — useDocuments // --------------------------------------------------------------------------- vi.mock('~/composables/useDocuments', () => ({ useDocuments: () => ({ loadDocumentsByPiece: 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/modelUtils', () => ({ formatPieceStructurePreview: () => '', })) vi.mock('~/shared/constructeurUtils', () => ({ uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)], constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId), })) vi.mock('~/utils/documentPreview', () => ({ canPreviewDocument: () => false, })) vi.mock('~/services/modelTypes', () => ({ getModelType: vi.fn().mockResolvedValue(null), })) vi.mock('~/shared/apiRelations', () => ({ extractRelationId: (rel: any) => { if (typeof rel === 'string') return rel if (rel && typeof rel === 'object' && 'id' in rel) return rel.id return null }, })) // --------------------------------------------------------------------------- // Import under test (AFTER all vi.mock calls) // --------------------------------------------------------------------------- import { usePieceEdit } from '~/composables/usePieceEdit' // --------------------------------------------------------------------------- // Test data // --------------------------------------------------------------------------- const PIECE_ID = 'piece-001' const mockPieceType = { id: 'tp-bearing-001', name: 'Roulement', code: 'ROUL', category: 'PIECE', structure: { products: [ { typeProductId: 'tprod-grease-001', typeProductLabel: 'Graisse SKF', familyCode: 'LUB', role: 'lubrification', }, ], customFields: [], }, } function buildPieceWithProducts() { return { ...mockPieceFromApi, id: PIECE_ID, '@id': `/api/pieces/${PIECE_ID}`, description: 'Roulement haute performance', prix: '42.50', typePieceId: 'tp-bearing-001', productIds: ['prod-001'], } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const tick = () => new Promise(r => setTimeout(r, 0)) async function createAndHydrate(overrides?: Record) { const pieceData = { ...buildPieceWithProducts(), ...overrides } mockGet.mockImplementation((url: string) => { if (url.includes(`/pieces/${PIECE_ID}`)) { return Promise.resolve({ success: true, data: structuredClone(pieceData) }) } return Promise.resolve({ success: true, data: wrapCollection([]) }) }) mockFetchLinks.mockResolvedValue([ { ...mockLinkSKF }, { ...mockLinkFAG }, ]) const composable = usePieceEdit(PIECE_ID) await composable.fetchPiece() await tick() return composable } // --------------------------------------------------------------------------- // beforeEach // --------------------------------------------------------------------------- beforeEach(() => { vi.clearAllMocks() mockPieceTypes.value = [mockPieceType] }) // --------------------------------------------------------------------------- // fetchPiece — hydration // --------------------------------------------------------------------------- describe('fetchPiece — hydration', () => { it('loads all simple fields (name, reference, description, prix)', async () => { const composable = await createAndHydrate() expect(composable.editionForm.name).toBe('Roulement 6205') expect(composable.editionForm.reference).toBe('ROUL-6205') expect(composable.editionForm.description).toBe('Roulement haute performance') expect(composable.editionForm.prix).toBe('42.50') }) it('loads piece with product slots', async () => { const composable = await createAndHydrate() expect(composable.piece.value).not.toBeNull() expect(composable.piece.value.productSlots).toHaveLength(1) expect(composable.piece.value.productSlots[0].product.id).toBe('prod-001') }) it('loads constructeur links via fetchLinks', async () => { const composable = await createAndHydrate() expect(mockFetchLinks).toHaveBeenCalledWith('piece', PIECE_ID) expect(composable.constructeurLinks.value).toHaveLength(2) expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id) expect(composable.constructeurLinks.value[1].constructeurId).toBe(mockConstructeurFAG.id) }) }) // --------------------------------------------------------------------------- // Product selections // --------------------------------------------------------------------------- describe('product selections', () => { it('setProductSelection updates the correct index', async () => { const composable = await createAndHydrate() // The structure has 1 product requirement, so productSelections should have 1 entry composable.setProductSelection(0, 'prod-new-001') await tick() expect(composable.productSelections.value[0]).toBe('prod-new-001') }) it('setProductSelection to null does not crash', async () => { const composable = await createAndHydrate() // Set then clear composable.setProductSelection(0, 'prod-001') await tick() composable.setProductSelection(0, null) await tick() expect(composable.productSelections.value[0]).toBeNull() }) }) // --------------------------------------------------------------------------- // submitEdition — no data loss // --------------------------------------------------------------------------- describe('submitEdition — no data loss', () => { it('sends all form fields in update payload', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.editionForm.name = 'Roulement modifie' composable.editionForm.description = 'Nouvelle description' composable.editionForm.reference = 'REF-MOD-001' composable.editionForm.prix = '99.99' // Ensure product selection is filled so submit proceeds composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload).toMatchObject({ name: 'Roulement modifie', description: 'Nouvelle description', reference: 'REF-MOD-001', prix: '99.99', }) }) it('saves custom fields after piece update', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) expect(mockSaveAll).toHaveBeenCalledTimes(1) }) it('syncs constructeur links', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockSyncLinks).toHaveBeenCalledTimes(1) const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]! expect(entityType).toBe('piece') expect(entityId).toBe(PIECE_ID) expect(origLinks).toHaveLength(2) expect(formLinks).toHaveLength(2) }) it('editing name does not lose constructeur links', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() // Only edit name composable.editionForm.name = 'Nouveau nom piece' composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.name).toBe('Nouveau nom piece') // syncLinks still called with constructeur links preserved expect(mockSyncLinks).toHaveBeenCalledTimes(1) const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]! expect(origLinks).toHaveLength(2) expect(formLinks).toHaveLength(2) expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id) expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id) }) it('editing name does not lose product slots', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() // Set product selection composable.setProductSelection(0, 'prod-001') await tick() // Now edit only name composable.editionForm.name = 'Autre nom' await tick() await composable.submitEdition() const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.name).toBe('Autre nom') // productIds should still contain the selection expect(payload.productIds).toContain('prod-001') }) it('adding a constructeur preserves existing ones', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.setProductSelection(0, 'prod-001') await tick() // Initially has SKF + FAG from fetchLinks expect(composable.constructeurLinks.value).toHaveLength(2) // Add a third constructeur const newLink = { linkId: null as string | null, constructeurId: 'cstr-new-003', constructeur: { id: 'cstr-new-003', name: 'NEW Corp', email: null, phone: null }, supplierReference: 'NEW-REF-001', } composable.constructeurLinks.value = [ ...composable.constructeurLinks.value, newLink, ] await tick() await composable.submitEdition() expect(mockSyncLinks).toHaveBeenCalledTimes(1) const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]! // Original had 2 (SKF + FAG) expect(origLinks).toHaveLength(2) // Form now has 3 (SKF + FAG + NEW) expect(formLinks).toHaveLength(3) expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id) expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id) expect(formLinks[2].constructeurId).toBe('cstr-new-003') }) it('sends both productId and productIds in payload', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.productId).toBe('prod-001') expect(payload.productIds).toEqual(['prod-001']) }) it('productId is the first product selection when multiple exist', async () => { // Override the piece type to have 2 product requirements const multiProductType = { ...mockPieceType, structure: { ...mockPieceType.structure, products: [ { typeProductId: 'tprod-grease-001', typeProductLabel: 'Graisse SKF', familyCode: 'LUB', role: 'lubrification', }, { typeProductId: 'tprod-oil-002', typeProductLabel: 'Huile', familyCode: 'LUB', role: 'lubrification secondaire', }, ], customFields: [], }, } mockPieceTypes.value = [multiProductType] mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate({ productIds: ['prod-001', 'prod-002'], }) composable.setProductSelection(0, 'prod-001') composable.setProductSelection(1, 'prod-002') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.productId).toBe('prod-001') expect(payload.productIds).toEqual(['prod-001', 'prod-002']) }) }) // --------------------------------------------------------------------------- // submitEdition — null field handling // --------------------------------------------------------------------------- describe('submitEdition — null field handling', () => { it('empty prix sends null', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.editionForm.prix = '' composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.prix).toBeNull() }) it('whitespace-only prix sends null', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.editionForm.prix = ' ' composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.prix).toBeNull() }) it('empty reference sends null', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.editionForm.reference = '' composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.reference).toBeNull() }) it('valid prix is sent as string number', async () => { mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) const composable = await createAndHydrate() composable.editionForm.prix = '99.50' composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() const payload = mockUpdatePiece.mock.calls[0]![1] expect(payload.prix).toBe('99.5') }) }) // --------------------------------------------------------------------------- // submitEdition — error paths // --------------------------------------------------------------------------- describe('submitEdition — error paths', () => { it('does not save custom fields when updatePiece fails', async () => { mockUpdatePiece.mockResolvedValue({ success: false, error: 'Server error' }) const composable = await createAndHydrate() composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) expect(mockSaveAll).not.toHaveBeenCalled() expect(mockSyncLinks).not.toHaveBeenCalled() }) it('does not save custom fields when updatePiece throws', async () => { mockUpdatePiece.mockRejectedValue(new Error('Network failure')) const composable = await createAndHydrate() composable.setProductSelection(0, 'prod-001') await tick() await composable.submitEdition() expect(mockUpdatePiece).toHaveBeenCalledTimes(1) expect(mockSaveAll).not.toHaveBeenCalled() expect(mockSyncLinks).not.toHaveBeenCalled() expect(mockShowError).toHaveBeenCalledWith('Network failure') }) it('shows error toast when product selection is not filled', async () => { const composable = await createAndHydrate() // Clear product selection composable.setProductSelection(0, null) await tick() await composable.submitEdition() expect(mockUpdatePiece).not.toHaveBeenCalled() expect(mockShowError).toHaveBeenCalledWith('Sélectionnez un produit conforme au squelette.') }) })