CreateTransactionTool.test.ts•15.9 kB
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import * as ynab from 'ynab';
import CreateTransactionTool from '../tools/CreateTransactionTool';
// Mock the entire ynab module
vi.mock('ynab');
// Mock the mcp-framework logger
vi.mock('mcp-framework', () => ({
MCPTool: class {
constructor() {}
},
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
describe('CreateTransactionTool', () => {
let tool: CreateTransactionTool;
let mockApi: {
transactions: {
createTransaction: Mock;
};
};
beforeEach(() => {
vi.clearAllMocks();
// Create mock API instance
mockApi = {
transactions: {
createTransaction: vi.fn(),
},
};
// Mock the ynab.API constructor
(ynab.API as any).mockImplementation(() => mockApi);
// Set environment variables
process.env.YNAB_API_TOKEN = 'test-token';
process.env.YNAB_BUDGET_ID = 'test-budget-id';
tool = new CreateTransactionTool();
});
describe('execute', () => {
const mockCreatedTransaction = {
id: 'transaction-123',
account_id: 'account-456',
payee_name: 'Test Payee',
amount: -50000, // -$50.00 in milliunits
category_id: 'category-789',
memo: 'Test memo',
date: '2023-12-01',
cleared: ynab.TransactionClearedStatus.Uncleared,
approved: false,
flag_color: null,
};
it('should successfully create a transaction with payee_name', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'custom-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: -50.00,
payeeName: 'Test Payee',
categoryId: 'category-789',
memo: 'Test memo',
cleared: false,
approved: false,
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'custom-budget-id',
{
transaction: {
account_id: 'account-456',
date: '2023-12-01',
amount: -50000, // Converted to milliunits
payee_id: undefined,
payee_name: 'Test Payee',
category_id: 'category-789',
memo: 'Test memo',
cleared: ynab.TransactionClearedStatus.Uncleared,
approved: false,
flag_color: undefined,
},
}
);
expect(result).toEqual({
success: true,
transactionId: 'transaction-123',
message: 'Transaction created successfully',
});
});
it('should successfully create a transaction with payee_id', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 25.99,
payeeId: 'payee-123',
categoryId: 'category-789',
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: {
account_id: 'account-456',
date: '2023-12-01',
amount: 25990, // Converted to milliunits (25.99 * 1000)
payee_id: 'payee-123',
payee_name: undefined,
category_id: 'category-789',
memo: undefined,
cleared: ynab.TransactionClearedStatus.Uncleared,
approved: false,
flag_color: undefined,
},
}
);
expect(result).toEqual({
success: true,
transactionId: 'transaction-123',
message: 'Transaction created successfully',
});
});
it('should use budget ID from environment when not provided in input', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
accountId: 'account-456',
date: '2023-12-01',
amount: 10.50,
payeeName: 'Test Payee',
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id', // Should use environment variable
expect.any(Object)
);
expect(result).toEqual({
success: true,
transactionId: 'transaction-123',
message: 'Transaction created successfully',
});
});
it('should create a transaction with all optional parameters', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 75.25,
payeeId: 'payee-123',
categoryId: 'category-789',
memo: 'Test transaction with all fields',
cleared: true,
approved: true,
flagColor: 'red',
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: {
account_id: 'account-456',
date: '2023-12-01',
amount: 75250, // 75.25 * 1000
payee_id: 'payee-123',
payee_name: undefined,
category_id: 'category-789',
memo: 'Test transaction with all fields',
cleared: ynab.TransactionClearedStatus.Cleared,
approved: true,
flag_color: 'red',
},
}
);
expect(result).toEqual({
success: true,
transactionId: 'transaction-123',
message: 'Transaction created successfully',
});
});
it('should handle decimal amounts correctly (rounding)', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 12.996, // Should round to 12996 milliunits
payeeName: 'Test Payee',
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: expect.objectContaining({
amount: 12996, // Math.round(12.996 * 1000) = 12996
}),
}
);
expect(result.success).toBe(true);
});
it('should throw error when no budget ID is provided', async () => {
// Clear environment budget ID
delete process.env.YNAB_BUDGET_ID;
tool = new CreateTransactionTool();
const input = {
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
};
await expect(tool.execute(input)).rejects.toThrow(
'No budget ID provided. Please provide a budget ID or set the YNAB_BUDGET_ID environment variable.'
);
});
it('should throw error when neither payee_id nor payee_name is provided', async () => {
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
// Neither payeeId nor payeeName provided
};
await expect(tool.execute(input)).rejects.toThrow(
'Either payee_id or payee_name must be provided'
);
});
it('should return success false when API call fails', async () => {
// Setup mock to throw API error
const apiError = new Error('API Error: Budget not found');
mockApi.transactions.createTransaction.mockRejectedValue(apiError);
const input = {
budgetId: 'invalid-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
};
const result = await tool.execute(input);
expect(result).toEqual({
success: false,
error: 'API Error: Budget not found',
});
});
it('should return success false when API returns no transaction data', async () => {
// Setup mock to return no transaction
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: null },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
};
const result = await tool.execute(input);
expect(result).toEqual({
success: false,
error: 'Failed to create transaction - no transaction data returned',
});
});
it('should handle non-Error objects in catch block', async () => {
// Setup mock to throw non-Error object
const nonErrorObject = { message: 'Custom error object', code: 500 };
mockApi.transactions.createTransaction.mockRejectedValue(nonErrorObject);
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
};
const result = await tool.execute(input);
expect(result).toEqual({
success: false,
error: 'Unknown error occurred',
});
});
it('should handle cleared status correctly when false', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
cleared: false,
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: expect.objectContaining({
cleared: ynab.TransactionClearedStatus.Uncleared,
}),
}
);
expect(result.success).toBe(true);
});
it('should handle cleared status correctly when true', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
cleared: true,
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: expect.objectContaining({
cleared: ynab.TransactionClearedStatus.Cleared,
}),
}
);
expect(result.success).toBe(true);
});
it('should handle approved status with nullish coalescing', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
// approved not provided, should default to false
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: expect.objectContaining({
approved: false, // Should be false when not provided
}),
}
);
expect(result.success).toBe(true);
});
it('should handle flag color as enum value', async () => {
// Setup mock
mockApi.transactions.createTransaction.mockResolvedValue({
data: { transaction: mockCreatedTransaction },
});
const input = {
budgetId: 'test-budget-id',
accountId: 'account-456',
date: '2023-12-01',
amount: 50.00,
payeeName: 'Test Payee',
flagColor: 'blue',
};
const result = await tool.execute(input);
expect(mockApi.transactions.createTransaction).toHaveBeenCalledWith(
'test-budget-id',
{
transaction: expect.objectContaining({
flag_color: 'blue',
}),
}
);
expect(result.success).toBe(true);
});
});
describe('tool configuration', () => {
it('should have correct name and description', () => {
expect(tool.name).toBe('create_transaction');
expect(tool.description).toBe('Creates a new transaction in your YNAB budget. Either payee_id or payee_name must be provided in addition to the other required fields.');
});
it('should have correct schema definition', () => {
expect(tool.schema).toHaveProperty('budgetId');
expect(tool.schema).toHaveProperty('accountId');
expect(tool.schema).toHaveProperty('date');
expect(tool.schema).toHaveProperty('amount');
expect(tool.schema).toHaveProperty('payeeId');
expect(tool.schema).toHaveProperty('payeeName');
expect(tool.schema).toHaveProperty('categoryId');
expect(tool.schema).toHaveProperty('memo');
expect(tool.schema).toHaveProperty('cleared');
expect(tool.schema).toHaveProperty('approved');
expect(tool.schema).toHaveProperty('flagColor');
// Check descriptions contain expected content
expect(tool.schema.budgetId.description).toContain('budget to create the transaction in');
expect(tool.schema.accountId.description).toContain('account to create the transaction in');
expect(tool.schema.date.description).toContain('date of the transaction in ISO format');
expect(tool.schema.amount.description).toContain('amount in dollars');
expect(tool.schema.payeeId.description).toContain('payee_name is provided');
expect(tool.schema.payeeName.description).toContain('payee_id is provided');
expect(tool.schema.categoryId.description).toContain('category id');
expect(tool.schema.memo.description).toContain('memo/note');
expect(tool.schema.cleared.description).toContain('Whether the transaction is cleared');
expect(tool.schema.approved.description).toContain('Whether the transaction is approved');
expect(tool.schema.flagColor.description).toContain('transaction flag color');
});
it('should have correct required vs optional fields', () => {
// Required fields (no optional() call)
expect(tool.schema.accountId.type._def.typeName).toBe('ZodString');
expect(tool.schema.date.type._def.typeName).toBe('ZodString');
expect(tool.schema.amount.type._def.typeName).toBe('ZodNumber');
// Optional fields (have optional() call)
expect(tool.schema.budgetId.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.payeeId.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.payeeName.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.categoryId.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.memo.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.cleared.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.approved.type._def.typeName).toBe('ZodOptional');
expect(tool.schema.flagColor.type._def.typeName).toBe('ZodOptional');
});
});
});