mirror of
https://github.com/coleam00/Archon.git
synced 2026-01-01 12:18:41 -05:00
- Add Document interface for type safety - Fix error messages to include projectId context - Add unit tests for all projectService document methods - Add integration tests for DocsTab deletion flow - Update vitest config to include new test files
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
|
import React from 'react'
|
|
|
|
// Mock the dependencies
|
|
vi.mock('../../../src/contexts/ToastContext', () => ({
|
|
useToast: () => ({
|
|
showToast: vi.fn()
|
|
})
|
|
}))
|
|
|
|
vi.mock('../../../src/services/projectService', () => ({
|
|
projectService: {
|
|
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('../../../src/services/knowledgeBaseService', () => ({
|
|
knowledgeBaseService: {
|
|
getItems: vi.fn().mockResolvedValue([])
|
|
}
|
|
}))
|
|
|
|
// Create a minimal DocsTab component for testing
|
|
const DocsTabTest = () => {
|
|
const [documents, setDocuments] = React.useState([
|
|
{
|
|
id: 'doc-1',
|
|
title: 'Document 1',
|
|
content: { type: 'prp' },
|
|
document_type: 'prp',
|
|
updated_at: '2025-07-30T12:00:00Z'
|
|
},
|
|
{
|
|
id: 'doc-2',
|
|
title: 'Document 2',
|
|
content: { type: 'technical' },
|
|
document_type: 'technical',
|
|
updated_at: '2025-07-30T13:00:00Z'
|
|
},
|
|
{
|
|
id: 'doc-3',
|
|
title: 'Document 3',
|
|
content: { type: 'business' },
|
|
document_type: 'business',
|
|
updated_at: '2025-07-30T14:00:00Z'
|
|
}
|
|
])
|
|
|
|
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
|
|
const { showToast } = { showToast: vi.fn() }
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
|
|
{documents.map(doc => (
|
|
<div
|
|
key={doc.id}
|
|
data-testid={`document-card-${doc.id}`}
|
|
className={`flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer ${
|
|
selectedDocument?.id === doc.id ? 'border-2 border-blue-500' : 'border border-gray-200'
|
|
}`}
|
|
onClick={() => setSelectedDocument(doc)}
|
|
>
|
|
<div className={`text-xs ${doc.document_type}`}>{doc.document_type}</div>
|
|
<h4>{doc.title}</h4>
|
|
{selectedDocument?.id !== doc.id && (
|
|
<button
|
|
data-testid={`delete-${doc.id}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (confirm(`Delete "${doc.title}"?`)) {
|
|
setDocuments(prev => prev.filter(d => d.id !== doc.id))
|
|
if (selectedDocument?.id === doc.id) {
|
|
setSelectedDocument(documents.find(d => d.id !== doc.id) || null)
|
|
}
|
|
showToast('Document deleted', 'success')
|
|
}
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div
|
|
data-testid="new-document-card"
|
|
className="flex-shrink-0 w-48 h-32 rounded-lg border-2 border-dashed"
|
|
onClick={() => console.log('New document')}
|
|
>
|
|
New Document
|
|
</div>
|
|
</div>
|
|
{selectedDocument && (
|
|
<div data-testid="selected-document">
|
|
Selected: {selectedDocument.title}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
describe('DocsTab Document Cards Integration', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
test('renders all document cards', () => {
|
|
render(<DocsTabTest />)
|
|
|
|
expect(screen.getByTestId('document-card-doc-1')).toBeInTheDocument()
|
|
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
|
|
expect(screen.getByTestId('document-card-doc-3')).toBeInTheDocument()
|
|
expect(screen.getByTestId('new-document-card')).toBeInTheDocument()
|
|
})
|
|
|
|
test('shows active state on selected document', () => {
|
|
render(<DocsTabTest />)
|
|
|
|
const doc1 = screen.getByTestId('document-card-doc-1')
|
|
expect(doc1.className).toContain('border-blue-500')
|
|
|
|
const doc2 = screen.getByTestId('document-card-doc-2')
|
|
expect(doc2.className).not.toContain('border-blue-500')
|
|
})
|
|
|
|
test('switches between documents', () => {
|
|
render(<DocsTabTest />)
|
|
|
|
// Initially doc-1 is selected
|
|
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
|
|
|
|
// Click on doc-2
|
|
fireEvent.click(screen.getByTestId('document-card-doc-2'))
|
|
|
|
// Now doc-2 should be selected
|
|
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
|
|
|
|
// Check active states
|
|
expect(screen.getByTestId('document-card-doc-1').className).not.toContain('border-blue-500')
|
|
expect(screen.getByTestId('document-card-doc-2').className).toContain('border-blue-500')
|
|
})
|
|
|
|
test('deletes document with confirmation', () => {
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
|
|
render(<DocsTabTest />)
|
|
|
|
// Click delete on doc-2
|
|
const deleteButton = screen.getByTestId('delete-doc-2')
|
|
fireEvent.click(deleteButton)
|
|
|
|
expect(confirmSpy).toHaveBeenCalledWith('Delete "Document 2"?')
|
|
|
|
// Document should be removed
|
|
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
|
|
|
|
confirmSpy.mockRestore()
|
|
})
|
|
|
|
test('cancels delete when user declines', () => {
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
|
|
|
render(<DocsTabTest />)
|
|
|
|
// Click delete on doc-2
|
|
const deleteButton = screen.getByTestId('delete-doc-2')
|
|
fireEvent.click(deleteButton)
|
|
|
|
// Document should still be there
|
|
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
|
|
|
|
confirmSpy.mockRestore()
|
|
})
|
|
|
|
test('selects next document when deleting active document', () => {
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
|
|
|
render(<DocsTabTest />)
|
|
|
|
// doc-1 is initially selected
|
|
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
|
|
|
|
// Switch to doc-2
|
|
fireEvent.click(screen.getByTestId('document-card-doc-2'))
|
|
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
|
|
|
|
// 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)
|
|
|
|
// Should automatically select another document
|
|
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document')
|
|
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
|
|
|
|
confirmSpy.mockRestore()
|
|
})
|
|
|
|
test('does not show delete button on active card', () => {
|
|
render(<DocsTabTest />)
|
|
|
|
// doc-1 is active, should not have delete button
|
|
expect(screen.queryByTestId('delete-doc-1')).not.toBeInTheDocument()
|
|
|
|
// doc-2 is not active, should have delete button
|
|
expect(screen.getByTestId('delete-doc-2')).toBeInTheDocument()
|
|
})
|
|
|
|
test('horizontal scroll container has correct classes', () => {
|
|
const { container } = render(<DocsTabTest />)
|
|
|
|
const scrollContainer = container.querySelector('.overflow-x-auto')
|
|
expect(scrollContainer).toBeInTheDocument()
|
|
expect(scrollContainer?.className).toContain('scrollbar-thin')
|
|
expect(scrollContainer?.className).toContain('scrollbar-thumb-gray-300')
|
|
})
|
|
|
|
test('document cards maintain fixed width', () => {
|
|
render(<DocsTabTest />)
|
|
|
|
const cards = screen.getAllByTestId(/document-card-doc-/)
|
|
cards.forEach(card => {
|
|
expect(card.className).toContain('flex-shrink-0')
|
|
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 (
|
|
<div>
|
|
{documents.map(doc => (
|
|
<div key={doc.id} data-testid={`doc-${doc.id}`}>
|
|
<span>{doc.title}</span>
|
|
<button
|
|
data-testid={`delete-${doc.id}`}
|
|
onClick={() => {
|
|
if (confirm(`Delete "${doc.title}"?`)) {
|
|
handleDelete(doc.id)
|
|
}
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
render(<DocsTabWithAPI />)
|
|
|
|
// 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 (
|
|
<div>
|
|
{documents.map(doc => (
|
|
<div key={doc.id} data-testid={`doc-${doc.id}`}>
|
|
<button
|
|
data-testid={`delete-${doc.id}`}
|
|
onClick={() => {
|
|
if (confirm(`Delete "${doc.title}"?`)) {
|
|
handleDelete(doc.id)
|
|
}
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div data-testid="toast-container" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
render(<DocsTabWithError />)
|
|
|
|
// 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(<div data-testid="docs-count">{mockDocuments.length}</div>)
|
|
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(<div data-testid="docs-count">{refreshedDocs.length}</div>)
|
|
|
|
// 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')
|
|
})
|
|
}) |