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

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.');
});
});
});