import { describe, it, expect, vi, beforeEach } from 'vitest'; import { buildAndSaveTransaction, type TransactionStore } from '../src/domain/transactionBuilder'; import type { LedgerIndex } from '../src/domain/types'; // Mock store const mockAutoOpenAccounts = vi.fn().mockResolvedValue(undefined); const mockAddTransaction = vi.fn().mockResolvedValue('tx-123'); let mockLedger: LedgerIndex | null = null; const createMockStore = (): TransactionStore => ({ ledger: mockLedger, autoOpenAccounts: mockAutoOpenAccounts, addTransaction: mockAddTransaction, }); describe('buildAndSaveTransaction', () => { beforeEach(() => { vi.clearAllMocks(); mockLedger = null; }); describe('amount validation', () => { it('throws on empty amount', async () => { await expect( buildAndSaveTransaction({ date: '2026-07-18', amount: '', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()) ).rejects.toThrow('金额不能为空'); }); it('throws on zero amount', async () => { await expect( buildAndSaveTransaction({ date: '2026-07-18', amount: '0', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()) ).rejects.toThrow('金额数值必须大于 0'); }); it('throws on negative amount', async () => { await expect( buildAndSaveTransaction({ date: '2026-07-18', amount: '-100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()) ).rejects.toThrow('金额数值必须大于 0'); }); it('accepts valid decimal amount', async () => { await expect( buildAndSaveTransaction({ date: '2026-07-18', amount: '89.30', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()) ).resolves.not.toThrow(); }); }); describe('date normalization', () => { it('normalizes YYYY/M/D format', async () => { await buildAndSaveTransaction({ date: '2026/7/18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ date: '2026-07-18' }), undefined, undefined ); }); it('normalizes YYYY年M月D日 format', async () => { await buildAndSaveTransaction({ date: '2026年7月18日', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ date: '2026-07-18' }), undefined, undefined ); }); it('normalizes YYYY.M.D format', async () => { await buildAndSaveTransaction({ date: '2026.7.18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ date: '2026-07-18' }), undefined, undefined ); }); it('pads single digit month and day', async () => { await buildAndSaveTransaction({ date: '2026-1-5', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ date: '2026-01-05' }), undefined, undefined ); }); it('throws on invalid date format', async () => { await expect( buildAndSaveTransaction({ date: 'invalid', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()) ).rejects.toThrow('日期格式不正确'); }); }); describe('posting construction', () => { it('creates expense postings correctly', async () => { await buildAndSaveTransaction({ date: '2026-07-18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ postings: [ { account: 'Assets:Cash', amount: '-100', currency: 'CNY' }, { account: 'Expenses:Food', amount: '100', currency: 'CNY' }, ], }), undefined, undefined ); }); it('creates income postings correctly', async () => { await buildAndSaveTransaction({ date: '2026-07-18', amount: '5000', categoryAccount: 'Income:Salary', sourceAccount: 'Assets:Bank', direction: 'income', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ postings: [ { account: 'Assets:Bank', amount: '5000', currency: 'CNY' }, { account: 'Income:Salary', amount: '-5000', currency: 'CNY' }, ], }), undefined, undefined ); }); it('creates transfer postings correctly', async () => { await buildAndSaveTransaction({ date: '2026-07-18', amount: '1000', categoryAccount: 'Assets:Bank', sourceAccount: 'Assets:Cash', direction: 'transfer', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ postings: [ { account: 'Assets:Cash', amount: '-1000', currency: 'CNY' }, { account: 'Assets:Bank', amount: '1000', currency: 'CNY' }, ], }), undefined, undefined ); }); }); describe('narration defaults', () => { it('uses default narration for expense', async () => { await buildAndSaveTransaction({ date: '2026-07-18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ narration: '支出交易' }), undefined, undefined ); }); it('uses custom narration when provided', async () => { await buildAndSaveTransaction({ date: '2026-07-18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', narration: '午餐', }, createMockStore()); expect(mockAddTransaction).toHaveBeenCalledWith( expect.objectContaining({ narration: '午餐' }), undefined, undefined ); }); }); describe('auto-open accounts', () => { it('opens unopened accounts', async () => { mockLedger = { accounts: new Map([ ['Assets:Cash', { name: 'Assets:Cash' } as any], ]), } as unknown as LedgerIndex; await buildAndSaveTransaction({ date: '2026-07-18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); expect(mockAutoOpenAccounts).toHaveBeenCalledWith(['Expenses:Food']); }); it('opens multiple unopened accounts when ledger is null', async () => { mockLedger = null; await buildAndSaveTransaction({ date: '2026-07-18', amount: '100', categoryAccount: 'Expenses:Food', sourceAccount: 'Assets:Cash', direction: 'expense', }, createMockStore()); // When ledger is null, autoOpenAccounts is not called (graceful skip) expect(mockAutoOpenAccounts).not.toHaveBeenCalled(); }); }); });