import * as fs from 'fs/promises'; import * as path from 'path'; import type { RuleEntity } from '@actual-app/core/types/models'; import { vi } from 'vitest'; import * as api from './index'; declare global { var IS_TESTING: boolean; var currentMonth: string | null; } // In tests we run from source; loot-core's API fs uses __dirname (for the built dist/). // Mock the fs so path constants point at loot-core package root where migrations live. vi.mock( '../loot-core/src/platform/server/fs/index.api', async importOriginal => { const actual = (await importOriginal()) as Record; const pathMod = await import('path'); const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core'); return { ...actual, migrationsPath: pathMod.join(lootCoreRoot, 'migrations'), bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'), demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'), }; }, ); const budgetName = 'test-budget'; global.IS_TESTING = true; beforeEach(async () => { const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName); await fs.rm(budgetPath, { force: true, recursive: true }); await createTestBudget('default-budget-template', budgetName); await api.init({ dataDir: path.join(__dirname, '/mocks/budgets/'), }); }); afterEach(async () => { global.currentMonth = null; await api.shutdown(); }); async function createTestBudget(templateName: string, name: string) { const templatePath = path.join( __dirname, '/../loot-core/src/mocks/files', templateName, ); const budgetPath = path.join(__dirname, '/mocks/budgets/', name); await fs.mkdir(budgetPath); await fs.copyFile( path.join(templatePath, 'metadata.json'), path.join(budgetPath, 'metadata.json'), ); await fs.copyFile( path.join(templatePath, 'db.sqlite'), path.join(budgetPath, 'db.sqlite'), ); } describe('API setup and teardown', () => { // apis: loadBudget, getBudgetMonths test('successfully loads budget', async () => { await expect(api.loadBudget(budgetName)).resolves.toBeUndefined(); await expect(api.getBudgetMonths()).resolves.toMatchSnapshot(); }); }); describe('API budget import/export', () => { const importedBudgetIds = new Set(); beforeEach(async () => { await api.loadBudget(budgetName); }); afterEach(async () => { // Close any budget loaded by an import before removing its files await api.shutdown(); for (const id of importedBudgetIds) { await fs.rm(path.join(__dirname, '/mocks/budgets/', id), { force: true, recursive: true, }); } importedBudgetIds.clear(); }); // apis: exportBudget, importBudget test('exportBudget returns bytes that importBudget loads back', async () => { const accountId = await api.createAccount({ name: 'round-trip' }, 0); await api.addTransactions(accountId, [ { date: '2023-10-01', amount: 1234, notes: 'round-trip transaction' }, ]); const data = await api.exportBudget(); expect(data).toBeInstanceOf(Uint8Array); expect(data.length).toBeGreaterThan(0); const { id } = await api.importBudget(data); expect(id).toBeTruthy(); importedBudgetIds.add(id); // The imported budget is now the loaded one; verify the data // survived the round-trip const accounts = await api.getAccounts(); expect(accounts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: accountId, name: 'round-trip' }), ]), ); const transactions = await api.getTransactions( accountId, '2023-10-01', '2023-10-31', ); expect(transactions).toEqual( expect.arrayContaining([ expect.objectContaining({ amount: 1234, notes: 'round-trip transaction', }), ]), ); }); // apis: exportBudget, importBudget test('importBudget imports from a file path', async () => { const data = await api.exportBudget(); const filepath = path.join(__dirname, '/mocks/budgets/', 'export.zip'); await fs.writeFile(filepath, data); try { const { id } = await api.importBudget(filepath); expect(id).toBeTruthy(); importedBudgetIds.add(id); } finally { await fs.rm(filepath, { force: true }); } }); // apis: importBudget test('importBudget rejects with an Error on failure', async () => { await expect(api.importBudget('/does/not/exist.zip')).rejects.toThrow( /^Error importing budget:/, ); await expect(api.importBudget(new Uint8Array([1, 2, 3]))).rejects.toThrow( /^Error importing budget:/, ); }); }); describe('API CRUD operations', () => { beforeEach(async () => { // load test budget await api.loadBudget(budgetName); }); // api: getBudgets test('getBudgets', async () => { const budgets = await api.getBudgets(); expect(budgets).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'test-budget', name: 'Default Test Db', }), ]), ); }); // apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup test('CategoryGroups: successfully update category groups', async () => { const month = '2023-10'; global.currentMonth = month; // get existing category groups const groups = await api.getCategoryGroups(); expect(groups).toEqual( expect.arrayContaining([ expect.objectContaining({ hidden: false, id: 'fc3825fd-b982-4b72-b768-5b30844cf832', is_income: false, name: 'Usual Expenses', }), expect.objectContaining({ hidden: false, id: 'a137772f-cf2f-4089-9432-822d2ddc1466', is_income: false, name: 'Investments and Savings', }), expect.objectContaining({ hidden: false, id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00', is_income: true, name: 'Income', }), ]), ); // create our test category group const mainGroupId = await api.createCategoryGroup({ name: 'test-group', }); let budgetMonth = await api.getBudgetMonth(month); expect(budgetMonth.categoryGroups).toEqual( expect.arrayContaining([ expect.objectContaining({ id: mainGroupId, }), ]), ); // update group await api.updateCategoryGroup(mainGroupId, { name: 'update-tests', }); budgetMonth = await api.getBudgetMonth(month); expect(budgetMonth.categoryGroups).toEqual( expect.arrayContaining([ expect.objectContaining({ id: mainGroupId, }), ]), ); // delete group await api.deleteCategoryGroup(mainGroupId); budgetMonth = await api.getBudgetMonth(month); expect(budgetMonth.categoryGroups).toEqual( expect.arrayContaining([ expect.not.objectContaining({ id: mainGroupId, }), ]), ); }); // apis: createCategory, getCategories, updateCategory, deleteCategory test('Categories: successfully update categories', async () => { const month = '2023-10'; global.currentMonth = month; // create our test category group const mainGroupId = await api.createCategoryGroup({ name: 'test-group', }); const secondaryGroupId = await api.createCategoryGroup({ name: 'test-secondary-group', }); const categoryId = await api.createCategory({ name: 'test-budget', group_id: mainGroupId, }); const categoryIdHidden = await api.createCategory({ name: 'test-budget-hidden', group_id: mainGroupId, hidden: true, }); let categories = await api.getCategories(); expect(categories).toEqual( expect.arrayContaining([ expect.objectContaining({ id: categoryId, name: 'test-budget', hidden: false, group_id: mainGroupId, }), expect.objectContaining({ id: categoryIdHidden, name: 'test-budget-hidden', hidden: true, group_id: mainGroupId, }), ]), ); // update/move category await api.updateCategory(categoryId, { name: 'updated-budget', group_id: secondaryGroupId, }); await api.updateCategory(categoryIdHidden, { name: 'updated-budget-hidden', group_id: secondaryGroupId, hidden: false, }); categories = await api.getCategories(); expect(categories).toEqual( expect.arrayContaining([ expect.objectContaining({ id: categoryId, name: 'updated-budget', hidden: false, group_id: secondaryGroupId, }), expect.objectContaining({ id: categoryIdHidden, name: 'updated-budget-hidden', hidden: false, group_id: secondaryGroupId, }), ]), ); // delete categories await api.deleteCategory(categoryId); expect(categories).toEqual( expect.arrayContaining([ expect.not.objectContaining({ id: categoryId, }), ]), ); }); test.each([ { invalidGroupId: null, label: 'null' }, { invalidGroupId: '', label: 'empty string' }, ])( 'Categories: reject invalid group_id ($label)', async ({ invalidGroupId }) => { const groupId = await api.createCategoryGroup({ name: 'test-valid-group', }); const categoryId = await api.createCategory({ name: 'test-category', group_id: groupId, }); await expect( api.updateCategory(categoryId, { // @ts-expect-error - Testing invalid runtime input group_id: invalidGroupId, }), ).rejects.toThrow(); const categories = await api.getCategories(); expect(categories).toEqual( expect.arrayContaining([ expect.objectContaining({ id: categoryId, group_id: groupId, }), ]), ); }, ); test.each(['nope', '00000000-0000-4000-8000-000000000123'])( 'Categories: reject non-existent group_id (%s)', async invalidGroupId => { const groupId = await api.createCategoryGroup({ name: 'valid-group-for-invalid-id-test', }); const categoryId = await api.createCategory({ name: 'category-with-valid-group', group_id: groupId, }); await expect( api.updateCategory(categoryId, { group_id: invalidGroupId, }), ).rejects.toThrow('category group does not exist'); const categories = await api.getCategories(); expect(categories).toEqual( expect.arrayContaining([ expect.objectContaining({ id: categoryId, group_id: groupId, }), ]), ); }, ); test('Categories: reject deleted category group', async () => { const validGroupId = await api.createCategoryGroup({ name: 'valid-group-for-deleted-group-test', }); const deletedGroupId = await api.createCategoryGroup({ name: 'group-to-be-deleted', }); const categoryId = await api.createCategory({ name: 'category-to-preserve', group_id: validGroupId, }); await api.deleteCategoryGroup(deletedGroupId); await expect( api.updateCategory(categoryId, { group_id: deletedGroupId, }), ).rejects.toThrow('category group does not exist'); const categories = await api.getCategories(); expect(categories).toEqual( expect.arrayContaining([ expect.objectContaining({ id: categoryId, group_id: validGroupId, }), ]), ); }); test('Categories: partial update preserves existing group', async () => { const groupId = await api.createCategoryGroup({ name: 'partial-update-group', }); const categoryId = await api.createCategory({ name: 'original-category', group_id: groupId, }); // Update the name without providing group_id await api.updateCategory(categoryId, { name: 'renamed-category', }); // Update another field without providing group_id await api.updateCategory(categoryId, { hidden: true, }); const categories = await api.getCategories(); expect(categories).toEqual( expect.arrayContaining([ expect.objectContaining({ id: categoryId, name: 'renamed-category', group_id: groupId, hidden: true, }), ]), ); }); // apis: setBudgetAmount, setBudgetCarryover, getBudgetMonth test('Budgets: successfully update budgets', async () => { const month = '2023-10'; global.currentMonth = month; // create some new categories to test with const groupId = await api.createCategoryGroup({ name: 'tests', }); const categoryId = await api.createCategory({ name: 'test-budget', group_id: groupId, }); await api.setBudgetAmount(month, categoryId, 100); await api.setBudgetCarryover(month, categoryId, true); const budgetMonth = await api.getBudgetMonth(month); expect(budgetMonth.categoryGroups).toEqual( expect.arrayContaining([ expect.objectContaining({ id: groupId, categories: expect.arrayContaining([ expect.objectContaining({ id: categoryId, budgeted: 100, carryover: true, }), ]), }), ]), ); }); //apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount, getAccountBalance test('Accounts: successfully complete account operators', async () => { const accountId1 = await api.createAccount( { name: 'test-account1', offbudget: true }, 1000, ); const accountId2 = await api.createAccount({ name: 'test-account2' }, 0); let accounts = await api.getAccounts(); // accounts successfully created expect(accounts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: accountId1, name: 'test-account1', offbudget: true, }), expect.objectContaining({ id: accountId2, name: 'test-account2' }), ]), ); expect(await api.getAccountBalance(accountId1)).toEqual(1000); expect(await api.getAccountBalance(accountId2)).toEqual(0); await api.updateAccount(accountId1, { offbudget: false }); await api.closeAccount(accountId1, accountId2); await api.deleteAccount(accountId2); // accounts successfully updated, and one of them deleted accounts = await api.getAccounts(); expect(accounts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: accountId1, name: 'test-account1', closed: true, offbudget: false, }), expect.not.objectContaining({ id: accountId2 }), ]), ); await api.reopenAccount(accountId1); // the non-deleted account is reopened accounts = await api.getAccounts(); expect(accounts).toEqual( expect.arrayContaining([ expect.objectContaining({ id: accountId1, name: 'test-account1', closed: false, }), ]), ); }); // apis: createPayee, getPayees, updatePayee, deletePayee test('Payees: successfully update payees', async () => { const payeeId1 = await api.createPayee({ name: 'test-payee1' }); const payeeId2 = await api.createPayee({ name: 'test-payee2' }); let payees = await api.getPayees(); // payees successfully created expect(payees).toEqual( expect.arrayContaining([ expect.objectContaining({ id: payeeId1, name: 'test-payee1', }), expect.objectContaining({ id: payeeId2, name: 'test-payee2', }), ]), ); await api.updatePayee(payeeId1, { name: 'test-updated-payee' }); await api.deletePayee(payeeId2); // confirm update and delete were successful payees = await api.getPayees(); expect(payees).toEqual( expect.arrayContaining([ expect.objectContaining({ id: payeeId1, name: 'test-updated-payee', }), expect.not.objectContaining({ name: 'test-payee1', }), expect.not.objectContaining({ id: payeeId2, }), ]), ); }); // apis: createTag, getTags, updateTag, deleteTag test('Tags: successfully complete tag operations', async () => { // Create tags const tagId1 = await api.createTag({ tag: 'test-tag1', color: '#ff0000' }); const tagId2 = await api.createTag({ tag: 'test-tag2', description: 'A test tag', }); let tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId1, tag: 'test-tag1', color: '#ff0000', }), expect.objectContaining({ id: tagId2, tag: 'test-tag2', description: 'A test tag', }), ]), ); // Update tag await api.updateTag(tagId1, { tag: 'updated-tag', color: '#00ff00' }); tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId1, tag: 'updated-tag', color: '#00ff00', }), ]), ); // Delete tag await api.deleteTag(tagId2); tags = await api.getTags(); expect(tags).not.toEqual( expect.arrayContaining([expect.objectContaining({ id: tagId2 })]), ); }); test('Tags: create tag with minimal fields', async () => { const tagId = await api.createTag({ tag: 'minimal-tag' }); const tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId, tag: 'minimal-tag', color: null, description: null, }), ]), ); }); test('Tags: update single field only', async () => { const tagId = await api.createTag({ tag: 'original', color: '#ff0000' }); // Update only color, tag and description should remain unchanged await api.updateTag(tagId, { color: '#00ff00' }); const tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId, tag: 'original', color: '#00ff00', description: null, }), ]), ); }); test('Tags: handle null values correctly', async () => { const tagId = await api.createTag({ tag: 'with-nulls', color: null, description: null, }); const tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId, color: null, description: null, }), ]), ); }); test('Tags: clear optional field', async () => { const tagId = await api.createTag({ tag: 'clearable', color: '#ff0000', description: 'will be cleared', }); // Clear color by setting to null await api.updateTag(tagId, { color: null }); let tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId, tag: 'clearable', color: null, description: 'will be cleared', }), ]), ); // Clear description by setting to null await api.updateTag(tagId, { description: null }); tags = await api.getTags(); expect(tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: tagId, tag: 'clearable', color: null, description: null, }), ]), ); }); // apis: getNote, updateNote test('Notes: successfully get and update note', async () => { const categories = await api.getCategories(); const categoryId = categories[0].id; // No note exists initially const initial = await api.getNote(categoryId); expect(initial).toBeNull(); // Set a note await api.updateNote(categoryId, 'Test note content'); const afterSet = await api.getNote(categoryId); expect(afterSet).toEqual({ id: categoryId, note: 'Test note content' }); // Update the note await api.updateNote(categoryId, 'Updated note content'); const afterUpdate = await api.getNote(categoryId); expect(afterUpdate).toEqual({ id: categoryId, note: 'Updated note content', }); }); // apis: getRules, getPayeeRules, createRule, updateRule, deleteRule test('Rules: successfully update rules', async () => { await api.createPayee({ name: 'test-payee' }); await api.createPayee({ name: 'test-payee2' }); // create our test rules const rule = await api.createRule({ stage: 'pre', conditionsOp: 'and', conditions: [ { field: 'payee', op: 'is', value: 'test-payee', }, ], actions: [ { op: 'set', field: 'category', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }, ], }); const rule2 = await api.createRule({ stage: 'pre', conditionsOp: 'and', conditions: [ { field: 'payee', op: 'is', value: 'test-payee2', }, ], actions: [ { op: 'set', field: 'category', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }, ], }); // get existing rules const rules = await api.getRules(); expect(rules).toEqual( expect.arrayContaining([ expect.objectContaining({ actions: expect.arrayContaining([ expect.objectContaining({ field: 'category', op: 'set', type: 'id', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }), ]), conditions: expect.arrayContaining([ expect.objectContaining({ field: 'payee', op: 'is', type: 'id', value: 'test-payee2', }), ]), conditionsOp: 'and', id: rule2.id, stage: 'pre', }), expect.objectContaining({ actions: expect.arrayContaining([ expect.objectContaining({ field: 'category', op: 'set', type: 'id', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }), ]), conditions: expect.arrayContaining([ expect.objectContaining({ field: 'payee', op: 'is', type: 'id', value: 'test-payee', }), ]), conditionsOp: 'and', id: rule.id, stage: 'pre', }), ]), ); // get by payee expect(await api.getPayeeRules('test-payee')).toEqual( expect.arrayContaining([ expect.objectContaining({ actions: expect.arrayContaining([ expect.objectContaining({ field: 'category', op: 'set', type: 'id', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }), ]), conditions: expect.arrayContaining([ expect.objectContaining({ field: 'payee', op: 'is', type: 'id', value: 'test-payee', }), ]), conditionsOp: 'and', id: rule.id, stage: 'pre', }), ]), ); expect(await api.getPayeeRules('test-payee2')).toEqual( expect.arrayContaining([ expect.objectContaining({ actions: expect.arrayContaining([ expect.objectContaining({ field: 'category', op: 'set', type: 'id', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }), ]), conditions: expect.arrayContaining([ expect.objectContaining({ field: 'payee', op: 'is', type: 'id', value: 'test-payee2', }), ]), conditionsOp: 'and', id: rule2.id, stage: 'pre', }), ]), ); // update one rule const updatedRule = { ...rule, stage: 'post', conditionsOp: 'or', } satisfies RuleEntity; expect(await api.updateRule(updatedRule)).toEqual(updatedRule); expect(await api.getRules()).toEqual( expect.arrayContaining([ expect.objectContaining({ actions: expect.arrayContaining([ expect.objectContaining({ field: 'category', op: 'set', type: 'id', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }), ]), conditions: expect.arrayContaining([ expect.objectContaining({ field: 'payee', op: 'is', type: 'id', value: 'test-payee', }), ]), conditionsOp: 'or', id: rule.id, stage: 'post', }), expect.objectContaining({ actions: expect.arrayContaining([ expect.objectContaining({ field: 'category', op: 'set', type: 'id', value: 'fc3825fd-b982-4b72-b768-5b30844cf832', }), ]), conditions: expect.arrayContaining([ expect.objectContaining({ field: 'payee', op: 'is', type: 'id', value: 'test-payee2', }), ]), conditionsOp: 'and', id: rule2.id, stage: 'pre', }), ]), ); // delete rules await api.deleteRule(rules[1].id); expect(await api.getRules()).toHaveLength(1); await api.deleteRule(rules[0].id); expect(await api.getRules()).toHaveLength(0); }); // apis: addTransactions, getTransactions, importTransactions, updateTransaction, deleteTransaction test('Transactions: successfully update transactions', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); let newTransaction = [ { account: accountId, date: '2023-11-03', imported_id: '11', amount: 100, notes: 'notes', }, { account: accountId, date: '2023-11-03', imported_id: '12', amount: 100, notes: '', }, ]; const addResult = await api.addTransactions(accountId, newTransaction, { learnCategories: true, runTransfers: true, }); expect(addResult).toBe('ok'); expect(await api.getAccountBalance(accountId)).toEqual(200); expect( await api.getAccountBalance(accountId, new Date(2023, 10, 2)), ).toEqual(0); // confirm added transactions exist let transactions = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); expect(transactions).toEqual( expect.arrayContaining( newTransaction.map(trans => expect.objectContaining(trans)), ), ); expect(transactions).toHaveLength(2); newTransaction = [ { account: accountId, date: '2023-12-03', imported_id: '11', amount: 100, notes: 'notes', }, { account: accountId, date: '2023-12-03', imported_id: '12', amount: 100, notes: 'notes', }, { account: accountId, date: '2023-12-03', imported_id: '22', amount: 200, notes: '', }, ]; const reconciled = await api.importTransactions(accountId, newTransaction); // Expect it to reconcile and to have updated one of the previous transactions expect(reconciled.added).toHaveLength(1); expect(reconciled.updated).toHaveLength(1); // confirm imported transactions exist transactions = await api.getTransactions( accountId, '2023-12-01', '2023-12-31', ); expect(transactions).toEqual( expect.arrayContaining([ expect.objectContaining({ imported_id: '22', amount: 200 }), ]), ); expect(transactions).toHaveLength(1); // confirm imported transactions update perfomed transactions = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); expect(transactions).toEqual( expect.arrayContaining([ expect.objectContaining({ notes: 'notes', amount: 100 }), ]), ); expect(transactions).toHaveLength(2); const idToUpdate = reconciled.added[0]; const idToDelete = reconciled.updated[0]; await api.updateTransaction(idToUpdate, { amount: 500 }); await api.deleteTransaction(idToDelete); // confirm updates and deletions work transactions = await api.getTransactions( accountId, '2023-12-01', '2023-12-31', ); expect(transactions).toEqual( expect.arrayContaining([ expect.objectContaining({ id: idToUpdate, amount: 500 }), expect.not.objectContaining({ id: idToDelete }), ]), ); expect(transactions).toHaveLength(1); }); test('Transactions: updating a split child keeps its other fields', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); const groupId = await api.createCategoryGroup({ name: 'tests' }); const categoryId = await api.createCategory({ name: 'test-split', group_id: groupId, }); await api.addTransactions(accountId, [ { date: '2023-11-03', amount: 300, subtransactions: [ { amount: 100, category: categoryId, notes: 'first' }, { amount: 200, category: categoryId, notes: 'second' }, ], }, ]); let [parent] = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); const child = parent.subtransactions?.find(t => t.notes === 'first'); if (!child) { throw new Error('split child not found'); } await api.updateTransaction(child.id, { notes: 'updated' }); [parent] = await api.getTransactions(accountId, '2023-11-01', '2023-11-30'); expect(parent.subtransactions).toEqual( expect.arrayContaining([ expect.objectContaining({ id: child.id, notes: 'updated', amount: 100, category: categoryId, }), expect.objectContaining({ notes: 'second', amount: 200, category: categoryId, }), ]), ); expect(parent.amount).toBe(300); }); // apis: mergeTransactions test('Transactions: successfully merge two transactions', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); await api.addTransactions(accountId, [ { date: '2023-11-03', amount: 100, notes: 'notes' }, { date: '2023-11-03', amount: 100, imported_id: '1' }, ]); const before = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); expect(before).toHaveLength(2); const keptId = await api.mergeTransactions([before[0].id, before[1].id]); const after = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); expect(after).toHaveLength(1); expect(after[0]).toMatchObject({ id: keptId, notes: 'notes' }); }); test('Transactions: import notes are preserved when importing', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); // Test with notes const transactionsWithNotes = [ { date: '2023-11-03', imported_id: '11', amount: 100, notes: 'test note', }, ]; const addResultWithNotes = await api.addTransactions( accountId, transactionsWithNotes, { learnCategories: true, runTransfers: true, }, ); expect(addResultWithNotes).toBe('ok'); let transactions = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); expect(transactions[0].notes).toBe('test note'); // Clear transactions await api.deleteTransaction(transactions[0].id); // Test without notes const transactionsWithoutNotes = [ { date: '2023-11-03', imported_id: '11', amount: 100 }, ]; const addResultWithoutNotes = await api.addTransactions( accountId, transactionsWithoutNotes, { learnCategories: true, runTransfers: true, }, ); expect(addResultWithoutNotes).toBe('ok'); transactions = await api.getTransactions( accountId, '2023-11-01', '2023-11-30', ); expect(transactions[0].notes).toBeNull(); }); test('Transactions: reimportDeleted=false prevents reimporting deleted transactions', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); // Import a transaction const result1 = await api.importTransactions(accountId, [ { date: '2023-11-03', imported_id: 'reimport-test-1', amount: 100, account: accountId, }, ]); expect(result1.added).toHaveLength(1); // Delete the transaction await api.deleteTransaction(result1.added[0]); // Reimport the same transaction with reimportDeleted=false const result2 = await api.importTransactions( accountId, [ { date: '2023-11-03', imported_id: 'reimport-test-1', amount: 100, account: accountId, }, ], { reimportDeleted: false }, ); // Should match the deleted transaction and not create a new one expect(result2.added).toHaveLength(0); expect(result2.updated).toHaveLength(0); }); test('Transactions: reimportDeleted=true reimports deleted transactions', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); // Import a transaction const result1 = await api.importTransactions(accountId, [ { date: '2023-11-03', imported_id: 'reimport-test-2', amount: 200, account: accountId, }, ]); expect(result1.added).toHaveLength(1); // Delete the transaction await api.deleteTransaction(result1.added[0]); // Reimport the same transaction relying on reimportDeleted=true default const result2 = await api.importTransactions(accountId, [ { date: '2023-11-03', imported_id: 'reimport-test-2', amount: 200, account: accountId, }, ]); // Should create a new transaction since deleted ones are ignored expect(result2.added).toHaveLength(1); }); }); //apis: createSchedule, getSchedules, updateSchedule, deleteSchedule test('Schedules: successfully complete schedules operations', async () => { await api.loadBudget(budgetName); //test a schedule with a recuring configuration const ScheduleId1 = await api.createSchedule({ name: 'test-schedule 1', posts_transaction: true, // amount: -5000, amountOp: 'is', date: { frequency: 'monthly', interval: 1, start: '2025-06-13', patterns: [], skipWeekend: false, weekendSolveMode: 'after', endMode: 'never', }, }); //test the creation of non recurring schedule const ScheduleId2 = await api.createSchedule({ name: 'test-schedule 2', posts_transaction: false, amount: 4000, amountOp: 'is', date: '2025-06-13', }); let schedules = await api.getSchedules(); // Schedules successfully created expect(schedules).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'test-schedule 1', posts_transaction: true, // amount: -5000, amountOp: 'is', date: { frequency: 'monthly', interval: 1, start: '2025-06-13', patterns: [], skipWeekend: false, weekendSolveMode: 'after', endMode: 'never', }, }), expect.objectContaining({ name: 'test-schedule 2', posts_transaction: false, amount: 4000, amountOp: 'is', date: '2025-06-13', }), ]), ); //check getIDByName works on schedules expect(await api.getIDByName('schedules', 'test-schedule 1')).toEqual( ScheduleId1, ); expect(await api.getIDByName('schedules', 'test-schedule 2')).toEqual( ScheduleId2, ); //check getIDByName works on accounts const schedAccountId1 = await api.createAccount( { name: 'sched-test-account1', offbudget: true }, 1000, ); expect(await api.getIDByName('accounts', 'sched-test-account1')).toEqual( schedAccountId1, ); //check getIDByName works on payees const schedPayeeId1 = await api.createPayee({ name: 'sched-test-payee1' }); expect(await api.getIDByName('payees', 'sched-test-payee1')).toEqual( schedPayeeId1, ); await api.updateSchedule(ScheduleId1, { amount: -10000, account: schedAccountId1, }); await api.deleteSchedule(ScheduleId2); // schedules successfully updated, and one of them deleted await api.updateSchedule(ScheduleId1, { amount: -10000, account: schedAccountId1, payee: schedPayeeId1, }); await api.deleteSchedule(ScheduleId2); schedules = await api.getSchedules(); expect(schedules).toEqual( expect.arrayContaining([ expect.objectContaining({ id: ScheduleId1, posts_transaction: true, amount: -10000, account: schedAccountId1, payee: schedPayeeId1, amountOp: 'is', date: { frequency: 'monthly', interval: 1, start: '2025-06-13', patterns: [], skipWeekend: false, weekendSolveMode: 'after', endMode: 'never', }, }), expect.not.objectContaining({ id: ScheduleId2 }), ]), ); }); describe('API schedules: getScheduleDates', () => { beforeEach(async () => { await api.loadBudget(budgetName); }); // apis: createSchedule, getSchedules, getScheduleDates test('expands the date of a stored schedule', async () => { const id = await api.createSchedule({ name: 'rent', amount: -120000, amountOp: 'is', posts_transaction: false, date: { frequency: 'monthly', start: '2024-06-01', skipWeekend: true, weekendSolveMode: 'after', endMode: 'never', }, }); const [schedule] = (await api.getSchedules()).filter(s => s.id === id); expect( await api.getScheduleDates(schedule.date, { start: '2024-06-01', end: '2024-09-30', }), ).toEqual(['2024-06-03', '2024-07-01', '2024-08-01', '2024-09-02']); }); // apis: createSchedule, getSchedules, getScheduleDates test('starts at the next date the app shows and never repeats a date', async () => { // Tests run with today pinned to Sunday 2017-01-01. The occurrences on // Saturday 31 December and Sunday 1 January both move to Monday 2 // January, which must be listed once and match the stored next_date. const id = await api.createSchedule({ name: 'daily weekdays', amount: -500, amountOp: 'is', posts_transaction: false, date: { frequency: 'daily', start: '2016-12-31', skipWeekend: true, weekendSolveMode: 'after', endMode: 'never', }, }); const [schedule] = (await api.getSchedules()).filter(s => s.id === id); const dates = await api.getScheduleDates(schedule.date, { count: 3 }); expect(dates[0]).toEqual(schedule.next_date); expect(dates).toEqual(['2017-01-02', '2017-01-03', '2017-01-04']); }); // apis: getScheduleDates test('expands a single date', async () => { expect( await api.getScheduleDates('2024-06-13', { start: '2024-06-01', end: '2024-06-30', }), ).toEqual(['2024-06-13']); }); // apis: getScheduleDates test('starts from today when no start is given', async () => { // Tests run with today pinned to 2017-01-01. expect( await api.getScheduleDates( { frequency: 'yearly', start: '2016-03-01' }, { count: 1 }, ), ).toEqual(['2017-03-01']); }); // apis: getScheduleDates test('rejects invalid requests', async () => { const daily = { frequency: 'daily', start: '2024-01-01' } as const; await expect( api.getScheduleDates(daily, { start: '2024-13-01', count: 1 }), ).rejects.toThrow('Invalid start date'); await expect( api.getScheduleDates(daily, { start: '2024-02-01', end: '2024-01-01' }), ).rejects.toThrow('is before start'); await expect( api.getScheduleDates(daily, { start: '2024-01-01', count: -1 }), ).rejects.toThrow('count must be a non-negative integer'); await expect( api.getScheduleDates(daily, { start: '2024-01-01' }), ).rejects.toThrow('needs an `end` date or a `count`'); await expect( api.getScheduleDates( { ...daily, endMode: 'after_n_occurrences', endOccurrences: 0 }, { start: '2024-01-01' }, ), ).rejects.toThrow('endOccurrences must be a positive integer'); await expect( api.getScheduleDates( { ...daily, endMode: 'on_date', endDate: 'soon' }, { start: '2024-01-01' }, ), ).rejects.toThrow('Invalid recurrence end date'); await expect( api.getScheduleDates('06/13/2024', { start: '2024-01-01' }), ).rejects.toThrow('Invalid schedule date'); }); }); // apis: getPreferences test('Preferences: successfully read synced preferences', async () => { await api.loadBudget(budgetName); await api.internal?.send('preferences/save', { id: 'numberFormat', value: '1.234,56', }); await api.internal?.send('preferences/save', { id: 'hideFraction', value: 'true', }); const preferences = await api.getPreferences(); expect(preferences).toMatchObject({ numberFormat: '1.234,56', hideFraction: 'true', }); }); describe('API preferences: setPreference', () => { beforeEach(async () => { await api.loadBudget(budgetName); }); // apis: setPreference test('successfully sets a single preference', async () => { await api.setPreference('numberFormat', '1,234.5'); const preferences = await api.getPreferences(); expect(preferences.numberFormat).toBe('1,234.5'); }); // apis: setPreference test('can set multiple preferences', async () => { await api.setPreference('numberFormat', '1,234.5'); await api.setPreference('hideFraction', 'true'); await api.setPreference('defaultCurrencyCode', 'USD'); const preferences = await api.getPreferences(); expect(preferences).toMatchObject({ numberFormat: '1,234.5', hideFraction: 'true', defaultCurrencyCode: 'USD', }); }); // apis: setPreference test('can set feature flag preferences', async () => { await api.setPreference('flags.newSidebarUI', 'true'); const preferences = await api.getPreferences(); expect(preferences['flags.newSidebarUI']).toBe('true'); }); // apis: setPreference test('can set account-specific preferences', async () => { const accountId = 'test-account-123'; await api.setPreference( `show-account-${accountId}-net-worth-chart`, 'false', ); const preferences = await api.getPreferences(); expect(preferences[`show-account-${accountId}-net-worth-chart`]).toBe( 'false', ); }); // apis: setPreference test('can set CSV import preferences', async () => { await api.setPreference( `csv-mappings-${budgetName}`, 'col1:name,col2:amount', ); const preferences = await api.getPreferences(); expect(preferences[`csv-mappings-${budgetName}`]).toBe( 'col1:name,col2:amount', ); }); });