diff --git a/docs/tasks/plan.md b/docs/tasks/plan.md index fb7ac57..8f4c4e8 100644 --- a/docs/tasks/plan.md +++ b/docs/tasks/plan.md @@ -31,8 +31,8 @@ Raise test coverage from 46.92% to 90%+ across the entire repository by adding u | # | Task | Status | Depends On | File | |---|------|--------|------------|------| | 7 | Domain & application layer test coverage | done (PR #9) | — | [007](007-domain-app-test-coverage.md) | -| 8 | Infrastructure utilities & events test coverage | reviewed | — | [008](008-infra-utils-events-test-coverage.md) | -| 9 | Infrastructure adapters test coverage | pending | — | [009](009-infra-adapters-test-coverage.md) | +| 8 | Infrastructure utilities & events test coverage | done (PR #10) | — | [008](008-infra-utils-events-test-coverage.md) | +| 9 | Infrastructure adapters test coverage | reviewed | — | [009](009-infra-adapters-test-coverage.md) | ### Dependency Graph diff --git a/src/infrastructure/gmail/__tests__/email-processor.test.ts b/src/infrastructure/gmail/__tests__/email-processor.test.ts new file mode 100644 index 0000000..f94e2e4 --- /dev/null +++ b/src/infrastructure/gmail/__tests__/email-processor.test.ts @@ -0,0 +1,116 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock googleapis +const mockMessagesList = jest.fn(); +const mockMessagesGet = jest.fn(); +const mockMessagesModify = jest.fn(); + +jest.mock('googleapis', () => ({ + google: { + gmail: () => ({ + users: { + messages: { + list: mockMessagesList, + get: mockMessagesGet, + modify: mockMessagesModify, + }, + }, + }), + }, +})); + +import { EmailProcessor } from '../email-processor'; + +describe('EmailProcessor', () => { + let processor: EmailProcessor; + const mockAuth = {}; + + beforeEach(() => { + jest.clearAllMocks(); + processor = new EmailProcessor(mockAuth); + }); + + describe('fetchUnreadEmails', () => { + it('should return parsed emails', async () => { + mockMessagesList.mockResolvedValue({ + data: { + messages: [{ id: 'msg-1' }], + }, + }); + + mockMessagesGet.mockResolvedValue({ + data: { + payload: { + headers: [ + { name: 'Subject', value: 'Test' }, + { name: 'From', value: 'from@test.com' }, + { name: 'To', value: 'to@test.com' }, + { name: 'Date', value: '2024-03-15' }, + ], + body: { + data: Buffer.from('Email body').toString('base64'), + }, + }, + }, + }); + + const emails = await processor.fetchUnreadEmails('from:test'); + expect(emails).toHaveLength(1); + expect(emails[0].id).toBe('msg-1'); + expect(emails[0].subject).toBe('Test'); + expect(emails[0].body).toBe('Email body'); + }); + + it('should return empty array when no messages', async () => { + mockMessagesList.mockResolvedValue({ + data: { messages: undefined }, + }); + + const emails = await processor.fetchUnreadEmails('from:test'); + expect(emails).toHaveLength(0); + }); + + it('should throw on API error', async () => { + mockMessagesList.mockRejectedValue(new Error('API error')); + await expect(processor.fetchUnreadEmails('from:test')).rejects.toThrow('API error'); + }); + + it('should skip emails that fail to fetch details', async () => { + mockMessagesList.mockResolvedValue({ + data: { messages: [{ id: 'msg-1' }] }, + }); + + mockMessagesGet.mockRejectedValue(new Error('Not found')); + + const emails = await processor.fetchUnreadEmails('from:test'); + expect(emails).toHaveLength(0); + }); + }); + + describe('markAsRead', () => { + it('should call modify API to remove UNREAD label', async () => { + mockMessagesModify.mockResolvedValue({}); + + await processor.markAsRead('msg-1'); + expect(mockMessagesModify).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'msg-1', + userId: 'me', + requestBody: { removeLabelIds: ['UNREAD'] }, + }) + ); + }); + + it('should throw on API error', async () => { + mockMessagesModify.mockRejectedValue(new Error('API error')); + await expect(processor.markAsRead('msg-1')).rejects.toThrow('API error'); + }); + }); +}); diff --git a/src/infrastructure/gmail/__tests__/email.utils.test.ts b/src/infrastructure/gmail/__tests__/email.utils.test.ts new file mode 100644 index 0000000..2ffde4d --- /dev/null +++ b/src/infrastructure/gmail/__tests__/email.utils.test.ts @@ -0,0 +1,110 @@ +import { extractEmailHeaders, extractEmailBody, htmlToPlainText, processEmailContent } from '../email.utils'; + +describe('email.utils', () => { + describe('extractEmailHeaders', () => { + it('should extract all headers', () => { + const headers = [ + { name: 'Subject', value: 'Test Subject' }, + { name: 'From', value: 'sender@test.com' }, + { name: 'To', value: 'recipient@test.com' }, + { name: 'Date', value: '2024-03-15T10:00:00Z' }, + ]; + + const result = extractEmailHeaders(headers); + expect(result).toEqual({ + subject: 'Test Subject', + from: 'sender@test.com', + to: 'recipient@test.com', + date: '2024-03-15T10:00:00Z', + }); + }); + + it('should return empty strings for missing headers', () => { + const result = extractEmailHeaders([]); + expect(result).toEqual({ + subject: '', + from: '', + to: '', + date: '', + }); + }); + }); + + describe('extractEmailBody', () => { + it('should decode base64 body from direct payload', () => { + const payload = { + body: { + data: Buffer.from('Hello World').toString('base64'), + }, + }; + + const result = extractEmailBody(payload); + expect(result).toBe('Hello World'); + }); + + it('should prefer text/plain from multipart', () => { + const payload = { + parts: [ + { + mimeType: 'text/html', + body: { data: Buffer.from('HTML').toString('base64') }, + }, + { + mimeType: 'text/plain', + body: { data: Buffer.from('Plain text').toString('base64') }, + }, + ], + }; + + const result = extractEmailBody(payload); + expect(result).toBe('Plain text'); + }); + + it('should fall back to text/html when no text/plain', () => { + const payload = { + parts: [ + { + mimeType: 'text/html', + body: { data: Buffer.from('
Hello
').toString('base64') }, + }, + ], + }; + + const result = extractEmailBody(payload); + // Should be converted from HTML to plain text + expect(result).toContain('Hello'); + }); + + it('should return empty string for empty payload', () => { + const result = extractEmailBody({}); + expect(result).toBe(''); + }); + }); + + describe('htmlToPlainText', () => { + it('should convert HTML to plain text', () => { + const result = htmlToPlainText('Hello World
'); + expect(result).toContain('Hello'); + expect(result).toContain('World'); + }); + + it('should return empty string for empty input', () => { + expect(htmlToPlainText('')).toBe(''); + }); + }); + + describe('processEmailContent', () => { + it('should return plain text as-is', () => { + expect(processEmailContent('Hello World')).toBe('Hello World'); + }); + + it('should convert HTML to plain text', () => { + const result = processEmailContent('Hello
'); + expect(result).toContain('Hello'); + }); + + it('should return empty string for empty input', () => { + expect(processEmailContent('')).toBe(''); + }); + }); +}); diff --git a/src/infrastructure/gmail/__tests__/gmail.adapter.test.ts b/src/infrastructure/gmail/__tests__/gmail.adapter.test.ts new file mode 100644 index 0000000..dd8d7b0 --- /dev/null +++ b/src/infrastructure/gmail/__tests__/gmail.adapter.test.ts @@ -0,0 +1,85 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock TokenManager +const mockTokenManagerInit = jest.fn().mockResolvedValue(undefined); +const mockGetAuth = jest.fn().mockReturnValue({}); +const mockGenerateAuthUrl = jest.fn().mockReturnValue('https://auth.url'); +const mockGetInitialTokens = jest.fn(); + +jest.mock('../token-manager', () => ({ + TokenManager: { + initialize: jest.fn().mockResolvedValue({ + getAuth: mockGetAuth, + generateAuthUrl: mockGenerateAuthUrl, + getInitialTokens: mockGetInitialTokens, + }), + loadCredentials: jest.fn(), + loadTokens: jest.fn(), + }, +})); + +// Mock EmailProcessor +const mockFetchUnreadEmails = jest.fn().mockResolvedValue([]); +const mockMarkAsRead = jest.fn().mockResolvedValue(undefined); + +jest.mock('../email-processor', () => ({ + EmailProcessor: jest.fn().mockImplementation(() => ({ + fetchUnreadEmails: mockFetchUnreadEmails, + markAsRead: mockMarkAsRead, + })), +})); + +// Mock googleapis for getProfile +const mockGetProfile = jest.fn().mockResolvedValue({ + data: { emailAddress: 'test@gmail.com' }, +}); + +jest.mock('googleapis', () => ({ + google: { + gmail: jest.fn().mockReturnValue({ + users: { + getProfile: mockGetProfile, + }, + }), + }, +})); + +import { GmailAdapter } from '../gmail.adapter'; +import { TokenManager } from '../token-manager'; + +describe('GmailAdapter', () => { + describe('initialize', () => { + it('should initialize token manager and verify connection', async () => { + const adapter = await GmailAdapter.initialize(); + expect(TokenManager.initialize).toHaveBeenCalled(); + expect(mockGetProfile).toHaveBeenCalled(); + expect(adapter).toBeInstanceOf(GmailAdapter); + }); + }); + + describe('fetchUnreadEmails', () => { + it('should delegate to email processor', async () => { + const mockEmails = [{ id: '1', subject: 'Test', from: '', to: '', body: '' }]; + mockFetchUnreadEmails.mockResolvedValue(mockEmails); + + const adapter = await GmailAdapter.initialize(); + const result = await adapter.fetchUnreadEmails('from:test'); + expect(result).toEqual(mockEmails); + }); + }); + + describe('markAsRead', () => { + it('should delegate to email processor', async () => { + const adapter = await GmailAdapter.initialize(); + await adapter.markAsRead('msg-1'); + expect(mockMarkAsRead).toHaveBeenCalledWith('msg-1'); + }); + }); +}); diff --git a/src/infrastructure/gmail/__tests__/token-manager.test.ts b/src/infrastructure/gmail/__tests__/token-manager.test.ts new file mode 100644 index 0000000..c21816d --- /dev/null +++ b/src/infrastructure/gmail/__tests__/token-manager.test.ts @@ -0,0 +1,391 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../utils/container', () => ({ + container: { + getByClass: jest.fn().mockReturnValue({ + sendNotification: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +const mockSetCredentials = jest.fn(); +const mockGenerateAuthUrl = jest.fn().mockReturnValue('https://auth.url'); +const mockRefreshAccessToken = jest.fn(); +const mockGetToken = jest.fn(); +const mockOn = jest.fn(); + +jest.mock('googleapis', () => ({ + google: { + auth: { + OAuth2: jest.fn().mockImplementation(() => ({ + setCredentials: mockSetCredentials, + generateAuthUrl: mockGenerateAuthUrl, + refreshAccessToken: mockRefreshAccessToken, + getToken: mockGetToken, + on: mockOn, + })), + }, + }, +})); + +const mockReadFile = jest.fn(); +const mockWriteFile = jest.fn().mockResolvedValue(undefined); + +jest.mock('fs', () => ({ + promises: { + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), + }, +})); + +import { TokenManager } from '../token-manager'; +import { GmailCredentials, GmailTokens } from '../types'; + +describe('TokenManager', () => { + const credentials: GmailCredentials = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + redirect_uri: 'http://localhost:3000/callback', + }; + + const tokens: GmailTokens = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + scope: 'https://www.googleapis.com/auth/gmail.readonly', + token_type: 'Bearer', + expiry_date: Date.now() + 3600000, // 1 hour from now + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('constructor', () => { + it('should create instance with credentials and tokens', () => { + const manager = new TokenManager(credentials, tokens); + expect(manager.getTokens()).toEqual(tokens); + }); + + it('should create instance with default empty tokens', () => { + const manager = new TokenManager(credentials); + expect(manager.getTokens().access_token).toBe(''); + }); + }); + + describe('loadCredentials', () => { + it('should load and parse web credentials', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ + web: { + client_id: 'web-id', + client_secret: 'web-secret', + redirect_uris: ['https://example.com/callback'], + }, + })); + + const result = await TokenManager.loadCredentials(); + expect(result.client_id).toBe('web-id'); + expect(result.client_secret).toBe('web-secret'); + }); + + it('should load installed credentials', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ + installed: { + client_id: 'installed-id', + client_secret: 'installed-secret', + redirect_uris: ['http://localhost:3000/callback'], + }, + })); + + const result = await TokenManager.loadCredentials(); + expect(result.client_id).toBe('installed-id'); + }); + + it('should throw for invalid credentials format', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({})); + await expect(TokenManager.loadCredentials()).rejects.toThrow( + 'Invalid credentials format' + ); + }); + }); + + describe('loadTokens', () => { + it('should load and parse tokens', async () => { + mockReadFile.mockResolvedValue(JSON.stringify(tokens)); + const result = await TokenManager.loadTokens(); + expect(result).toEqual(tokens); + }); + + it('should return null when file not found', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + const result = await TokenManager.loadTokens(); + expect(result).toBeNull(); + }); + }); + + describe('init', () => { + it('should set credentials and register token refresh handler', async () => { + mockRefreshAccessToken.mockResolvedValue({ + credentials: { ...tokens }, + }); + + const manager = new TokenManager(credentials, tokens); + await manager.init(); + + expect(mockSetCredentials).toHaveBeenCalledWith(tokens); + expect(mockOn).toHaveBeenCalledWith('tokens', expect.any(Function)); + }); + }); + + describe('checkAndRefreshToken', () => { + it('should refresh when token is about to expire', async () => { + const expiringTokens = { + ...tokens, + expiry_date: Date.now() + 5 * 60 * 1000, // 5 min from now (< 10 min threshold) + }; + + mockRefreshAccessToken.mockResolvedValue({ + credentials: { + access_token: 'new-access', + refresh_token: 'new-refresh', + expiry_date: Date.now() + 3600000, + }, + }); + + const manager = new TokenManager(credentials, expiringTokens); + await manager.checkAndRefreshToken(); + + expect(mockRefreshAccessToken).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalled(); + }); + + it('should schedule refresh when token is still valid', async () => { + const validTokens = { + ...tokens, + expiry_date: Date.now() + 60 * 60 * 1000, // 1 hour from now + }; + + const manager = new TokenManager(credentials, validTokens); + await manager.checkAndRefreshToken(); + + // Should not have tried to refresh + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it('should handle invalid_grant error by triggering re-auth', async () => { + const expiringTokens = { + ...tokens, + expiry_date: Date.now() - 1000, // Already expired + }; + + mockRefreshAccessToken.mockRejectedValue({ + response: { data: { error: 'invalid_grant' } }, + }); + + const manager = new TokenManager(credentials, expiringTokens); + // handleTokenInvalidation starts getInitialTokens which creates an HTTP server. + // Mock the private method to avoid that blocking behavior + (manager as any).handleTokenInvalidation = jest.fn().mockResolvedValue(undefined); + + await manager.checkAndRefreshToken(); + expect((manager as any).handleTokenInvalidation).toHaveBeenCalled(); + }); + + it('should rethrow non-invalid_grant errors', async () => { + const expiringTokens = { + ...tokens, + expiry_date: Date.now() - 1000, + }; + + const networkError = new Error('Network error'); + mockRefreshAccessToken.mockRejectedValue(networkError); + + const manager = new TokenManager(credentials, expiringTokens); + await expect(manager.checkAndRefreshToken()).rejects.toThrow('Network error'); + }); + }); + + describe('generateAuthUrl', () => { + it('should generate OAuth URL', () => { + const manager = new TokenManager(credentials); + const url = manager.generateAuthUrl(); + expect(url).toBe('https://auth.url'); + expect(mockGenerateAuthUrl).toHaveBeenCalledWith( + expect.objectContaining({ + access_type: 'offline', + prompt: 'consent', + }) + ); + }); + }); + + describe('saveTokens', () => { + it('should write tokens to file', async () => { + const manager = new TokenManager(credentials, tokens); + // saveTokens is private, but we can trigger it through init + mockRefreshAccessToken.mockResolvedValue({ credentials: tokens }); + + // Access private method via any + await (manager as any).saveTokens(); + expect(mockWriteFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('test-access-token') + ); + }); + }); + + describe('initialize', () => { + it('should create token manager with existing valid tokens', async () => { + // loadCredentials + mockReadFile + .mockResolvedValueOnce(JSON.stringify({ + web: { + client_id: 'id', + client_secret: 'secret', + redirect_uris: ['https://example.com/callback'], + }, + })) + // loadTokens + .mockResolvedValueOnce(JSON.stringify(tokens)); + + // For init() -> checkAndRefreshToken, token is valid so just schedule + const manager = await TokenManager.initialize(); + expect(manager).toBeInstanceOf(TokenManager); + expect(mockSetCredentials).toHaveBeenCalled(); + }); + + it('should start OAuth flow when no tokens exist', async () => { + // loadCredentials + mockReadFile + .mockResolvedValueOnce(JSON.stringify({ + web: { + client_id: 'id', + client_secret: 'secret', + redirect_uris: ['https://example.com/callback'], + }, + })) + // loadTokens throws + .mockRejectedValueOnce(new Error('ENOENT')) + // loadConfigFromFile for the new token manager (if needed) + .mockResolvedValueOnce(JSON.stringify({})); + + // Mock getInitialTokens to avoid starting HTTP server + const mockGetInitialTokensSpy = jest.fn().mockResolvedValue(tokens); + const originalPrototype = TokenManager.prototype; + const originalGetInitialTokens = originalPrototype.getInitialTokens; + originalPrototype.getInitialTokens = mockGetInitialTokensSpy; + + try { + const manager = await TokenManager.initialize(); + expect(manager).toBeInstanceOf(TokenManager); + expect(mockGetInitialTokensSpy).toHaveBeenCalled(); + } finally { + originalPrototype.getInitialTokens = originalGetInitialTokens; + } + }); + }); + + describe('sendAuthorizationNotification', () => { + it('should send auth URL via Telegram', async () => { + const manager = new TokenManager(credentials, tokens); + await (manager as any).sendAuthorizationNotification('https://auth.url/test'); + // container.getByClass returns mock with sendNotification + }); + }); + + describe('handleTokenInvalidation', () => { + it('should clear tokens and request re-authorization', async () => { + const manager = new TokenManager(credentials, tokens); + // Mock getInitialTokens to avoid HTTP server + (manager as any).getInitialTokens = jest.fn().mockResolvedValue(tokens); + + await (manager as any).handleTokenInvalidation(); + // Should have cleared tokens and saved + expect(mockWriteFile).toHaveBeenCalled(); + expect((manager as any).getInitialTokens).toHaveBeenCalled(); + }); + + it('should rethrow if getInitialTokens fails', async () => { + const manager = new TokenManager(credentials, tokens); + (manager as any).getInitialTokens = jest.fn().mockRejectedValue(new Error('auth failed')); + + await expect((manager as any).handleTokenInvalidation()).rejects.toThrow('auth failed'); + }); + }); + + describe('init token refresh event', () => { + it('should update tokens when refresh event fires', async () => { + mockRefreshAccessToken.mockResolvedValue({ credentials: tokens }); + + const manager = new TokenManager(credentials, tokens); + await manager.init(); + + // Get the callback registered with auth.on('tokens', callback) + const tokenCallback = mockOn.mock.calls.find( + (call: [string, Function]) => call[0] === 'tokens' + )?.[1]; + + expect(tokenCallback).toBeDefined(); + + // Simulate token refresh event + await tokenCallback({ + refresh_token: 'new-refresh', + access_token: 'new-access', + expiry_date: Date.now() + 7200000, + }); + + // Should have saved tokens + expect(mockWriteFile).toHaveBeenCalled(); + const savedTokens = manager.getTokens(); + expect(savedTokens.refresh_token).toBe('new-refresh'); + expect(savedTokens.access_token).toBe('new-access'); + }); + }); + + describe('saveTokens error', () => { + it('should throw when write fails', async () => { + mockWriteFile.mockRejectedValueOnce(new Error('write error')); + const manager = new TokenManager(credentials, tokens); + await expect((manager as any).saveTokens()).rejects.toThrow('write error'); + }); + }); + + describe('getAuth / getTokens', () => { + it('should return auth and tokens', () => { + const manager = new TokenManager(credentials, tokens); + expect(manager.getAuth()).toBeDefined(); + expect(manager.getTokens()).toEqual(tokens); + }); + }); + + describe('scheduleTokenRefresh', () => { + it('should schedule a refresh timer', async () => { + const manager = new TokenManager(credentials, tokens); + const futureExpiry = Date.now() + 60 * 60 * 1000; // 1 hour + + await manager.scheduleTokenRefresh(futureExpiry); + // Timer should be scheduled (we use fake timers) + expect(jest.getTimerCount()).toBeGreaterThan(0); + }); + + it('should not schedule if refresh time is negative', async () => { + const manager = new TokenManager(credentials, tokens); + const pastExpiry = Date.now() - 1000; // Already expired + + await manager.scheduleTokenRefresh(pastExpiry); + // No timer should be scheduled + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/src/infrastructure/openai/__tests__/openai.adapter.test.ts b/src/infrastructure/openai/__tests__/openai.adapter.test.ts new file mode 100644 index 0000000..06cbf19 --- /dev/null +++ b/src/infrastructure/openai/__tests__/openai.adapter.test.ts @@ -0,0 +1,95 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockCreate = jest.fn(); + +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate, + }, + }, + })); +}); + +import { OpenAIAdapter } from '../openai.adapter'; + +describe('OpenAIAdapter', () => { + let adapter: OpenAIAdapter; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new OpenAIAdapter({ apiKey: 'test-key' }); + }); + + describe('constructor', () => { + it('should use default model gpt-4o-mini', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'response' } }], + }); + + await adapter.processMessage('system', 'user'); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4o-mini' }) + ); + }); + + it('should use custom model when specified', async () => { + const customAdapter = new OpenAIAdapter({ + apiKey: 'key', + model: 'gpt-4', + }); + + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'response' } }], + }); + + await customAdapter.processMessage('system', 'user'); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4' }) + ); + }); + }); + + describe('processMessage', () => { + it('should call OpenAI with system and user messages', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'Hello!' } }], + }); + + const result = await adapter.processMessage('Be helpful', 'Hi'); + expect(result).toBe('Hello!'); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { role: 'system', content: 'Be helpful' }, + { role: 'user', content: 'Hi' }, + ], + temperature: 0.3, + max_tokens: 1000, + }) + ); + }); + + it('should return empty string when no content', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + const result = await adapter.processMessage('system', 'user'); + expect(result).toBe(''); + }); + + it('should throw on API error', async () => { + mockCreate.mockRejectedValue(new Error('Rate limit')); + await expect(adapter.processMessage('s', 'u')).rejects.toThrow('Rate limit'); + }); + }); +}); diff --git a/src/infrastructure/telegram/__tests__/telegram.adapter.test.ts b/src/infrastructure/telegram/__tests__/telegram.adapter.test.ts new file mode 100644 index 0000000..7a8acd7 --- /dev/null +++ b/src/infrastructure/telegram/__tests__/telegram.adapter.test.ts @@ -0,0 +1,241 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + Logger: jest.fn(), +})); + +// Mock merchant-category-mapping to avoid file I/O +jest.mock('../../../domain/models/merchant-category-mapping', () => ({ + get merchantCategoryMappings() { return {}; }, + findCategoryForMerchant: jest.fn(), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), +})); + +const mockSendMessage = jest.fn().mockResolvedValue({}); +const mockSetMyCommands = jest.fn().mockResolvedValue(undefined); +const mockUse = jest.fn(); +const mockCommand = jest.fn(); +const mockCallbackQuery = jest.fn(); +const mockOn = jest.fn(); +const mockCatch = jest.fn(); +const mockStart = jest.fn(); +const mockStop = jest.fn(); + +jest.mock('../bot', () => ({ + createBot: jest.fn().mockReturnValue({ + use: mockUse, + command: mockCommand, + callbackQuery: mockCallbackQuery, + on: mockOn, + catch: mockCatch, + start: mockStart, + stop: mockStop, + api: { + sendMessage: mockSendMessage, + setMyCommands: mockSetMyCommands, + }, + token: 'test-token', + }), +})); + +jest.mock('@grammyjs/conversations', () => ({ + createConversation: jest.fn().mockReturnValue(jest.fn()), +})); + +jest.mock('../conversations/add-bill', () => ({ + addBillConversation: jest.fn(), + ADD_BILL_CONVERSATION_ID: 'addBill', +})); + +jest.mock('../conversations/categorization', () => ({ + categorizationConversation: jest.fn(), + CATEGORIZATION_CONVERSATION_ID: 'categorization', +})); + +const mockGetByClass = jest.fn().mockReturnValue({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}); + +jest.mock('../../utils/container', () => ({ + container: { + getByClass: (...args: unknown[]) => mockGetByClass(...args), + }, +})); + +describe('TelegramAdapter', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + TELEGRAM_BOT_TOKEN: 'test-bot-token', + TELEGRAM_CHAT_ID: '12345', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + // Import after mocks are set up + function importAdapter() { + // Reset module to pick up env vars + jest.resetModules(); + // Re-set mocks after resetModules + jest.doMock('../../utils/logger', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + Logger: jest.fn(), + })); + jest.doMock('../../utils/container', () => ({ + container: { getByClass: mockGetByClass }, + })); + jest.doMock('../bot', () => ({ + createBot: jest.fn().mockReturnValue({ + use: mockUse, command: mockCommand, callbackQuery: mockCallbackQuery, + on: mockOn, catch: mockCatch, start: mockStart, stop: mockStop, + api: { sendMessage: mockSendMessage, setMyCommands: mockSetMyCommands }, + token: 'test-token', + }), + })); + jest.doMock('@grammyjs/conversations', () => ({ + createConversation: jest.fn().mockReturnValue(jest.fn()), + })); + jest.doMock('../conversations/add-bill', () => ({ + addBillConversation: jest.fn(), + ADD_BILL_CONVERSATION_ID: 'addBill', + })); + jest.doMock('../conversations/categorization', () => ({ + categorizationConversation: jest.fn(), + CATEGORIZATION_CONVERSATION_ID: 'categorization', + })); + jest.doMock('../../../domain/models/merchant-category-mapping', () => ({ + get merchantCategoryMappings() { return {}; }, + findCategoryForMerchant: jest.fn(), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), + })); + + return require('../telegram.adapter'); + } + + describe('constructor', () => { + it('should create adapter with TELEGRAM_BOT_TOKEN', () => { + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + expect(adapter).toBeDefined(); + }); + + it('should throw when TELEGRAM_BOT_TOKEN is missing', () => { + delete process.env.TELEGRAM_BOT_TOKEN; + const mod = importAdapter(); + expect(() => new mod.TelegramAdapter()).toThrow('TELEGRAM_BOT_TOKEN is required'); + }); + + it('should setup conversations and command handlers', () => { + const mod = importAdapter(); + new mod.TelegramAdapter(); + // Should have registered conversations with bot.use() + expect(mockUse).toHaveBeenCalled(); + // Should have registered commands + expect(mockCommand).toHaveBeenCalled(); + }); + }); + + describe('init', () => { + it('should start bot polling', async () => { + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + await adapter.init(); + expect(mockStart).toHaveBeenCalled(); + expect(mockSetMyCommands).toHaveBeenCalled(); + }); + }); + + describe('sendNotification', () => { + it('should send plain message without merchantId', async () => { + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + await adapter.sendNotification('Hello'); + expect(mockSendMessage).toHaveBeenCalledWith('12345', 'Hello', { parse_mode: 'HTML' }); + }); + + it('should send message with inline keyboard when merchantId is provided', async () => { + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + await adapter.sendNotification('Categorize?', 'merchant-1', { merchant: 'GRAB', merchantId: 'merchant-1' }); + expect(mockSendMessage).toHaveBeenCalledWith( + '12345', + 'Categorize?', + expect.objectContaining({ + parse_mode: 'HTML', + reply_markup: expect.objectContaining({ + inline_keyboard: expect.any(Array), + }), + }) + ); + }); + + it('should skip when no chatId configured', async () => { + delete process.env.TELEGRAM_CHAT_ID; + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + await adapter.sendNotification('Hello'); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('should retry on send failure', async () => { + jest.useFakeTimers(); + mockSendMessage + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({}); + + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + + const promise = adapter.sendNotification('Hello'); + + // Advance timers for retry delays + await jest.advanceTimersByTimeAsync(10000); + await jest.advanceTimersByTimeAsync(10000); + + await promise; + expect(mockSendMessage).toHaveBeenCalledTimes(3); + jest.useRealTimers(); + }); + }); + + describe('pending merchant helpers', () => { + it('getPendingMerchant should return undefined for unknown shortId', () => { + const mod = importAdapter(); + expect(mod.getPendingMerchant('unknown')).toBeUndefined(); + }); + + it('removePendingMerchant should not throw for unknown shortId', () => { + const mod = importAdapter(); + expect(() => mod.removePendingMerchant('unknown')).not.toThrow(); + }); + + it('removePendingMerchantByMerchantId should not throw for unknown id', () => { + const mod = importAdapter(); + expect(() => mod.removePendingMerchantByMerchantId('unknown')).not.toThrow(); + }); + }); + + describe('getBotInstance', () => { + it('should return the bot', () => { + const mod = importAdapter(); + const adapter = new mod.TelegramAdapter(); + expect(adapter.getBotInstance()).toBeDefined(); + }); + }); +}); diff --git a/src/infrastructure/telegram/commands/__tests__/categorization-constants.test.ts b/src/infrastructure/telegram/commands/__tests__/categorization-constants.test.ts new file mode 100644 index 0000000..8d83ee8 --- /dev/null +++ b/src/infrastructure/telegram/commands/__tests__/categorization-constants.test.ts @@ -0,0 +1,47 @@ +import { CALLBACK_PREFIXES, MESSAGES, CATEGORY_TYPES } from '../categorization-constants'; + +describe('categorization-constants', () => { + describe('CALLBACK_PREFIXES', () => { + it('should have all required prefixes', () => { + expect(CALLBACK_PREFIXES.SELECT_CATEGORY).toBeDefined(); + expect(CALLBACK_PREFIXES.CANCEL_CATEGORIZATION).toBeDefined(); + expect(CALLBACK_PREFIXES.CATEGORIZE_MERCHANT).toBeDefined(); + }); + + it('should have non-empty string values', () => { + expect(typeof CALLBACK_PREFIXES.SELECT_CATEGORY).toBe('string'); + expect(CALLBACK_PREFIXES.SELECT_CATEGORY.length).toBeGreaterThan(0); + }); + }); + + describe('MESSAGES', () => { + it('should have all required message templates', () => { + expect(MESSAGES.CATEGORIZATION_PROMPT).toBeDefined(); + expect(MESSAGES.CATEGORIZATION_CANCELLED).toBeDefined(); + expect(MESSAGES.CATEGORIZATION_ERROR).toBeDefined(); + expect(MESSAGES.ANALYZING).toBeDefined(); + expect(MESSAGES.ERROR_MERCHANT_ID_NOT_FOUND).toBeDefined(); + }); + + it('CATEGORIZATION_PROMPT should be a function', () => { + expect(typeof MESSAGES.CATEGORIZATION_PROMPT).toBe('function'); + const result = MESSAGES.CATEGORIZATION_PROMPT('GRAB'); + expect(result).toContain('GRAB'); + }); + + it('CATEGORY_SELECTED should be a function', () => { + expect(typeof MESSAGES.CATEGORY_SELECTED).toBe('function'); + const result = MESSAGES.CATEGORY_SELECTED('GRAB', 'Expenses:Food'); + expect(result).toContain('GRAB'); + expect(result).toContain('Expenses:Food'); + }); + }); + + describe('CATEGORY_TYPES', () => { + it('should have all required types', () => { + expect(CATEGORY_TYPES.PRIMARY).toBeDefined(); + expect(CATEGORY_TYPES.ALTERNATIVE).toBeDefined(); + expect(CATEGORY_TYPES.SUGGESTED).toBeDefined(); + }); + }); +}); diff --git a/src/infrastructure/telegram/commands/__tests__/custom-query-command-handler.test.ts b/src/infrastructure/telegram/commands/__tests__/custom-query-command-handler.test.ts new file mode 100644 index 0000000..dd0f302 --- /dev/null +++ b/src/infrastructure/telegram/commands/__tests__/custom-query-command-handler.test.ts @@ -0,0 +1,125 @@ +jest.mock('../../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + Logger: jest.fn(), +})); + +const mockQueryByDateRange = jest.fn().mockResolvedValue({ + assets: [], + expenses: [], +}); + +const mockParseDateRange = jest.fn(); + +jest.mock('../../../utils/container', () => ({ + container: { + getByClass: jest.fn().mockImplementation((cls: { name: string }) => { + if (cls.name === 'NLPService') { + return { parseDateRange: mockParseDateRange }; + } + if (cls.name === 'BeancountQueryService') { + return { queryByDateRange: mockQueryByDateRange }; + } + // Logger + return { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + }), + }, +})); + +jest.mock('../../../utils/query-result-formatter', () => ({ + formatQueryResult: jest.fn().mockReturnValue('Formatted result'), +})); + +// Mock merchant-category-mapping +jest.mock('../../../../domain/models/merchant-category-mapping', () => ({ + get merchantCategoryMappings() { return {}; }, + findCategoryForMerchant: jest.fn(), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), +})); + +import { CustomQueryCommandHandler } from '../custom-query-command-handler'; +import { Bot } from 'grammy'; +import { BotContext } from '../../grammy-types'; + +describe('CustomQueryCommandHandler', () => { + let handler: CustomQueryCommandHandler; + let mockBot: Bot