diff --git a/packages/api/.gitignore b/packages/api/.gitignore index 1682676f0b6..25df8197844 100644 --- a/packages/api/.gitignore +++ b/packages/api/.gitignore @@ -2,3 +2,4 @@ app/bundle.api.js* app/stats.json migrations default-db.sqlite +mocks/budgets/**/* diff --git a/packages/api/__snapshots__/methods.test.ts.snap b/packages/api/__snapshots__/methods.test.ts.snap new file mode 100644 index 00000000000..806d2194337 --- /dev/null +++ b/packages/api/__snapshots__/methods.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`API setup and teardown successfully loads budget 1`] = ` +Array [ + "2016-10", + "2016-11", + "2016-12", + "2017-01", + "2017-02", + "2017-03", + "2017-04", + "2017-05", + "2017-06", + "2017-07", + "2017-08", + "2017-09", + "2017-10", + "2017-11", + "2017-12", +] +`; diff --git a/packages/api/index.js b/packages/api/index.js index aebc7c061d4..4473e540c86 100644 --- a/packages/api/index.js +++ b/packages/api/index.js @@ -1,5 +1,3 @@ -/* eslint-disable import/no-unused-modules */ - // eslint-disable-next-line import/extensions import * as bundle from './app/bundle.api.js'; import * as injected from './injected'; diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js new file mode 100644 index 00000000000..2d98fde77d2 --- /dev/null +++ b/packages/api/jest.config.js @@ -0,0 +1,24 @@ +module.exports = { + moduleFileExtensions: [ + 'testing.js', + 'testing.ts', + 'api.js', + 'api.ts', + 'api.tsx', + 'electron.js', + 'electron.ts', + 'mjs', + 'js', + 'ts', + 'tsx', + 'json', + ], + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/'], + watchPathIgnorePatterns: ['/mocks/budgets/'], + setupFilesAfterEnv: ['/../loot-core/src/mocks/setup.ts'], + transformIgnorePatterns: ['/node_modules/'], + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + }, +}; diff --git a/packages/api/methods.test.ts b/packages/api/methods.test.ts new file mode 100644 index 00000000000..f5284966b98 --- /dev/null +++ b/packages/api/methods.test.ts @@ -0,0 +1,147 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import * as api from './index'; + +const budgetName = 'test-budget'; + +beforeEach(async () => { + // we need real datetime if we are going to mix new timestamps with our mock data + global.restoreDateNow(); + + 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 CRUD operations', () => { + beforeEach(async () => { + // load test budget + await api.loadBudget(budgetName); + }); + + // 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 + 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' }), + ]), + ); + + await api.updateAccount(accountId1, { offbudget: false }); + await api.closeAccount(accountId1, accountId2, null); + 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, + }), + ]), + ); + }); +}); diff --git a/packages/api/mocks/budgets/.gitkeep b/packages/api/mocks/budgets/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/api/package.json b/packages/api/package.json index 0ef8b7de68e..e3c71255ff5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -16,7 +16,8 @@ "build:node": "tsc --p tsconfig.dist.json", "build:migrations": "cp migrations/*.sql dist/migrations", "build:default-db": "cp default-db.sqlite dist/", - "build": "rm -rf dist && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db" + "build": "rm -rf dist && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db", + "test": "yarn run build:app && jest -c jest.config.js" }, "dependencies": { "better-sqlite3": "^9.1.1", @@ -25,7 +26,11 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@swc/core": "^1.3.82", + "@swc/jest": "^0.2.29", + "@types/jest": "^27.5.0", "@types/uuid": "^9.0.2", + "jest": "^27.0.0", "typescript": "^5.0.2" } } diff --git a/packages/desktop-client/src/components/budget/BudgetTotals.js b/packages/desktop-client/src/components/budget/BudgetTotals.tsx similarity index 90% rename from packages/desktop-client/src/components/budget/BudgetTotals.js rename to packages/desktop-client/src/components/budget/BudgetTotals.tsx index 5f2fa3079ff..fbcc9aca998 100644 --- a/packages/desktop-client/src/components/budget/BudgetTotals.js +++ b/packages/desktop-client/src/components/budget/BudgetTotals.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState } from 'react'; +import React, { type ComponentProps, memo, useState } from 'react'; import DotsHorizontalTriple from '../../icons/v1/DotsHorizontalTriple'; import { theme, styles } from '../../style'; @@ -10,12 +10,19 @@ import { Tooltip } from '../tooltips'; import RenderMonths from './RenderMonths'; import { getScrollbarWidth } from './util'; +type BudgetTotalsProps = { + MonthComponent: ComponentProps['component']; + toggleHiddenCategories: () => void; + expandAllCategories: () => void; + collapseAllCategories: () => void; +}; + const BudgetTotals = memo(function BudgetTotals({ MonthComponent, toggleHiddenCategories, expandAllCategories, collapseAllCategories, -}) { +}: BudgetTotalsProps) { const [menuOpen, setMenuOpen] = useState(false); return ( - diff --git a/packages/desktop-client/src/components/reports/DateRange.js b/packages/desktop-client/src/components/reports/DateRange.tsx similarity index 60% rename from packages/desktop-client/src/components/reports/DateRange.js rename to packages/desktop-client/src/components/reports/DateRange.tsx index 2e2ba1234e5..df3d6eb50ec 100644 --- a/packages/desktop-client/src/components/reports/DateRange.js +++ b/packages/desktop-client/src/components/reports/DateRange.tsx @@ -1,16 +1,24 @@ -import React from 'react'; +import React, { type ReactElement } from 'react'; import * as d from 'date-fns'; import { theme } from '../../style'; import Block from '../common/Block'; -function DateRange({ start, end }) { - start = d.parseISO(start); - end = d.parseISO(end); +type DateRangeProps = { + start: string; + end: string; +}; - let content; - if (start.getYear() !== end.getYear()) { +function DateRange({ + start: startProp, + end: endProp, +}: DateRangeProps): ReactElement { + const start = d.parseISO(startProp); + const end = d.parseISO(endProp); + + let content: string | ReactElement; + if (start.getFullYear() !== end.getFullYear()) { content = (
{d.format(start, 'MMM yyyy')} - {d.format(end, 'MMM yyyy')} diff --git a/packages/desktop-client/src/components/reports/useReport.js b/packages/desktop-client/src/components/reports/useReport.tsx similarity index 61% rename from packages/desktop-client/src/components/reports/useReport.js rename to packages/desktop-client/src/components/reports/useReport.tsx index 15d5a3fb986..0e010d13517 100644 --- a/packages/desktop-client/src/components/reports/useReport.js +++ b/packages/desktop-client/src/components/reports/useReport.tsx @@ -1,8 +1,14 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, type SetStateAction } from 'react'; import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; -function useReport(sheetName, getData) { +function useReport( + sheetName: string, + getData: ( + spreadsheet: ReturnType, + setData: (results: unknown) => SetStateAction, + ) => Promise, +) { const spreadsheet = useSpreadsheet(); const [results, setResults] = useState(null); @@ -15,7 +21,6 @@ function useReport(sheetName, getData) { cleanup?.(); }; }, [getData]); - return results; } diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts index c35ca100ee9..9a5321cf3dc 100644 --- a/packages/loot-core/src/client/actions/budgets.ts +++ b/packages/loot-core/src/client/actions/budgets.ts @@ -110,10 +110,7 @@ export function closeBudgetUI() { }; } -export function deleteBudget( - id: string | undefined, - cloudFileId: string | undefined, -) { +export function deleteBudget(id?: string, cloudFileId?: string) { return async (dispatch: Dispatch) => { await send('delete-budget', { id, cloudFileId }); await dispatch(loadAllFiles()); diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 517f1778aaa..1c12c19f09d 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -1,8 +1,8 @@ +import { type File } from '../../types/file'; import type { AccountEntity, GoCardlessToken } from '../../types/models'; import type { RuleEntity } from '../../types/models/rule'; import type { EmptyObject, StripNever } from '../../types/util'; import type * as constants from '../constants'; - export type ModalType = keyof FinanceModals; export type OptionlessModal = { @@ -70,6 +70,16 @@ type FinanceModals = { onSuccess: (data: GoCardlessToken) => Promise; }; + 'delete-budget': { file: File }; + + import: null; + + 'import-ynab4': null; + + 'import-ynab5': null; + + 'import-actual': null; + 'create-encryption-key': { recreate?: boolean }; 'fix-encryption-key': { hasExistingKey?: boolean; diff --git a/packages/loot-core/src/types/file.d.ts b/packages/loot-core/src/types/file.d.ts index 394adee0718..30301b1fb14 100644 --- a/packages/loot-core/src/types/file.d.ts +++ b/packages/loot-core/src/types/file.d.ts @@ -11,11 +11,13 @@ export type FileState = export type LocalFile = Omit & { state: 'local'; }; + export type SyncableLocalFile = Budget & { cloudFileId: string; groupId: string; state: 'broken' | 'unknown'; }; + export type SyncedLocalFile = Budget & { cloudFileId: string; groupId: string; @@ -23,6 +25,7 @@ export type SyncedLocalFile = Budget & { hasKey: boolean; state: 'synced' | 'detached'; }; + export type RemoteFile = { cloudFileId: string; groupId: string; diff --git a/upcoming-release-notes/1991.md b/upcoming-release-notes/1991.md new file mode 100644 index 00000000000..5fa4b94bddd --- /dev/null +++ b/upcoming-release-notes/1991.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [twk3] +--- + +Add some initial api tests for budgets and accounts diff --git a/upcoming-release-notes/2004.md b/upcoming-release-notes/2004.md new file mode 100644 index 00000000000..0ff9d19d9bf --- /dev/null +++ b/upcoming-release-notes/2004.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MikesGlitch] +--- + +Convert BudgetTotals, GoCardlessLink, Import, WelcomeScreen components to Typescript. diff --git a/upcoming-release-notes/2007.md b/upcoming-release-notes/2007.md new file mode 100644 index 00000000000..cb0aa92f926 --- /dev/null +++ b/upcoming-release-notes/2007.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [ameekSinghUniversityAcc] +--- + +Migrating the DateRange and UseReport files to typescript diff --git a/yarn.lock b/yarn.lock index 1dd1f7c5915..2251b1091e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,9 +23,13 @@ __metadata: version: 0.0.0-use.local resolution: "@actual-app/api@workspace:packages/api" dependencies: + "@swc/core": "npm:^1.3.82" + "@swc/jest": "npm:^0.2.29" + "@types/jest": "npm:^27.5.0" "@types/uuid": "npm:^9.0.2" better-sqlite3: "npm:^9.1.1" compare-versions: "npm:^6.1.0" + jest: "npm:^27.0.0" node-fetch: "npm:^3.3.2" typescript: "npm:^5.0.2" uuid: "npm:^9.0.0"