diff --git a/frontend/tests/composables/useDocuments.test.ts b/frontend/tests/composables/useDocuments.test.ts new file mode 100644 index 0000000..f239676 --- /dev/null +++ b/frontend/tests/composables/useDocuments.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { wrapCollection } from '../fixtures/mockData' + +// --------------------------------------------------------------------------- +// Mocks — API layer +// --------------------------------------------------------------------------- + +const mockGet = vi.fn() +const mockPatch = vi.fn() +const mockPostFormData = vi.fn() +const mockDel = vi.fn() + +vi.mock('~/composables/useApi', () => ({ + useApi: () => ({ + get: mockGet, + post: vi.fn(), + 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(), + }), +})) + +// --------------------------------------------------------------------------- +// Import under test (AFTER all vi.mock calls) +// --------------------------------------------------------------------------- + +import { useDocuments } from '~/composables/useDocuments' + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const mockDocument = { + id: 'doc-001', + name: 'photo.jpg', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 12345, + fileUrl: '/files/photo.jpg', + downloadUrl: '/files/photo.jpg/download', + createdAt: '2025-01-10T08:00:00+00:00', +} + +const mockDocument2 = { + id: 'doc-002', + name: 'schema.pdf', + filename: 'schema.pdf', + mimeType: 'application/pdf', + size: 54321, + fileUrl: '/files/schema.pdf', + downloadUrl: '/files/schema.pdf/download', + createdAt: '2025-01-11T09:00:00+00:00', +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockFile(name: string, type = 'image/jpeg'): File { + return new File(['content'], name, { type }) +} + +// --------------------------------------------------------------------------- +// beforeEach +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// uploadDocuments — FormData is built correctly +// --------------------------------------------------------------------------- + +describe('uploadDocuments', () => { + it('builds FormData with file and context fields', async () => { + mockPostFormData.mockResolvedValue({ success: true, data: mockDocument }) + + const { uploadDocuments } = useDocuments() + const file = createMockFile('photo.jpg') + + await uploadDocuments({ + files: [file], + context: { pieceId: 'piece-001', composantId: 'comp-001' }, + }) + + expect(mockPostFormData).toHaveBeenCalledTimes(1) + const [endpoint, formData] = mockPostFormData.mock.calls[0]! + expect(endpoint).toBe('/documents') + + expect(formData).toBeInstanceOf(FormData) + expect(formData.get('file')).toBe(file) + expect(formData.get('name')).toBe('photo.jpg') + expect(formData.get('pieceId')).toBe('piece-001') + expect(formData.get('composantId')).toBe('comp-001') + }) + + it('uploads multiple files separately', async () => { + mockPostFormData + .mockResolvedValueOnce({ success: true, data: mockDocument }) + .mockResolvedValueOnce({ success: true, data: mockDocument2 }) + + const { uploadDocuments } = useDocuments() + const file1 = createMockFile('photo.jpg') + const file2 = createMockFile('schema.pdf', 'application/pdf') + + const result = await uploadDocuments({ + files: [file1, file2], + context: { machineId: 'machine-001' }, + }) + + expect(mockPostFormData).toHaveBeenCalledTimes(2) + + // First call + const [, formData1] = mockPostFormData.mock.calls[0]! + expect(formData1.get('name')).toBe('photo.jpg') + expect(formData1.get('machineId')).toBe('machine-001') + + // Second call + const [, formData2] = mockPostFormData.mock.calls[1]! + expect(formData2.get('name')).toBe('schema.pdf') + expect(formData2.get('machineId')).toBe('machine-001') + + expect(result.success).toBe(true) + expect(Array.isArray(result.data) ? result.data : []).toHaveLength(2) + }) + + it('appends type to FormData when provided in context', async () => { + mockPostFormData.mockResolvedValue({ success: true, data: mockDocument }) + + const { uploadDocuments } = useDocuments() + const file = createMockFile('facture.pdf', 'application/pdf') + + await uploadDocuments({ + files: [file], + context: { siteId: 'site-001', type: 'facture' }, + }) + + const [, formData] = mockPostFormData.mock.calls[0]! + expect(formData.get('type')).toBe('facture') + expect(formData.get('siteId')).toBe('site-001') + }) + + it('returns error when no files provided', async () => { + const { uploadDocuments } = useDocuments() + + const result = await uploadDocuments({ files: [], context: {} }) + + expect(result.success).toBe(false) + expect(mockPostFormData).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// loadDocumentsByComponent +// --------------------------------------------------------------------------- + +describe('loadDocumentsByComponent', () => { + it('calls correct endpoint /documents/composant/{id}', async () => { + mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) }) + + const { loadDocumentsByComponent } = useDocuments() + const result = await loadDocumentsByComponent('comp-001') + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith('/documents/composant/comp-001') + expect(result.success).toBe(true) + }) + + it('returns error for empty componentId', async () => { + const { loadDocumentsByComponent } = useDocuments() + const result = await loadDocumentsByComponent('') + + expect(mockGet).not.toHaveBeenCalled() + expect(result.success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// loadDocumentsByPiece +// --------------------------------------------------------------------------- + +describe('loadDocumentsByPiece', () => { + it('calls correct endpoint /documents/piece/{id}', async () => { + mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) }) + + const { loadDocumentsByPiece } = useDocuments() + const result = await loadDocumentsByPiece('piece-001') + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith('/documents/piece/piece-001') + expect(result.success).toBe(true) + }) + + it('returns error for empty pieceId', async () => { + const { loadDocumentsByPiece } = useDocuments() + const result = await loadDocumentsByPiece('') + + expect(mockGet).not.toHaveBeenCalled() + expect(result.success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// loadDocumentsByMachine +// --------------------------------------------------------------------------- + +describe('loadDocumentsByMachine', () => { + it('calls correct endpoint /documents/machine/{id}', async () => { + mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) }) + + const { loadDocumentsByMachine } = useDocuments() + const result = await loadDocumentsByMachine('machine-001') + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith('/documents/machine/machine-001') + expect(result.success).toBe(true) + }) + + it('returns error for empty machineId', async () => { + const { loadDocumentsByMachine } = useDocuments() + const result = await loadDocumentsByMachine('') + + expect(mockGet).not.toHaveBeenCalled() + expect(result.success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// loadDocumentsByProduct +// --------------------------------------------------------------------------- + +describe('loadDocumentsByProduct', () => { + it('calls correct endpoint /documents/product/{id}', async () => { + mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) }) + + const { loadDocumentsByProduct } = useDocuments() + const result = await loadDocumentsByProduct('prod-001') + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith('/documents/product/prod-001') + expect(result.success).toBe(true) + }) + + it('returns error for empty productId', async () => { + const { loadDocumentsByProduct } = useDocuments() + const result = await loadDocumentsByProduct('') + + expect(mockGet).not.toHaveBeenCalled() + expect(result.success).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// deleteDocument +// --------------------------------------------------------------------------- + +describe('deleteDocument', () => { + it('calls DELETE on correct endpoint', async () => { + mockDel.mockResolvedValue({ success: true }) + + const { deleteDocument } = useDocuments() + const result = await deleteDocument('doc-001') + + expect(mockDel).toHaveBeenCalledTimes(1) + expect(mockDel).toHaveBeenCalledWith('/documents/doc-001') + expect(result.success).toBe(true) + }) + + it('removes from store when updateStore is true', async () => { + mockGet.mockResolvedValue({ + success: true, + data: wrapCollection([mockDocument, mockDocument2]), + }) + mockDel.mockResolvedValue({ success: true }) + + const { loadDocuments, deleteDocument, documents } = useDocuments() + + // Load documents into store first + await loadDocuments({ force: true }) + + expect(documents.value).toHaveLength(2) + + // Delete with updateStore: true + await deleteDocument('doc-001', { updateStore: true }) + + expect(documents.value).toHaveLength(1) + expect(documents.value[0]!.id).toBe('doc-002') + }) + + it('shows success toast on successful delete', async () => { + mockDel.mockResolvedValue({ success: true }) + + const { deleteDocument } = useDocuments() + await deleteDocument('doc-001') + + expect(mockShowSuccess).toHaveBeenCalledWith('Document supprimé') + }) +}) diff --git a/frontend/tests/composables/usePieceEdit.test.ts b/frontend/tests/composables/usePieceEdit.test.ts index 0952445..de4592e 100644 --- a/frontend/tests/composables/usePieceEdit.test.ts +++ b/frontend/tests/composables/usePieceEdit.test.ts @@ -466,4 +466,179 @@ describe('submitEdition — no data loss', () => { expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id) expect(formLinks[2].constructeurId).toBe('cstr-new-003') }) + + it('sends both productId and productIds in payload', 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) + const payload = mockUpdatePiece.mock.calls[0]![1] + expect(payload.productId).toBe('prod-001') + expect(payload.productIds).toEqual(['prod-001']) + }) + + it('productId is the first product selection when multiple exist', async () => { + // Override the piece type to have 2 product requirements + const multiProductType = { + ...mockPieceType, + structure: { + ...mockPieceType.structure, + products: [ + { + typeProductId: 'tprod-grease-001', + typeProductLabel: 'Graisse SKF', + familyCode: 'LUB', + role: 'lubrification', + }, + { + typeProductId: 'tprod-oil-002', + typeProductLabel: 'Huile', + familyCode: 'LUB', + role: 'lubrification secondaire', + }, + ], + customFields: [], + }, + } + mockPieceTypes.value = [multiProductType] + + mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) + + const composable = await createAndHydrate({ + productIds: ['prod-001', 'prod-002'], + }) + + composable.setProductSelection(0, 'prod-001') + composable.setProductSelection(1, 'prod-002') + await tick() + + await composable.submitEdition() + + expect(mockUpdatePiece).toHaveBeenCalledTimes(1) + const payload = mockUpdatePiece.mock.calls[0]![1] + expect(payload.productId).toBe('prod-001') + expect(payload.productIds).toEqual(['prod-001', 'prod-002']) + }) +}) + +// --------------------------------------------------------------------------- +// submitEdition — null field handling +// --------------------------------------------------------------------------- + +describe('submitEdition — null field handling', () => { + it('empty prix sends null', async () => { + mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) + + const composable = await createAndHydrate() + + composable.editionForm.prix = '' + composable.setProductSelection(0, 'prod-001') + await tick() + + await composable.submitEdition() + + const payload = mockUpdatePiece.mock.calls[0]![1] + expect(payload.prix).toBeNull() + }) + + it('whitespace-only prix sends null', async () => { + mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) + + const composable = await createAndHydrate() + + composable.editionForm.prix = ' ' + composable.setProductSelection(0, 'prod-001') + await tick() + + await composable.submitEdition() + + const payload = mockUpdatePiece.mock.calls[0]![1] + expect(payload.prix).toBeNull() + }) + + it('empty reference sends null', async () => { + mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) + + const composable = await createAndHydrate() + + composable.editionForm.reference = '' + composable.setProductSelection(0, 'prod-001') + await tick() + + await composable.submitEdition() + + const payload = mockUpdatePiece.mock.calls[0]![1] + expect(payload.reference).toBeNull() + }) + + it('valid prix is sent as string number', async () => { + mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } }) + + const composable = await createAndHydrate() + + composable.editionForm.prix = '99.50' + composable.setProductSelection(0, 'prod-001') + await tick() + + await composable.submitEdition() + + const payload = mockUpdatePiece.mock.calls[0]![1] + expect(payload.prix).toBe('99.5') + }) +}) + +// --------------------------------------------------------------------------- +// submitEdition — error paths +// --------------------------------------------------------------------------- + +describe('submitEdition — error paths', () => { + it('does not save custom fields when updatePiece fails', async () => { + mockUpdatePiece.mockResolvedValue({ success: false, error: 'Server error' }) + + const composable = await createAndHydrate() + + composable.setProductSelection(0, 'prod-001') + await tick() + + await composable.submitEdition() + + expect(mockUpdatePiece).toHaveBeenCalledTimes(1) + expect(mockSaveAll).not.toHaveBeenCalled() + expect(mockSyncLinks).not.toHaveBeenCalled() + }) + + it('does not save custom fields when updatePiece throws', async () => { + mockUpdatePiece.mockRejectedValue(new Error('Network failure')) + + const composable = await createAndHydrate() + + composable.setProductSelection(0, 'prod-001') + await tick() + + await composable.submitEdition() + + expect(mockUpdatePiece).toHaveBeenCalledTimes(1) + expect(mockSaveAll).not.toHaveBeenCalled() + expect(mockSyncLinks).not.toHaveBeenCalled() + expect(mockShowError).toHaveBeenCalledWith('Network failure') + }) + + it('shows error toast when product selection is not filled', async () => { + const composable = await createAndHydrate() + + // Clear product selection + composable.setProductSelection(0, null) + await tick() + + await composable.submitEdition() + + expect(mockUpdatePiece).not.toHaveBeenCalled() + expect(mockShowError).toHaveBeenCalledWith('Sélectionnez un produit conforme au squelette.') + }) })