From 8008a56045bbefdb98e82e50ce7bde08410010b8 Mon Sep 17 00:00:00 2001 From: eward Date: Sun, 5 Apr 2026 16:08:40 +0800 Subject: [PATCH] test: add domain and application layer unit tests Add comprehensive test coverage for: - merchant-category-mapping: load, hot-reload, find, add, persist - accounting.service: all methods including error paths - nlp.service: all NLP methods with mocked OpenAI - automation.service: scheduled check, partial failures, notifications All 4 modules now at 100% statement coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tasks/plan.md | 2 +- .../__tests__/automation.service.test.ts | 165 +++++++++++++ .../merchant-category-mapping.test.ts | 180 ++++++++++++++ .../__tests__/accounting.service.test.ts | 176 ++++++++++++++ .../services/__tests__/nlp.service.test.ts | 225 ++++++++++++++++++ 5 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 src/application/services/__tests__/automation.service.test.ts create mode 100644 src/domain/models/__tests__/merchant-category-mapping.test.ts create mode 100644 src/domain/services/__tests__/accounting.service.test.ts create mode 100644 src/domain/services/__tests__/nlp.service.test.ts diff --git a/docs/tasks/plan.md b/docs/tasks/plan.md index 2a43eaa..f3db6a8 100644 --- a/docs/tasks/plan.md +++ b/docs/tasks/plan.md @@ -30,7 +30,7 @@ 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 | pending | — | [007](007-domain-app-test-coverage.md) | +| 7 | Domain & application layer test coverage | reviewed | — | [007](007-domain-app-test-coverage.md) | | 8 | Infrastructure utilities & events test coverage | pending | — | [008](008-infra-utils-events-test-coverage.md) | | 9 | Infrastructure adapters test coverage | pending | — | [009](009-infra-adapters-test-coverage.md) | diff --git a/src/application/services/__tests__/automation.service.test.ts b/src/application/services/__tests__/automation.service.test.ts new file mode 100644 index 0000000..d7e1801 --- /dev/null +++ b/src/application/services/__tests__/automation.service.test.ts @@ -0,0 +1,165 @@ +jest.mock('../../../infrastructure/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(), +})); + +import { container } from '../../../infrastructure/utils'; +import { AutomationService } from '../automation.service'; +import { GmailAdapter, Email } from '../../../infrastructure/gmail/gmail.adapter'; +import { TelegramAdapter } from '../../../infrastructure/telegram/telegram.adapter'; +import { BillParserService } from '../../../domain/services/bill-parser.service'; +import { AccountingService } from '../../../domain/services/accounting.service'; +import { Logger } from '../../../infrastructure/utils'; +import { AccountName } from '../../../domain/models/account'; +import { Currency } from '../../../domain/models/types'; +import { Transaction } from '../../../domain/models/transaction'; + +function createTestEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + subject: 'Card Transaction Alert', + from: 'alert@dbs.com', + to: 'test@iling.fun', + date: '2024-03-15T10:00:00+08:00', + body: 'test body', + ...overrides, + }; +} + +function createTestTransaction(overrides: Partial = {}): Transaction { + return { + date: new Date('2024-03-15'), + description: 'GRAB FOOD', + entries: [ + { account: AccountName.AssetsDBSSGDSaving, amount: { value: -50, currency: Currency.SGD } }, + { account: AccountName.ExpensesFoodDining, amount: { value: 50, currency: Currency.SGD } }, + ], + ...overrides, + }; +} + +describe('AutomationService', () => { + let service: AutomationService; + let mockGmail: jest.Mocked; + let mockTelegram: jest.Mocked; + let mockBillParser: jest.Mocked; + let mockAccounting: jest.Mocked; + let mockLogger: { info: jest.Mock; error: jest.Mock; warn: jest.Mock; debug: jest.Mock }; + + beforeEach(() => { + jest.clearAllMocks(); + container.clear(); + + mockGmail = { + fetchUnreadEmails: jest.fn().mockResolvedValue([]), + markAsRead: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockTelegram = { + sendNotification: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockBillParser = { + parseBillText: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked; + + mockAccounting = { + addTransaction: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + container.registerClass(GmailAdapter, mockGmail); + container.registerClass(TelegramAdapter, mockTelegram); + container.registerClass(BillParserService, mockBillParser); + container.registerClass(AccountingService, mockAccounting); + container.registerClass(Logger, mockLogger as unknown as Logger); + + service = new AutomationService(); + }); + + afterEach(() => { + container.clear(); + }); + + describe('scheduledCheck', () => { + it('should return early when no emails found', async () => { + mockGmail.fetchUnreadEmails.mockResolvedValue([]); + await service.scheduledCheck(); + expect(mockBillParser.parseBillText).not.toHaveBeenCalled(); + }); + + it('should process emails and send notifications', async () => { + const email = createTestEmail(); + const transaction = createTestTransaction(); + mockGmail.fetchUnreadEmails.mockResolvedValue([email]); + mockBillParser.parseBillText.mockResolvedValue(transaction); + + await service.scheduledCheck(); + + expect(mockBillParser.parseBillText).toHaveBeenCalledWith(email); + expect(mockAccounting.addTransaction).toHaveBeenCalledWith(transaction); + expect(mockGmail.markAsRead).toHaveBeenCalledWith('email-1'); + expect(mockTelegram.sendNotification).toHaveBeenCalled(); + + // Verify notification contains key info + const msg = mockTelegram.sendNotification.mock.calls[0][0]; + expect(msg).toContain('50'); + expect(msg).toContain('GRAB FOOD'); + }); + + it('should continue processing when one email fails', async () => { + const email1 = createTestEmail({ id: 'e1' }); + const email2 = createTestEmail({ id: 'e2' }); + const transaction = createTestTransaction(); + + mockGmail.fetchUnreadEmails.mockResolvedValue([email1, email2]); + mockBillParser.parseBillText + .mockRejectedValueOnce(new Error('parse error')) + .mockResolvedValueOnce(transaction); + + await service.scheduledCheck(); + + // Second email should still be processed + expect(mockAccounting.addTransaction).toHaveBeenCalledTimes(1); + expect(mockGmail.markAsRead).toHaveBeenCalledWith('e2'); + }); + + it('should warn when bill parser returns null', async () => { + const email = createTestEmail(); + mockGmail.fetchUnreadEmails.mockResolvedValue([email]); + mockBillParser.parseBillText.mockResolvedValue(null); + + await service.scheduledCheck(); + + expect(mockAccounting.addTransaction).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should handle Gmail fetch error gracefully', async () => { + mockGmail.fetchUnreadEmails.mockRejectedValue(new Error('Gmail API error')); + + // Should not throw + await service.scheduledCheck(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/domain/models/__tests__/merchant-category-mapping.test.ts b/src/domain/models/__tests__/merchant-category-mapping.test.ts new file mode 100644 index 0000000..c2b4aeb --- /dev/null +++ b/src/domain/models/__tests__/merchant-category-mapping.test.ts @@ -0,0 +1,180 @@ +// Mock logger +jest.mock('../../../infrastructure/utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +// We use jest.resetModules() + require() to get fresh module state. +// The fs mock must be set up via jest.mock at the top level, +// but we configure return values before each require(). + +const mockReadFileSync = jest.fn(); +const mockWriteFileSync = jest.fn(); +const mockStatSync = jest.fn(); + +jest.mock('fs', () => ({ + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + statSync: mockStatSync, +})); + +const defaultMapping = { + 'GRAB FOOD': 'Expenses:Food:Dining', + 'AMAZON': 'Expenses:Shopping:Online', + 'NTUC': 'Expenses:Shopping:Supermarket', +}; + +function setupFsMocks(mapping = defaultMapping, mtime = 1000) { + mockReadFileSync.mockReturnValue(JSON.stringify(mapping)); + mockStatSync.mockReturnValue({ mtimeMs: mtime }); +} + +function loadFreshModule() { + jest.resetModules(); + // Re-mock logger after resetModules + jest.doMock('../../../infrastructure/utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + })); + return require('../merchant-category-mapping'); +} + +describe('merchant-category-mapping', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupFsMocks(); + }); + + describe('module initialization', () => { + it('should load mappings from config file on module load', () => { + const mod = loadFreshModule(); + expect(mod.merchantCategoryMappings).toEqual(defaultMapping); + expect(mockReadFileSync).toHaveBeenCalled(); + }); + + it('should return empty object when config file is missing', () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + const mod = loadFreshModule(); + expect(mod.merchantCategoryMappings).toEqual({}); + }); + + it('should handle statSync error during initialization', () => { + mockStatSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const mod = loadFreshModule(); + // Should still load mappings, just fail to set lastModifiedTime + expect(mod.merchantCategoryMappings).toEqual(defaultMapping); + }); + }); + + describe('findCategoryForMerchant', () => { + it('should find category by exact match', () => { + const mod = loadFreshModule(); + expect(mod.findCategoryForMerchant('GRAB FOOD')).toBe('Expenses:Food:Dining'); + }); + + it('should find category by partial match (merchant contains key)', () => { + const mod = loadFreshModule(); + expect(mod.findCategoryForMerchant('GRAB FOOD SG')).toBe('Expenses:Food:Dining'); + }); + + it('should find category by partial match (key contains merchant)', () => { + const mod = loadFreshModule(); + expect(mod.findCategoryForMerchant('GRAB')).toBe('Expenses:Food:Dining'); + }); + + it('should return undefined when no match found', () => { + const mod = loadFreshModule(); + expect(mod.findCategoryForMerchant('UNKNOWN MERCHANT')).toBeUndefined(); + }); + + it('should be case-insensitive for partial matching', () => { + const mod = loadFreshModule(); + expect(mod.findCategoryForMerchant('grab food')).toBe('Expenses:Food:Dining'); + }); + }); + + describe('updateMerchantCategoryMappingsIfNeeded', () => { + it('should reload config when file modification time changes', () => { + const mod = loadFreshModule(); + + // Clear call counts from initialization + mockReadFileSync.mockClear(); + + // Simulate file update: mtime increases + mockStatSync.mockReturnValue({ mtimeMs: 2000 }); + const updatedMapping = { 'NEW MERCHANT': 'Expenses:Food' }; + mockReadFileSync.mockReturnValue(JSON.stringify(updatedMapping)); + + mod.updateMerchantCategoryMappingsIfNeeded(); + expect(mod.merchantCategoryMappings).toEqual(updatedMapping); + }); + + it('should not reload when file modification time is unchanged', () => { + const mod = loadFreshModule(); + mockReadFileSync.mockClear(); + + // statSync returns same mtime (1000) + mod.updateMerchantCategoryMappingsIfNeeded(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('should handle statSync error during update check', () => { + const mod = loadFreshModule(); + mockReadFileSync.mockClear(); + mockStatSync.mockImplementation(() => { + throw new Error('EACCES'); + }); + + // Should not throw, should not reload + mod.updateMerchantCategoryMappingsIfNeeded(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('addMerchantToMapping', () => { + it('should add merchant with category and persist to file', () => { + const mod = loadFreshModule(); + mod.addMerchantToMapping('NEW STORE', 'Expenses:Shopping:Misc'); + + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const writtenContent = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(writtenContent['NEW STORE']).toBe('Expenses:Shopping:Misc'); + }); + + it('should add merchant with empty category when no category provided', () => { + const mod = loadFreshModule(); + mod.addMerchantToMapping('NEW STORE'); + + const writtenContent = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(writtenContent['NEW STORE']).toBe(''); + }); + + it('should update in-memory mapping after adding', () => { + const mod = loadFreshModule(); + mod.addMerchantToMapping('NEW STORE', 'Expenses:Food'); + expect(mod.merchantCategoryMappings['NEW STORE']).toBe('Expenses:Food'); + }); + + it('should handle write error gracefully', () => { + const mod = loadFreshModule(); + mockWriteFileSync.mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + // Should not throw + expect(() => mod.addMerchantToMapping('STORE', 'Expenses:Food')).not.toThrow(); + }); + }); +}); diff --git a/src/domain/services/__tests__/accounting.service.test.ts b/src/domain/services/__tests__/accounting.service.test.ts new file mode 100644 index 0000000..7fa905e --- /dev/null +++ b/src/domain/services/__tests__/accounting.service.test.ts @@ -0,0 +1,176 @@ +// Mock merchant-category-mapping before importing +jest.mock('../../models/merchant-category-mapping', () => { + const mappings: Record = { + 'GRAB FOOD': 'Expenses:Food:Dining', + }; + return { + get merchantCategoryMappings() { return { ...mappings }; }, + findCategoryForMerchant: jest.fn((merchant: string) => mappings[merchant] || undefined), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), + }; +}); + +jest.mock('../../../infrastructure/utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../../infrastructure/utils/container', () => ({ + container: { + getByClass: jest.fn(), + }, +})); + +import { AccountingService } from '../accounting.service'; +import { AccountName } from '../../models/account'; +import { AccountType, Currency } from '../../models/types'; +import { Transaction } from '../../models/transaction'; +import { BeancountService } from '../beancount.service'; +import { + findCategoryForMerchant, + addMerchantToMapping, +} from '../../models/merchant-category-mapping'; + +describe('AccountingService', () => { + let service: AccountingService; + let mockBeancountService: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockBeancountService = { + appendTransaction: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + service = new AccountingService(mockBeancountService); + }); + + describe('addTransaction', () => { + const transaction: Transaction = { + date: new Date('2024-03-15'), + description: 'Test', + entries: [ + { account: AccountName.AssetsDBSSGDSaving, amount: { value: -50, currency: Currency.SGD } }, + { account: AccountName.ExpensesFood, amount: { value: 50, currency: Currency.SGD } }, + ], + }; + + it('should delegate to BeancountService', async () => { + await service.addTransaction(transaction); + expect(mockBeancountService.appendTransaction).toHaveBeenCalledWith(transaction); + }); + + it('should throw when BeancountService fails', async () => { + mockBeancountService.appendTransaction.mockRejectedValue(new Error('write failed')); + await expect(service.addTransaction(transaction)).rejects.toThrow('write failed'); + }); + }); + + describe('getAccountType', () => { + it('should return Asset for Assets: prefix', () => { + expect(service.getAccountType(AccountName.AssetsDBSSGDSaving)).toBe(AccountType.Asset); + }); + + it('should return Expense for Expenses: prefix', () => { + expect(service.getAccountType(AccountName.ExpensesFood)).toBe(AccountType.Expense); + }); + + it('should return Income for Income: prefix', () => { + expect(service.getAccountType(AccountName.IncomeSalary)).toBe(AccountType.Income); + }); + + it('should return Liability for Liabilities: prefix', () => { + expect(service.getAccountType(AccountName.LiabilitiesLoanJihui)).toBe(AccountType.Liability); + }); + + it('should throw for unknown prefix', () => { + expect(() => service.getAccountType('Unknown:Account' as AccountName)).toThrow( + 'Unknown account type for account: Unknown:Account' + ); + }); + }); + + describe('getAccountByName', () => { + it('should return Account object with correct structure', () => { + const account = service.getAccountByName(AccountName.ExpensesFood); + expect(account).toEqual({ + name: AccountName.ExpensesFood, + type: AccountType.Expense, + openDate: '2011-10-10', + }); + }); + }); + + describe('getAccountsByType', () => { + it('should return all accounts of given type', () => { + const incomeAccounts = service.getAccountsByType(AccountType.Income); + expect(incomeAccounts.length).toBeGreaterThan(0); + incomeAccounts.forEach(acc => { + expect(acc.type).toBe(AccountType.Income); + expect(acc.name).toMatch(/^Income:/); + }); + }); + + it('should return empty array for type with no accounts', () => { + const equityAccounts = service.getAccountsByType(AccountType.Equity); + expect(equityAccounts).toEqual([]); + }); + }); + + describe('getAllAccountNames', () => { + it('should return all AccountName enum values', () => { + const names = service.getAllAccountNames(); + expect(names).toEqual(Object.values(AccountName)); + expect(names.length).toBeGreaterThan(0); + }); + }); + + describe('findCategoryForMerchant', () => { + it('should delegate to merchant-category-mapping module', () => { + const result = service.findCategoryForMerchant('GRAB FOOD'); + expect(findCategoryForMerchant).toHaveBeenCalledWith('GRAB FOOD'); + expect(result).toBe('Expenses:Food:Dining'); + }); + + it('should return undefined when no match', () => { + (findCategoryForMerchant as jest.Mock).mockReturnValueOnce(undefined); + expect(service.findCategoryForMerchant('UNKNOWN')).toBeUndefined(); + }); + + it('should return undefined and log error when delegate throws', () => { + (findCategoryForMerchant as jest.Mock).mockImplementationOnce(() => { + throw new Error('read error'); + }); + expect(service.findCategoryForMerchant('BAD')).toBeUndefined(); + }); + }); + + describe('addMerchantToCategory', () => { + it('should delegate to addMerchantToMapping with category', () => { + service.addMerchantToCategory('NEW', 'Expenses:Food'); + expect(addMerchantToMapping).toHaveBeenCalledWith('NEW', 'Expenses:Food'); + }); + + it('should delegate without category', () => { + service.addMerchantToCategory('NEW'); + expect(addMerchantToMapping).toHaveBeenCalledWith('NEW', undefined); + }); + + it('should throw when delegate throws', () => { + (addMerchantToMapping as jest.Mock).mockImplementationOnce(() => { + throw new Error('write error'); + }); + expect(() => service.addMerchantToCategory('BAD')).toThrow('write error'); + }); + }); + + describe('getAllMerchantCategoryMappings', () => { + it('should return a copy of mappings', () => { + const mappings = service.getAllMerchantCategoryMappings(); + expect(mappings).toHaveProperty('GRAB FOOD', 'Expenses:Food:Dining'); + }); + }); +}); diff --git a/src/domain/services/__tests__/nlp.service.test.ts b/src/domain/services/__tests__/nlp.service.test.ts new file mode 100644 index 0000000..c66d039 --- /dev/null +++ b/src/domain/services/__tests__/nlp.service.test.ts @@ -0,0 +1,225 @@ +jest.mock('../../../infrastructure/utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../../infrastructure/utils/container', () => ({ + container: { + getByClass: jest.fn(), + }, +})); + +// Mock merchant-category-mapping to avoid file I/O +jest.mock('../../models/merchant-category-mapping', () => ({ + get merchantCategoryMappings() { return {}; }, + findCategoryForMerchant: jest.fn(), + addMerchantToMapping: jest.fn(), + updateMerchantCategoryMappingsIfNeeded: jest.fn(), +})); + +import { NLPService } from '../nlp.service'; +import { OpenAIAdapter } from '../../../infrastructure/openai/openai.adapter'; +import { AccountingService } from '../accounting.service'; +import { AccountName } from '../../models/account'; + +describe('NLPService', () => { + let service: NLPService; + let mockOpenAI: jest.Mocked; + let mockAccounting: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockOpenAI = { + processMessage: jest.fn(), + } as unknown as jest.Mocked; + mockAccounting = { + getAllAccountNames: jest.fn().mockReturnValue(Object.values(AccountName)), + } as unknown as jest.Mocked; + service = new NLPService(mockOpenAI, mockAccounting); + }); + + describe('categorizeMerchant', () => { + it('should parse three category options from response', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '1. Primary Category: Expenses:Food:Dining\n' + + '2. Alternative Category: Expenses:Food\n' + + '3. Suggested New Category: Expenses:Food:Delivery' + ); + + const result = await service.categorizeMerchant('GRAB FOOD', 'food app'); + expect(result).toEqual({ + primaryCategory: 'Expenses:Food:Dining', + alternativeCategory: 'Expenses:Food', + suggestedNewCategory: 'Expenses:Food:Delivery', + }); + + // Verify prompt contains merchant and additional info + const prompt = mockOpenAI.processMessage.mock.calls[0][0]; + expect(prompt).toContain('GRAB FOOD'); + expect(prompt).toContain('food app'); + }); + + it('should throw when OpenAI fails', async () => { + mockOpenAI.processMessage.mockRejectedValue(new Error('API error')); + await expect(service.categorizeMerchant('M', 'info')).rejects.toThrow('API error'); + }); + }); + + describe('autoCategorizeMerchant', () => { + it('should parse JSON response with category and confidence', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"category": "Expenses:Food:Dining", "confidence": 0.95}' + ); + + const result = await service.autoCategorizeMerchant('GRAB FOOD'); + expect(result).toEqual({ + category: 'Expenses:Food:Dining', + confidence: 0.95, + }); + }); + + it('should handle JSON embedded in other text', async () => { + mockOpenAI.processMessage.mockResolvedValue( + 'Here is the result: {"category": "Expenses:Food", "confidence": 0.8} end' + ); + + const result = await service.autoCategorizeMerchant('GRAB'); + expect(result.category).toBe('Expenses:Food'); + expect(result.confidence).toBe(0.8); + }); + + it('should return safe defaults when no JSON found', async () => { + mockOpenAI.processMessage.mockResolvedValue('No valid response'); + + const result = await service.autoCategorizeMerchant('UNKNOWN'); + expect(result).toEqual({ category: '', confidence: 0 }); + }); + + it('should return safe defaults on API error', async () => { + mockOpenAI.processMessage.mockRejectedValue(new Error('timeout')); + + const result = await service.autoCategorizeMerchant('M'); + expect(result).toEqual({ category: '', confidence: 0 }); + }); + + it('should handle missing category or non-numeric confidence', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"confidence": "high"}' + ); + + const result = await service.autoCategorizeMerchant('M'); + expect(result).toEqual({ category: '', confidence: 0 }); + }); + + it('should only include expense accounts in prompt', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"category": "Expenses:Food", "confidence": 0.9}' + ); + + await service.autoCategorizeMerchant('GRAB'); + const prompt = mockOpenAI.processMessage.mock.calls[0][0]; + // Should contain expense accounts + expect(prompt).toContain('Expenses:Food'); + // Should NOT contain asset accounts + expect(prompt).not.toContain('Assets:DBS'); + }); + }); + + describe('parseExpenseInput', () => { + it('should parse JSON response into expense data', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"amount": 50, "currency": "SGD", "description": "lunch", "category": "Expenses:Food"}' + ); + + const result = await service.parseExpenseInput('lunch 50 sgd'); + expect(result).toEqual({ + amount: 50, + currency: 'SGD', + description: 'lunch', + category: 'Expenses:Food', + }); + }); + + it('should throw on API error', async () => { + mockOpenAI.processMessage.mockRejectedValue(new Error('API error')); + await expect(service.parseExpenseInput('bad')).rejects.toThrow('API error'); + }); + + it('should throw on invalid JSON response', async () => { + mockOpenAI.processMessage.mockResolvedValue('not json'); + await expect(service.parseExpenseInput('test')).rejects.toThrow(); + }); + }); + + describe('parseDateRange', () => { + it('should parse valid date range response', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"startDate": "2024-03-15T00:00:00.000+08:00", "endDate": "2024-03-15T23:59:59.999+08:00"}' + ); + + const result = await service.parseDateRange('yesterday'); + expect(result).not.toBeNull(); + expect(result!.startDate).toBeInstanceOf(Date); + expect(result!.endDate).toBeInstanceOf(Date); + }); + + it('should return null on empty response', async () => { + mockOpenAI.processMessage.mockResolvedValue(''); + const result = await service.parseDateRange('blah'); + expect(result).toBeNull(); + }); + + it('should return null when no JSON found', async () => { + mockOpenAI.processMessage.mockResolvedValue('I cannot parse this'); + const result = await service.parseDateRange('blah'); + expect(result).toBeNull(); + }); + + it('should return null when startDate is missing', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"endDate": "2024-03-15T23:59:59.999+08:00"}' + ); + const result = await service.parseDateRange('test'); + expect(result).toBeNull(); + }); + + it('should return null when endDate is missing', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"startDate": "2024-03-15T00:00:00.000+08:00"}' + ); + const result = await service.parseDateRange('test'); + expect(result).toBeNull(); + }); + + it('should return null on API error', async () => { + mockOpenAI.processMessage.mockRejectedValue(new Error('timeout')); + const result = await service.parseDateRange('test'); + expect(result).toBeNull(); + }); + + it('should add one day to endDate', async () => { + mockOpenAI.processMessage.mockResolvedValue( + '{"startDate": "2024-03-15T00:00:00.000+08:00", "endDate": "2024-03-15T23:59:59.999+08:00"}' + ); + + const result = await service.parseDateRange('yesterday'); + // endDate should be March 16 (original March 15 + 1 day) + expect(result!.endDate.getDate()).toBe( + new Date('2024-03-15T23:59:59.999+08:00').getDate() + 1 + ); + }); + + it('should handle JSON embedded in other text', async () => { + mockOpenAI.processMessage.mockResolvedValue( + 'The date range is: {"startDate": "2024-03-15T00:00:00.000+08:00", "endDate": "2024-03-15T23:59:59.999+08:00"} hope that helps!' + ); + + const result = await service.parseDateRange('yesterday'); + expect(result).not.toBeNull(); + }); + }); +});