test(piece-edit) : add edit flow and product slot data integrity tests
This commit is contained in:
469
frontend/tests/composables/usePieceEdit.test.ts
Normal file
469
frontend/tests/composables/usePieceEdit.test.ts
Normal file
@@ -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<string, any>) {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user