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