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();
+ });
+ });
+});