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
4 changes: 2 additions & 2 deletions docs/tasks/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -97,4 +109,38 @@ Assets:DBS:SGD:Wife -102.32 SGD
});
});
});
});

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'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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
});
});
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading