test(component-edit) : add edit flow and slot data integrity tests
This commit is contained in:
554
frontend/tests/composables/useComponentEdit.test.ts
Normal file
554
frontend/tests/composables/useComponentEdit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user