Files
actual/packages/api/methods.test.ts
EMAD BASHARIandGitHub 5897cfa65a fix: reject invalid category group updates (#8856) (#9374)
* fix: reject invalid category group updates (#8856)

* fix: validate category group existence on update

* test: mock existing category group in update unit test
2026-10-31 16:31:59 +00:00

1603 lines
43 KiB
TypeScript

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<string, unknown>;
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<string>();
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',
);
});
});