import { getClock, deserializeClock } from '@actual-app/crdt'; import { expectSnapshotWithDiffer } from '../mocks/util'; import * as connection from '../platform/server/connection'; import * as fs from '../platform/server/fs'; import * as monthUtils from '../shared/months'; import * as budgetActions from './budget/actions'; import * as budget from './budget/base'; import * as db from './db'; import { handlers } from './main'; import { runHandler, runMutator, disableGlobalMutations, enableGlobalMutations, } from './mutators'; import { post } from './post'; import * as prefs from './prefs'; import * as sheet from './sheet'; jest.mock('./post'); beforeEach(async () => { await global.emptyDatabase()(); disableGlobalMutations(); }); afterEach(async () => { await runHandler(handlers['close-budget']); connection.resetEvents(); enableGlobalMutations(); global.currentMonth = null; }); async function createTestBudget(name) { let templatePath = fs.join(__dirname, '/../mocks/files', name); let budgetPath = fs.join(__dirname, '/../mocks/files/budgets/test-budget'); fs._setDocumentDir(fs.join(budgetPath, '..')); await fs.mkdir(budgetPath); await fs.copyFile( fs.join(templatePath, 'metadata.json'), fs.join(budgetPath, 'metadata.json'), ); await fs.copyFile( fs.join(templatePath, 'db.sqlite'), fs.join(budgetPath, 'db.sqlite'), ); } describe('Budgets', () => { afterEach(async () => { fs._setDocumentDir(null); let budgetPath = fs.join(__dirname, '/../mocks/files/budgets/test-budget'); if (await fs.exists(budgetPath)) { await fs.removeDirRecursively(budgetPath); } }); test('budget is successfully loaded', async () => { await createTestBudget('default-budget-template'); // Grab the clock to compare later await db.openDatabase('test-budget'); let row = await db.first('SELECT * FROM messages_clock'); let { error } = await runHandler(handlers['load-budget'], { id: 'test-budget', }); expect(error).toBe(undefined); // Make sure the prefs were loaded expect(prefs.getPrefs().id).toBe('test-budget'); // Make sure the clock has been loaded expect(getClock()).toEqual(deserializeClock(row.clock)); }); test('budget detects out of sync migrations', async () => { await createTestBudget('default-budget-template'); await db.openDatabase('test-budget'); await db.runQuery('INSERT INTO __migrations__ (id) VALUES (1000)'); const spy = jest.spyOn(console, 'warn').mockImplementation(); let { error } = await runHandler(handlers['load-budget'], { id: 'test-budget', }); // There should be an error and the budget should be unloaded expect(error).toBe('out-of-sync-migrations'); expect(db.getDatabase()).toBe(null); expect(prefs.getPrefs()).toBe(null); spy.mockRestore(); }); }); describe('Accounts', () => { test('create accounts with correct starting balance', async () => { prefs.loadPrefs(); prefs.savePrefs({ groupId: 'group' }); await runMutator(async () => { // An income category is required because the starting balance is // categorized to it. Create one now. await db.insertCategoryGroup({ id: 'group1', name: 'income', is_income: 1, }); await db.insertCategory({ name: 'income', cat_group: 'group1', is_income: 1, }); }); // Get accounts from the server. This isn't the normal API call, // we know that the mock server just returns hardcoded accounts let { accounts } = await post('/plaid/accounts', {}); // Create the accounts for the bank (bank is generally ignored in tests) await runHandler(handlers['accounts-connect'], { institution: { institution_id: 1, name: 'Jamesy Bank' }, publicToken: 'foo', accountIds: accounts.map(acct => acct.account_id), }); // Import transactions for all accounts await runHandler(handlers['accounts-sync'], {}); // Go through each account and make sure the starting balance was // created correctly const res = await db.all('SELECT * FROM accounts'); for (let account of res) { const sum = await db.first( 'SELECT sum(amount) as sum FROM transactions WHERE acct = ? AND starting_balance_flag = 0', [account.id], ); const starting = await db.first( 'SELECT * FROM transactions WHERE acct = ? AND starting_balance_flag = 1', [account.id], ); expect(account.balance_current - sum.sum).toBe(starting.amount); // Also ensure that the starting balance has the earliest date // possible const earliestTrans = await db.first( 'SELECT p.name as payee_name FROM transactions t LEFT JOIN payees p ON p.id = t.description WHERE acct = ? ORDER BY date LIMIT 1', [account.id], ); expect(earliestTrans.payee_name).toBe('Starting Balance'); } }); test('Transfers are properly updated', async () => { await runMutator(async () => { await db.insertAccount({ id: 'one', name: 'one' }); await db.insertAccount({ id: 'two', name: 'two' }); await db.insertAccount({ id: 'three', name: 'three' }); await db.insertPayee({ id: 'transfer-one', name: '', transfer_acct: 'one', }); await db.insertPayee({ id: 'transfer-two', name: '', transfer_acct: 'two', }); await db.insertPayee({ id: 'transfer-three', name: '', transfer_acct: 'three', }); }); const id = 'test-transfer'; await runHandler(handlers['transaction-add'], { id, account: 'one', amount: 5000, payee: 'transfer-two', date: '2017-01-01', }); let differ = expectSnapshotWithDiffer( await db.all('SELECT * FROM transactions'), ); let transaction = await db.getTransaction(id); await runHandler(handlers['transaction-update'], { ...(await db.getTransaction(id)), payee: 'transfer-three', date: '2017-01-03', }); differ.expectToMatchDiff(await db.all('SELECT * FROM transactions')); transaction = await db.getTransaction(id); await runHandler(handlers['transaction-delete'], transaction); differ.expectToMatchDiff(await db.all('SELECT * FROM transactions')); }); }); describe('Budget', () => { test('new budgets should be created', async () => { const spreadsheet = await sheet.loadSpreadsheet(db); await runMutator(async () => { await db.insertCategoryGroup({ id: 'incomeGroup', name: 'incomeGroup', is_income: 1, }); await db.insertCategoryGroup({ id: 'group1', name: 'group1' }); await db.insertCategory({ name: 'foo', cat_group: 'group1' }); await db.insertCategory({ name: 'bar', cat_group: 'group1' }); }); let bounds = await runHandler(handlers['get-budget-bounds']); expect(bounds.start).toBe('2016-10'); expect(bounds.end).toBe('2018-01'); expect(spreadsheet.meta().createdMonths).toMatchSnapshot(); // Add a transaction (which needs an account) earlier then the // current earliest budget to test if it creates the necessary // budgets for the earlier months await db.runQuery("INSERT INTO accounts (id, name) VALUES ('one', 'boa')"); await runHandler(handlers['transaction-add'], { date: '2016-05-06', amount: 50, account: 'one', }); // Fast-forward in time to a future month and make sure it creates // budgets for the months in the future global.currentMonth = '2017-02'; bounds = await runHandler(handlers['get-budget-bounds']); expect(bounds.start).toBe('2016-02'); expect(bounds.end).toBe('2018-02'); expect(spreadsheet.meta().createdMonths).toMatchSnapshot(); await new Promise(resolve => spreadsheet.onFinish(resolve)); }); test('budget updates when changing a category', async () => { const spreadsheet = await sheet.loadSpreadsheet(db); function captureChangedCells(func) { return new Promise<unknown[]>(async resolve => { let changed = []; let remove = spreadsheet.addEventListener('change', ({ names }) => { changed = changed.concat(names); }); await func(); remove(); spreadsheet.onFinish(() => { resolve(changed); }); }); } // Force the system to start tracking these months so budgets are // automatically updated when adding/deleting categories await db.runQuery('INSERT INTO created_budgets (month) VALUES (?)', [ '2017-01', ]); await db.runQuery('INSERT INTO created_budgets (month) VALUES (?)', [ '2017-02', ]); await db.runQuery('INSERT INTO created_budgets (month) VALUES (?)', [ '2017-03', ]); await db.runQuery('INSERT INTO created_budgets (month) VALUES (?)', [ '2017-04', ]); let categories; await captureChangedCells(async () => { await runMutator(() => db.insertCategoryGroup({ id: 'group1', name: 'group1' }), ); categories = [ await runHandler(handlers['category-create'], { name: 'foo', groupId: 'group1', }), await runHandler(handlers['category-create'], { name: 'bar', groupId: 'group1', }), await runHandler(handlers['category-create'], { name: 'baz', groupId: 'group1', }), await runHandler(handlers['category-create'], { name: 'biz', groupId: 'group1', }), ]; }); await db.runQuery("INSERT INTO accounts (id, name) VALUES ('boa', 'boa')"); let trans = { id: 'boa-transaction', date: '2017-02-06', amount: 5000, account: 'boa', category: categories[0], }; // Test insertions let changed = await captureChangedCells(() => runHandler(handlers['transaction-add'], trans), ); expect(changed.sort()).toMatchSnapshot(); // Test updates changed = await captureChangedCells(async () => { await runHandler(handlers['transaction-update'], { ...(await db.getTransaction(trans.id)), amount: 7000, }); }); expect(changed.sort()).toMatchSnapshot(); // Test deletions changed = await captureChangedCells(async () => { await runHandler(handlers['transaction-delete'], { id: trans.id }); }); expect(changed.sort()).toMatchSnapshot(); }); }); describe('Categories', () => { test('can be deleted', async () => { await sheet.loadSpreadsheet(db); await runMutator(async () => { await db.insertCategoryGroup({ id: 'group1', name: 'group1' }); await db.insertCategory({ id: 'foo', name: 'foo', cat_group: 'group1' }); await db.insertCategory({ id: 'bar', name: 'bar', cat_group: 'group1' }); }); let categories = await db.getCategories(); expect(categories.length).toBe(2); expect(categories.find(cat => cat.name === 'foo')).not.toBeNull(); expect(categories.find(cat => cat.name === 'bar')).not.toBeNull(); await runHandler(handlers['category-delete'], { id: 'foo' }); categories = await db.getCategories(); expect(categories.length).toBe(1); expect(categories.find(cat => cat.name === 'bar')).not.toBeNull(); }); test('transfers properly when deleted', async () => { await sheet.loadSpreadsheet(db); let transId = await runMutator(async () => { await db.insertCategoryGroup({ id: 'group1', name: 'group1' }); await db.insertCategoryGroup({ id: 'group1b', name: 'group1b' }); await db.insertCategoryGroup({ id: 'group2', name: 'group2', is_income: 1, }); await db.insertCategory({ id: 'foo', name: 'foo', cat_group: 'group1' }); await db.insertCategory({ id: 'bar', name: 'bar', cat_group: 'group1b' }); await db.insertCategory({ id: 'income1', name: 'income1', is_income: 1, cat_group: 'group2', }); await db.insertCategory({ id: 'income2', name: 'income2', is_income: 1, cat_group: 'group2', }); return await db.insertTransaction({ date: '2017-01-01', account: 'acct', amount: 4500, category: 'foo', }); }); await budget.createAllBudgets(); // Set a budget value for the category `foo` of 1000 let sheetName = monthUtils.sheetForMonth('2018-01'); await budgetActions.setBudget({ category: 'foo', month: '2018-01', amount: 1000, }); expect(sheet.getCellValue(sheetName, 'group-budget-group1')).toBe(1000); expect(sheet.getCellValue(sheetName, 'group-budget-group1b')).toBe(0); // Make sure the transaction has a category of `foo` let trans = await db.getTransaction(transId); expect(trans.category).toBe('foo'); await runHandler(handlers['category-delete'], { id: 'foo', transferId: 'bar', }); // Make sure the transaction has been updated trans = await db.getTransaction(transId); expect(trans.category).toBe('bar'); // Make sure the budget value was transferred expect(sheet.getCellValue(sheetName, 'group-budget-group1')).toBe(0); expect(sheet.getCellValue(sheetName, 'group-budget-group1b')).toBe(1000); // Transfering an income category to an expense just doesn't make // sense. Make sure this doesn't do anything. let { error } = await runHandler(handlers['category-delete'], { id: 'income1', transferId: 'bar', }); expect(error).toBe('category-type'); let categories = await db.getCategories(); expect(categories.find(cat => cat.id === 'income1')).toBeDefined(); // Make sure you can delete income categories await runHandler(handlers['category-delete'], { id: 'income1', transferId: 'income2', }); categories = await db.getCategories(); expect(categories.find(cat => cat.id === 'income1')).not.toBeDefined(); }); });