|
|
@ -1,42 +1,53 @@ |
|
|
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|
|
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|
|
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; |
|
|
import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; |
|
|
|
|
|
import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
|
|
|
|
|
import { |
|
|
|
|
|
DEFAULT_LANGUAGE_CODE, |
|
|
|
|
|
TAG_ID_EXCLUDE_FROM_ANALYSIS |
|
|
|
|
|
} from '@ghostfolio/common/config'; |
|
|
|
|
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; |
|
|
import { AccountWithValue } from '@ghostfolio/common/types'; |
|
|
import { AccountWithValue } from '@ghostfolio/common/types'; |
|
|
|
|
|
|
|
|
|
|
|
import { AssetClass, AssetSubClass } from '@prisma/client'; |
|
|
|
|
|
|
|
|
import { PortfolioTableService } from './portfolio-table.service'; |
|
|
import { PortfolioTableService } from './portfolio-table.service'; |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* The markdown table is rendered by a package which ships as an ECMAScript |
|
|
* The markdown table is rendered by a package which ships as an ECMAScript |
|
|
* module only, which Jest cannot run. The mock renders the columns and the |
|
|
* module only, which Jest cannot run. The mock keeps the mapping of the |
|
|
* rows in a simple form, so that the tests can read them. |
|
|
* columns and of the rows and writes them in the same shape as the renderer |
|
|
*/ |
|
|
*/ |
|
|
jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { |
|
|
jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { |
|
|
|
|
|
const { getTableInput } = jest.requireActual< |
|
|
|
|
|
typeof import('@ghostfolio/api/helper/markdown-table.helper') |
|
|
|
|
|
>('@ghostfolio/api/helper/markdown-table.helper'); |
|
|
|
|
|
|
|
|
return { |
|
|
return { |
|
|
|
|
|
getTableInput, |
|
|
getMarkdownTable: jest.fn( |
|
|
getMarkdownTable: jest.fn( |
|
|
({ |
|
|
(parameters: TableParameters<unknown, unknown>) => { |
|
|
columnDefinitions, |
|
|
const { columns, rows } = getTableInput(parameters); |
|
|
rows |
|
|
|
|
|
}: { |
|
|
const names = columns.map(({ name }) => { |
|
|
columnDefinitions: { |
|
|
return name; |
|
|
getValue: (row: unknown) => string; |
|
|
}); |
|
|
name: string; |
|
|
|
|
|
}[]; |
|
|
|
|
|
rows: unknown[]; |
|
|
|
|
|
}) => { |
|
|
|
|
|
return Promise.resolve( |
|
|
return Promise.resolve( |
|
|
[ |
|
|
[ |
|
|
columnDefinitions |
|
|
names, |
|
|
.map(({ name }) => { |
|
|
names.map(() => { |
|
|
return name; |
|
|
return '---'; |
|
|
}) |
|
|
}), |
|
|
.join(' | '), |
|
|
|
|
|
...rows.map((row) => { |
|
|
...rows.map((row) => { |
|
|
return columnDefinitions |
|
|
return names.map((name) => { |
|
|
.map(({ getValue }) => { |
|
|
return row[name]; |
|
|
return getValue(row); |
|
|
}); |
|
|
}) |
|
|
|
|
|
.join(' | '); |
|
|
|
|
|
}) |
|
|
}) |
|
|
].join('\n') |
|
|
] |
|
|
|
|
|
.map((cells) => { |
|
|
|
|
|
return `| ${cells.join(' | ')} |`; |
|
|
|
|
|
}) |
|
|
|
|
|
.join('\n') |
|
|
); |
|
|
); |
|
|
} |
|
|
} |
|
|
) |
|
|
) |
|
|
@ -65,12 +76,56 @@ function createAccount({ |
|
|
} as unknown as AccountWithValue; |
|
|
} as unknown as AccountWithValue; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function createPortfolioTableService(accounts: AccountWithValue[]) { |
|
|
function createHolding({ |
|
|
|
|
|
allocationInPercentage = 0.75, |
|
|
|
|
|
assetClass = AssetClass.EQUITY, |
|
|
|
|
|
assetSubClass = AssetSubClass.STOCK, |
|
|
|
|
|
symbol = 'AAPL' |
|
|
|
|
|
}: { |
|
|
|
|
|
allocationInPercentage?: number; |
|
|
|
|
|
assetClass?: AssetClass; |
|
|
|
|
|
assetSubClass?: AssetSubClass; |
|
|
|
|
|
symbol?: string; |
|
|
|
|
|
} = {}) { |
|
|
|
|
|
return { |
|
|
|
|
|
allocationInPercentage, |
|
|
|
|
|
activitiesCount: 3, |
|
|
|
|
|
assetProfile: { |
|
|
|
|
|
assetClass, |
|
|
|
|
|
assetSubClass, |
|
|
|
|
|
symbol, |
|
|
|
|
|
currency: 'CHF', |
|
|
|
|
|
name: `Name of ${symbol}` |
|
|
|
|
|
}, |
|
|
|
|
|
dateOfFirstActivity: new Date('2024-01-01'), |
|
|
|
|
|
grossPerformance: 100, |
|
|
|
|
|
netPerformance: 90, |
|
|
|
|
|
quantity: 5, |
|
|
|
|
|
valueInBaseCurrency: 2000 |
|
|
|
|
|
} as unknown as PortfolioPosition; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function createPortfolioTableService({ |
|
|
|
|
|
accounts = [], |
|
|
|
|
|
holdings = [] |
|
|
|
|
|
}: { |
|
|
|
|
|
accounts?: AccountWithValue[]; |
|
|
|
|
|
holdings?: PortfolioPosition[]; |
|
|
|
|
|
} = {}) { |
|
|
|
|
|
// The mock gives the identifier of the translation, so that a test can tell
|
|
|
|
|
|
// the translation of the asset class from that of the asset sub class
|
|
|
|
|
|
const i18nService = { |
|
|
|
|
|
getTranslation: jest.fn(({ id }: { id: string }) => { |
|
|
|
|
|
return `translation of ${id}`; |
|
|
|
|
|
}) |
|
|
|
|
|
} as unknown as I18nService; |
|
|
|
|
|
|
|
|
const portfolioService = { |
|
|
const portfolioService = { |
|
|
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) |
|
|
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }), |
|
|
|
|
|
getDetails: jest.fn().mockResolvedValue({ holdings }) |
|
|
} as unknown as PortfolioService; |
|
|
} as unknown as PortfolioService; |
|
|
|
|
|
|
|
|
return new PortfolioTableService(null, null, portfolioService); |
|
|
return new PortfolioTableService(null, i18nService, portfolioService); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
describe('PortfolioTableService', () => { |
|
|
describe('PortfolioTableService', () => { |
|
|
@ -122,9 +177,9 @@ describe('PortfolioTableService', () => { |
|
|
|
|
|
|
|
|
describe('getAccountsTable', () => { |
|
|
describe('getAccountsTable', () => { |
|
|
it('gives no cash balance and no value of an account', async () => { |
|
|
it('gives no cash balance and no value of an account', async () => { |
|
|
const portfolioTableService = createPortfolioTableService([ |
|
|
const portfolioTableService = createPortfolioTableService({ |
|
|
createAccount() |
|
|
accounts: [createAccount()] |
|
|
]); |
|
|
}); |
|
|
|
|
|
|
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
userId: 'user-id' |
|
|
userId: 'user-id' |
|
|
@ -138,9 +193,9 @@ describe('PortfolioTableService', () => { |
|
|
// The accountIds parameter of the tool takes the identifiers, hence the
|
|
|
// The accountIds parameter of the tool takes the identifiers, hence the
|
|
|
// table has to give them
|
|
|
// table has to give them
|
|
|
it('gives the identifier of an account', async () => { |
|
|
it('gives the identifier of an account', async () => { |
|
|
const portfolioTableService = createPortfolioTableService([ |
|
|
const portfolioTableService = createPortfolioTableService({ |
|
|
createAccount() |
|
|
accounts: [createAccount()] |
|
|
]); |
|
|
}); |
|
|
|
|
|
|
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
userId: 'user-id' |
|
|
userId: 'user-id' |
|
|
@ -150,10 +205,12 @@ describe('PortfolioTableService', () => { |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
it('marks an account which is excluded from the analysis', async () => { |
|
|
it('marks an account which is excluded from the analysis', async () => { |
|
|
const portfolioTableService = createPortfolioTableService([ |
|
|
const portfolioTableService = createPortfolioTableService({ |
|
|
createAccount({ isExcluded: true }), |
|
|
accounts: [ |
|
|
createAccount({ id: 'account-b-id', name: 'Account B' }) |
|
|
createAccount({ isExcluded: true }), |
|
|
]); |
|
|
createAccount({ id: 'account-b-id', name: 'Account B' }) |
|
|
|
|
|
] |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
userId: 'user-id' |
|
|
userId: 'user-id' |
|
|
@ -162,7 +219,7 @@ describe('PortfolioTableService', () => { |
|
|
const [rowOfAccountA, rowOfAccountB] = result |
|
|
const [rowOfAccountA, rowOfAccountB] = result |
|
|
.split('\n') |
|
|
.split('\n') |
|
|
.filter((line) => { |
|
|
.filter((line) => { |
|
|
return line.startsWith('account-'); |
|
|
return line.startsWith('| account-'); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
expect(rowOfAccountA).toContain('true'); |
|
|
expect(rowOfAccountA).toContain('true'); |
|
|
@ -170,7 +227,7 @@ describe('PortfolioTableService', () => { |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
it('tells that no accounts are found if the result is empty', async () => { |
|
|
it('tells that no accounts are found if the result is empty', async () => { |
|
|
const portfolioTableService = createPortfolioTableService([]); |
|
|
const portfolioTableService = createPortfolioTableService(); |
|
|
|
|
|
|
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
const result = await portfolioTableService.getAccountsTable({ |
|
|
userId: 'user-id' |
|
|
userId: 'user-id' |
|
|
@ -179,4 +236,37 @@ describe('PortfolioTableService', () => { |
|
|
expect(result).toContain('No accounts found.'); |
|
|
expect(result).toContain('No accounts found.'); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('getHoldingsTable', () => { |
|
|
|
|
|
function getHoldingsTable(holdings: PortfolioPosition[]) { |
|
|
|
|
|
return createPortfolioTableService({ holdings }).getHoldingsTable({ |
|
|
|
|
|
languageCode: DEFAULT_LANGUAGE_CODE, |
|
|
|
|
|
userId: 'user-id' |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
it('gives the translation of the asset class and of the asset sub class', async () => { |
|
|
|
|
|
const result = await getHoldingsTable([createHolding()]); |
|
|
|
|
|
|
|
|
|
|
|
const [row] = result.split('\n').filter((line) => { |
|
|
|
|
|
return line.startsWith('| Name of AAPL'); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
expect(row).toContain('translation of assetClass.EQUITY'); |
|
|
|
|
|
expect(row).toContain('translation of assetSubClass.STOCK'); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
it('gives the holding with the largest allocation first', async () => { |
|
|
|
|
|
const result = await getHoldingsTable([ |
|
|
|
|
|
createHolding({ allocationInPercentage: 0.25, symbol: 'MSFT' }), |
|
|
|
|
|
createHolding({ allocationInPercentage: 0.75, symbol: 'AAPL' }) |
|
|
|
|
|
]); |
|
|
|
|
|
|
|
|
|
|
|
const [firstRow, secondRow] = result.split('\n').filter((line) => { |
|
|
|
|
|
return line.startsWith('| Name of'); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
expect(firstRow).toContain('AAPL'); |
|
|
|
|
|
expect(secondRow).toContain('MSFT'); |
|
|
|
|
|
}); |
|
|
|
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|