From abddfad063cf0d5101755d9119129ebe820bbbe2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 05:16:06 +0000 Subject: [PATCH] test: add comprehensive unit tests for IdeationService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 28 unit tests covering all major IdeationService functionality - Test session management (start, get, stop, running state) - Test idea CRUD operations (create, read, update, delete, archive) - Test idea to feature conversion with user stories and notes - Test project analysis and caching - Test prompt management and filtering - Test AI-powered suggestion generation - Mock all external dependencies (fs, platform, utils, providers) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Web Dev Cody --- .../unit/services/ideation-service.test.ts | 763 ++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 apps/server/tests/unit/services/ideation-service.test.ts diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts new file mode 100644 index 00000000..6cc9e340 --- /dev/null +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -0,0 +1,763 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { IdeationService } from '@/services/ideation-service.js'; +import type { EventEmitter } from '@/lib/events.js'; +import type { SettingsService } from '@/services/settings-service.js'; +import type { FeatureLoader } from '@/services/feature-loader.js'; +import * as secureFs from '@/lib/secure-fs.js'; +import * as platform from '@automaker/platform'; +import * as utils from '@automaker/utils'; +import type { + CreateIdeaInput, + UpdateIdeaInput, + Idea, + IdeationSession, + StartSessionOptions, +} from '@automaker/types'; +import { ProviderFactory } from '@/providers/provider-factory.js'; + +// Mock dependencies +vi.mock('@/lib/secure-fs.js'); +vi.mock('@automaker/platform'); +vi.mock('@automaker/utils'); +vi.mock('@/providers/provider-factory.js'); +vi.mock('@/lib/sdk-options.js', () => ({ + createChatOptions: vi.fn(() => ({ + model: 'claude-sonnet-4-20250514', + systemPrompt: 'test prompt', + })), + validateWorkingDirectory: vi.fn(), +})); + +describe('IdeationService', () => { + let service: IdeationService; + let mockEvents: EventEmitter; + let mockSettingsService: SettingsService; + let mockFeatureLoader: FeatureLoader; + const testProjectPath = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock event emitter + mockEvents = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + } as unknown as EventEmitter; + + // Create mock settings service + mockSettingsService = {} as SettingsService; + + // Create mock feature loader + mockFeatureLoader = { + getAll: vi.fn().mockResolvedValue([]), + } as unknown as FeatureLoader; + + // Mock platform functions + vi.mocked(platform.ensureIdeationDir).mockResolvedValue(undefined); + vi.mocked(platform.getIdeaDir).mockReturnValue('/test/project/.automaker/ideation/ideas/idea-123'); + vi.mocked(platform.getIdeaPath).mockReturnValue( + '/test/project/.automaker/ideation/ideas/idea-123/idea.json' + ); + vi.mocked(platform.getIdeasDir).mockReturnValue('/test/project/.automaker/ideation/ideas'); + vi.mocked(platform.getIdeationSessionPath).mockReturnValue( + '/test/project/.automaker/ideation/sessions/session-123.json' + ); + vi.mocked(platform.getIdeationSessionsDir).mockReturnValue( + '/test/project/.automaker/ideation/sessions' + ); + vi.mocked(platform.getIdeationAnalysisPath).mockReturnValue( + '/test/project/.automaker/ideation/analysis.json' + ); + + // Mock utils + vi.mocked(utils.loadContextFiles).mockResolvedValue({ + formattedPrompt: 'Test context', + files: [], + }); + vi.mocked(utils.isAbortError).mockReturnValue(false); + + service = new IdeationService(mockEvents, mockSettingsService, mockFeatureLoader); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ============================================================================ + // Session Management Tests + // ============================================================================ + + describe('Session Management', () => { + describe('startSession', () => { + it('should create a new session with default options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + + expect(session).toBeDefined(); + expect(session.id).toMatch(/^session-/); + expect(session.projectPath).toBe(testProjectPath); + expect(session.status).toBe('active'); + expect(session.createdAt).toBeDefined(); + expect(session.updatedAt).toBeDefined(); + expect(platform.ensureIdeationDir).toHaveBeenCalledWith(testProjectPath); + expect(secureFs.writeFile).toHaveBeenCalled(); + expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-started', { + sessionId: session.id, + projectPath: testProjectPath, + }); + }); + + it('should create session with custom options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const options: StartSessionOptions = { + promptCategory: 'features', + promptId: 'new-features', + }; + + const session = await service.startSession(testProjectPath, options); + + expect(session.promptCategory).toBe('features'); + expect(session.promptId).toBe('new-features'); + }); + + it('should send initial message if provided in options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({ features: [] })); + + // Mock provider + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: 'AI response', + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const options: StartSessionOptions = { + initialMessage: 'Hello, AI!', + }; + + await service.startSession(testProjectPath, options); + + // Give time for the async message to process + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockProvider.executeQuery).toHaveBeenCalled(); + }); + }); + + describe('getSession', () => { + it('should return null for non-existent session', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await service.getSession(testProjectPath, 'non-existent'); + + expect(result).toBeNull(); + }); + + it('should return active session from memory', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + const retrieved = await service.getSession(testProjectPath, session.id); + + expect(retrieved).toBeDefined(); + expect(retrieved?.id).toBe(session.id); + expect(retrieved?.messages).toEqual([]); + }); + + it('should load session from disk if not in memory', async () => { + const mockSession: IdeationSession = { + id: 'session-123', + projectPath: testProjectPath, + status: 'active', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const sessionData = { + session: mockSession, + messages: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(sessionData)); + + const result = await service.getSession(testProjectPath, 'session-123'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('session-123'); + }); + }); + + describe('stopSession', () => { + it('should stop an active session', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + await service.stopSession(session.id); + + expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-ended', { + sessionId: session.id, + }); + }); + + it('should handle stopping non-existent session gracefully', async () => { + await expect(service.stopSession('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('isSessionRunning', () => { + it('should return false for non-existent session', () => { + expect(service.isSessionRunning('non-existent')).toBe(false); + }); + + it('should return false for idle session', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + expect(service.isSessionRunning(session.id)).toBe(false); + }); + }); + }); + + // ============================================================================ + // Ideas CRUD Tests + // ============================================================================ + + describe('Ideas CRUD', () => { + describe('createIdea', () => { + it('should create a new idea with required fields', async () => { + vi.mocked(secureFs.mkdir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const input: CreateIdeaInput = { + title: 'Test Idea', + description: 'This is a test idea', + category: 'features', + }; + + const idea = await service.createIdea(testProjectPath, input); + + expect(idea).toBeDefined(); + expect(idea.id).toMatch(/^idea-/); + expect(idea.title).toBe('Test Idea'); + expect(idea.description).toBe('This is a test idea'); + expect(idea.category).toBe('features'); + expect(idea.status).toBe('raw'); + expect(idea.impact).toBe('medium'); + expect(idea.effort).toBe('medium'); + expect(secureFs.mkdir).toHaveBeenCalled(); + expect(secureFs.writeFile).toHaveBeenCalled(); + }); + + it('should create idea with all optional fields', async () => { + vi.mocked(secureFs.mkdir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const input: CreateIdeaInput = { + title: 'Full Idea', + description: 'Complete idea', + category: 'features', + status: 'refined', + impact: 'high', + effort: 'low', + conversationId: 'conv-123', + sourcePromptId: 'prompt-123', + userStories: ['Story 1', 'Story 2'], + notes: 'Additional notes', + }; + + const idea = await service.createIdea(testProjectPath, input); + + expect(idea.status).toBe('refined'); + expect(idea.impact).toBe('high'); + expect(idea.effort).toBe('low'); + expect(idea.conversationId).toBe('conv-123'); + expect(idea.sourcePromptId).toBe('prompt-123'); + expect(idea.userStories).toEqual(['Story 1', 'Story 2']); + expect(idea.notes).toBe('Additional notes'); + }); + }); + + describe('getIdeas', () => { + it('should return empty array when ideas directory does not exist', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toEqual([]); + }); + + it('should load all ideas from disk', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'idea-1', isDirectory: () => true } as any, + { name: 'idea-2', isDirectory: () => true } as any, + ]); + + const idea1: Idea = { + id: 'idea-1', + title: 'Idea 1', + description: 'First idea', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const idea2: Idea = { + id: 'idea-2', + title: 'Idea 2', + description: 'Second idea', + category: 'bugs', + status: 'refined', + impact: 'high', + effort: 'low', + createdAt: '2024-01-02T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(JSON.stringify(idea1)) + .mockResolvedValueOnce(JSON.stringify(idea2)); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toHaveLength(2); + expect(ideas[0].id).toBe('idea-2'); // Sorted by updatedAt descending + expect(ideas[1].id).toBe('idea-1'); + }); + + it('should skip invalid idea files', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'idea-1', isDirectory: () => true } as any, + { name: 'idea-2', isDirectory: () => true } as any, + ]); + + const validIdea: Idea = { + id: 'idea-1', + title: 'Valid Idea', + description: 'Valid', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(JSON.stringify(validIdea)) + .mockRejectedValueOnce(new Error('Invalid JSON')); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toHaveLength(1); + expect(ideas[0].id).toBe('idea-1'); + }); + }); + + describe('getIdea', () => { + it('should return idea by id', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test Idea', + description: 'Test', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const idea = await service.getIdea(testProjectPath, 'idea-123'); + + expect(idea).toBeDefined(); + expect(idea?.id).toBe('idea-123'); + }); + + it('should return null for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const idea = await service.getIdea(testProjectPath, 'non-existent'); + + expect(idea).toBeNull(); + }); + }); + + describe('updateIdea', () => { + it('should update idea fields', async () => { + const existingIdea: Idea = { + id: 'idea-123', + title: 'Original Title', + description: 'Original', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea)); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const updates: UpdateIdeaInput = { + title: 'Updated Title', + status: 'refined', + }; + + const updated = await service.updateIdea(testProjectPath, 'idea-123', updates); + + expect(updated).toBeDefined(); + expect(updated?.title).toBe('Updated Title'); + expect(updated?.status).toBe('refined'); + expect(updated?.description).toBe('Original'); // Unchanged + expect(updated?.updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); // Should be updated + }); + + it('should return null for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const updated = await service.updateIdea(testProjectPath, 'non-existent', { + title: 'New Title', + }); + + expect(updated).toBeNull(); + }); + }); + + describe('deleteIdea', () => { + it('should delete idea directory', async () => { + vi.mocked(secureFs.rm).mockResolvedValue(undefined); + + await service.deleteIdea(testProjectPath, 'idea-123'); + + expect(secureFs.rm).toHaveBeenCalledWith( + expect.stringContaining('idea-123'), + expect.objectContaining({ recursive: true }) + ); + }); + + it('should handle non-existent idea gracefully', async () => { + vi.mocked(secureFs.rm).mockRejectedValue(new Error('ENOENT')); + + await expect(service.deleteIdea(testProjectPath, 'non-existent')).resolves.not.toThrow(); + }); + }); + + describe('archiveIdea', () => { + it('should set idea status to archived', async () => { + const existingIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Test', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea)); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const archived = await service.archiveIdea(testProjectPath, 'idea-123'); + + expect(archived).toBeDefined(); + expect(archived?.status).toBe('archived'); + }); + }); + }); + + // ============================================================================ + // Conversion Tests + // ============================================================================ + + describe('Idea to Feature Conversion', () => { + describe('convertToFeature', () => { + it('should convert idea to feature with basic fields', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Add Dark Mode', + description: 'Implement dark mode theme', + category: 'features', + status: 'refined', + impact: 'high', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature).toBeDefined(); + expect(feature.id).toMatch(/^feature-/); + expect(feature.title).toBe('Add Dark Mode'); + expect(feature.description).toBe('Implement dark mode theme'); + expect(feature.category).toBe('ui'); // features -> ui mapping + expect(feature.status).toBe('backlog'); + }); + + it('should include user stories in feature description', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Base description', + category: 'features', + status: 'refined', + impact: 'medium', + effort: 'medium', + userStories: ['As a user, I want X', 'As a user, I want Y'], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature.description).toContain('Base description'); + expect(feature.description).toContain('## User Stories'); + expect(feature.description).toContain('As a user, I want X'); + expect(feature.description).toContain('As a user, I want Y'); + }); + + it('should include notes in feature description', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Base description', + category: 'features', + status: 'refined', + impact: 'medium', + effort: 'medium', + notes: 'Important implementation notes', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature.description).toContain('Base description'); + expect(feature.description).toContain('## Notes'); + expect(feature.description).toContain('Important implementation notes'); + }); + + it('should throw error for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + await expect(service.convertToFeature(testProjectPath, 'non-existent')).rejects.toThrow( + 'Idea non-existent not found' + ); + }); + }); + }); + + // ============================================================================ + // Project Analysis Tests + // ============================================================================ + + describe('Project Analysis', () => { + describe('analyzeProject', () => { + it('should analyze project and generate suggestions', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue( + JSON.stringify({ + name: 'test-project', + dependencies: {}, + }) + ); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([]); + + const result = await service.analyzeProject(testProjectPath); + + expect(result).toBeDefined(); + expect(result.projectPath).toBe(testProjectPath); + expect(result.analyzedAt).toBeDefined(); + expect(result.suggestions).toBeDefined(); + expect(Array.isArray(result.suggestions)).toBe(true); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-started', + }) + ); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-complete', + }) + ); + }); + + it('should emit error event on failure', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('Read failed')); + + await expect(service.analyzeProject(testProjectPath)).rejects.toThrow(); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-error', + }) + ); + }); + }); + + describe('getCachedAnalysis', () => { + it('should return cached analysis if exists', async () => { + const mockAnalysis = { + projectPath: testProjectPath, + analyzedAt: '2024-01-01T00:00:00.000Z', + totalFiles: 10, + suggestions: [], + summary: 'Test summary', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockAnalysis)); + + const result = await service.getCachedAnalysis(testProjectPath); + + expect(result).toEqual(mockAnalysis); + }); + + it('should return null if cache does not exist', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await service.getCachedAnalysis(testProjectPath); + + expect(result).toBeNull(); + }); + }); + }); + + // ============================================================================ + // Prompt Management Tests + // ============================================================================ + + describe('Prompt Management', () => { + describe('getPromptCategories', () => { + it('should return list of prompt categories', () => { + const categories = service.getPromptCategories(); + + expect(Array.isArray(categories)).toBe(true); + expect(categories.length).toBeGreaterThan(0); + expect(categories[0]).toHaveProperty('id'); + expect(categories[0]).toHaveProperty('label'); + }); + }); + + describe('getAllPrompts', () => { + it('should return all guided prompts', () => { + const prompts = service.getAllPrompts(); + + expect(Array.isArray(prompts)).toBe(true); + expect(prompts.length).toBeGreaterThan(0); + expect(prompts[0]).toHaveProperty('id'); + expect(prompts[0]).toHaveProperty('category'); + expect(prompts[0]).toHaveProperty('title'); + expect(prompts[0]).toHaveProperty('prompt'); + }); + }); + + describe('getPromptsByCategory', () => { + it('should return prompts filtered by category', () => { + const allPrompts = service.getAllPrompts(); + const firstCategory = allPrompts[0].category; + + const filtered = service.getPromptsByCategory(firstCategory); + + expect(Array.isArray(filtered)).toBe(true); + filtered.forEach((prompt) => { + expect(prompt.category).toBe(firstCategory); + }); + }); + + it('should return empty array for non-existent category', () => { + const filtered = service.getPromptsByCategory('non-existent-category' as any); + + expect(filtered).toEqual([]); + }); + }); + }); + + // ============================================================================ + // Suggestions Generation Tests + // ============================================================================ + + describe('Suggestion Generation', () => { + describe('generateSuggestions', () => { + it('should generate suggestions for a prompt', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([ + { + title: 'Add user authentication', + description: 'Implement auth', + category: 'security', + impact: 'high', + effort: 'high', + }, + ]), + }; + }, + }), + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + const firstPrompt = prompts[0]; + + const suggestions = await service.generateSuggestions( + testProjectPath, + firstPrompt.id, + 'features', + 5 + ); + + expect(Array.isArray(suggestions)).toBe(true); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:suggestions', + expect.objectContaining({ + type: 'started', + }) + ); + }); + + it('should throw error for non-existent prompt', async () => { + await expect( + service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5) + ).rejects.toThrow('Prompt non-existent not found'); + }); + }); + }); +});