- Add ?v= cache-bust parameters to CSS references in index.html, home-ai.html, and timeline.html (were missing, causing stale CSS) - Fix version.json: disable forceUpdate (was causing 10s auto-reload loops), fix minVersion paradox (was 0.2.1 > current 0.1.3) - Fix update-cache-version.js: stop always setting forceUpdate=true, add 7 missing HTML files to cache-bust list, add bare CSS/JS reference detection - Fix ClaudeAPI.test.js: generateBlogTopics now takes context object, not positional arguments - Add spacing between honesty note and Koha section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
589 lines
19 KiB
JavaScript
589 lines
19 KiB
JavaScript
/**
|
|
* Unit Tests - ClaudeAPI Service
|
|
* Tests AI service methods with mocked API responses
|
|
*/
|
|
|
|
const ClaudeAPI = require('../../src/services/ClaudeAPI.service');
|
|
|
|
describe('ClaudeAPI Service', () => {
|
|
let originalApiKey;
|
|
let originalMakeRequest;
|
|
|
|
beforeAll(() => {
|
|
// Store original API key
|
|
originalApiKey = ClaudeAPI.apiKey;
|
|
// Ensure API key is set for tests
|
|
ClaudeAPI.apiKey = 'test_api_key';
|
|
// Store original _makeRequest for restoration
|
|
originalMakeRequest = ClaudeAPI._makeRequest;
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Restore original API key and method
|
|
ClaudeAPI.apiKey = originalApiKey;
|
|
ClaudeAPI._makeRequest = originalMakeRequest;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Mock _makeRequest for each test
|
|
ClaudeAPI._makeRequest = jest.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Constructor and Configuration', () => {
|
|
test('should initialize with default model and max tokens', () => {
|
|
expect(ClaudeAPI.model).toBeDefined();
|
|
expect(ClaudeAPI.maxTokens).toBeDefined();
|
|
expect(typeof ClaudeAPI.maxTokens).toBe('number');
|
|
});
|
|
|
|
test('should use environment variables for configuration', () => {
|
|
expect(ClaudeAPI.apiVersion).toBe('2023-06-01');
|
|
expect(ClaudeAPI.hostname).toBe('api.anthropic.com');
|
|
});
|
|
});
|
|
|
|
describe('sendMessage()', () => {
|
|
test('should throw error if API key not configured', async () => {
|
|
const tempKey = ClaudeAPI.apiKey;
|
|
ClaudeAPI.apiKey = null;
|
|
|
|
await expect(
|
|
ClaudeAPI.sendMessage([{ role: 'user', content: 'Test' }])
|
|
).rejects.toThrow('Claude API key not configured');
|
|
|
|
ClaudeAPI.apiKey = tempKey;
|
|
});
|
|
|
|
test('should send message with default options', async () => {
|
|
const mockResponse = {
|
|
content: [{ type: 'text', text: 'Response text' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 }
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue(mockResponse);
|
|
|
|
const messages = [{ role: 'user', content: 'Test message' }];
|
|
const response = await ClaudeAPI.sendMessage(messages);
|
|
|
|
expect(ClaudeAPI._makeRequest).toHaveBeenCalledWith({
|
|
model: ClaudeAPI.model,
|
|
max_tokens: ClaudeAPI.maxTokens,
|
|
messages: messages
|
|
});
|
|
expect(response).toEqual(mockResponse);
|
|
});
|
|
|
|
test('should send message with custom options', async () => {
|
|
const mockResponse = {
|
|
content: [{ type: 'text', text: 'Response' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 }
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue(mockResponse);
|
|
|
|
const messages = [{ role: 'user', content: 'Test' }];
|
|
const options = {
|
|
model: 'claude-3-opus-20240229',
|
|
max_tokens: 2048,
|
|
temperature: 0.7,
|
|
system: 'You are a helpful assistant.'
|
|
};
|
|
|
|
await ClaudeAPI.sendMessage(messages, options);
|
|
|
|
expect(ClaudeAPI._makeRequest).toHaveBeenCalledWith({
|
|
model: options.model,
|
|
max_tokens: options.max_tokens,
|
|
messages: messages,
|
|
system: options.system,
|
|
temperature: options.temperature
|
|
});
|
|
});
|
|
|
|
test('should log usage information', async () => {
|
|
const mockResponse = {
|
|
content: [{ type: 'text', text: 'Response' }],
|
|
usage: { input_tokens: 100, output_tokens: 50 }
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue(mockResponse);
|
|
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
|
|
await ClaudeAPI.sendMessage([{ role: 'user', content: 'Test' }]);
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('[ClaudeAPI] Usage: 100 in, 50 out')
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
test('should handle API errors', async () => {
|
|
ClaudeAPI._makeRequest.mockRejectedValue(new Error('API Error'));
|
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
|
|
await expect(
|
|
ClaudeAPI.sendMessage([{ role: 'user', content: 'Test' }])
|
|
).rejects.toThrow('API Error');
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('[ClaudeAPI] Error:'),
|
|
expect.any(String)
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('extractTextContent()', () => {
|
|
test('should extract text from valid response', () => {
|
|
const response = {
|
|
content: [
|
|
{ type: 'text', text: 'This is the response text' }
|
|
]
|
|
};
|
|
|
|
const text = ClaudeAPI.extractTextContent(response);
|
|
expect(text).toBe('This is the response text');
|
|
});
|
|
|
|
test('should handle multiple content blocks', () => {
|
|
const response = {
|
|
content: [
|
|
{ type: 'tool_use', name: 'calculator' },
|
|
{ type: 'text', text: 'Answer is 42' }
|
|
]
|
|
};
|
|
|
|
const text = ClaudeAPI.extractTextContent(response);
|
|
expect(text).toBe('Answer is 42');
|
|
});
|
|
|
|
test('should throw error for invalid response format', () => {
|
|
expect(() => ClaudeAPI.extractTextContent(null)).toThrow('Invalid Claude API response format');
|
|
expect(() => ClaudeAPI.extractTextContent({})).toThrow('Invalid Claude API response format');
|
|
expect(() => ClaudeAPI.extractTextContent({ content: 'not an array' })).toThrow('Invalid Claude API response format');
|
|
});
|
|
|
|
test('should throw error when no text block found', () => {
|
|
const response = {
|
|
content: [
|
|
{ type: 'tool_use', name: 'calculator' }
|
|
]
|
|
};
|
|
|
|
expect(() => ClaudeAPI.extractTextContent(response)).toThrow('No text content in Claude API response');
|
|
});
|
|
});
|
|
|
|
describe('extractJSON()', () => {
|
|
test('should parse plain JSON', () => {
|
|
const response = {
|
|
content: [{ type: 'text', text: '{"key": "value", "number": 42}' }]
|
|
};
|
|
|
|
const json = ClaudeAPI.extractJSON(response);
|
|
expect(json).toEqual({ key: 'value', number: 42 });
|
|
});
|
|
|
|
test('should handle JSON in markdown code block', () => {
|
|
const response = {
|
|
content: [{ type: 'text', text: '```json\n{"key": "value"}\n```' }]
|
|
};
|
|
|
|
const json = ClaudeAPI.extractJSON(response);
|
|
expect(json).toEqual({ key: 'value' });
|
|
});
|
|
|
|
test('should handle JSON in generic code block', () => {
|
|
const response = {
|
|
content: [{ type: 'text', text: '```\n{"key": "value"}\n```' }]
|
|
};
|
|
|
|
const json = ClaudeAPI.extractJSON(response);
|
|
expect(json).toEqual({ key: 'value' });
|
|
});
|
|
|
|
test('should throw error for invalid JSON', () => {
|
|
const response = {
|
|
content: [{ type: 'text', text: 'This is not JSON' }]
|
|
};
|
|
|
|
expect(() => ClaudeAPI.extractJSON(response)).toThrow('Failed to parse JSON from Claude response');
|
|
});
|
|
});
|
|
|
|
describe('classifyInstruction()', () => {
|
|
test('should classify instruction into quadrant', async () => {
|
|
const mockResponse = {
|
|
content: [{
|
|
type: 'text',
|
|
text: JSON.stringify({
|
|
quadrant: 'STRATEGIC',
|
|
persistence: 'HIGH',
|
|
temporal_scope: 'PROJECT',
|
|
verification_required: 'MANDATORY',
|
|
explicitness: 0.9,
|
|
reasoning: 'Sets long-term project direction'
|
|
})
|
|
}],
|
|
usage: { input_tokens: 50, output_tokens: 100 }
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue(mockResponse);
|
|
|
|
const result = await ClaudeAPI.classifyInstruction('Always use TypeScript for new projects');
|
|
|
|
expect(result.quadrant).toBe('STRATEGIC');
|
|
expect(result.persistence).toBe('HIGH');
|
|
expect(result.temporal_scope).toBe('PROJECT');
|
|
expect(ClaudeAPI._makeRequest).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('generateBlogTopics()', () => {
|
|
test('should generate blog topics for audience without theme', async () => {
|
|
const mockTopics = [
|
|
{
|
|
title: 'Understanding AI Safety Through Sovereignty',
|
|
subtitle: 'How Tractatus preserves human agency',
|
|
word_count: 1200,
|
|
key_points: ['sovereignty', 'agency', 'values'],
|
|
tractatus_angle: 'Core principle'
|
|
}
|
|
];
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockTopics) }],
|
|
usage: { input_tokens: 100, output_tokens: 200 }
|
|
});
|
|
|
|
const result = await ClaudeAPI.generateBlogTopics('researcher');
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result[0]).toHaveProperty('title');
|
|
expect(ClaudeAPI._makeRequest).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should generate blog topics with theme', async () => {
|
|
const mockTopics = [
|
|
{ title: 'Topic 1', subtitle: 'Subtitle', word_count: 1000, key_points: [], tractatus_angle: 'Angle' }
|
|
];
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockTopics) }],
|
|
usage: { input_tokens: 100, output_tokens: 200 }
|
|
});
|
|
|
|
const result = await ClaudeAPI.generateBlogTopics({
|
|
audience: 'implementer',
|
|
theme: 'governance frameworks',
|
|
tone: 'standard',
|
|
culture: 'universal',
|
|
language: 'en'
|
|
});
|
|
|
|
expect(result).toEqual(mockTopics);
|
|
const callArgs = ClaudeAPI._makeRequest.mock.calls[0][0];
|
|
expect(callArgs.messages[0].content).toContain('governance frameworks');
|
|
});
|
|
});
|
|
|
|
describe('classifyMediaInquiry()', () => {
|
|
test('should classify media inquiry by priority', async () => {
|
|
const mockClassification = {
|
|
priority: 'HIGH',
|
|
reasoning: 'Major outlet with tight deadline',
|
|
recommended_response_time: '24 hours',
|
|
suggested_spokesperson: 'framework creator'
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockClassification) }],
|
|
usage: { input_tokens: 80, output_tokens: 100 }
|
|
});
|
|
|
|
const inquiry = {
|
|
outlet: 'TechCrunch',
|
|
request: 'Interview about AI safety frameworks',
|
|
deadline: '2025-10-15'
|
|
};
|
|
|
|
const result = await ClaudeAPI.classifyMediaInquiry(inquiry);
|
|
|
|
expect(result.priority).toBe('HIGH');
|
|
expect(result.recommended_response_time).toBeDefined();
|
|
expect(ClaudeAPI._makeRequest).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should handle inquiry without deadline', async () => {
|
|
const mockClassification = {
|
|
priority: 'MEDIUM',
|
|
reasoning: 'No urgent deadline',
|
|
recommended_response_time: '3-5 days',
|
|
suggested_spokesperson: 'technical expert'
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockClassification) }],
|
|
usage: { input_tokens: 60, output_tokens: 80 }
|
|
});
|
|
|
|
const inquiry = {
|
|
outlet: 'Medium Blog',
|
|
request: 'Feature article about AI governance'
|
|
};
|
|
|
|
const result = await ClaudeAPI.classifyMediaInquiry(inquiry);
|
|
|
|
expect(result.priority).toBe('MEDIUM');
|
|
const callArgs = ClaudeAPI._makeRequest.mock.calls[0][0];
|
|
expect(callArgs.messages[0].content).toContain('Not specified');
|
|
});
|
|
});
|
|
|
|
describe('draftMediaResponse()', () => {
|
|
test('should draft response to media inquiry', async () => {
|
|
const mockDraft = 'Thank you for your interest in the Tractatus AI Safety Framework...';
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: mockDraft }],
|
|
usage: { input_tokens: 100, output_tokens: 150 }
|
|
});
|
|
|
|
const inquiry = {
|
|
outlet: 'Wired Magazine',
|
|
request: 'Expert quote on AI safety'
|
|
};
|
|
|
|
const result = await ClaudeAPI.draftMediaResponse(inquiry, 'HIGH');
|
|
|
|
expect(typeof result).toBe('string');
|
|
expect(result).toContain('Tractatus');
|
|
expect(ClaudeAPI._makeRequest).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('analyzeCaseRelevance()', () => {
|
|
test('should analyze case study relevance', async () => {
|
|
const mockAnalysis = {
|
|
relevance_score: 85,
|
|
strengths: ['Clear evidence', 'Framework alignment'],
|
|
weaknesses: ['Needs more detail'],
|
|
recommended_action: 'PUBLISH',
|
|
ethical_concerns: null,
|
|
suggested_improvements: ['Add metrics']
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockAnalysis) }],
|
|
usage: { input_tokens: 120, output_tokens: 180 }
|
|
});
|
|
|
|
const caseStudy = {
|
|
title: 'AI System Prevented from Making Values Decision',
|
|
description: 'Case study of boundary enforcement',
|
|
evidence: 'System logs and decision audit trail'
|
|
};
|
|
|
|
const result = await ClaudeAPI.analyzeCaseRelevance(caseStudy);
|
|
|
|
expect(result.relevance_score).toBe(85);
|
|
expect(result.recommended_action).toBe('PUBLISH');
|
|
expect(Array.isArray(result.strengths)).toBe(true);
|
|
});
|
|
|
|
test('should handle case study without evidence', async () => {
|
|
const mockAnalysis = {
|
|
relevance_score: 45,
|
|
strengths: ['Interesting topic'],
|
|
weaknesses: ['No evidence provided'],
|
|
recommended_action: 'EDIT',
|
|
ethical_concerns: null,
|
|
suggested_improvements: ['Add concrete evidence']
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockAnalysis) }],
|
|
usage: { input_tokens: 100, output_tokens: 140 }
|
|
});
|
|
|
|
const caseStudy = {
|
|
title: 'Interesting AI Safety Case',
|
|
description: 'A case that might be relevant'
|
|
};
|
|
|
|
const result = await ClaudeAPI.analyzeCaseRelevance(caseStudy);
|
|
|
|
expect(result.relevance_score).toBeLessThan(60);
|
|
const callArgs = ClaudeAPI._makeRequest.mock.calls[0][0];
|
|
expect(callArgs.messages[0].content).toContain('Not provided');
|
|
});
|
|
});
|
|
|
|
describe('curateResource()', () => {
|
|
test('should curate external resource', async () => {
|
|
const mockCuration = {
|
|
recommended: true,
|
|
category: 'PAPERS',
|
|
alignment_score: 92,
|
|
target_audience: ['researcher', 'implementer'],
|
|
tags: ['AI safety', 'governance', 'frameworks'],
|
|
reasoning: 'Highly aligned with Tractatus principles',
|
|
concerns: null
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockCuration) }],
|
|
usage: { input_tokens: 90, output_tokens: 120 }
|
|
});
|
|
|
|
const resource = {
|
|
url: 'https://example.com/paper',
|
|
title: 'AI Safety Research Paper',
|
|
description: 'Comprehensive framework for AI governance'
|
|
};
|
|
|
|
const result = await ClaudeAPI.curateResource(resource);
|
|
|
|
expect(result.recommended).toBe(true);
|
|
expect(result.category).toBe('PAPERS');
|
|
expect(result.alignment_score).toBeGreaterThan(80);
|
|
expect(Array.isArray(result.target_audience)).toBe(true);
|
|
});
|
|
|
|
test('should identify concerns in resources', async () => {
|
|
const mockCuration = {
|
|
recommended: false,
|
|
category: 'ARTICLES',
|
|
alignment_score: 35,
|
|
target_audience: [],
|
|
tags: ['AI'],
|
|
reasoning: 'Conflicting values approach',
|
|
concerns: ['Promotes full automation of values decisions', 'No human oversight']
|
|
};
|
|
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify(mockCuration) }],
|
|
usage: { input_tokens: 80, output_tokens: 110 }
|
|
});
|
|
|
|
const resource = {
|
|
url: 'https://example.com/article',
|
|
title: 'Fully Automated AI Ethics',
|
|
description: 'Let AI make all ethical decisions'
|
|
};
|
|
|
|
const result = await ClaudeAPI.curateResource(resource);
|
|
|
|
expect(result.recommended).toBe(false);
|
|
expect(result.concerns).toBeDefined();
|
|
expect(Array.isArray(result.concerns)).toBe(true);
|
|
expect(result.concerns.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
test('should handle network errors', async () => {
|
|
ClaudeAPI._makeRequest.mockRejectedValue(new Error('Network timeout'));
|
|
|
|
await expect(
|
|
ClaudeAPI.sendMessage([{ role: 'user', content: 'Test' }])
|
|
).rejects.toThrow('Network timeout');
|
|
});
|
|
|
|
test('should handle malformed responses', async () => {
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [{ type: 'text', text: 'Not valid JSON' }],
|
|
usage: { input_tokens: 10, output_tokens: 5 }
|
|
});
|
|
|
|
await expect(
|
|
ClaudeAPI.classifyInstruction('Test instruction')
|
|
).rejects.toThrow('Failed to parse JSON from Claude response');
|
|
});
|
|
|
|
test('should handle empty responses gracefully', async () => {
|
|
ClaudeAPI._makeRequest.mockResolvedValue({
|
|
content: [],
|
|
usage: { input_tokens: 10, output_tokens: 0 }
|
|
});
|
|
|
|
const response = await ClaudeAPI.sendMessage([{ role: 'user', content: 'Test' }]);
|
|
|
|
// sendMessage doesn't validate content, it just returns the response
|
|
expect(response.content).toEqual([]);
|
|
|
|
// But extractTextContent should throw an error
|
|
expect(() => ClaudeAPI.extractTextContent(response)).toThrow('No text content in Claude API response');
|
|
});
|
|
});
|
|
|
|
describe('_makeRequest() [Private Method Testing]', () => {
|
|
beforeEach(() => {
|
|
// Restore original _makeRequest for these tests
|
|
ClaudeAPI._makeRequest = originalMakeRequest;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Re-mock for other tests
|
|
ClaudeAPI._makeRequest = jest.fn();
|
|
});
|
|
|
|
test('should construct proper HTTPS request', () => {
|
|
// This test verifies the method exists and has proper structure
|
|
expect(typeof ClaudeAPI._makeRequest).toBe('function');
|
|
|
|
const payload = {
|
|
model: 'claude-sonnet-4-5-20250929',
|
|
max_tokens: 1024,
|
|
messages: [{ role: 'user', content: 'Test' }]
|
|
};
|
|
|
|
// Verify the method accepts a payload parameter
|
|
expect(() => ClaudeAPI._makeRequest(payload)).not.toThrow(TypeError);
|
|
});
|
|
|
|
test('should include required headers in request', () => {
|
|
// Verify method signature and structure
|
|
const methodString = ClaudeAPI._makeRequest.toString();
|
|
expect(methodString).toContain('x-api-key');
|
|
expect(methodString).toContain('anthropic-version');
|
|
expect(methodString).toContain('Content-Type');
|
|
});
|
|
|
|
test('should use correct API endpoint', () => {
|
|
const methodString = ClaudeAPI._makeRequest.toString();
|
|
expect(methodString).toContain('/v1/messages');
|
|
expect(methodString).toContain('443'); // HTTPS port
|
|
});
|
|
});
|
|
|
|
describe('Service Integration', () => {
|
|
test('should maintain singleton pattern', () => {
|
|
const ClaudeAPI2 = require('../../src/services/ClaudeAPI.service');
|
|
expect(ClaudeAPI).toBe(ClaudeAPI2);
|
|
});
|
|
|
|
test('should have all expected public methods', () => {
|
|
expect(typeof ClaudeAPI.sendMessage).toBe('function');
|
|
expect(typeof ClaudeAPI.extractTextContent).toBe('function');
|
|
expect(typeof ClaudeAPI.extractJSON).toBe('function');
|
|
expect(typeof ClaudeAPI.classifyInstruction).toBe('function');
|
|
expect(typeof ClaudeAPI.generateBlogTopics).toBe('function');
|
|
expect(typeof ClaudeAPI.classifyMediaInquiry).toBe('function');
|
|
expect(typeof ClaudeAPI.draftMediaResponse).toBe('function');
|
|
expect(typeof ClaudeAPI.analyzeCaseRelevance).toBe('function');
|
|
expect(typeof ClaudeAPI.curateResource).toBe('function');
|
|
});
|
|
|
|
test('should expose private _makeRequest method for testing', () => {
|
|
// Verify _makeRequest exists (even though private)
|
|
expect(typeof ClaudeAPI._makeRequest).toBe('function');
|
|
});
|
|
});
|
|
});
|