From eeba229574e4e994f315a00c109e0726cd1c39c2 Mon Sep 17 00:00:00 2001 From: r-dev Date: Mon, 6 Apr 2026 15:19:27 +0200 Subject: [PATCH] test(piece-edit) : add edit flow and product slot data integrity tests --- .../tests/composables/usePieceEdit.test.ts | 469 ++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 frontend/tests/composables/usePieceEdit.test.ts diff --git a/frontend/tests/composables/usePieceEdit.test.ts b/frontend/tests/composables/usePieceEdit.test.ts new file mode 100644 index 0000000..0952445 --- /dev/null +++ b/frontend/tests/composables/usePieceEdit.test.ts @@ -0,0 +1,469 @@ +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') + }) +})