test: increase coverage for ClaudeAPI and koha services (9% → 86%)
Major test coverage improvements for Week 1 Task 3 (PHASE-4-PREPARATION-CHECKLIST). ClaudeAPI.service.js Coverage: - Before: 9.41% (CRITICAL - lowest coverage in codebase) - After: 85.88% ✅ (exceeds 80% target) - Tests: 34 passing - File: tests/unit/ClaudeAPI.test.js (NEW) Test Coverage: - Constructor and configuration - sendMessage() with various options - extractTextContent() edge cases - extractJSON() with markdown code blocks - classifyInstruction() AI classification - generateBlogTopics() content generation - classifyMediaInquiry() triage system - draftMediaResponse() AI drafting - analyzeCaseRelevance() case study scoring - curateResource() resource evaluation - Error handling (network, parsing, empty responses) - Private _makeRequest() method validation Mocking Strategy: - Mocked _makeRequest() to avoid real API calls - Tested all public methods with mock responses - Validated error paths and edge cases koha.service.js Coverage: - Before: 13.76% (improved from 5.79% after integration tests) - After: 86.23% ✅ (exceeds 80% target) - Tests: 34 passing - File: tests/unit/koha.service.test.js (NEW) Test Coverage: - createCheckoutSession() validation and Stripe calls - handleWebhook() event routing (7 event types) - handleCheckoutComplete() donation creation/update - handlePaymentSuccess/Failure() status updates - handleInvoicePaid() recurring payments - verifyWebhookSignature() security - getTransparencyMetrics() public data - sendReceiptEmail() receipt generation - cancelRecurringDonation() subscription management - getStatistics() admin reporting Mocking Strategy: - Mocked Stripe SDK (customers, checkout, subscriptions, webhooks) - Mocked Donation model (all database operations) - Mocked currency utilities (exchange rates) - Suppressed console output in tests Impact: - 2 of 4 critical services now have >80% coverage - Added 68 comprehensive test cases - Improved codebase reliability and maintainability - Reduced risk for Phase 4 deployment Remaining Coverage Targets (Task 3): - governance.routes.js: 31.81% → 80%+ (pending) - markdown.util.js: 17.39% → 80%+ (pending) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
71b3ac0f5c
commit
9b79c8dea3
2 changed files with 1203 additions and 0 deletions
583
tests/unit/ClaudeAPI.test.js
Normal file
583
tests/unit/ClaudeAPI.test.js
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
/**
|
||||||
|
* 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('implementer', 'governance frameworks');
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
620
tests/unit/koha.service.test.js
Normal file
620
tests/unit/koha.service.test.js
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
/**
|
||||||
|
* Unit Tests - Koha Service
|
||||||
|
* Tests donation processing with mocked Stripe and Donation model
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock Stripe before requiring the service
|
||||||
|
jest.mock('stripe', () => {
|
||||||
|
const mockStripe = {
|
||||||
|
customers: {
|
||||||
|
list: jest.fn(),
|
||||||
|
create: jest.fn()
|
||||||
|
},
|
||||||
|
checkout: {
|
||||||
|
sessions: {
|
||||||
|
create: jest.fn()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
retrieve: jest.fn(),
|
||||||
|
cancel: jest.fn()
|
||||||
|
},
|
||||||
|
webhooks: {
|
||||||
|
constructEvent: jest.fn()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return jest.fn(() => mockStripe);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Donation model
|
||||||
|
jest.mock('../../src/models/Donation.model', () => ({
|
||||||
|
create: jest.fn(),
|
||||||
|
findByPaymentIntentId: jest.fn(),
|
||||||
|
findBySubscriptionId: jest.fn(),
|
||||||
|
updateStatus: jest.fn(),
|
||||||
|
cancelSubscription: jest.fn(),
|
||||||
|
markReceiptSent: jest.fn(),
|
||||||
|
getTransparencyMetrics: jest.fn(),
|
||||||
|
getStatistics: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock currency utilities
|
||||||
|
jest.mock('../../src/config/currencies.config', () => ({
|
||||||
|
isSupportedCurrency: jest.fn((curr) => ['NZD', 'USD', 'AUD', 'EUR', 'GBP'].includes(curr.toUpperCase())),
|
||||||
|
convertToNZD: jest.fn((amount, curr) => {
|
||||||
|
const rates = { NZD: 1, USD: 1.65, AUD: 1.07, EUR: 1.82, GBP: 2.05 };
|
||||||
|
return Math.round(amount * (rates[curr.toUpperCase()] || 1));
|
||||||
|
}),
|
||||||
|
getExchangeRate: jest.fn((curr) => {
|
||||||
|
const rates = { NZD: 1, USD: 1.65, AUD: 1.07, EUR: 1.82, GBP: 2.05 };
|
||||||
|
return rates[curr.toUpperCase()] || 1;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const kohaService = require('../../src/services/koha.service');
|
||||||
|
const Donation = require('../../src/models/Donation.model');
|
||||||
|
const stripe = require('stripe')();
|
||||||
|
|
||||||
|
describe('Koha Service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Suppress console output in tests
|
||||||
|
jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Constructor and Configuration', () => {
|
||||||
|
test('should initialize with stripe instance', () => {
|
||||||
|
expect(kohaService.stripe).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have price IDs configured', () => {
|
||||||
|
expect(kohaService.priceIds).toBeDefined();
|
||||||
|
expect(kohaService.priceIds).toHaveProperty('monthly_5');
|
||||||
|
expect(kohaService.priceIds).toHaveProperty('monthly_15');
|
||||||
|
expect(kohaService.priceIds).toHaveProperty('monthly_50');
|
||||||
|
expect(kohaService.priceIds).toHaveProperty('one_time');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCheckoutSession()', () => {
|
||||||
|
const validDonation = {
|
||||||
|
amount: 1000,
|
||||||
|
currency: 'NZD',
|
||||||
|
frequency: 'one_time',
|
||||||
|
tier: 'custom',
|
||||||
|
donor: {
|
||||||
|
name: 'Test Donor',
|
||||||
|
email: 'test@example.com',
|
||||||
|
country: 'NZ'
|
||||||
|
},
|
||||||
|
public_acknowledgement: false
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should create one-time donation checkout session', async () => {
|
||||||
|
stripe.customers.list.mockResolvedValue({ data: [] });
|
||||||
|
stripe.customers.create.mockResolvedValue({ id: 'cus_test123' });
|
||||||
|
stripe.checkout.sessions.create.mockResolvedValue({
|
||||||
|
id: 'cs_test123',
|
||||||
|
url: 'https://checkout.stripe.com/test'
|
||||||
|
});
|
||||||
|
Donation.create.mockResolvedValue({ _id: 'donation_id' });
|
||||||
|
|
||||||
|
const result = await kohaService.createCheckoutSession(validDonation);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('sessionId', 'cs_test123');
|
||||||
|
expect(result).toHaveProperty('checkoutUrl');
|
||||||
|
expect(stripe.customers.create).toHaveBeenCalled();
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalled();
|
||||||
|
expect(Donation.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create monthly subscription checkout session', async () => {
|
||||||
|
// Set price ID before test
|
||||||
|
const originalPriceId = kohaService.priceIds.monthly_15;
|
||||||
|
kohaService.priceIds.monthly_15 = 'price_15';
|
||||||
|
|
||||||
|
const monthlyDonation = {
|
||||||
|
...validDonation,
|
||||||
|
frequency: 'monthly',
|
||||||
|
tier: '15'
|
||||||
|
};
|
||||||
|
|
||||||
|
stripe.customers.list.mockResolvedValue({ data: [] });
|
||||||
|
stripe.customers.create.mockResolvedValue({ id: 'cus_test123' });
|
||||||
|
stripe.checkout.sessions.create.mockResolvedValue({
|
||||||
|
id: 'cs_test456',
|
||||||
|
url: 'https://checkout.stripe.com/test2'
|
||||||
|
});
|
||||||
|
Donation.create.mockResolvedValue({ _id: 'donation_id2' });
|
||||||
|
|
||||||
|
const result = await kohaService.createCheckoutSession(monthlyDonation);
|
||||||
|
|
||||||
|
expect(result.frequency).toBe('monthly');
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
mode: 'subscription',
|
||||||
|
line_items: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ price: 'price_15' })
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore original
|
||||||
|
kohaService.priceIds.monthly_15 = originalPriceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reuse existing Stripe customer', async () => {
|
||||||
|
const existingCustomer = { id: 'cus_existing', email: 'test@example.com' };
|
||||||
|
stripe.customers.list.mockResolvedValue({ data: [existingCustomer] });
|
||||||
|
stripe.checkout.sessions.create.mockResolvedValue({
|
||||||
|
id: 'cs_test789',
|
||||||
|
url: 'https://checkout.stripe.com/test3'
|
||||||
|
});
|
||||||
|
Donation.create.mockResolvedValue({ _id: 'donation_id3' });
|
||||||
|
|
||||||
|
await kohaService.createCheckoutSession(validDonation);
|
||||||
|
|
||||||
|
expect(stripe.customers.create).not.toHaveBeenCalled();
|
||||||
|
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
customer: 'cus_existing'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject unsupported currency', async () => {
|
||||||
|
const invalidCurrency = {
|
||||||
|
...validDonation,
|
||||||
|
currency: 'JPY' // Not supported
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.createCheckoutSession(invalidCurrency)
|
||||||
|
).rejects.toThrow('Unsupported currency');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject amount below minimum', async () => {
|
||||||
|
const lowAmount = {
|
||||||
|
...validDonation,
|
||||||
|
amount: 50 // Less than $1.00
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.createCheckoutSession(lowAmount)
|
||||||
|
).rejects.toThrow('Minimum donation amount is $1.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid frequency', async () => {
|
||||||
|
const invalidFreq = {
|
||||||
|
...validDonation,
|
||||||
|
frequency: 'weekly' // Not supported
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.createCheckoutSession(invalidFreq)
|
||||||
|
).rejects.toThrow('Invalid frequency');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should require donor email', async () => {
|
||||||
|
const noEmail = {
|
||||||
|
...validDonation,
|
||||||
|
donor: { name: 'Test' } // No email
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.createCheckoutSession(noEmail)
|
||||||
|
).rejects.toThrow('Donor email is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid monthly tier', async () => {
|
||||||
|
const invalidTier = {
|
||||||
|
...validDonation,
|
||||||
|
frequency: 'monthly',
|
||||||
|
tier: '100' // Not a valid tier
|
||||||
|
};
|
||||||
|
|
||||||
|
stripe.customers.list.mockResolvedValue({ data: [] });
|
||||||
|
stripe.customers.create.mockResolvedValue({ id: 'cus_test' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.createCheckoutSession(invalidTier)
|
||||||
|
).rejects.toThrow('Invalid monthly tier');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle customer creation failure', async () => {
|
||||||
|
stripe.customers.list.mockRejectedValue(new Error('Stripe API error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.createCheckoutSession(validDonation)
|
||||||
|
).rejects.toThrow('Failed to process donor information');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleWebhook()', () => {
|
||||||
|
test('should handle checkout.session.completed', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'checkout.session.completed',
|
||||||
|
data: { object: { id: 'cs_test', metadata: {} } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckoutCompleteSpy = jest.spyOn(kohaService, 'handleCheckoutComplete').mockResolvedValue();
|
||||||
|
|
||||||
|
await kohaService.handleWebhook(event);
|
||||||
|
|
||||||
|
expect(handleCheckoutCompleteSpy).toHaveBeenCalled();
|
||||||
|
handleCheckoutCompleteSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle payment_intent.succeeded', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'payment_intent.succeeded',
|
||||||
|
data: { object: { id: 'pi_test' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentSuccessSpy = jest.spyOn(kohaService, 'handlePaymentSuccess').mockResolvedValue();
|
||||||
|
|
||||||
|
await kohaService.handleWebhook(event);
|
||||||
|
|
||||||
|
expect(handlePaymentSuccessSpy).toHaveBeenCalled();
|
||||||
|
handlePaymentSuccessSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle payment_intent.payment_failed', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'payment_intent.payment_failed',
|
||||||
|
data: { object: { id: 'pi_test' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaymentFailureSpy = jest.spyOn(kohaService, 'handlePaymentFailure').mockResolvedValue();
|
||||||
|
|
||||||
|
await kohaService.handleWebhook(event);
|
||||||
|
|
||||||
|
expect(handlePaymentFailureSpy).toHaveBeenCalled();
|
||||||
|
handlePaymentFailureSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle invoice.paid', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'invoice.paid',
|
||||||
|
data: { object: { id: 'in_test' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvoicePaidSpy = jest.spyOn(kohaService, 'handleInvoicePaid').mockResolvedValue();
|
||||||
|
|
||||||
|
await kohaService.handleWebhook(event);
|
||||||
|
|
||||||
|
expect(handleInvoicePaidSpy).toHaveBeenCalled();
|
||||||
|
handleInvoicePaidSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle customer.subscription.deleted', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'customer.subscription.deleted',
|
||||||
|
data: { object: { id: 'sub_test' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscriptionCancellationSpy = jest.spyOn(kohaService, 'handleSubscriptionCancellation').mockResolvedValue();
|
||||||
|
|
||||||
|
await kohaService.handleWebhook(event);
|
||||||
|
|
||||||
|
expect(handleSubscriptionCancellationSpy).toHaveBeenCalled();
|
||||||
|
handleSubscriptionCancellationSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should log unhandled event types', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'unknown.event.type',
|
||||||
|
data: { object: {} }
|
||||||
|
};
|
||||||
|
|
||||||
|
await kohaService.handleWebhook(event);
|
||||||
|
|
||||||
|
expect(console.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Unhandled webhook event type')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error if webhook processing fails', async () => {
|
||||||
|
const event = {
|
||||||
|
type: 'checkout.session.completed',
|
||||||
|
data: { object: { id: 'cs_test' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(kohaService, 'handleCheckoutComplete').mockRejectedValue(new Error('Processing failed'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.handleWebhook(event)
|
||||||
|
).rejects.toThrow('Processing failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleCheckoutComplete()', () => {
|
||||||
|
test('should create new donation record', async () => {
|
||||||
|
const session = {
|
||||||
|
id: 'cs_test',
|
||||||
|
amount_total: 1500,
|
||||||
|
currency: 'nzd',
|
||||||
|
customer_email: 'test@example.com',
|
||||||
|
customer: 'cus_test',
|
||||||
|
payment_intent: 'pi_test',
|
||||||
|
subscription: null,
|
||||||
|
metadata: {
|
||||||
|
frequency: 'one_time',
|
||||||
|
tier: 'custom',
|
||||||
|
currency: 'NZD',
|
||||||
|
amount_nzd: '1500',
|
||||||
|
exchange_rate: '1.0',
|
||||||
|
donor_name: 'Test Donor',
|
||||||
|
public_acknowledgement: 'no'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Donation.findByPaymentIntentId.mockResolvedValue(null);
|
||||||
|
Donation.create.mockResolvedValue({ _id: 'donation_id', donor: { email: 'test@example.com' } });
|
||||||
|
|
||||||
|
await kohaService.handleCheckoutComplete(session);
|
||||||
|
|
||||||
|
expect(Donation.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
amount: 1500,
|
||||||
|
frequency: 'one_time',
|
||||||
|
status: 'completed'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update existing donation record', async () => {
|
||||||
|
const session = {
|
||||||
|
id: 'cs_test',
|
||||||
|
amount_total: 1500,
|
||||||
|
payment_intent: 'pi_existing',
|
||||||
|
subscription: 'sub_test',
|
||||||
|
metadata: {
|
||||||
|
frequency: 'monthly',
|
||||||
|
tier: '15',
|
||||||
|
currency: 'NZD',
|
||||||
|
amount_nzd: '1500'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingDonation = { _id: 'existing_id', status: 'pending' };
|
||||||
|
Donation.findByPaymentIntentId.mockResolvedValue(existingDonation);
|
||||||
|
Donation.updateStatus.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await kohaService.handleCheckoutComplete(session);
|
||||||
|
|
||||||
|
expect(Donation.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'existing_id',
|
||||||
|
'completed',
|
||||||
|
expect.objectContaining({
|
||||||
|
'stripe.subscription_id': 'sub_test'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handlePaymentSuccess()', () => {
|
||||||
|
test('should update pending donation to completed', async () => {
|
||||||
|
const paymentIntent = { id: 'pi_test' };
|
||||||
|
const donation = { _id: 'donation_id', status: 'pending' };
|
||||||
|
|
||||||
|
Donation.findByPaymentIntentId.mockResolvedValue(donation);
|
||||||
|
Donation.updateStatus.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await kohaService.handlePaymentSuccess(paymentIntent);
|
||||||
|
|
||||||
|
expect(Donation.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'donation_id',
|
||||||
|
'completed',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not update non-pending donations', async () => {
|
||||||
|
const paymentIntent = { id: 'pi_test' };
|
||||||
|
const donation = { _id: 'donation_id', status: 'completed' };
|
||||||
|
|
||||||
|
Donation.findByPaymentIntentId.mockResolvedValue(donation);
|
||||||
|
|
||||||
|
await kohaService.handlePaymentSuccess(paymentIntent);
|
||||||
|
|
||||||
|
expect(Donation.updateStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handlePaymentFailure()', () => {
|
||||||
|
test('should mark donation as failed', async () => {
|
||||||
|
const paymentIntent = {
|
||||||
|
id: 'pi_test',
|
||||||
|
last_payment_error: { message: 'Card declined' }
|
||||||
|
};
|
||||||
|
const donation = { _id: 'donation_id' };
|
||||||
|
|
||||||
|
Donation.findByPaymentIntentId.mockResolvedValue(donation);
|
||||||
|
Donation.updateStatus.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await kohaService.handlePaymentFailure(paymentIntent);
|
||||||
|
|
||||||
|
expect(Donation.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'donation_id',
|
||||||
|
'failed',
|
||||||
|
expect.objectContaining({
|
||||||
|
'metadata.failure_reason': 'Card declined'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleInvoicePaid()', () => {
|
||||||
|
test('should create donation for recurring payment', async () => {
|
||||||
|
const invoice = {
|
||||||
|
id: 'in_test',
|
||||||
|
subscription: 'sub_test',
|
||||||
|
customer_email: 'test@example.com',
|
||||||
|
customer: 'cus_test',
|
||||||
|
amount_paid: 1500,
|
||||||
|
currency: 'nzd',
|
||||||
|
charge: 'ch_test',
|
||||||
|
created: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = {
|
||||||
|
metadata: {
|
||||||
|
tier: '15',
|
||||||
|
public_acknowledgement: 'yes',
|
||||||
|
currency: 'NZD'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
stripe.subscriptions.retrieve.mockResolvedValue(subscription);
|
||||||
|
Donation.create.mockResolvedValue({ _id: 'donation_id' });
|
||||||
|
|
||||||
|
await kohaService.handleInvoicePaid(invoice);
|
||||||
|
|
||||||
|
expect(Donation.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
frequency: 'monthly',
|
||||||
|
status: 'completed',
|
||||||
|
amount: 1500
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyWebhookSignature()', () => {
|
||||||
|
test('should verify valid webhook signature', () => {
|
||||||
|
const payload = 'webhook payload';
|
||||||
|
const signature = 'sig_test';
|
||||||
|
const event = { type: 'test', data: {} };
|
||||||
|
|
||||||
|
stripe.webhooks.constructEvent.mockReturnValue(event);
|
||||||
|
|
||||||
|
const result = kohaService.verifyWebhookSignature(payload, signature);
|
||||||
|
|
||||||
|
expect(result).toEqual(event);
|
||||||
|
expect(stripe.webhooks.constructEvent).toHaveBeenCalledWith(
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
process.env.STRIPE_KOHA_WEBHOOK_SECRET
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error for invalid signature', () => {
|
||||||
|
stripe.webhooks.constructEvent.mockImplementation(() => {
|
||||||
|
throw new Error('Invalid signature');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
kohaService.verifyWebhookSignature('payload', 'bad_sig');
|
||||||
|
}).toThrow('Invalid webhook signature');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTransparencyMetrics()', () => {
|
||||||
|
test('should return transparency metrics', async () => {
|
||||||
|
const mockMetrics = {
|
||||||
|
total_received: 5000,
|
||||||
|
monthly_supporters: 10,
|
||||||
|
one_time_donations: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
Donation.getTransparencyMetrics.mockResolvedValue(mockMetrics);
|
||||||
|
|
||||||
|
const result = await kohaService.getTransparencyMetrics();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockMetrics);
|
||||||
|
expect(Donation.getTransparencyMetrics).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error if metrics retrieval fails', async () => {
|
||||||
|
Donation.getTransparencyMetrics.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.getTransparencyMetrics()
|
||||||
|
).rejects.toThrow('Database error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendReceiptEmail()', () => {
|
||||||
|
test('should generate receipt number and mark as sent', async () => {
|
||||||
|
const donation = {
|
||||||
|
_id: 'donation123',
|
||||||
|
donor: { email: 'test@example.com' }
|
||||||
|
};
|
||||||
|
|
||||||
|
Donation.markReceiptSent.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await kohaService.sendReceiptEmail(donation);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(Donation.markReceiptSent).toHaveBeenCalledWith(
|
||||||
|
'donation123',
|
||||||
|
expect.stringMatching(/^KOHA-\d{4}-[A-Z0-9]{8}$/)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelRecurringDonation()', () => {
|
||||||
|
test('should cancel subscription in Stripe and database', async () => {
|
||||||
|
stripe.subscriptions.cancel.mockResolvedValue({ id: 'sub_test', status: 'canceled' });
|
||||||
|
Donation.cancelSubscription.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await kohaService.cancelRecurringDonation('sub_test');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
message: 'Subscription cancelled successfully'
|
||||||
|
});
|
||||||
|
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith('sub_test');
|
||||||
|
expect(Donation.cancelSubscription).toHaveBeenCalledWith('sub_test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error if cancellation fails', async () => {
|
||||||
|
stripe.subscriptions.cancel.mockRejectedValue(new Error('Subscription not found'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
kohaService.cancelRecurringDonation('sub_nonexistent')
|
||||||
|
).rejects.toThrow('Subscription not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatistics()', () => {
|
||||||
|
test('should return donation statistics', async () => {
|
||||||
|
const mockStats = {
|
||||||
|
total_count: 100,
|
||||||
|
total_amount: 10000,
|
||||||
|
by_frequency: { monthly: 20, one_time: 80 }
|
||||||
|
};
|
||||||
|
|
||||||
|
Donation.getStatistics.mockResolvedValue(mockStats);
|
||||||
|
|
||||||
|
const result = await kohaService.getStatistics();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockStats);
|
||||||
|
expect(Donation.getStatistics).toHaveBeenCalledWith(null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support date range filtering', async () => {
|
||||||
|
const mockStats = { total_count: 10, total_amount: 1000 };
|
||||||
|
|
||||||
|
Donation.getStatistics.mockResolvedValue(mockStats);
|
||||||
|
|
||||||
|
await kohaService.getStatistics('2025-01-01', '2025-12-31');
|
||||||
|
|
||||||
|
expect(Donation.getStatistics).toHaveBeenCalledWith('2025-01-01', '2025-12-31');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Service Singleton', () => {
|
||||||
|
test('should export singleton instance', () => {
|
||||||
|
const kohaService2 = require('../../src/services/koha.service');
|
||||||
|
expect(kohaService).toBe(kohaService2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue