mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
4 changed files with 317 additions and 85 deletions
@ -0,0 +1,150 @@ |
|||||
|
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({ |
||||
|
isExcluded = false, |
||||
|
name = 'Account A' |
||||
|
}: { |
||||
|
isExcluded?: boolean; |
||||
|
name?: string; |
||||
|
} = {}) { |
||||
|
return { |
||||
|
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([ |
||||
|
'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'); |
||||
|
}); |
||||
|
|
||||
|
it('marks an account which is excluded from the analysis', async () => { |
||||
|
const aiService = createAiService([ |
||||
|
createAccount({ isExcluded: true }), |
||||
|
createAccount({ 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.'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue