test(component-edit) : add edit flow and slot data integrity tests

This commit is contained in:
2026-04-06 15:15:55 +02:00
parent 1e40334e11
commit 4454bbea3d

View File

@@ -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<ReturnType<typeof buildComponentWithStructure>>) {
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)
})
})