mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
164 lines
4.8 KiB
164 lines
4.8 KiB
import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
|
|
import { AccountWithValue } from '@ghostfolio/common/types';
|
|
|
|
import { AiService } from './ai.service';
|
|
|
|
// The service imports two packages which ship as an ECMAScript module only,
|
|
// which Jest cannot transform. The mocks only make the imports resolvable,
|
|
// because no test calls them.
|
|
jest.mock('@openrouter/ai-sdk-provider', () => {
|
|
return { createOpenRouter: jest.fn() };
|
|
});
|
|
|
|
jest.mock('ai', () => {
|
|
return { generateText: jest.fn() };
|
|
});
|
|
|
|
/**
|
|
* The markdown table is rendered by a package which ships as an ECMAScript
|
|
* module only, hence the service loads it with a dynamic import which Jest
|
|
* cannot run. The tests replace the method by a simple renderer, so that they
|
|
* can read the columns and the rows which the service gives to it.
|
|
*/
|
|
interface AiServiceWithMarkdownTable {
|
|
getMarkdownTable(parameters: {
|
|
columnDefinitions: readonly { name: string }[];
|
|
rows: Record<string, string>[];
|
|
}): Promise<string>;
|
|
}
|
|
|
|
function createAccount({
|
|
id = 'account-a-id',
|
|
isExcluded = false,
|
|
name = 'Account A'
|
|
}: {
|
|
id?: string;
|
|
isExcluded?: boolean;
|
|
name?: string;
|
|
} = {}) {
|
|
return {
|
|
id,
|
|
name,
|
|
activitiesCount: 3,
|
|
allocationInPercentage: 0.25,
|
|
balance: 1000,
|
|
currency: 'CHF',
|
|
platform: { name: 'Platform A' },
|
|
tags: isExcluded ? [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] : [],
|
|
value: 2000
|
|
} as unknown as AccountWithValue;
|
|
}
|
|
|
|
function createAiService(accounts: AccountWithValue[]) {
|
|
const portfolioService = {
|
|
getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts })
|
|
} as unknown as PortfolioService;
|
|
|
|
const aiService = new AiService(null, null, null, portfolioService, null);
|
|
|
|
jest
|
|
.spyOn(
|
|
aiService as unknown as AiServiceWithMarkdownTable,
|
|
'getMarkdownTable'
|
|
)
|
|
.mockImplementation(async ({ columnDefinitions, rows }) => {
|
|
const columnNames = columnDefinitions.map(({ name }) => {
|
|
return name;
|
|
});
|
|
|
|
return [
|
|
columnNames.join(' | '),
|
|
...rows.map((row) => {
|
|
return columnNames
|
|
.map((columnName) => {
|
|
return row[columnName];
|
|
})
|
|
.join(' | ');
|
|
})
|
|
].join('\n');
|
|
});
|
|
|
|
return aiService;
|
|
}
|
|
|
|
describe('AiService', () => {
|
|
// The tools of the model context protocol are the only callers, and an
|
|
// access of that type never grants the scope to read the monetary values,
|
|
// hence no table has a column with such a value
|
|
describe('getAccountsTableColumnNames', () => {
|
|
it('gives no column with a monetary value', () => {
|
|
expect(AiService.getAccountsTableColumnNames()).toEqual([
|
|
'Id',
|
|
'Name',
|
|
'Currency',
|
|
'Platform',
|
|
'Activities Count',
|
|
'Allocation in Percentage',
|
|
'Excluded from Analysis'
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getActivitiesTableColumnNames', () => {
|
|
it('gives no column with a monetary value', () => {
|
|
expect(AiService.getActivitiesTableColumnNames()).toEqual([
|
|
'Date',
|
|
'Type',
|
|
'Name',
|
|
'Symbol',
|
|
'Currency',
|
|
'Unit Price',
|
|
'Account'
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getAccountsTable', () => {
|
|
it('gives no cash balance and no value of an account', async () => {
|
|
const aiService = createAiService([createAccount()]);
|
|
|
|
const result = await aiService.getAccountsTable({ userId: 'user-id' });
|
|
|
|
expect(result).not.toContain('Cash Balance');
|
|
expect(result).not.toContain('1000');
|
|
expect(result).not.toContain('2000');
|
|
});
|
|
|
|
// The accountIds parameter of the tool takes the identifiers, hence the
|
|
// table has to give them
|
|
it('gives the identifier of an account', async () => {
|
|
const aiService = createAiService([createAccount()]);
|
|
|
|
const result = await aiService.getAccountsTable({ userId: 'user-id' });
|
|
|
|
expect(result).toContain('account-a-id');
|
|
});
|
|
|
|
it('marks an account which is excluded from the analysis', async () => {
|
|
const aiService = createAiService([
|
|
createAccount({ isExcluded: true }),
|
|
createAccount({ id: 'account-b-id', name: 'Account B' })
|
|
]);
|
|
|
|
const result = await aiService.getAccountsTable({ userId: 'user-id' });
|
|
|
|
const [rowOfAccountA, rowOfAccountB] = result
|
|
.split('\n')
|
|
.filter((line) => {
|
|
return line.startsWith('account-');
|
|
});
|
|
|
|
expect(rowOfAccountA).toContain('true');
|
|
expect(rowOfAccountB).toContain('false');
|
|
});
|
|
|
|
it('tells that no accounts are found if the result is empty', async () => {
|
|
const aiService = createAiService([]);
|
|
|
|
const result = await aiService.getAccountsTable({ userId: 'user-id' });
|
|
|
|
expect(result).toContain('No accounts found.');
|
|
});
|
|
});
|
|
});
|
|
|