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; + let mockCtx: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + mockBot = {} as Bot; + mockCtx = { + message: { text: '查昨天' } as any, + from: { id: 123 } as any, + reply: jest.fn().mockResolvedValue(undefined), + }; + + handler = new CustomQueryCommandHandler(mockBot); + }); + + describe('handle', () => { + it('should return false for non-查 messages', async () => { + const ctx = { ...mockCtx, message: { text: 'hello' } as any } as BotContext; + const result = await handler.handle(ctx); + expect(result).toBe(false); + }); + + it('should return false for empty text', async () => { + const ctx = { ...mockCtx, message: undefined } as unknown as BotContext; + const result = await handler.handle(ctx); + expect(result).toBe(false); + }); + + it('should return false when no userId', async () => { + const ctx = { ...mockCtx, from: undefined } as unknown as BotContext; + const result = await handler.handle(ctx); + expect(result).toBe(false); + }); + + it('should process query starting with 查', async () => { + mockParseDateRange.mockResolvedValue({ + startDate: new Date('2024-03-14'), + endDate: new Date('2024-03-15'), + }); + + const result = await handler.handle(mockCtx as BotContext); + expect(result).toBe(true); + expect(mockQueryByDateRange).toHaveBeenCalled(); + expect(mockCtx.reply).toHaveBeenCalledWith( + 'Formatted result', + { parse_mode: 'HTML' } + ); + }); + + it('should show error when NLP cannot parse date', async () => { + mockParseDateRange.mockResolvedValue(null); + + const result = await handler.handle(mockCtx as BotContext); + expect(result).toBe(true); + expect(mockCtx.reply).toHaveBeenCalledWith( + expect.stringContaining("couldn't understand") + ); + }); + + it('should handle query error gracefully', async () => { + mockParseDateRange.mockRejectedValue(new Error('NLP error')); + + const result = await handler.handle(mockCtx as BotContext); + expect(result).toBe(true); + expect(mockCtx.reply).toHaveBeenCalledWith( + expect.stringContaining('error') + ); + }); + }); +}); diff --git a/src/infrastructure/telegram/commands/__tests__/query-command-handler.test.ts b/src/infrastructure/telegram/commands/__tests__/query-command-handler.test.ts new file mode 100644 index 0000000..4bad0a5 --- /dev/null +++ b/src/infrastructure/telegram/commands/__tests__/query-command-handler.test.ts @@ -0,0 +1,157 @@ +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: [], +}); + +jest.mock('../../../utils/container', () => ({ + container: { + getByClass: jest.fn().mockReturnValue({ + queryByDateRange: mockQueryByDateRange, + 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 { QueryCommandHandler, TimeRange } from '../query-command-handler'; +import { Bot } from 'grammy'; +import { BotContext } from '../../grammy-types'; + +describe('QueryCommandHandler', () => { + let handler: QueryCommandHandler; + let mockBot: Bot; + let mockCtx: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + mockBot = { + callbackQuery: jest.fn(), + } as unknown as Bot; + + mockCtx = { + reply: jest.fn().mockResolvedValue(undefined), + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + }; + + handler = new QueryCommandHandler(mockBot); + }); + + describe('handle', () => { + it('should reply with inline keyboard', async () => { + await handler.handle(mockCtx as BotContext); + expect(mockCtx.reply).toHaveBeenCalledWith( + 'Please select a time range:', + expect.objectContaining({ reply_markup: expect.anything() }) + ); + }); + }); + + describe('registerCallbackHandlers', () => { + it('should register callback handler on bot', () => { + handler.registerCallbackHandlers(); + expect(mockBot.callbackQuery).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Function) + ); + }); + + it('should handle callback with time range data', async () => { + handler.registerCallbackHandlers(); + // Get the registered callback function + const callback = (mockBot.callbackQuery as jest.Mock).mock.calls[0][1]; + + const callbackCtx = { + callbackQuery: { data: TimeRange.TODAY }, + reply: jest.fn().mockResolvedValue(undefined), + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + }; + + await callback(callbackCtx); + expect(callbackCtx.answerCallbackQuery).toHaveBeenCalled(); + expect(callbackCtx.reply).toHaveBeenCalled(); + }); + + it('should handle callback error', async () => { + handler.registerCallbackHandlers(); + const callback = (mockBot.callbackQuery as jest.Mock).mock.calls[0][1]; + + mockQueryByDateRange.mockRejectedValueOnce(new Error('DB error')); + + const callbackCtx = { + callbackQuery: { data: TimeRange.TODAY }, + reply: jest.fn().mockResolvedValue(undefined), + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + }; + + await callback(callbackCtx); + expect(callbackCtx.reply).toHaveBeenCalledWith( + expect.stringContaining('error') + ); + }); + }); + + describe('time range handlers', () => { + it('should process today query', async () => { + await (handler as any).handleToday(mockCtx); + expect(mockCtx.reply).toHaveBeenCalledTimes(2); // "Querying..." + result + expect(mockQueryByDateRange).toHaveBeenCalled(); + }); + + it('should process yesterday query', async () => { + await (handler as any).handleYesterday(mockCtx); + expect(mockQueryByDateRange).toHaveBeenCalled(); + }); + + it('should process this week query', async () => { + await (handler as any).handleThisWeek(mockCtx); + expect(mockQueryByDateRange).toHaveBeenCalled(); + }); + + it('should process last week query', async () => { + await (handler as any).handleLastWeek(mockCtx); + expect(mockQueryByDateRange).toHaveBeenCalled(); + }); + + it('should process this month query', async () => { + await (handler as any).handleThisMonth(mockCtx); + expect(mockQueryByDateRange).toHaveBeenCalled(); + }); + + it('should process last month query', async () => { + await (handler as any).handleLastMonth(mockCtx); + expect(mockQueryByDateRange).toHaveBeenCalled(); + }); + + it('should handle query error', async () => { + mockQueryByDateRange.mockRejectedValueOnce(new Error('Query failed')); + await (handler as any).handleToday(mockCtx); + expect(mockCtx.reply).toHaveBeenCalledWith( + expect.stringContaining('error') + ); + }); + }); +}); diff --git a/src/infrastructure/telegram/conversations/__tests__/add-bill.test.ts b/src/infrastructure/telegram/conversations/__tests__/add-bill.test.ts index 1a368cb..c29903d 100644 --- a/src/infrastructure/telegram/conversations/__tests__/add-bill.test.ts +++ b/src/infrastructure/telegram/conversations/__tests__/add-bill.test.ts @@ -1,3 +1,33 @@ +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({ + parseExpenseInput: jest.fn().mockResolvedValue({ + amount: 50, + currency: 'SGD', + description: 'lunch', + category: 'Expenses:Food', + }), + addTransaction: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +jest.mock('../../../../domain/models/merchant-category-mapping', () => ({ + get merchantCategoryMappings() { return {}; }, + findCategoryForMerchant: jest.fn(), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), +})); + import { ADD_BILL_CONVERSATION_ID, addBillConversation } from '../add-bill'; describe('addBillConversation', () => { @@ -7,7 +37,268 @@ describe('addBillConversation', () => { }); it('should have correct function signature (conversation, ctx)', () => { - // Conversation builders take (conversation, ctx) — verify arity expect(addBillConversation.length).toBe(2); }); + + it('should prompt user for input on entry', async () => { + const mockReply = jest.fn().mockResolvedValue(undefined); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: '/cancel' }, + reply: jest.fn().mockResolvedValue(undefined), + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: jest.fn((fn: () => unknown) => fn()), + skip: jest.fn(), + wait: jest.fn(), + }; + + const mockCtx = { + reply: mockReply, + }; + + await addBillConversation(mockConversation as any, mockCtx as any); + + // Should have sent the initial prompt + expect(mockReply).toHaveBeenCalledWith( + expect.stringContaining('Please enter your expense information') + ); + }); + + it('should handle empty text input', async () => { + const mockReply = jest.fn().mockResolvedValue(undefined); + const inputReply = jest.fn().mockResolvedValue(undefined); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: undefined }, + reply: inputReply, + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: jest.fn((fn: () => unknown) => fn()), + skip: jest.fn(), + wait: jest.fn(), + }; + + const mockCtx = { + reply: mockReply, + }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(inputReply).toHaveBeenCalledWith('Operation cancelled.'); + }); + + it('should handle /cancel command', async () => { + const mockReply = jest.fn().mockResolvedValue(undefined); + const inputReply = jest.fn().mockResolvedValue(undefined); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: '/cancel' }, + reply: inputReply, + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: jest.fn((fn: () => unknown) => fn()), + skip: jest.fn(), + wait: jest.fn(), + }; + + const mockCtx = { reply: mockReply }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(inputReply).toHaveBeenCalledWith('Operation cancelled.'); + }); + + it('should skip other commands', async () => { + const mockReply = jest.fn().mockResolvedValue(undefined); + const mockSkip = jest.fn(); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: '/query' }, + reply: jest.fn().mockResolvedValue(undefined), + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: jest.fn((fn: () => unknown) => fn()), + skip: mockSkip, + wait: jest.fn(), + }; + + const mockCtx = { reply: mockReply }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(mockSkip).toHaveBeenCalledWith({ next: true }); + }); + + it('should handle user without username', async () => { + const inputReply = jest.fn().mockResolvedValue(undefined); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'lunch 50' }, + reply: inputReply, + from: undefined, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: jest.fn((fn: () => unknown) => fn()), + skip: jest.fn(), + wait: jest.fn(), + }; + + const mockCtx = { reply: jest.fn().mockResolvedValue(undefined) }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(inputReply).toHaveBeenCalledWith( + expect.stringContaining('Unable to identify user') + ); + }); + + it('should handle unauthorized user', async () => { + const inputReply = jest.fn().mockResolvedValue(undefined); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'lunch 50' }, + reply: inputReply, + from: { username: 'unknown_user' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: jest.fn((fn: () => unknown) => fn()), + skip: jest.fn(), + wait: jest.fn(), + }; + + const mockCtx = { reply: jest.fn().mockResolvedValue(undefined) }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(inputReply).toHaveBeenCalledWith( + expect.stringContaining('do not have permission') + ); + }); + + it('should handle NLP parse error', async () => { + const inputReply = jest.fn().mockResolvedValue(undefined); + const mockExternal = jest.fn().mockRejectedValueOnce(new Error('NLP error')); + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'lunch 50' }, + reply: inputReply, + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: mockExternal, + skip: jest.fn(), + wait: jest.fn(), + }; + + const mockCtx = { reply: jest.fn().mockResolvedValue(undefined) }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(inputReply).toHaveBeenCalledWith( + expect.stringContaining('cannot process') + ); + }); + + it('should show confirmation after parsing expense', async () => { + const inputReply = jest.fn().mockResolvedValue(undefined); + const mockExternal = jest.fn((fn: () => unknown) => fn()); + const mockWait = jest.fn().mockResolvedValue({ + message: { text: '/cancel' }, + reply: jest.fn().mockResolvedValue(undefined), + callbackQuery: undefined, + }); + + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'lunch 50' }, + reply: inputReply, + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: mockExternal, + skip: jest.fn(), + wait: mockWait, + }; + + const mockCtx = { reply: jest.fn().mockResolvedValue(undefined) }; + + await addBillConversation(mockConversation as any, mockCtx as any); + // Should have shown transaction details with confirm/cancel keyboard + expect(inputReply).toHaveBeenCalledWith( + expect.stringContaining('Transaction Details'), + expect.objectContaining({ parse_mode: 'HTML' }) + ); + }); + + it('should save transaction on confirm', async () => { + const inputReply = jest.fn().mockResolvedValue(undefined); + const editMessageText = jest.fn().mockResolvedValue(undefined); + const answerCallbackQuery = jest.fn().mockResolvedValue(undefined); + const mockExternal = jest.fn((fn: () => unknown) => fn()); + + // After parsing, user clicks confirm + const mockWait = jest.fn().mockResolvedValue({ + callbackQuery: { data: 'add_confirm' }, + message: undefined, + editMessageText, + answerCallbackQuery, + }); + + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'lunch 50' }, + reply: inputReply, + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: mockExternal, + skip: jest.fn(), + wait: mockWait, + }; + + const mockCtx = { reply: jest.fn().mockResolvedValue(undefined) }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(answerCallbackQuery).toHaveBeenCalledWith('Transaction saved!'); + }); + + it('should cancel transaction on cancel button', async () => { + const inputReply = jest.fn().mockResolvedValue(undefined); + const editMessageText = jest.fn().mockResolvedValue(undefined); + const answerCallbackQuery = jest.fn().mockResolvedValue(undefined); + const mockExternal = jest.fn((fn: () => unknown) => fn()); + + const mockWait = jest.fn().mockResolvedValue({ + callbackQuery: { data: 'add_cancel' }, + message: undefined, + editMessageText, + answerCallbackQuery, + }); + + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'lunch 50' }, + reply: inputReply, + from: { username: 'ewardsong' }, + }); + + const mockConversation = { + waitFor: mockWaitFor, + external: mockExternal, + skip: jest.fn(), + wait: mockWait, + }; + + const mockCtx = { reply: jest.fn().mockResolvedValue(undefined) }; + + await addBillConversation(mockConversation as any, mockCtx as any); + expect(answerCallbackQuery).toHaveBeenCalledWith('Transaction cancelled.'); + }); }); diff --git a/src/infrastructure/telegram/conversations/__tests__/categorization.test.ts b/src/infrastructure/telegram/conversations/__tests__/categorization.test.ts index 03343f8..681986d 100644 --- a/src/infrastructure/telegram/conversations/__tests__/categorization.test.ts +++ b/src/infrastructure/telegram/conversations/__tests__/categorization.test.ts @@ -1,4 +1,41 @@ +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({ + categorizeMerchant: jest.fn().mockResolvedValue({ + primaryCategory: 'Expenses:Food:Dining', + alternativeCategory: 'Expenses:Food', + suggestedNewCategory: 'Expenses:Food:Delivery', + }), + emit: jest.fn(), + }), + }, +})); + +jest.mock('../../../../domain/models/merchant-category-mapping', () => ({ + get merchantCategoryMappings() { return {}; }, + findCategoryForMerchant: jest.fn(), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), +})); + +jest.mock('../../telegram.adapter', () => ({ + getPendingMerchant: jest.fn(), + removePendingMerchant: jest.fn(), + removePendingMerchantByMerchantId: jest.fn(), +})); + import { CATEGORIZATION_CONVERSATION_ID, categorizationConversation } from '../categorization'; +import { getPendingMerchant } from '../../telegram.adapter'; +import { CALLBACK_PREFIXES } from '../../commands/categorization-constants'; describe('categorizationConversation', () => { it('should export conversation function and ID', () => { @@ -9,4 +46,182 @@ describe('categorizationConversation', () => { it('should have correct function signature (conversation, ctx)', () => { expect(categorizationConversation.length).toBe(2); }); + + it('should return early when callback data does not start with CATEGORIZE_MERCHANT prefix', async () => { + const mockConversation = { + external: jest.fn((fn: () => unknown) => fn()), + waitFor: jest.fn(), + wait: jest.fn(), + skip: jest.fn(), + }; + const mockCtx = { + callbackQuery: { data: 'wrong_prefix' }, + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + }; + + await categorizationConversation(mockConversation as any, mockCtx as any); + // Should not have called waitFor or wait + expect(mockConversation.waitFor).not.toHaveBeenCalled(); + }); + + it('should return early when no callback data', async () => { + const mockConversation = { + external: jest.fn((fn: () => unknown) => fn()), + waitFor: jest.fn(), + wait: jest.fn(), + skip: jest.fn(), + }; + const mockCtx = { + callbackQuery: undefined, + }; + + await categorizationConversation(mockConversation as any, mockCtx as any); + expect(mockConversation.waitFor).not.toHaveBeenCalled(); + }); + + it('should handle missing merchant from registry', async () => { + (getPendingMerchant as jest.Mock).mockReturnValue(undefined); + + const mockConversation = { + external: jest.fn((fn: () => unknown) => fn()), + waitFor: jest.fn(), + wait: jest.fn(), + skip: jest.fn(), + }; + const mockCtx = { + callbackQuery: { data: `${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}abc123` }, + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + }; + + await categorizationConversation(mockConversation as any, mockCtx as any); + expect(mockCtx.answerCallbackQuery).toHaveBeenCalledWith( + expect.stringContaining('') + ); + }); + + it('should process categorization flow with merchant', async () => { + (getPendingMerchant as jest.Mock).mockReturnValue({ + merchantId: 'grab_food', + merchant: 'GRAB FOOD', + }); + + const editReply = jest.fn().mockResolvedValue(undefined); + const answerCallbackQuery = jest.fn().mockResolvedValue(undefined); + const contextReply = jest.fn().mockResolvedValue(undefined); + + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: '/cancel' }, + reply: contextReply, + }); + + const mockConversation = { + external: jest.fn((fn: () => unknown) => fn()), + waitFor: mockWaitFor, + wait: jest.fn(), + skip: jest.fn(), + }; + + const mockCtx = { + callbackQuery: { data: `${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}abc123` }, + editMessageReplyMarkup: editReply, + answerCallbackQuery, + reply: jest.fn().mockResolvedValue(undefined), + }; + + await categorizationConversation(mockConversation as any, mockCtx as any); + // Should have removed the inline keyboard + expect(editReply).toHaveBeenCalled(); + // Should have prompted for context + expect(mockCtx.reply).toHaveBeenCalled(); + // User cancelled + expect(contextReply).toHaveBeenCalledWith( + expect.stringContaining('') + ); + }); + + it('should handle NLP categorization and category selection', async () => { + (getPendingMerchant as jest.Mock).mockReturnValue({ + merchantId: 'grab_food', + merchant: 'GRAB FOOD', + }); + + const contextReply = jest.fn().mockResolvedValue(undefined); + const editMessageText = jest.fn().mockResolvedValue(undefined); + const answerCb = jest.fn().mockResolvedValue(undefined); + + // First waitFor: user provides additional context + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'food delivery app' }, + reply: contextReply, + from: { username: 'ewardsong' }, + }); + + // Then wait: user selects primary category + const mockWait = jest.fn().mockResolvedValue({ + callbackQuery: { data: 'cat:primary' }, + message: undefined, + editMessageText, + answerCallbackQuery: answerCb, + }); + + const mockConversation = { + external: jest.fn((fn: () => unknown) => fn()), + waitFor: mockWaitFor, + wait: mockWait, + skip: jest.fn(), + }; + + const mockCtx = { + callbackQuery: { data: `${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}abc123` }, + editMessageReplyMarkup: jest.fn().mockResolvedValue(undefined), + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + reply: jest.fn().mockResolvedValue(undefined), + }; + + await categorizationConversation(mockConversation as any, mockCtx as any); + // Should have shown category options + expect(contextReply).toHaveBeenCalledWith( + expect.stringContaining('GRAB FOOD'), + expect.anything() + ); + // Should have updated with selected category + expect(answerCb).toHaveBeenCalled(); + }); + + it('should handle NLP error', async () => { + (getPendingMerchant as jest.Mock).mockReturnValue({ + merchantId: 'grab_food', + merchant: 'GRAB FOOD', + }); + + const contextReply = jest.fn().mockResolvedValue(undefined); + const mockExternal = jest.fn() + .mockImplementationOnce((fn: () => unknown) => fn()) // getPendingMerchant + .mockRejectedValueOnce(new Error('NLP error')); // categorizeMerchant + + const mockWaitFor = jest.fn().mockResolvedValue({ + message: { text: 'food' }, + reply: contextReply, + }); + + const mockConversation = { + external: mockExternal, + waitFor: mockWaitFor, + wait: jest.fn(), + skip: jest.fn(), + }; + + const mockCtx = { + callbackQuery: { data: `${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}abc123` }, + editMessageReplyMarkup: jest.fn().mockResolvedValue(undefined), + answerCallbackQuery: jest.fn().mockResolvedValue(undefined), + reply: jest.fn().mockResolvedValue(undefined), + }; + + await categorizationConversation(mockConversation as any, mockCtx as any); + // Should have sent error message + expect(contextReply).toHaveBeenCalledWith( + expect.stringContaining('') + ); + }); });