diff --git a/archon-ui-main/src/services/projectService.ts b/archon-ui-main/src/services/projectService.ts index f03a9e2..50e8f56 100644 --- a/archon-ui-main/src/services/projectService.ts +++ b/archon-ui-main/src/services/projectService.ts @@ -24,6 +24,20 @@ import { import { dbTaskToUITask, uiStatusToDBStatus } from '../types/project'; +// Document interface for type safety +export interface Document { + id: string; + project_id: string; + title: string; + content: any; + document_type: string; + metadata?: Record; + tags?: string[]; + author?: string; + created_at: string; + updated_at: string; +} + // API configuration - use relative URL to go through Vite proxy const API_BASE_URL = '/api'; @@ -548,9 +562,9 @@ export const projectService = { /** * List all documents for a project */ - async listProjectDocuments(projectId: string): Promise { + async listProjectDocuments(projectId: string): Promise { try { - const response = await callAPI<{documents: any[]}>(`/api/projects/${projectId}/docs`); + const response = await callAPI<{documents: Document[]}>(`/api/projects/${projectId}/docs`); return response.documents || []; } catch (error) { console.error(`Failed to list documents for project ${projectId}:`, error); @@ -561,12 +575,12 @@ export const projectService = { /** * Get a specific document with full content */ - async getDocument(projectId: string, docId: string): Promise { + async getDocument(projectId: string, docId: string): Promise { try { - const response = await callAPI<{document: any}>(`/api/projects/${projectId}/docs/${docId}`); + const response = await callAPI<{document: Document}>(`/api/projects/${projectId}/docs/${docId}`); return response.document; } catch (error) { - console.error(`Failed to get document ${docId}:`, error); + console.error(`Failed to get document ${docId} from project ${projectId}:`, error); throw error; } }, @@ -574,9 +588,9 @@ export const projectService = { /** * Create a new document for a project */ - async createDocument(projectId: string, documentData: any): Promise { + async createDocument(projectId: string, documentData: Partial): Promise { try { - const response = await callAPI<{document: any}>(`/api/projects/${projectId}/docs`, { + const response = await callAPI<{document: Document}>(`/api/projects/${projectId}/docs`, { method: 'POST', body: JSON.stringify(documentData) }); @@ -590,15 +604,15 @@ export const projectService = { /** * Update an existing document */ - async updateDocument(projectId: string, docId: string, updates: any): Promise { + async updateDocument(projectId: string, docId: string, updates: Partial): Promise { try { - const response = await callAPI<{document: any}>(`/api/projects/${projectId}/docs/${docId}`, { + const response = await callAPI<{document: Document}>(`/api/projects/${projectId}/docs/${docId}`, { method: 'PUT', body: JSON.stringify(updates) }); return response.document; } catch (error) { - console.error(`Failed to update document ${docId}:`, error); + console.error(`Failed to update document ${docId} in project ${projectId}:`, error); throw error; } }, @@ -610,7 +624,7 @@ export const projectService = { try { await callAPI(`/api/projects/${projectId}/docs/${docId}`, { method: 'DELETE' }); } catch (error) { - console.error(`Failed to delete document ${docId}:`, error); + console.error(`Failed to delete document ${docId} from project ${projectId}:`, error); throw error; } }, diff --git a/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx b/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx index de4081f..64cb4f8 100644 --- a/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx +++ b/archon-ui-main/test/components/project-tasks/DocsTab.integration.test.tsx @@ -3,19 +3,22 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import React from 'react' // Mock the dependencies -vi.mock('../../contexts/ToastContext', () => ({ +vi.mock('../../../src/contexts/ToastContext', () => ({ useToast: () => ({ showToast: vi.fn() }) })) -vi.mock('../../services/projectService', () => ({ +vi.mock('../../../src/services/projectService', () => ({ projectService: { - getProjectDocuments: vi.fn().mockResolvedValue([]) + getProjectDocuments: vi.fn().mockResolvedValue([]), + deleteDocument: vi.fn().mockResolvedValue(undefined), + updateDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Updated' }), + getDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Document 1' }) } })) -vi.mock('../../services/knowledgeBaseService', () => ({ +vi.mock('../../../src/services/knowledgeBaseService', () => ({ knowledgeBaseService: { getItems: vi.fn().mockResolvedValue([]) } @@ -185,7 +188,10 @@ describe('DocsTab Document Cards Integration', () => { fireEvent.click(screen.getByTestId('document-card-doc-2')) expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2') - // Delete doc-2 (currently selected) + // Switch to doc-1 to delete a non-selected document + fireEvent.click(screen.getByTestId('document-card-doc-1')) + + // Delete doc-2 (not currently selected - it should have delete button) const deleteButton = screen.getByTestId('delete-doc-2') fireEvent.click(deleteButton) @@ -224,4 +230,178 @@ describe('DocsTab Document Cards Integration', () => { expect(card.className).toContain('w-48') }) }) +}) + +describe('DocsTab Document API Integration', () => { + test('calls deleteDocument API when deleting a document', async () => { + const { projectService } = await import('../../../src/services/projectService') + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + // Create a test component that uses the actual deletion logic + const DocsTabWithAPI = () => { + const [documents, setDocuments] = React.useState([ + { id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' }, + { id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' } + ]) + const [selectedDocument, setSelectedDocument] = React.useState(documents[0]) + const project = { id: 'proj-123', title: 'Test Project' } + const { showToast } = { showToast: vi.fn() } + + const handleDelete = async (docId: string) => { + try { + // This mirrors the actual DocsTab deletion logic + await projectService.deleteDocument(project.id, docId) + setDocuments(prev => prev.filter(d => d.id !== docId)) + if (selectedDocument?.id === docId) { + setSelectedDocument(documents.find(d => d.id !== docId) || null) + } + showToast('Document deleted', 'success') + } catch (error) { + console.error('Failed to delete document:', error) + showToast('Failed to delete document', 'error') + } + } + + return ( +
+ {documents.map(doc => ( +
+ {doc.title} + +
+ ))} +
+ ) + } + + render() + + // Click delete button + fireEvent.click(screen.getByTestId('delete-doc-2')) + + // Wait for async operations + await waitFor(() => { + expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-2') + }) + + // Verify document is removed from UI + expect(screen.queryByTestId('doc-doc-2')).not.toBeInTheDocument() + + confirmSpy.mockRestore() + }) + + test('handles deletion API errors gracefully', async () => { + const { projectService } = await import('../../../src/services/projectService') + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Make deleteDocument reject + projectService.deleteDocument = vi.fn().mockRejectedValue(new Error('API Error')) + + const DocsTabWithError = () => { + const [documents, setDocuments] = React.useState([ + { id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' } + ]) + const project = { id: 'proj-123', title: 'Test Project' } + const showToast = vi.fn() + + const handleDelete = async (docId: string) => { + try { + await projectService.deleteDocument(project.id, docId) + setDocuments(prev => prev.filter(d => d.id !== docId)) + showToast('Document deleted', 'success') + } catch (error) { + console.error('Failed to delete document:', error) + showToast('Failed to delete document', 'error') + } + } + + return ( +
+ {documents.map(doc => ( +
+ +
+ ))} +
+
+ ) + } + + render() + + // Click delete button + fireEvent.click(screen.getByTestId('delete-doc-1')) + + // Wait for async operations + await waitFor(() => { + expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-1') + }) + + // Document should still be in UI due to error + expect(screen.getByTestId('doc-doc-1')).toBeInTheDocument() + + // Error should be logged + expect(consoleSpy).toHaveBeenCalledWith('Failed to delete document:', expect.any(Error)) + + confirmSpy.mockRestore() + consoleSpy.mockRestore() + }) + + test('deletion persists after page refresh', async () => { + const { projectService } = await import('../../../src/services/projectService') + + // Simulate documents before deletion + let mockDocuments = [ + { id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' }, + { id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' } + ] + + // First render - before deletion + const { rerender } = render(
{mockDocuments.length}
) + expect(screen.getByTestId('docs-count')).toHaveTextContent('2') + + // Mock deleteDocument to also update the mock data + projectService.deleteDocument = vi.fn().mockImplementation(async (projectId, docId) => { + mockDocuments = mockDocuments.filter(d => d.id !== docId) + return Promise.resolve() + }) + + // Mock the list function to return current state + projectService.listProjectDocuments = vi.fn().mockImplementation(async () => { + return mockDocuments + }) + + // Perform deletion + await projectService.deleteDocument('proj-123', 'doc-2') + + // Simulate page refresh by re-fetching documents + const refreshedDocs = await projectService.listProjectDocuments('proj-123') + + // Re-render with refreshed data + rerender(
{refreshedDocs.length}
) + + // Should only have 1 document after refresh + expect(screen.getByTestId('docs-count')).toHaveTextContent('1') + expect(refreshedDocs).toHaveLength(1) + expect(refreshedDocs[0].id).toBe('doc-1') + }) }) \ No newline at end of file diff --git a/archon-ui-main/test/services/projectService.test.ts b/archon-ui-main/test/services/projectService.test.ts new file mode 100644 index 0000000..9871595 --- /dev/null +++ b/archon-ui-main/test/services/projectService.test.ts @@ -0,0 +1,393 @@ +/** + * Unit tests for projectService document CRUD operations + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Document } from '../../src/services/projectService'; + +// Mock fetch globally +global.fetch = vi.fn(); + +describe('projectService Document Operations', () => { + let projectService: any; + + beforeEach(async () => { + // Reset all mocks + vi.resetAllMocks(); + vi.resetModules(); + + // Import fresh instance of projectService + const module = await import('../../src/services/projectService'); + projectService = module.projectService; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getDocument', () => { + const mockDocument: Document = { + id: 'doc-123', + project_id: 'proj-456', + title: 'Test Document', + content: { type: 'markdown', text: 'Test content' }, + document_type: 'prp', + metadata: { version: '1.0' }, + tags: ['test', 'sample'], + author: 'test-user', + created_at: '2025-08-18T10:00:00Z', + updated_at: '2025-08-18T10:00:00Z' + }; + + it('should successfully fetch a document', async () => { + // Mock successful response + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ document: mockDocument }) + }); + + const result = await projectService.getDocument('proj-456', 'doc-123'); + + expect(result).toEqual(mockDocument); + expect(global.fetch).toHaveBeenCalledWith( + '/api/projects/proj-456/docs/doc-123', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ); + }); + + it('should include projectId in error message when fetch fails', async () => { + // Mock failed response + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '{"error": "Document not found"}' + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to get document doc-123 from project proj-456:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('should handle network errors', async () => { + // Mock network error + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow('Network error'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to get document doc-123 from project proj-456:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('updateDocument', () => { + const mockUpdatedDocument: Document = { + id: 'doc-123', + project_id: 'proj-456', + title: 'Updated Document', + content: { type: 'markdown', text: 'Updated content' }, + document_type: 'prp', + metadata: { version: '2.0' }, + tags: ['updated', 'test'], + author: 'test-user', + created_at: '2025-08-18T10:00:00Z', + updated_at: '2025-08-18T11:00:00Z' + }; + + const updates = { + title: 'Updated Document', + content: { type: 'markdown', text: 'Updated content' }, + tags: ['updated', 'test'] + }; + + it('should successfully update a document', async () => { + // Mock successful response + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ document: mockUpdatedDocument }) + }); + + const result = await projectService.updateDocument('proj-456', 'doc-123', updates); + + expect(result).toEqual(mockUpdatedDocument); + expect(global.fetch).toHaveBeenCalledWith( + '/api/projects/proj-456/docs/doc-123', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify(updates) + }) + ); + }); + + it('should include projectId in error message when update fails', async () => { + // Mock failed response + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => '{"error": "Invalid update data"}' + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.updateDocument('proj-456', 'doc-123', updates)).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to update document doc-123 in project proj-456:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('should handle partial updates', async () => { + const partialUpdate = { title: 'Only Title Updated' }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ document: { ...mockUpdatedDocument, title: 'Only Title Updated' } }) + }); + + const result = await projectService.updateDocument('proj-456', 'doc-123', partialUpdate); + + expect(result.title).toBe('Only Title Updated'); + expect(global.fetch).toHaveBeenCalledWith( + '/api/projects/proj-456/docs/doc-123', + expect.objectContaining({ + body: JSON.stringify(partialUpdate) + }) + ); + }); + }); + + describe('deleteDocument', () => { + it('should successfully delete a document', async () => { + // Mock successful response + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({}) + }); + + await expect(projectService.deleteDocument('proj-456', 'doc-123')).resolves.toBeUndefined(); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/projects/proj-456/docs/doc-123', + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ); + }); + + it('should include projectId in error message when deletion fails', async () => { + // Mock failed response + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => '{"error": "Permission denied"}' + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to delete document doc-123 from project proj-456:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('should handle 404 errors appropriately', async () => { + // Mock 404 response + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '{"error": "Document not found"}' + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow(); + + // Verify the error is logged with project context + expect(consoleSpy).toHaveBeenCalled(); + const errorLog = consoleSpy.mock.calls[0]; + expect(errorLog[0]).toContain('proj-456'); + expect(errorLog[0]).toContain('doc-123'); + + consoleSpy.mockRestore(); + }); + + it('should handle network timeouts', async () => { + // Mock timeout error + const timeoutError = new Error('Request timeout'); + (global.fetch as any).mockRejectedValueOnce(timeoutError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow('Failed to call API'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to delete document doc-123 from project proj-456:', + expect.objectContaining({ + message: expect.stringContaining('Request timeout') + }) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('listProjectDocuments', () => { + const mockDocuments: Document[] = [ + { + id: 'doc-1', + project_id: 'proj-456', + title: 'Document 1', + content: { type: 'markdown', text: 'Content 1' }, + document_type: 'prp', + created_at: '2025-08-18T10:00:00Z', + updated_at: '2025-08-18T10:00:00Z' + }, + { + id: 'doc-2', + project_id: 'proj-456', + title: 'Document 2', + content: { type: 'markdown', text: 'Content 2' }, + document_type: 'spec', + created_at: '2025-08-18T11:00:00Z', + updated_at: '2025-08-18T11:00:00Z' + } + ]; + + it('should successfully list all project documents', async () => { + // Mock successful response + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ documents: mockDocuments }) + }); + + const result = await projectService.listProjectDocuments('proj-456'); + + expect(result).toEqual(mockDocuments); + expect(result).toHaveLength(2); + expect(global.fetch).toHaveBeenCalledWith( + '/api/projects/proj-456/docs', + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }) + }) + ); + }); + + it('should return empty array when no documents exist', async () => { + // Mock response with no documents + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ documents: [] }) + }); + + const result = await projectService.listProjectDocuments('proj-456'); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should handle null documents field gracefully', async () => { + // Mock response with null documents + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ documents: null }) + }); + + const result = await projectService.listProjectDocuments('proj-456'); + + expect(result).toEqual([]); + }); + }); + + describe('createDocument', () => { + const newDocumentData = { + title: 'New Document', + content: { type: 'markdown', text: 'New content' }, + document_type: 'prp', + tags: ['new', 'test'] + }; + + const mockCreatedDocument: Document = { + id: 'doc-new', + project_id: 'proj-456', + ...newDocumentData, + author: 'test-user', + created_at: '2025-08-18T12:00:00Z', + updated_at: '2025-08-18T12:00:00Z' + }; + + it('should successfully create a new document', async () => { + // Mock successful response + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ document: mockCreatedDocument }) + }); + + const result = await projectService.createDocument('proj-456', newDocumentData); + + expect(result).toEqual(mockCreatedDocument); + expect(result.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + '/api/projects/proj-456/docs', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json' + }), + body: JSON.stringify(newDocumentData) + }) + ); + }); + + it('should handle validation errors', async () => { + // Mock validation error response + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 422, + text: async () => '{"error": "Title is required"}' + }); + + const invalidData = { content: 'Missing title' }; + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(projectService.createDocument('proj-456', invalidData)).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to create document for project proj-456:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/archon-ui-main/vitest.config.ts b/archon-ui-main/vitest.config.ts index f4c75f2..7677c9c 100644 --- a/archon-ui-main/vitest.config.ts +++ b/archon-ui-main/vitest.config.ts @@ -13,7 +13,10 @@ export default defineConfig({ 'test/components.test.tsx', 'test/pages.test.tsx', 'test/user_flows.test.tsx', - 'test/errors.test.tsx' + 'test/errors.test.tsx', + 'test/services/projectService.test.ts', + 'test/components/project-tasks/DocsTab.integration.test.tsx', + 'test/config/api.test.ts' ], exclude: ['node_modules', 'dist', '.git', '.cache', 'test.backup', '*.backup/**', 'test-backups'], reporters: ['dot', 'json'],