Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tasks/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
165 changes: 165 additions & 0 deletions src/application/services/__tests__/automation.service.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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<GmailAdapter>;
let mockTelegram: jest.Mocked<TelegramAdapter>;
let mockBillParser: jest.Mocked<BillParserService>;
let mockAccounting: jest.Mocked<AccountingService>;
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<GmailAdapter>;

mockTelegram = {
sendNotification: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<TelegramAdapter>;

mockBillParser = {
parseBillText: jest.fn().mockResolvedValue(null),
} as unknown as jest.Mocked<BillParserService>;

mockAccounting = {
addTransaction: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<AccountingService>;

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();
});
});
});
180 changes: 180 additions & 0 deletions src/domain/models/__tests__/merchant-category-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading