tractatus/tests/unit/ClaudeAPI.test.js
TheFlow c80cc29936 fix: Resolve stale CSS caching and CI test failure
- 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>
2026-02-07 16:10:29 +13:00

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');
});
});
});