From fb85dd37327dff574fb0edf82630a3cd87904216 Mon Sep 17 00:00:00 2001 From: TheFlow Date: Thu, 9 Oct 2025 21:17:32 +1300 Subject: [PATCH] =?UTF-8?q?test:=20increase=20coverage=20for=20ClaudeAPI?= =?UTF-8?q?=20and=20koha=20services=20(9%=20=E2=86=92=2086%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/unit/ClaudeAPI.test.js | 583 ++++++++++++++++++++++++++++++ tests/unit/koha.service.test.js | 620 ++++++++++++++++++++++++++++++++ 2 files changed, 1203 insertions(+) create mode 100644 tests/unit/ClaudeAPI.test.js create mode 100644 tests/unit/koha.service.test.js diff --git a/tests/unit/ClaudeAPI.test.js b/tests/unit/ClaudeAPI.test.js new file mode 100644 index 00000000..a40d3e42 --- /dev/null +++ b/tests/unit/ClaudeAPI.test.js @@ -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'); + }); + }); +}); diff --git a/tests/unit/koha.service.test.js b/tests/unit/koha.service.test.js new file mode 100644 index 00000000..8dc6763e --- /dev/null +++ b/tests/unit/koha.service.test.js @@ -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); + }); + }); +});