- 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
393 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
}); |