Files
Inventory/frontend/tests/composables/usePieceEdit.test.ts

470 lines
15 KiB
TypeScript

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')
})
})