diff --git a/frontend/tests/composables/useComponentEdit.test.ts b/frontend/tests/composables/useComponentEdit.test.ts index 28a1cfe..d31e97c 100644 --- a/frontend/tests/composables/useComponentEdit.test.ts +++ b/frontend/tests/composables/useComponentEdit.test.ts @@ -112,11 +112,15 @@ vi.mock('~/composables/useProductTypes', () => ({ // Mocks — useDocuments // --------------------------------------------------------------------------- +const mockLoadDocumentsByComponent = vi.fn().mockResolvedValue({ success: true, data: [] }) +const mockUploadDocuments = vi.fn().mockResolvedValue({ success: true, data: [] }) +const mockDeleteDocument = vi.fn().mockResolvedValue({ success: true }) + 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 }), + loadDocumentsByComponent: mockLoadDocumentsByComponent, + uploadDocuments: mockUploadDocuments, + deleteDocument: mockDeleteDocument, documents: { value: [] }, loading: { value: false }, }), @@ -552,3 +556,335 @@ describe('submitEdition — no data loss', () => { expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id) }) }) + +// --------------------------------------------------------------------------- +// Document operations +// --------------------------------------------------------------------------- + +describe('document operations', () => { + it('populates componentDocuments from fetchComponent response', async () => { + const docFixtures = [ + { id: 'doc-1', name: 'photo.jpg', type: 'photo' }, + { id: 'doc-2', name: 'schema.pdf', type: 'schema' }, + ] + const composable = await createAndHydrate({ documents: docFixtures } as any) + + expect(composable.componentDocuments.value).toHaveLength(2) + expect(composable.componentDocuments.value[0].id).toBe('doc-1') + expect(composable.componentDocuments.value[1].id).toBe('doc-2') + }) + + it('sets componentDocuments to empty array when response has no documents', async () => { + const composable = await createAndHydrate({ documents: undefined } as any) + + expect(composable.componentDocuments.value).toEqual([]) + }) + + it('handleFilesAdded calls uploadDocuments with composantId context', async () => { + mockUploadDocuments.mockResolvedValue({ success: true, data: [] }) + mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: [] }) + + const composable = await createAndHydrate() + + const files = [new File(['content'], 'test.pdf', { type: 'application/pdf' })] + await composable.handleFilesAdded(files) + + expect(mockUploadDocuments).toHaveBeenCalledTimes(1) + const callArgs = mockUploadDocuments.mock.calls[0]![0] + expect(callArgs.files).toBe(files) + expect(callArgs.context.composantId).toBe(COMPONENT_ID) + }) + + it('handleFilesAdded does nothing when files array is empty', async () => { + const composable = await createAndHydrate() + + await composable.handleFilesAdded([]) + + expect(mockUploadDocuments).not.toHaveBeenCalled() + }) + + it('handleFilesAdded refreshes documents after successful upload', async () => { + const refreshedDocs = [{ id: 'doc-new', name: 'uploaded.pdf' }] + mockUploadDocuments.mockResolvedValue({ success: true, data: [] }) + mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: refreshedDocs }) + + const composable = await createAndHydrate() + + const files = [new File(['data'], 'uploaded.pdf')] + await composable.handleFilesAdded(files) + + expect(mockLoadDocumentsByComponent).toHaveBeenCalledWith(COMPONENT_ID, { updateStore: false }) + expect(composable.componentDocuments.value).toHaveLength(1) + expect(composable.componentDocuments.value[0].id).toBe('doc-new') + }) + + it('removeDocument calls deleteDocument and removes from local list', async () => { + mockDeleteDocument.mockResolvedValue({ success: true }) + + const composable = await createAndHydrate({ + documents: [ + { id: 'doc-a', name: 'a.pdf' }, + { id: 'doc-b', name: 'b.pdf' }, + ], + } as any) + + expect(composable.componentDocuments.value).toHaveLength(2) + + await composable.removeDocument('doc-a') + + expect(mockDeleteDocument).toHaveBeenCalledWith('doc-a', { updateStore: false }) + expect(composable.componentDocuments.value).toHaveLength(1) + expect(composable.componentDocuments.value[0].id).toBe('doc-b') + }) + + it('removeDocument does nothing when documentId is falsy', async () => { + const composable = await createAndHydrate() + + await composable.removeDocument(null) + await composable.removeDocument(undefined) + + expect(mockDeleteDocument).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// Null field handling in PATCH payload +// --------------------------------------------------------------------------- + +describe('null field handling in PATCH payload', () => { + it('empty prix string sends null in payload', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.prix = '' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.prix).toBeNull() + }) + + it('whitespace-only prix string sends null in payload', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.prix = ' ' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.prix).toBeNull() + }) + + it('valid prix string sends stringified number', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.prix = '42.50' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.prix).toBe('42.5') + }) + + it('empty reference string sends null in payload', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.reference = '' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.reference).toBeNull() + }) + + it('empty description string sends null in payload', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.description = '' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.description).toBeNull() + }) + + it('whitespace-only description sends null in payload', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.description = ' ' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.description).toBeNull() + }) + + it('name is trimmed but never null', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + + const composable = await createAndHydrate() + composable.editionForm.name = ' Moteur ' + + await composable.submitEdition() + + const payload = mockUpdateComposant.mock.calls[0]![1] + expect(payload.name).toBe('Moteur') + }) +}) + +// --------------------------------------------------------------------------- +// Error paths +// --------------------------------------------------------------------------- + +describe('error paths', () => { + it('does not save custom fields when updateComposant returns { success: false }', async () => { + mockUpdateComposant.mockResolvedValue({ success: false }) + + const composable = await createAndHydrate() + composable.editionForm.name = 'Test' + + await composable.submitEdition() + + expect(mockUpdateComposant).toHaveBeenCalledTimes(1) + expect(mockSaveAll).not.toHaveBeenCalled() + expect(mockPatch).not.toHaveBeenCalled() + expect(mockSyncLinks).not.toHaveBeenCalled() + }) + + it('does not patch slots when updateComposant returns { success: false }', async () => { + mockUpdateComposant.mockResolvedValue({ success: false }) + + const composable = await createAndHydrate() + composable.setPieceSlotSelection('ps-001', 'piece-new') + composable.setProductSlotSelection('prs-001', 'prod-new') + + await composable.submitEdition() + + expect(mockPatch).not.toHaveBeenCalled() + }) + + it('does not sync constructeur links when updateComposant fails', async () => { + mockUpdateComposant.mockResolvedValue({ success: false }) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + expect(mockSyncLinks).not.toHaveBeenCalled() + }) + + it('shows error toast when saveAllCustomFields returns failed fields', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + mockSaveAll.mockResolvedValue(['Tension nominale', 'Indice de protection']) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + expect(mockShowError).toHaveBeenCalledTimes(1) + expect(mockShowError.mock.calls[0]![0]).toContain('Tension nominale') + expect(mockShowError.mock.calls[0]![0]).toContain('Indice de protection') + }) + + it('still saves slots and syncs links even when custom fields fail', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + mockSaveAll.mockResolvedValue(['Tension nominale']) + mockPatch.mockResolvedValue({ success: true, data: {} }) + + const composable = await createAndHydrate() + composable.setPieceSlotSelection('ps-001', 'piece-after-cf-fail') + + await composable.submitEdition() + + // Slots still patched despite custom field failure + expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-after-cf-fail' }) + // Links still synced + expect(mockSyncLinks).toHaveBeenCalledTimes(1) + // Success toast still shown (alongside the error toast for CF) + expect(mockShowSuccess).toHaveBeenCalledTimes(1) + }) + + it('shows error toast when submitEdition throws', async () => { + mockUpdateComposant.mockRejectedValue(new Error('Network failure')) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + expect(mockShowError).toHaveBeenCalledTimes(1) + expect(mockShowError.mock.calls[0]![0]).toContain('Network failure') + expect(composable.saving.value).toBe(false) + }) + + it('resets saving flag even when updateComposant throws', async () => { + mockUpdateComposant.mockRejectedValue(new Error('Server error')) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + expect(composable.saving.value).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Custom field save verification +// --------------------------------------------------------------------------- + +describe('custom field save verification', () => { + it('saveAllCustomFields is called after successful updateComposant', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + mockSaveAll.mockResolvedValue([]) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + expect(mockSaveAll).toHaveBeenCalledTimes(1) + }) + + it('does not show error toast when saveAll returns empty array (no failures)', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + mockSaveAll.mockResolvedValue([]) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + // showError should NOT have been called (only showSuccess) + expect(mockShowError).not.toHaveBeenCalled() + expect(mockShowSuccess).toHaveBeenCalledTimes(1) + }) + + it('shows error with all failed field names joined', async () => { + mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } }) + mockSaveAll.mockResolvedValue(['Champ A', 'Champ B', 'Champ C']) + + const composable = await createAndHydrate() + + await composable.submitEdition() + + expect(mockShowError).toHaveBeenCalledTimes(1) + const errorMsg = mockShowError.mock.calls[0]![0] as string + expect(errorMsg).toContain('Champ A') + expect(errorMsg).toContain('Champ B') + expect(errorMsg).toContain('Champ C') + }) + + it('submitEdition does nothing when component is null', async () => { + const composable = await createAndHydrate() + + // Force component to null + composable.component.value = null + + await composable.submitEdition() + + expect(mockUpdateComposant).not.toHaveBeenCalled() + expect(mockSaveAll).not.toHaveBeenCalled() + }) +})