Archon/archon-ui-main/test/services/projectService.test.ts
Rasmus Widing 4c02dfc15d Add comprehensive test coverage for document CRUD operations
- 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
2025-08-18 13:27:20 +03:00

393 lines
12 KiB
TypeScript

/**
* 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();
});
});
});