- Add Claude API client with Anthropic SDK - Retry logic with exponential backoff (3 attempts) - Handle auth errors, rate limits, and server errors - Architecture classification with JSON parsing - Research query generation from architecture - Add Perplexity API client with native fetch - Retry logic with exponential backoff - Parse responses with sources and citations - Execute multiple research queries - Add prompt templates for architecture classification - Add default query generation as fallback - Add ArchitectureGenerator combining Claude + Perplexity - Add 44 new tests (80 total, all passing) All Phase 2 acceptance criteria met: - Claude client sends prompts and retries on failure - Perplexity client searches and parses with sources - Architecture classification returns valid JSON - Research queries generated (4-6 per architecture) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
200 lines
5.9 KiB
TypeScript
200 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Create a mock for the messages.create function
|
|
const mockCreate = vi.fn();
|
|
|
|
// Mock the Anthropic SDK as a class
|
|
vi.mock('@anthropic-ai/sdk', () => {
|
|
return {
|
|
default: class MockAnthropic {
|
|
messages = {
|
|
create: mockCreate,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
// Import after mocking
|
|
import { ClaudeClient } from '../clients/claude.js';
|
|
|
|
describe('ClaudeClient', () => {
|
|
let client: ClaudeClient;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
client = new ClaudeClient({ apiKey: 'test-key' });
|
|
});
|
|
|
|
describe('analyze', () => {
|
|
it('returns text response from Claude', async () => {
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: 'Hello, world!' }],
|
|
});
|
|
|
|
const result = await client.analyze('test prompt');
|
|
expect(result).toBe('Hello, world!');
|
|
});
|
|
|
|
it('throws error when no text block in response', async () => {
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [],
|
|
});
|
|
|
|
await expect(client.analyze('test prompt')).rejects.toThrow('No text response from Claude');
|
|
});
|
|
|
|
it('throws error for invalid API key', async () => {
|
|
const authError = new Error('Unauthorized') as Error & { status: number };
|
|
authError.status = 401;
|
|
mockCreate.mockRejectedValueOnce(authError);
|
|
|
|
await expect(client.analyze('test prompt')).rejects.toThrow('Invalid Claude API key');
|
|
});
|
|
|
|
it('retries on server errors', async () => {
|
|
const serverError = new Error('Server Error') as Error & { status: number };
|
|
serverError.status = 500;
|
|
|
|
mockCreate
|
|
.mockRejectedValueOnce(serverError)
|
|
.mockRejectedValueOnce(serverError)
|
|
.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: 'Success after retry' }],
|
|
});
|
|
|
|
const result = await client.analyze('test prompt');
|
|
expect(result).toBe('Success after retry');
|
|
expect(mockCreate).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe('classifyArchitecture', () => {
|
|
it('parses valid architecture JSON', async () => {
|
|
const architectureJson = JSON.stringify({
|
|
appType: 'cli',
|
|
appTypeReason: 'Command-line tool',
|
|
interfaces: ['cli_args'],
|
|
interfacesReason: 'CLI arguments',
|
|
persistence: 'file_based',
|
|
persistenceReason: 'File storage',
|
|
deployment: 'package_registry',
|
|
deploymentReason: 'npm',
|
|
suggestedTechStack: {
|
|
language: 'TypeScript',
|
|
framework: 'Commander.js',
|
|
reasoning: 'CLI framework',
|
|
},
|
|
});
|
|
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: architectureJson }],
|
|
});
|
|
|
|
const result = await client.classifyArchitecture('A CLI tool for converting files');
|
|
|
|
expect(result.appType).toBe('cli');
|
|
expect(result.interfaces).toContain('cli_args');
|
|
expect(result.persistence).toBe('file_based');
|
|
expect(result.deployment).toBe('package_registry');
|
|
});
|
|
|
|
it('handles JSON wrapped in markdown code blocks', async () => {
|
|
const architectureJson = `\`\`\`json
|
|
{
|
|
"appType": "web",
|
|
"appTypeReason": "Web app",
|
|
"interfaces": ["rest_api"],
|
|
"interfacesReason": "REST API",
|
|
"persistence": "remote_db",
|
|
"persistenceReason": "Database",
|
|
"deployment": "cloud",
|
|
"deploymentReason": "Cloud deployment",
|
|
"suggestedTechStack": {
|
|
"language": "TypeScript",
|
|
"framework": "Express",
|
|
"reasoning": "Node.js backend"
|
|
}
|
|
}
|
|
\`\`\``;
|
|
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: architectureJson }],
|
|
});
|
|
|
|
const result = await client.classifyArchitecture('A web application');
|
|
|
|
expect(result.appType).toBe('web');
|
|
expect(result.interfaces).toContain('rest_api');
|
|
});
|
|
|
|
it('throws error for invalid JSON', async () => {
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: 'not valid json' }],
|
|
});
|
|
|
|
await expect(client.classifyArchitecture('test')).rejects.toThrow('Failed to parse architecture');
|
|
});
|
|
|
|
it('throws error for missing required fields', async () => {
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: '{"appType": "web"}' }],
|
|
});
|
|
|
|
await expect(client.classifyArchitecture('test')).rejects.toThrow('Missing required fields');
|
|
});
|
|
});
|
|
|
|
describe('generateResearchQueries', () => {
|
|
const mockArchitecture = {
|
|
appType: 'cli' as const,
|
|
appTypeReason: 'Command-line tool',
|
|
interfaces: ['cli_args' as const],
|
|
interfacesReason: 'CLI arguments',
|
|
persistence: 'file_based' as const,
|
|
persistenceReason: 'File storage',
|
|
deployment: 'package_registry' as const,
|
|
deploymentReason: 'npm',
|
|
suggestedTechStack: {
|
|
language: 'TypeScript',
|
|
framework: 'Commander.js',
|
|
reasoning: 'CLI framework',
|
|
},
|
|
};
|
|
|
|
it('parses valid query array', async () => {
|
|
const queries = ['query1', 'query2', 'query3', 'query4'];
|
|
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: JSON.stringify(queries) }],
|
|
});
|
|
|
|
const result = await client.generateResearchQueries(mockArchitecture);
|
|
|
|
expect(result).toEqual(queries);
|
|
expect(result.length).toBe(4);
|
|
});
|
|
|
|
it('handles JSON wrapped in markdown code blocks', async () => {
|
|
const queriesJson = `\`\`\`json
|
|
["query1", "query2", "query3", "query4"]
|
|
\`\`\``;
|
|
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: queriesJson }],
|
|
});
|
|
|
|
const result = await client.generateResearchQueries(mockArchitecture);
|
|
|
|
expect(result).toEqual(['query1', 'query2', 'query3', 'query4']);
|
|
});
|
|
|
|
it('throws error for non-array response', async () => {
|
|
mockCreate.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: '{"not": "an array"}' }],
|
|
});
|
|
|
|
await expect(client.generateResearchQueries(mockArchitecture)).rejects.toThrow('not an array');
|
|
});
|
|
});
|
|
});
|