diff --git a/frontend/tests/composables/useComponentEdit.test.ts b/frontend/tests/composables/useComponentEdit.test.ts new file mode 100644 index 0000000..28a1cfe --- /dev/null +++ b/frontend/tests/composables/useComponentEdit.test.ts @@ -0,0 +1,554 @@ +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) + }) +})