From 87d13874dfb7b2ad66d9223ef2995321dc145d81 Mon Sep 17 00:00:00 2001 From: eward Date: Sun, 5 Apr 2026 16:43:04 +0800 Subject: [PATCH] test: add infrastructure utilities, events, and email parser tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test coverage for: - query-result-formatter: assets/expenses formatting, user aggregation - date.utils: all 4 format functions with edge cases - logger: log levels, output routing, object pretty-printing - container: register/get/factory/has/remove/clear - telegram utils: account maps, getCashAccount, getCardAccount, getAccountByEmail - message-queue.service: enqueue, dedup, complete, timeout, clearByMerchant - beancount-query.service: queryByDateRange, executeQuery error handling - dbs-transaction-extractor: amount formats, missing fields, rounding - email-parser-factory: find/register/parse with custom parsers Overall coverage: 46.92% → 60.78% Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tasks/plan.md | 4 +- .../__tests__/beancount-query.service.test.ts | 48 ++++++- .../dbs-transaction-extractor.test.ts | 134 +++++++++++++++++ .../__tests__/email-parser-factory.test.ts | 118 +++++++++++++++ .../__tests__/message-queue.service.test.ts | 136 ++++++++++++++++++ .../utils/__tests__/container.test.ts | 105 ++++++++++++++ .../utils/__tests__/date.utils.test.ts | 64 +++++++++ .../utils/__tests__/logger.test.ts | 130 +++++++++++++++++ .../__tests__/query-result-formatter.test.ts | 103 +++++++++++++ .../utils/__tests__/telegram.test.ts | 60 ++++++++ 10 files changed, 899 insertions(+), 3 deletions(-) create mode 100644 src/infrastructure/email-parsers/__tests__/dbs-transaction-extractor.test.ts create mode 100644 src/infrastructure/email-parsers/__tests__/email-parser-factory.test.ts create mode 100644 src/infrastructure/events/__tests__/message-queue.service.test.ts create mode 100644 src/infrastructure/utils/__tests__/container.test.ts create mode 100644 src/infrastructure/utils/__tests__/date.utils.test.ts create mode 100644 src/infrastructure/utils/__tests__/logger.test.ts create mode 100644 src/infrastructure/utils/__tests__/query-result-formatter.test.ts create mode 100644 src/infrastructure/utils/__tests__/telegram.test.ts diff --git a/docs/tasks/plan.md b/docs/tasks/plan.md index f3db6a8..fb7ac57 100644 --- a/docs/tasks/plan.md +++ b/docs/tasks/plan.md @@ -30,8 +30,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 | reviewed | — | [007](007-domain-app-test-coverage.md) | -| 8 | Infrastructure utilities & events test coverage | pending | — | [008](008-infra-utils-events-test-coverage.md) | +| 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) | ### Dependency Graph diff --git a/src/infrastructure/beancount/__tests__/beancount-query.service.test.ts b/src/infrastructure/beancount/__tests__/beancount-query.service.test.ts index cd7f86c..4dba12c 100644 --- a/src/infrastructure/beancount/__tests__/beancount-query.service.test.ts +++ b/src/infrastructure/beancount/__tests__/beancount-query.service.test.ts @@ -1,4 +1,16 @@ import { BeancountQueryService } from '../beancount-query.service'; +import * as child_process from 'child_process'; +import * as util from 'util'; + +// Mock child_process.exec for executeQuery tests +jest.mock('child_process'); +jest.mock('util', () => { + const original = jest.requireActual('util'); + return { + ...original, + promisify: jest.fn((fn: unknown) => fn), + }; +}); class TestBeancountQueryService extends BeancountQueryService { public processQueryResult(rawResult: string) { @@ -97,4 +109,38 @@ Assets:DBS:SGD:Wife -102.32 SGD }); }); }); -}); \ No newline at end of file + + describe('queryByDateRange', () => { + it('should execute query and process result', async () => { + const mockExec = child_process.exec as unknown as jest.Mock; + mockExec.mockResolvedValue({ + stdout: ` + account ps +----------------------------- ----------- +Assets:DBS:SGD:Saving -50.00 SGD +Expenses:Food 50.00 SGD +`, + }); + + const startDate = new Date('2024-03-01'); + const endDate = new Date('2024-03-31'); + const result = await service.queryByDateRange(startDate, endDate); + + expect(result.assets).toHaveLength(1); + expect(result.expenses).toHaveLength(1); + expect(result.assets[0].amount).toBe(-50); + }); + + it('should throw when command execution fails', async () => { + const mockExec = child_process.exec as unknown as jest.Mock; + mockExec.mockRejectedValue(new Error('command not found')); + + const startDate = new Date('2024-03-01'); + const endDate = new Date('2024-03-31'); + + await expect(service.queryByDateRange(startDate, endDate)).rejects.toThrow( + 'Failed to execute Beancount query' + ); + }); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/email-parsers/__tests__/dbs-transaction-extractor.test.ts b/src/infrastructure/email-parsers/__tests__/dbs-transaction-extractor.test.ts new file mode 100644 index 0000000..631a750 --- /dev/null +++ b/src/infrastructure/email-parsers/__tests__/dbs-transaction-extractor.test.ts @@ -0,0 +1,134 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +import { extractTransactionData } from '../dbs-transaction-extractor'; +import { Email } from '../../gmail/gmail.adapter'; +import { Currency } from '../../../domain/models/types'; + +function createEmail(body: string): Email { + return { + id: 'test-1', + subject: 'Card Transaction Alert', + from: 'alert@dbs.com', + to: 'test@iling.fun', + body, + }; +} + +const STANDARD_BODY = `Card Transaction Alert +Transaction Ref: 510805332088 +Dear Sir / Madam, +Date & Time: 18 Apr 13:29 (SGT) +Amount: SGD50.00 +From: DBS/POSB card ending 8558 +To: GRAB FOOD + +Please do not reply to this email`; + +describe('extractTransactionData', () => { + it('should extract all fields from standard DBS email', () => { + const result = extractTransactionData(createEmail(STANDARD_BODY)); + + expect(result).not.toBeNull(); + expect(result!.amount).toBe(50); + expect(result!.currency).toBe(Currency.SGD); + expect(result!.merchant).toBe('GRAB FOOD'); + expect(result!.cardInfo).toContain('8558'); + expect(result!.date).toBeInstanceOf(Date); + expect(result!.date.getMonth()).toBe(3); // April = 3 + expect(result!.date.getDate()).toBe(18); + }); + + it('should handle S$ amount format', () => { + const body = `Date & Time: 18 Apr 13:29 (SGT) +Amount: S$123.45 +From: DBS card ending 1234 +To: STORE`; + + const result = extractTransactionData(createEmail(body)); + expect(result).not.toBeNull(); + expect(result!.amount).toBe(123.45); + expect(result!.currency).toBe(Currency.SGD); + }); + + it('should handle USD amount format', () => { + const body = `Date & Time: 18 Apr 13:29 (SGT) +Amount: USD29.99 +From: DBS card ending 1234 +To: AMAZON`; + + const result = extractTransactionData(createEmail(body)); + expect(result).not.toBeNull(); + expect(result!.amount).toBe(29.99); + expect(result!.currency).toBe('USD'); + }); + + it('should return null when amount is missing', () => { + const body = `Date & Time: 18 Apr 13:29 (SGT) +From: DBS card ending 1234 +To: GRAB FOOD`; + + const result = extractTransactionData(createEmail(body)); + expect(result).toBeNull(); + }); + + it('should return null when date is missing', () => { + const body = `Amount: SGD50.00 +From: DBS card ending 1234 +To: GRAB FOOD`; + + const result = extractTransactionData(createEmail(body)); + expect(result).toBeNull(); + }); + + it('should return null when merchant is missing', () => { + const body = `Date & Time: 18 Apr 13:29 (SGT) +Amount: SGD50.00 +From: DBS card ending 1234`; + + const result = extractTransactionData(createEmail(body)); + expect(result).toBeNull(); + }); + + it('should return empty card info when From: is not found', () => { + // Craft a body where Amount can be found but no From:/To: after it in normal position + const body = `Date & Time: 18 Apr 13:29 (SGT) +Amount: SGD50.00 +From: +To: GRAB FOOD`; + + const result = extractTransactionData(createEmail(body)); + // The extraction depends on format, cardInfo may be empty + if (result) { + expect(typeof result.cardInfo).toBe('string'); + } + }); + + it('should handle amount with no decimal', () => { + const body = `Date & Time: 18 Apr 13:29 (SGT) +Amount: SGD100 +From: DBS card ending 1234 +To: STORE`; + + const result = extractTransactionData(createEmail(body)); + expect(result).not.toBeNull(); + expect(result!.amount).toBe(100); + }); + + it('should round floating point amounts to 2 decimals', () => { + const body = `Date & Time: 18 Apr 13:29 (SGT) +Amount: SGD10.105 +From: DBS card ending 1234 +To: STORE`; + + const result = extractTransactionData(createEmail(body)); + expect(result).not.toBeNull(); + expect(result!.amount).toBe(10.11); // Rounded + }); +}); diff --git a/src/infrastructure/email-parsers/__tests__/email-parser-factory.test.ts b/src/infrastructure/email-parsers/__tests__/email-parser-factory.test.ts new file mode 100644 index 0000000..f5c69ce --- /dev/null +++ b/src/infrastructure/email-parsers/__tests__/email-parser-factory.test.ts @@ -0,0 +1,118 @@ +jest.mock('../../utils/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: 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 { EmailParserFactory } from '../email-parser-factory'; +import { EmailParser } from '../email-parser.interface'; +import { Email } from '../../gmail/gmail.adapter'; +import { Transaction } from '../../../domain/models/transaction'; + +describe('EmailParserFactory', () => { + let factory: EmailParserFactory; + + beforeEach(() => { + factory = new EmailParserFactory(); + }); + + describe('findParser', () => { + it('should find DBS parser for DBS emails', () => { + const email: Email = { + id: '1', + subject: 'Card Transaction Alert', + from: 'ibanking.alert@dbs.com', + to: 'test@test.com', + body: 'test', + }; + + const parser = factory.findParser(email); + expect(parser).not.toBeNull(); + }); + + it('should return null for unknown email source', () => { + const email: Email = { + id: '1', + subject: 'Random Email', + from: 'noreply@unknown.com', + to: 'test@test.com', + body: 'test', + }; + + expect(factory.findParser(email)).toBeNull(); + }); + }); + + describe('registerParser', () => { + it('should register and use custom parser', () => { + const mockParser: EmailParser = { + canParse: (email: Email) => email.from.includes('custom.com'), + parse: jest.fn().mockResolvedValue(null), + }; + + factory.registerParser(mockParser); + + const email: Email = { + id: '1', + subject: 'Custom', + from: 'noreply@custom.com', + to: 'test@test.com', + body: 'test', + }; + + expect(factory.findParser(email)).toBe(mockParser); + }); + }); + + describe('parseEmail', () => { + it('should delegate to found parser', async () => { + const mockTransaction = { + date: new Date(), + description: 'test', + entries: [], + } as Transaction; + + const mockParser: EmailParser = { + canParse: () => true, + parse: jest.fn().mockResolvedValue(mockTransaction), + }; + + factory.registerParser(mockParser); + + const email: Email = { + id: '1', + subject: 'Test', + from: 'test@test.com', + to: 'test@test.com', + body: 'test', + }; + + const result = await factory.parseEmail(email); + expect(result).toBe(mockTransaction); + }); + + it('should return null when no parser matches', async () => { + const email: Email = { + id: '1', + subject: 'Random', + from: 'unknown@unknown.com', + to: 'test@test.com', + body: 'test', + }; + + const result = await factory.parseEmail(email); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/infrastructure/events/__tests__/message-queue.service.test.ts b/src/infrastructure/events/__tests__/message-queue.service.test.ts new file mode 100644 index 0000000..a88722f --- /dev/null +++ b/src/infrastructure/events/__tests__/message-queue.service.test.ts @@ -0,0 +1,136 @@ +import { EventEmitter } from 'events'; + +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({ + scheduledCheck: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +jest.mock('../../telegram/telegram.adapter', () => ({ + removePendingMerchantByMerchantId: jest.fn(), +})); + +import { MessageQueueService } from '../message-queue.service'; +import { removePendingMerchantByMerchantId } from '../../telegram/telegram.adapter'; + +describe('MessageQueueService', () => { + let emitter: EventEmitter; + let queue: MessageQueueService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + emitter = new EventEmitter(); + // Use a short timeout for tests + queue = new MessageQueueService(emitter, 1000); + }); + + afterEach(() => { + jest.useRealTimers(); + emitter.removeAllListeners(); + }); + + describe('enqueue', () => { + it('should add item to queue and start processing', () => { + // Set up a listener that the queue will emit to + const handler = jest.fn((_data, callback) => callback()); + emitter.on('queue:testEvent', handler); + + queue.enqueue('testEvent', { key: 'value' }); + expect(queue.getQueueLength()).toBe(1); + }); + + it('should deduplicate by taskId', () => { + const handler = jest.fn((_data, callback) => callback()); + emitter.on('queue:testEvent', handler); + + queue.enqueue('testEvent', { key: 1 }, 'task-1'); + queue.enqueue('testEvent', { key: 2 }, 'task-1'); + expect(queue.getQueueLength()).toBe(1); + }); + + it('should allow different taskIds', () => { + const handler = jest.fn((_data, callback) => callback()); + emitter.on('queue:testEvent', handler); + + queue.enqueue('testEvent', { key: 1 }, 'task-1'); + queue.enqueue('testEvent', { key: 2 }, 'task-2'); + expect(queue.getQueueLength()).toBe(2); + }); + }); + + describe('completeTask', () => { + it('should remove task from queue', () => { + // Don't set up handler so queue stays in processing state + queue.enqueue('testEvent', { key: 1 }, 'task-1'); + queue.enqueue('testEvent', { key: 2 }, 'task-2'); + + queue.completeTask('task-1'); + expect(queue.getQueueLength()).toBe(1); + }); + + it('should ignore completion for non-existent task', () => { + // Should not throw + queue.completeTask('non-existent'); + expect(queue.getQueueLength()).toBe(0); + }); + }); + + describe('task timeout', () => { + it('should auto-complete task after timeout', () => { + queue.enqueue('testEvent', { merchant: 'GRAB' }, 'task-1'); + + // Fast-forward past timeout + jest.advanceTimersByTime(1100); + + // removePendingMerchantByMerchantId should have been called + expect(removePendingMerchantByMerchantId).toHaveBeenCalledWith('task-1'); + }); + }); + + describe('clearTasksByMerchant', () => { + it('should remove tasks matching merchant name', () => { + queue.enqueue('e', { merchant: 'GRAB' }, 'task-1'); + queue.enqueue('e', { merchant: 'AMAZON' }, 'task-2'); + queue.enqueue('e', { merchant: 'GRAB' }, 'task-3'); + + queue.clearTasksByMerchant('GRAB'); + + // Only AMAZON should remain (task-2), plus whatever is processing + // The first item is being processed, second got cleared + expect(queue.getQueueLength()).toBe(1); + }); + + it('should not remove tasks without merchant data', () => { + queue.enqueue('e', { other: 'data' }, 'task-1'); + queue.clearTasksByMerchant('GRAB'); + expect(queue.getQueueLength()).toBe(1); + }); + }); + + describe('state accessors', () => { + it('getQueueLength should return 0 for empty queue', () => { + expect(queue.getQueueLength()).toBe(0); + }); + + it('isQueueProcessing should return false initially', () => { + expect(queue.isQueueProcessing()).toBe(false); + }); + + it('isQueueProcessing should return true while processing', () => { + queue.enqueue('testEvent', {}); + expect(queue.isQueueProcessing()).toBe(true); + }); + }); +}); diff --git a/src/infrastructure/utils/__tests__/container.test.ts b/src/infrastructure/utils/__tests__/container.test.ts new file mode 100644 index 0000000..6ca8738 --- /dev/null +++ b/src/infrastructure/utils/__tests__/container.test.ts @@ -0,0 +1,105 @@ +import { Container } from '../container'; + +describe('Container', () => { + let container: Container; + + beforeEach(() => { + // Get the singleton and clear it + container = Container.getInstance(); + container.clear(); + }); + + afterEach(() => { + container.clear(); + }); + + describe('getInstance', () => { + it('should return the same instance', () => { + expect(Container.getInstance()).toBe(Container.getInstance()); + }); + }); + + describe('registerClass / getByClass', () => { + it('should register and retrieve a service instance', () => { + class MyService {} + const instance = new MyService(); + container.registerClass(MyService, instance); + expect(container.getByClass(MyService)).toBe(instance); + }); + + it('should throw when service not found', () => { + class Unknown {} + expect(() => container.getByClass(Unknown)).toThrow( + "Service 'Unknown' not found in the container" + ); + }); + }); + + describe('registerClassFactory', () => { + it('should create service lazily on first getByClass call', () => { + class LazyService { + public value = 'created'; + } + const factory = jest.fn(() => new LazyService()); + container.registerClassFactory(LazyService, factory); + + expect(factory).not.toHaveBeenCalled(); + + const instance = container.getByClass(LazyService); + expect(factory).toHaveBeenCalledTimes(1); + expect(instance.value).toBe('created'); + }); + + it('should cache the instance after first creation', () => { + class CachedService {} + const factory = jest.fn(() => new CachedService()); + container.registerClassFactory(CachedService, factory); + + const first = container.getByClass(CachedService); + const second = container.getByClass(CachedService); + expect(first).toBe(second); + expect(factory).toHaveBeenCalledTimes(1); + }); + }); + + describe('hasClass', () => { + it('should return true for registered instance', () => { + class Svc {} + container.registerClass(Svc, new Svc()); + expect(container.hasClass(Svc)).toBe(true); + }); + + it('should return true for registered factory', () => { + class Svc {} + container.registerClassFactory(Svc, () => new Svc()); + expect(container.hasClass(Svc)).toBe(true); + }); + + it('should return false for unregistered service', () => { + class Svc {} + expect(container.hasClass(Svc)).toBe(false); + }); + }); + + describe('removeClass', () => { + it('should remove both instance and factory', () => { + class Svc {} + container.registerClass(Svc, new Svc()); + container.removeClass(Svc); + expect(container.hasClass(Svc)).toBe(false); + }); + }); + + describe('clear', () => { + it('should remove all services', () => { + class A {} + class B {} + container.registerClass(A, new A()); + container.registerClassFactory(B, () => new B()); + + container.clear(); + expect(container.hasClass(A)).toBe(false); + expect(container.hasClass(B)).toBe(false); + }); + }); +}); diff --git a/src/infrastructure/utils/__tests__/date.utils.test.ts b/src/infrastructure/utils/__tests__/date.utils.test.ts new file mode 100644 index 0000000..43a7f54 --- /dev/null +++ b/src/infrastructure/utils/__tests__/date.utils.test.ts @@ -0,0 +1,64 @@ +import { + formatDateToUTC8, + formatTimeToUTC8, + formatDateToYYYYMMDD, + formatDateToMMDD, +} from '../date.utils'; + +describe('date.utils', () => { + describe('formatDateToUTC8', () => { + it('should format a date string with month, day, time, and weekday', () => { + const result = formatDateToUTC8('2024-03-15T10:00:00Z'); + // Should contain month/day + expect(result).toMatch(/03\/15/); + // Should contain time component + expect(result).toMatch(/\d{1,2}:\d{2}:\d{2}/); + // Should contain weekday + expect(result).toMatch(/Friday/); + }); + + it('should return empty string for undefined', () => { + expect(formatDateToUTC8(undefined)).toBe(''); + }); + + it('should return empty string for empty string', () => { + expect(formatDateToUTC8('')).toBe(''); + }); + }); + + describe('formatTimeToUTC8', () => { + it('should return time in Asia/Shanghai timezone', () => { + const result = formatTimeToUTC8('2024-03-15T10:00:00Z'); + // Should contain time only + expect(result).toMatch(/\d{1,2}:\d{2}:\d{2}/); + }); + + it('should return empty string for undefined', () => { + expect(formatTimeToUTC8(undefined)).toBe(''); + }); + }); + + describe('formatDateToYYYYMMDD', () => { + it('should format date as YYYY-MM-DD', () => { + const date = new Date(2024, 2, 15); // March 15, 2024 + expect(formatDateToYYYYMMDD(date)).toBe('2024-03-15'); + }); + + it('should zero-pad month and day', () => { + const date = new Date(2024, 0, 5); // Jan 5, 2024 + expect(formatDateToYYYYMMDD(date)).toBe('2024-01-05'); + }); + }); + + describe('formatDateToMMDD', () => { + it('should format date as MM-DD', () => { + const date = new Date(2024, 2, 15); // March 15, 2024 + expect(formatDateToMMDD(date)).toBe('03-15'); + }); + + it('should zero-pad month and day', () => { + const date = new Date(2024, 0, 5); // Jan 5, 2024 + expect(formatDateToMMDD(date)).toBe('01-05'); + }); + }); +}); diff --git a/src/infrastructure/utils/__tests__/logger.test.ts b/src/infrastructure/utils/__tests__/logger.test.ts new file mode 100644 index 0000000..be9f267 --- /dev/null +++ b/src/infrastructure/utils/__tests__/logger.test.ts @@ -0,0 +1,130 @@ +import { Logger, LogLevel, createLogger } from '../logger'; + +describe('Logger', () => { + let loggerInstance: Logger; + let stdoutSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + // Create a fresh instance for each test (bypass singleton) + loggerInstance = new Logger(); + stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + describe('getInstance', () => { + it('should return the same instance', () => { + const a = Logger.getInstance(); + const b = Logger.getInstance(); + expect(a).toBe(b); + }); + }); + + describe('setLogLevel', () => { + it('should suppress messages below the set level', () => { + loggerInstance.setLogLevel(LogLevel.WARN); + + loggerInstance.debug('debug msg'); + loggerInstance.info('info msg'); + expect(stdoutSpy).not.toHaveBeenCalled(); + + loggerInstance.warn('warn msg'); + expect(stdoutSpy).toHaveBeenCalled(); + }); + + it('should show all messages at DEBUG level', () => { + loggerInstance.setLogLevel(LogLevel.DEBUG); + + loggerInstance.debug('debug msg'); + expect(stdoutSpy).toHaveBeenCalled(); + }); + + it('should suppress all messages at NONE level', () => { + loggerInstance.setLogLevel(LogLevel.NONE); + + loggerInstance.debug('d'); + loggerInstance.info('i'); + loggerInstance.warn('w'); + loggerInstance.error('e'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + }); + + describe('log methods', () => { + beforeEach(() => { + loggerInstance.setLogLevel(LogLevel.DEBUG); + }); + + it('debug should write to stdout', () => { + loggerInstance.debug('test debug'); + expect(stdoutSpy).toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('info should write to stdout', () => { + loggerInstance.info('test info'); + expect(stdoutSpy).toHaveBeenCalled(); + }); + + it('warn should write to stdout (only error goes to stderr)', () => { + loggerInstance.warn('test warn'); + expect(stdoutSpy).toHaveBeenCalled(); + }); + + it('error should write to stderr', () => { + loggerInstance.error('test error'); + expect(stderrSpy).toHaveBeenCalled(); + }); + + it('error should log Error stack trace', () => { + loggerInstance.error('oops', new Error('boom')); + // Should have at least 2 calls: error message + stack + expect(stderrSpy).toHaveBeenCalledTimes(2); + const stackOutput = stderrSpy.mock.calls[1][0]; + expect(stackOutput).toContain('Stack:'); + }); + + it('error should log non-Error details', () => { + stderrSpy.mockClear(); + loggerInstance.error('oops', { code: 500 }); + // error message + error details line + expect(stderrSpy.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('should pretty-print object args', () => { + loggerInstance.info('data', { key: 'value' }); + // Should write the JSON-stringified object + const allWrites = stdoutSpy.mock.calls.map((c: [string]) => c[0]).join(''); + expect(allWrites).toContain('"key"'); + expect(allWrites).toContain('"value"'); + }); + + it('should handle non-object args', () => { + loggerInstance.info('count', 42); + const allWrites = stdoutSpy.mock.calls.map((c: [string]) => c[0]).join(''); + expect(allWrites).toContain('42'); + }); + }); + + describe('createLogger', () => { + it('should return the singleton instance', () => { + const l = createLogger(); + expect(l).toBe(Logger.getInstance()); + }); + + it('should set log level when provided', () => { + const l = createLogger(LogLevel.ERROR); + // The singleton's level is now ERROR + stdoutSpy.mockClear(); + (l as Logger).info('should not show'); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/infrastructure/utils/__tests__/query-result-formatter.test.ts b/src/infrastructure/utils/__tests__/query-result-formatter.test.ts new file mode 100644 index 0000000..30e02bd --- /dev/null +++ b/src/infrastructure/utils/__tests__/query-result-formatter.test.ts @@ -0,0 +1,103 @@ +import { formatQueryResult } from '../query-result-formatter'; +import { ProcessedQueryResult } from '../../beancount/beancount-query.service'; + +describe('formatQueryResult', () => { + it('should format assets and expenses sections', () => { + const result: ProcessedQueryResult = { + assets: [ + { account: 'Assets:DBS:SGD:Wife', amount: -100 }, + { account: 'Assets:DBS:SGD:Saving', amount: -50 }, + ], + expenses: [ + { category: 'Expenses:Food', amount: 120 }, + { category: 'Expenses:Transport', amount: 30 }, + ], + }; + + const output = formatQueryResult(result); + + // Check structure + expect(output).toContain('Assets'); + expect(output).toContain('Expenses by Category'); + + // Check user spending + expect(output).toContain('@LingerZou'); + expect(output).toContain('@ewardsong'); + expect(output).toContain('100.00 SGD'); + expect(output).toContain('50.00 SGD'); + expect(output).toContain('Total'); + expect(output).toContain('150.00 SGD'); + + // Check expenses + expect(output).toContain('Food'); + expect(output).toContain('120.00 SGD'); + expect(output).toContain('Transport'); + expect(output).toContain('30.00 SGD'); + }); + + it('should handle empty results', () => { + const result: ProcessedQueryResult = { + assets: [], + expenses: [], + }; + + const output = formatQueryResult(result); + expect(output).toContain('Assets'); + expect(output).toContain('Total'); + expect(output).toContain('0.00 SGD'); + }); + + it('should aggregate spending per user from multiple accounts', () => { + const result: ProcessedQueryResult = { + assets: [ + { account: 'Assets:DBS:SGD:Wife', amount: -60 }, + { account: 'Assets:Cash:Wife', amount: -40 }, + ], + expenses: [], + }; + + const output = formatQueryResult(result); + // Both accounts map to LingerZou, so should aggregate + expect(output).toContain('@LingerZou'); + expect(output).toContain('100.00 SGD'); + }); + + it('should skip assets without a telegram mapping', () => { + const result: ProcessedQueryResult = { + assets: [ + { account: 'Assets:Unknown:Account', amount: -200 }, + { account: 'Assets:DBS:SGD:Saving', amount: -50 }, + ], + expenses: [], + }; + + const output = formatQueryResult(result); + // Only ewardsong account should appear, total should be 50 + expect(output).toContain('50.00 SGD'); + }); + + it('should extract second-level category from full expense path', () => { + const result: ProcessedQueryResult = { + assets: [], + expenses: [ + { category: 'Expenses:Shopping', amount: 75 }, + ], + }; + + const output = formatQueryResult(result); + expect(output).toContain('Shopping'); + expect(output).toContain('75.00 SGD'); + }); + + it('should use full category if no colon separator', () => { + const result: ProcessedQueryResult = { + assets: [], + expenses: [ + { category: 'Miscellaneous', amount: 10 }, + ], + }; + + const output = formatQueryResult(result); + expect(output).toContain('Miscellaneous'); + }); +}); diff --git a/src/infrastructure/utils/__tests__/telegram.test.ts b/src/infrastructure/utils/__tests__/telegram.test.ts new file mode 100644 index 0000000..2192e83 --- /dev/null +++ b/src/infrastructure/utils/__tests__/telegram.test.ts @@ -0,0 +1,60 @@ +import { + ACCOUNT_TELEGRAM_MAP, + TG_ACCOUNTS, + getCashAccount, + getCardAccount, + getAccountByEmail, +} from '../telegram'; +import { AccountName } from '../../../domain/models/account'; + +describe('telegram utils', () => { + describe('ACCOUNT_TELEGRAM_MAP', () => { + it('should map wife accounts to LingerZou', () => { + expect(ACCOUNT_TELEGRAM_MAP[AccountName.AssetsDBSSGDWife]).toBe('LingerZou'); + expect(ACCOUNT_TELEGRAM_MAP[AccountName.AssetsCashWife]).toBe('LingerZou'); + }); + + it('should map personal accounts to ewardsong', () => { + expect(ACCOUNT_TELEGRAM_MAP[AccountName.AssetsDBSSGDSaving]).toBe('ewardsong'); + expect(ACCOUNT_TELEGRAM_MAP[AccountName.AssetsCash]).toBe('ewardsong'); + }); + }); + + describe('getCashAccount', () => { + it('should return CashWife for LingerZou', () => { + expect(getCashAccount('LingerZou')).toBe(AccountName.AssetsCashWife); + }); + + it('should return Cash for ewardsong', () => { + expect(getCashAccount('ewardsong')).toBe(AccountName.AssetsCash); + }); + + it('should return null for unknown username', () => { + expect(getCashAccount('unknown')).toBeNull(); + }); + }); + + describe('getCardAccount', () => { + it('should return DBS saving for iling.fun email', () => { + expect(getCardAccount('user@iling.fun')).toBe(AccountName.AssetsDBSSGDSaving); + }); + + it('should return DBS wife for other emails', () => { + expect(getCardAccount('other@gmail.com')).toBe(AccountName.AssetsDBSSGDWife); + }); + }); + + describe('getAccountByEmail', () => { + it('should return ewardsong for iling.fun email', () => { + expect(getAccountByEmail('user@iling.fun')).toBe(TG_ACCOUNTS[1]); + }); + + it('should return LingerZou for other emails', () => { + expect(getAccountByEmail('other@gmail.com')).toBe(TG_ACCOUNTS[0]); + }); + + it('should return null for undefined email', () => { + expect(getAccountByEmail(undefined)).toBeNull(); + }); + }); +});