mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
143 changed files with 10781 additions and 3485 deletions
@ -0,0 +1,279 @@ |
|||
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error'; |
|||
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { |
|||
AI_ACTIVITIES_TIMEOUT, |
|||
AI_MAX_ACTIVITIES, |
|||
THROTTLE_AI_ACTIVITIES_LIMIT, |
|||
THROTTLE_AI_ACTIVITIES_TTL |
|||
} from '@ghostfolio/common/config'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { HttpException, Logger } from '@nestjs/common'; |
|||
import { GUARDS_METADATA } from '@nestjs/common/constants'; |
|||
import { |
|||
THROTTLER_LIMIT, |
|||
THROTTLER_TRACKER, |
|||
THROTTLER_TTL |
|||
} from '@nestjs/throttler/dist/throttler.constants'; |
|||
import type { Response } from 'express'; |
|||
import { StatusCodes } from 'http-status-codes'; |
|||
import { EventEmitter } from 'node:events'; |
|||
|
|||
import { AiController } from './ai.controller'; |
|||
import { AiService } from './ai.service'; |
|||
|
|||
// The packages are ECMAScript modules, which Jest cannot load
|
|||
jest.mock('@openrouter/ai-sdk-provider', () => { |
|||
return {}; |
|||
}); |
|||
jest.mock('ai', () => { |
|||
return {}; |
|||
}); |
|||
|
|||
describe('AiController', () => { |
|||
const pdfFile = { |
|||
buffer: Buffer.from('%PDF-1.7'), |
|||
originalname: 'statement.pdf' |
|||
}; |
|||
|
|||
const user = { id: 'user-id' }; |
|||
|
|||
let aiController: AiController; |
|||
let aiService: AiService; |
|||
|
|||
function createCsvFile(dataRowCount: number) { |
|||
return { |
|||
buffer: Buffer.from( |
|||
[ |
|||
'Date,Symbol', |
|||
...Array.from({ length: dataRowCount }, () => { |
|||
return '2026-01-15,AAPL'; |
|||
}) |
|||
].join('\n') |
|||
), |
|||
originalname: 'statement.csv' |
|||
}; |
|||
} |
|||
|
|||
function createResponse() { |
|||
return Object.assign(new EventEmitter(), { |
|||
writableFinished: false |
|||
}) as unknown as Response; |
|||
} |
|||
|
|||
function getMetadataOfMethod<T>(metadataKey: string) { |
|||
return Reflect.getMetadata( |
|||
metadataKey, |
|||
AiController.prototype.getActivitiesFromFile |
|||
) as T; |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(); |
|||
|
|||
aiService = { |
|||
getActivitiesFromFile: jest.fn().mockResolvedValue({ |
|||
activities: [], |
|||
assetProfiles: [], |
|||
rowsWithoutMatch: [] |
|||
}) |
|||
} as unknown as AiService; |
|||
|
|||
aiController = new AiController( |
|||
aiService, |
|||
{} as ApiService, |
|||
{ |
|||
user |
|||
} as unknown as RequestWithUser |
|||
); |
|||
}); |
|||
|
|||
describe('getActivitiesFromFile', () => { |
|||
it('Requires the permission and limits the requests of each user per week', () => { |
|||
expect(getMetadataOfMethod<string>(HAS_PERMISSION_KEY)).toBe( |
|||
permissions.enableGhostfolioAi |
|||
); |
|||
|
|||
const guards = getMetadataOfMethod<unknown[]>(GUARDS_METADATA); |
|||
|
|||
expect(guards.slice(1)).toEqual([ |
|||
HasPermissionGuard, |
|||
CustomThrottlerGuard |
|||
]); |
|||
|
|||
expect(getMetadataOfMethod<number>(`${THROTTLER_LIMIT}default`)).toBe( |
|||
THROTTLE_AI_ACTIVITIES_LIMIT |
|||
); |
|||
|
|||
expect(getMetadataOfMethod<number>(`${THROTTLER_TTL}default`)).toBe( |
|||
THROTTLE_AI_ACTIVITIES_TTL |
|||
); |
|||
|
|||
expect( |
|||
getMetadataOfMethod<(request: unknown) => string>( |
|||
`${THROTTLER_TRACKER}default` |
|||
)({ ip: '127.0.0.1', user }) |
|||
).toBe(user.id); |
|||
}); |
|||
|
|||
it('Refuses a request without a file or with a file of another type', async () => { |
|||
for (const file of [ |
|||
undefined, |
|||
{ buffer: Buffer.from('Date,Symbol'), originalname: 'statement.txt' } |
|||
]) { |
|||
await expect( |
|||
aiController.getActivitiesFromFile(createResponse(), file) |
|||
).rejects.toThrow(HttpException); |
|||
} |
|||
|
|||
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it(`Refuses a CSV file with more than ${AI_MAX_ACTIVITIES} rows before a model call`, async () => { |
|||
await expect( |
|||
aiController.getActivitiesFromFile( |
|||
createResponse(), |
|||
createCsvFile(AI_MAX_ACTIVITIES + 1) |
|||
) |
|||
).rejects.toThrow(HttpException); |
|||
|
|||
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); |
|||
|
|||
expect(Logger.prototype.warn).toHaveBeenCalledWith( |
|||
`Rejected a CSV file with ${AI_MAX_ACTIVITIES + 1} rows` |
|||
); |
|||
}); |
|||
|
|||
it(`Accepts a CSV file with ${AI_MAX_ACTIVITIES} rows`, async () => { |
|||
const file = createCsvFile(AI_MAX_ACTIVITIES); |
|||
|
|||
await aiController.getActivitiesFromFile(createResponse(), file); |
|||
|
|||
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
file: { |
|||
buffer: file.buffer, |
|||
mediaType: 'text/csv', |
|||
name: 'statement.csv' |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('Passes the file with its media type and its name and the user of the request', async () => { |
|||
await expect( |
|||
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
|||
).resolves.toEqual({ |
|||
activities: [], |
|||
assetProfiles: [], |
|||
rowsWithoutMatch: [] |
|||
}); |
|||
|
|||
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
user, |
|||
file: { |
|||
buffer: pdfFile.buffer, |
|||
mediaType: 'application/pdf', |
|||
name: 'statement.pdf' |
|||
} |
|||
}) |
|||
); |
|||
|
|||
const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) |
|||
.mock.calls; |
|||
|
|||
expect(abortSignal).toBeInstanceOf(AbortSignal); |
|||
}); |
|||
|
|||
it('Stops the models if the client closes the request before the response', async () => { |
|||
const response = createResponse(); |
|||
|
|||
await aiController.getActivitiesFromFile(response, pdfFile); |
|||
|
|||
const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) |
|||
.mock.calls; |
|||
|
|||
expect(abortSignal.aborted).toBe(false); |
|||
|
|||
response.emit('close'); |
|||
|
|||
expect(abortSignal.aborted).toBe(true); |
|||
}); |
|||
|
|||
it('Does not stop the models when the response is finished', async () => { |
|||
const response = createResponse(); |
|||
|
|||
await aiController.getActivitiesFromFile(response, pdfFile); |
|||
|
|||
const [[{ abortSignal }]] = jest.mocked(aiService.getActivitiesFromFile) |
|||
.mock.calls; |
|||
|
|||
Object.assign(response, { writableFinished: true }); |
|||
response.emit('close'); |
|||
|
|||
expect(abortSignal.aborted).toBe(false); |
|||
}); |
|||
|
|||
it('Gives the status 400 with the message of an error for the caller', async () => { |
|||
jest |
|||
.mocked(aiService.getActivitiesFromFile) |
|||
.mockRejectedValueOnce( |
|||
new CallerFacingError('The file has too many activities') |
|||
); |
|||
|
|||
await expect( |
|||
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
|||
).rejects.toMatchObject({ |
|||
response: { message: ['The file has too many activities'] }, |
|||
status: StatusCodes.BAD_REQUEST |
|||
}); |
|||
}); |
|||
|
|||
it('Gives the status 400 with a message for the caller at the timeout', async () => { |
|||
const timeoutSignal = AbortSignal.abort( |
|||
new DOMException( |
|||
'The operation was aborted due to timeout', |
|||
'TimeoutError' |
|||
) |
|||
); |
|||
|
|||
jest.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutSignal); |
|||
|
|||
jest |
|||
.mocked(aiService.getActivitiesFromFile) |
|||
.mockRejectedValueOnce(timeoutSignal.reason); |
|||
|
|||
await expect( |
|||
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
|||
).rejects.toMatchObject({ |
|||
response: { |
|||
message: [ |
|||
'Ghostfolio AI needs too much time for the file. Split it into smaller files.' |
|||
] |
|||
}, |
|||
status: StatusCodes.BAD_REQUEST |
|||
}); |
|||
|
|||
expect(AbortSignal.timeout).toHaveBeenCalledWith(AI_ACTIVITIES_TIMEOUT); |
|||
|
|||
expect(Logger.prototype.warn).toHaveBeenCalledWith( |
|||
`Stopped reading a file of the type application/pdf at the timeout of ${AI_ACTIVITIES_TIMEOUT} ms` |
|||
); |
|||
}); |
|||
|
|||
it('Passes on every other error', async () => { |
|||
const error = new Error('Model error'); |
|||
|
|||
jest.mocked(aiService.getActivitiesFromFile).mockRejectedValueOnce(error); |
|||
|
|||
await expect( |
|||
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
|||
).rejects.toBe(error); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,99 @@ |
|||
import { |
|||
getDataRowCountOfCsvFile, |
|||
getMediaTypeOfActivityFile |
|||
} from './ai.helper'; |
|||
|
|||
describe('getDataRowCountOfCsvFile', () => { |
|||
it('Gives the number of the lines which are not empty, without the header', () => { |
|||
expect( |
|||
getDataRowCountOfCsvFile( |
|||
Buffer.from('Date,Symbol\n2026-01-15,AAPL\n\n2026-01-16,MSFT\n') |
|||
) |
|||
).toBe(2); |
|||
}); |
|||
|
|||
it('Counts each line of a value with a line break in quotes', () => { |
|||
expect( |
|||
getDataRowCountOfCsvFile( |
|||
Buffer.from('Date,Comment\r\n2026-01-15,"First\r\nsecond"\r\n') |
|||
) |
|||
).toBe(2); |
|||
}); |
|||
|
|||
it('Counts each line after a quote which is not closed', () => { |
|||
expect( |
|||
getDataRowCountOfCsvFile( |
|||
Buffer.from( |
|||
[ |
|||
'Date,Symbol,Quantity', |
|||
'2026-01-15,"AAPL,1', |
|||
...Array.from({ length: 200 }, () => { |
|||
return '2026-01-16,MSFT,1'; |
|||
}) |
|||
].join('\n') |
|||
) |
|||
) |
|||
).toBe(201); |
|||
}); |
|||
|
|||
it('Gives 0 for a file with a header only or without content', () => { |
|||
expect(getDataRowCountOfCsvFile(Buffer.from('Date,Symbol\n'))).toBe(0); |
|||
expect(getDataRowCountOfCsvFile(Buffer.from(''))).toBe(0); |
|||
}); |
|||
}); |
|||
|
|||
describe('getMediaTypeOfActivityFile', () => { |
|||
it('Identifies a JPEG, a PDF and a PNG file by the first bytes', () => { |
|||
expect( |
|||
getMediaTypeOfActivityFile({ |
|||
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xe0]), |
|||
fileName: 'statement' |
|||
}) |
|||
).toBe('image/jpeg'); |
|||
|
|||
expect( |
|||
getMediaTypeOfActivityFile({ |
|||
buffer: Buffer.from('%PDF-1.7'), |
|||
fileName: 'statement.csv' |
|||
}) |
|||
).toBe('application/pdf'); |
|||
|
|||
expect( |
|||
getMediaTypeOfActivityFile({ |
|||
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), |
|||
fileName: 'statement.jpg' |
|||
}) |
|||
).toBe('image/png'); |
|||
}); |
|||
|
|||
it('Identifies a CSV file by the extension and the content in UTF-8', () => { |
|||
for (const fileName of ['statement.csv', 'STATEMENT.CSV']) { |
|||
expect( |
|||
getMediaTypeOfActivityFile({ |
|||
fileName, |
|||
buffer: Buffer.from('Datum;Stück\n15.01.2026;10\n') |
|||
}) |
|||
).toBe('text/csv'); |
|||
} |
|||
}); |
|||
|
|||
it('Refuses a CSV file which is not in UTF-8', () => { |
|||
expect( |
|||
getMediaTypeOfActivityFile({ |
|||
buffer: Buffer.from([0x53, 0x74, 0xfc, 0x63, 0x6b]), |
|||
fileName: 'statement.csv' |
|||
}) |
|||
).toBeUndefined(); |
|||
}); |
|||
|
|||
it('Refuses any other file', () => { |
|||
for (const { buffer, fileName } of [ |
|||
{ buffer: Buffer.from('Date,Symbol'), fileName: 'statement.txt' }, |
|||
{ buffer: Buffer.from('<html></html>'), fileName: 'statement.pdf' }, |
|||
{ buffer: Buffer.from([0xff, 0xd8]), fileName: 'statement.jpg' }, |
|||
{ buffer: Buffer.from(''), fileName: 'statement.png' } |
|||
]) { |
|||
expect(getMediaTypeOfActivityFile({ buffer, fileName })).toBeUndefined(); |
|||
} |
|||
}); |
|||
}); |
|||
@ -0,0 +1,57 @@ |
|||
import { isUtf8 } from 'node:buffer'; |
|||
|
|||
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]; |
|||
const PDF_SIGNATURE = [0x25, 0x50, 0x44, 0x46, 0x2d]; |
|||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; |
|||
|
|||
/** |
|||
* Gives the number of the lines of a CSV file which are not empty, without |
|||
* the first line. A line before the header and each line of a value with a |
|||
* line break are counted as data rows, hence the number is never too low, |
|||
* also for a file with a quote which is not closed. |
|||
*/ |
|||
export function getDataRowCountOfCsvFile(buffer: Buffer) { |
|||
const lineCount = buffer |
|||
.toString('utf8') |
|||
.split(/\r\n|\r|\n/) |
|||
.filter((line) => { |
|||
return line.trim().length > 0; |
|||
}).length; |
|||
|
|||
return Math.max(lineCount - 1, 0); |
|||
} |
|||
|
|||
/** |
|||
* Gives the media type of a file which Ghostfolio AI reads. A JPEG, a PDF or |
|||
* a PNG file is identified by its first bytes, a CSV file by its extension |
|||
* and by its content in UTF-8, as the media type which a client sends for a |
|||
* CSV file varies. |
|||
*/ |
|||
export function getMediaTypeOfActivityFile({ |
|||
buffer, |
|||
fileName |
|||
}: { |
|||
buffer: Buffer; |
|||
fileName: string; |
|||
}) { |
|||
if (startsWith(buffer, JPEG_SIGNATURE)) { |
|||
return 'image/jpeg'; |
|||
} else if (startsWith(buffer, PDF_SIGNATURE)) { |
|||
return 'application/pdf'; |
|||
} else if (startsWith(buffer, PNG_SIGNATURE)) { |
|||
return 'image/png'; |
|||
} else if (fileName.toLowerCase().endsWith('.csv') && isUtf8(buffer)) { |
|||
return 'text/csv'; |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
|
|||
function startsWith(buffer: Buffer, signature: number[]) { |
|||
return ( |
|||
buffer.length >= signature.length && |
|||
signature.every((byte, index) => { |
|||
return buffer[index] === byte; |
|||
}) |
|||
); |
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
import { |
|||
AI_MAX_ACTIVITIES, |
|||
SEARCH_QUERY_MAXIMUM_LENGTH |
|||
} from '@ghostfolio/common/config'; |
|||
|
|||
import { isObject } from 'lodash-es'; |
|||
import { z } from 'zod'; |
|||
|
|||
import { ACTIVITY_ROWS_OUTPUT, ROW_MATCHES_OUTPUT } from './ai.schemas'; |
|||
import { createActivityRow } from './ai.test-utils'; |
|||
import { ActivityRow } from './types/activity-row.type'; |
|||
|
|||
describe('ACTIVITY_ROWS_OUTPUT', () => { |
|||
it('Accepts a row with null for the values which the file does not show', () => { |
|||
expect( |
|||
ACTIVITY_ROWS_OUTPUT.safeParse({ |
|||
broker: null, |
|||
rows: [ |
|||
createActivityRow({ |
|||
accountName: null, |
|||
currency: null, |
|||
isin: null, |
|||
symbol: null |
|||
}) |
|||
] |
|||
}).success |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it('Refuses a row with a value which is left out instead of null', () => { |
|||
const row: Partial<ActivityRow> = createActivityRow(); |
|||
|
|||
delete row.isin; |
|||
|
|||
expect( |
|||
ACTIVITY_ROWS_OUTPUT.safeParse({ broker: null, rows: [row] }).success |
|||
).toBe(false); |
|||
}); |
|||
|
|||
it('Accepts a currency, a date, a number, a printed text and a number of rows which the join checks, so that one value does not refuse all rows', () => { |
|||
expect( |
|||
ACTIVITY_ROWS_OUTPUT.safeParse({ |
|||
broker: null, |
|||
rows: [ |
|||
createActivityRow({ |
|||
currency: 'GBX', |
|||
date: '15.01.2026', |
|||
name: 'A'.repeat(SEARCH_QUERY_MAXIMUM_LENGTH + 1) |
|||
}), |
|||
createActivityRow({ fee: -1, quantity: -1, unitPrice: -1 }), |
|||
...Array.from({ length: AI_MAX_ACTIVITIES }, () => { |
|||
return createActivityRow(); |
|||
}) |
|||
] |
|||
}).success |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it('Trims a printed text', () => { |
|||
const { data } = ACTIVITY_ROWS_OUTPUT.safeParse({ |
|||
broker: ' Swissquote ', |
|||
rows: [createActivityRow({ name: ' Apple Inc. ' })] |
|||
}); |
|||
|
|||
expect(data.broker).toBe('Swissquote'); |
|||
expect(data.rows[0].name).toBe('Apple Inc.'); |
|||
}); |
|||
}); |
|||
|
|||
describe.each([ |
|||
['ACTIVITY_ROWS_OUTPUT', ACTIVITY_ROWS_OUTPUT], |
|||
['ROW_MATCHES_OUTPUT', ROW_MATCHES_OUTPUT] |
|||
])('%s', (_name, schema) => { |
|||
it('Gives a JSON schema which the strict mode of the structured outputs supports', () => { |
|||
// The options of the conversion in the AI SDK
|
|||
const jsonSchema = z.toJSONSchema(schema, { |
|||
io: 'input', |
|||
target: 'draft-7' |
|||
}); |
|||
|
|||
const objectJsonSchemas: { properties: object; required: string[] }[] = []; |
|||
|
|||
JSON.stringify(jsonSchema, (_key, value: unknown) => { |
|||
if (isObject(value) && 'properties' in value) { |
|||
objectJsonSchemas.push( |
|||
value as { properties: object; required: string[] } |
|||
); |
|||
} |
|||
|
|||
return value; |
|||
}); |
|||
|
|||
expect(objectJsonSchemas.length).toBeGreaterThan(1); |
|||
|
|||
for (const { properties, required } of objectJsonSchemas) { |
|||
expect([...required].sort()).toEqual(Object.keys(properties).sort()); |
|||
} |
|||
|
|||
expect(JSON.stringify(jsonSchema)).not.toMatch(/"(max|min)(imum|Length)"/); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,87 @@ |
|||
import { AI_MAX_ACTIVITIES } from '@ghostfolio/common/config'; |
|||
|
|||
import { Type as ActivityType } from '@prisma/client'; |
|||
import { z } from 'zod'; |
|||
|
|||
// The strict mode of the structured outputs requires every property, hence a
|
|||
// value which the file does not show is null instead of being left out. The
|
|||
// values are checked when the rows and the matches are joined to the
|
|||
// activities to import, so that a wrong value or a wrong match skips its rows
|
|||
// only
|
|||
const PRINTED_TEXT = z.string().trim().nullable(); |
|||
|
|||
export const ACTIVITY_ROWS_OUTPUT = z.object({ |
|||
broker: PRINTED_TEXT.describe( |
|||
'The name of the broker or of the bank which issued the file' |
|||
), |
|||
rows: z |
|||
.array( |
|||
z.object({ |
|||
accountName: PRINTED_TEXT.describe( |
|||
'The name or the number of the account, as printed' |
|||
), |
|||
currency: z |
|||
.string() |
|||
.nullable() |
|||
.describe( |
|||
'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' |
|||
), |
|||
date: z |
|||
.string() |
|||
.describe('The date of the activity, as an ISO 8601 date'), |
|||
fee: z.number().describe('The fee of the activity'), |
|||
isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'), |
|||
name: PRINTED_TEXT.describe( |
|||
'The name of the asset, or the description of a fee, an interest or a liability, as printed' |
|||
), |
|||
quantity: z.number().describe('The quantity of the activity'), |
|||
symbol: PRINTED_TEXT.describe( |
|||
'The ticker symbol of the asset, as printed, not a national number such as a valor, a WKN or a CUSIP' |
|||
), |
|||
type: z.enum(ActivityType).describe('The type of the activity'), |
|||
unitPrice: z.number().describe('The unit price of the activity') |
|||
}) |
|||
) |
|||
.describe(`The activities of the file, at most ${AI_MAX_ACTIVITIES}`) |
|||
}); |
|||
|
|||
export const ROW_MATCHES_OUTPUT = z.object({ |
|||
accounts: z |
|||
.array( |
|||
z.object({ |
|||
accountId: z |
|||
.string() |
|||
.nullable() |
|||
.describe( |
|||
'The id of the account of the user which matches, as given in the accounts, or null if no account matches' |
|||
), |
|||
id: z.number().describe('The id of the account hint, as given') |
|||
}) |
|||
) |
|||
.describe('One entry for each account hint of the file'), |
|||
assets: z |
|||
.array( |
|||
z.object({ |
|||
currency: z |
|||
.string() |
|||
.nullable() |
|||
.describe( |
|||
'The currency of the asset profile, as given in the asset profiles of the asset' |
|||
), |
|||
dataSource: z |
|||
.string() |
|||
.nullable() |
|||
.describe( |
|||
'The data source of the asset profile, as given in the asset profiles of the asset' |
|||
), |
|||
id: z.number().describe('The id of the asset, as given'), |
|||
symbol: z |
|||
.string() |
|||
.nullable() |
|||
.describe( |
|||
'The symbol of the asset profile, as given in the asset profiles of the asset, or null if no asset profile matches' |
|||
) |
|||
}) |
|||
) |
|||
.describe('One entry for each asset of the file') |
|||
}); |
|||
File diff suppressed because it is too large
@ -0,0 +1,21 @@ |
|||
import { Type as ActivityType } from '@prisma/client'; |
|||
|
|||
import { ActivityRow } from './types/activity-row.type'; |
|||
|
|||
export function createActivityRow( |
|||
overrides: Partial<ActivityRow> = {} |
|||
): ActivityRow { |
|||
return { |
|||
accountName: 'Brokerage', |
|||
currency: 'USD', |
|||
date: '2026-01-15', |
|||
fee: 1, |
|||
isin: 'US0378331005', |
|||
name: 'Apple Inc.', |
|||
quantity: 10, |
|||
symbol: 'AAPL', |
|||
type: ActivityType.BUY, |
|||
unitPrice: 230, |
|||
...overrides |
|||
}; |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
import { ActivityRow } from './activity-row.type'; |
|||
|
|||
export type AccountHint = Pick<ActivityRow, 'accountName'> & { |
|||
broker: string | null; |
|||
id: number; |
|||
}; |
|||
@ -0,0 +1,3 @@ |
|||
import { Account } from '@prisma/client'; |
|||
|
|||
export type AccountToMatch = Pick<Account, 'currency' | 'id' | 'name'>; |
|||
@ -0,0 +1,5 @@ |
|||
import { z } from 'zod'; |
|||
|
|||
import { ACTIVITY_ROWS_OUTPUT } from '../ai.schemas'; |
|||
|
|||
export type ActivityRow = z.infer<typeof ACTIVITY_ROWS_OUTPUT>['rows'][number]; |
|||
@ -0,0 +1,6 @@ |
|||
import { ActivityRow } from './activity-row.type'; |
|||
|
|||
export type AssetToMatch = Pick< |
|||
ActivityRow, |
|||
'currency' | 'isin' | 'name' | 'symbol' |
|||
> & { id: number }; |
|||
@ -0,0 +1,178 @@ |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { DataSource } from '@prisma/client'; |
|||
|
|||
import { BenchmarksService } from './benchmarks.service'; |
|||
|
|||
describe('BenchmarksService', () => { |
|||
let benchmarksService: BenchmarksService; |
|||
let get: jest.Mock; |
|||
let getExchangeRatesByCurrency: jest.Mock; |
|||
let getPerformance: jest.Mock; |
|||
let getRange: jest.Mock; |
|||
let marketDataItems: jest.Mock; |
|||
|
|||
beforeEach(() => { |
|||
get = jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 }); |
|||
getExchangeRatesByCurrency = jest.fn().mockResolvedValue({}); |
|||
getPerformance = jest.fn().mockResolvedValue({ |
|||
chart: [ |
|||
{ date: '2009-01-01' }, |
|||
{ date: '2009-06-01' }, |
|||
{ date: '2010-07-18' }, |
|||
{ date: '2010-07-19' } |
|||
] |
|||
}); |
|||
getRange = jest.fn().mockResolvedValue([]); |
|||
marketDataItems = jest.fn(); |
|||
|
|||
benchmarksService = new BenchmarksService( |
|||
new BenchmarkService(null, null, null, null, null, null), |
|||
{ getExchangeRatesByCurrency } as unknown as ExchangeRateDataService, |
|||
{ getRange, marketDataItems } as unknown as MarketDataService, |
|||
{ getPerformance } as unknown as PortfolioService, |
|||
{ get } as unknown as SymbolService |
|||
); |
|||
}); |
|||
|
|||
describe('getMarketDataForUser', () => { |
|||
const getMarketDataForUser = ({ |
|||
endDate = parseDate('2010-07-19') |
|||
}: { endDate?: Date } = {}) => { |
|||
return benchmarksService.getMarketDataForUser({ |
|||
endDate, |
|||
dataSource: DataSource.COINGECKO, |
|||
dateRange: 'max', |
|||
startDate: parseDate('2009-01-02'), |
|||
symbol: 'bitcoin', |
|||
userId: 'user-id', |
|||
userSettings: { baseCurrency: 'CHF' } |
|||
}); |
|||
}; |
|||
|
|||
it('calculates the change since the baseline date', async () => { |
|||
marketDataItems.mockResolvedValue([ |
|||
{ date: new Date('2009-01-01'), marketPrice: 50 }, |
|||
{ date: new Date('2009-06-01'), marketPrice: 25 }, |
|||
{ date: new Date('2010-07-18'), marketPrice: 75 }, |
|||
{ date: new Date('2010-07-19'), marketPrice: 100 } |
|||
]); |
|||
|
|||
await expect(getMarketDataForUser()).resolves.toEqual({ |
|||
marketData: [ |
|||
{ date: '2009-01-01', value: 0 }, |
|||
{ date: '2009-06-01', value: -50 }, |
|||
{ date: '2010-07-18', value: 50 }, |
|||
{ date: '2010-07-19', value: 100 } |
|||
] |
|||
}); |
|||
|
|||
expect(getRange).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('returns no values before the market data of the benchmark starts', async () => { |
|||
marketDataItems.mockResolvedValue([ |
|||
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
|||
{ date: new Date('2010-07-19'), marketPrice: 100 } |
|||
]); |
|||
|
|||
await expect(getMarketDataForUser()).resolves.toEqual({ |
|||
marketData: [ |
|||
{ date: '2010-07-18', value: 0 }, |
|||
{ date: '2010-07-19', value: 100 } |
|||
] |
|||
}); |
|||
|
|||
expect(getRange).toHaveBeenCalledWith({ |
|||
assetProfileIdentifiers: [ |
|||
{ dataSource: DataSource.COINGECKO, symbol: 'bitcoin' } |
|||
], |
|||
dateQuery: { lt: new Date('2009-01-01') }, |
|||
take: 1 |
|||
}); |
|||
|
|||
expect(marketDataItems).toHaveBeenCalledWith( |
|||
expect.objectContaining({ orderBy: { date: 'asc' } }) |
|||
); |
|||
}); |
|||
|
|||
it('uses the exchange rate at the start of the market data', async () => { |
|||
getExchangeRatesByCurrency.mockResolvedValue({ |
|||
USDCHF: { |
|||
'2009-01-01': 2, |
|||
'2009-06-01': 2, |
|||
'2010-07-18': 1, |
|||
'2010-07-19': 1.5 |
|||
} |
|||
}); |
|||
|
|||
marketDataItems.mockResolvedValue([ |
|||
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
|||
{ date: new Date('2010-07-19'), marketPrice: 100 } |
|||
]); |
|||
|
|||
await expect(getMarketDataForUser()).resolves.toEqual({ |
|||
marketData: [ |
|||
{ date: '2010-07-18', value: 0 }, |
|||
{ date: '2010-07-19', value: 200 } |
|||
] |
|||
}); |
|||
}); |
|||
|
|||
it('calculates the value at the end date since the start of the market data', async () => { |
|||
getExchangeRatesByCurrency.mockResolvedValue({ |
|||
USDCHF: { |
|||
'2009-01-01': 2, |
|||
'2010-07-18': 1, |
|||
'2010-07-19': 1, |
|||
'2010-07-20': 1.5 |
|||
} |
|||
}); |
|||
|
|||
marketDataItems.mockResolvedValue([ |
|||
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
|||
{ date: new Date('2010-07-19'), marketPrice: 100 } |
|||
]); |
|||
|
|||
await expect( |
|||
getMarketDataForUser({ endDate: parseDate('2010-07-20') }) |
|||
).resolves.toEqual({ |
|||
marketData: [ |
|||
{ date: '2010-07-18', value: 0 }, |
|||
{ date: '2010-07-19', value: 100 }, |
|||
{ date: '2010-07-20', value: 500 } |
|||
] |
|||
}); |
|||
}); |
|||
|
|||
it('returns no market data if the market data at the baseline date is missing', async () => { |
|||
getRange.mockResolvedValue([ |
|||
{ date: new Date('2008-12-31'), marketPrice: 40 } |
|||
]); |
|||
|
|||
marketDataItems.mockResolvedValue([ |
|||
{ date: new Date('2010-07-18'), marketPrice: 50 }, |
|||
{ date: new Date('2010-07-19'), marketPrice: 100 } |
|||
]); |
|||
|
|||
await expect(getMarketDataForUser()).resolves.toEqual({ |
|||
marketData: [] |
|||
}); |
|||
|
|||
expect(getExchangeRatesByCurrency).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('returns no market data if the benchmark has none', async () => { |
|||
marketDataItems.mockResolvedValue([]); |
|||
|
|||
await expect(getMarketDataForUser()).resolves.toEqual({ |
|||
marketData: [] |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,80 @@ |
|||
/** |
|||
* @jest-environment <rootDir>/jest-environment-tz.js |
|||
* @jest-environment-options {"timeZone": "America/New_York"} |
|||
*/ |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { DataSource } from '@prisma/client'; |
|||
|
|||
import { BenchmarksService } from './benchmarks.service'; |
|||
|
|||
describe('BenchmarksService in a time zone behind UTC', () => { |
|||
let benchmarksService: BenchmarksService; |
|||
|
|||
beforeEach(() => { |
|||
benchmarksService = new BenchmarksService( |
|||
new BenchmarkService(null, null, null, null, null, null), |
|||
{ |
|||
getExchangeRatesByCurrency: jest.fn().mockResolvedValue({}) |
|||
} as unknown as ExchangeRateDataService, |
|||
{ |
|||
getRange: jest.fn().mockResolvedValue([]), |
|||
// The market data is stored at midnight (UTC)
|
|||
marketDataItems: jest.fn().mockResolvedValue([ |
|||
{ date: new Date('2010-07-18T00:00:00.000Z'), marketPrice: 50 }, |
|||
{ date: new Date('2010-07-19T00:00:00.000Z'), marketPrice: 100 } |
|||
]) |
|||
} as unknown as MarketDataService, |
|||
{ |
|||
getPerformance: jest.fn().mockResolvedValue({ |
|||
chart: [{ date: '2010-07-18' }, { date: '2010-07-19' }] |
|||
}) |
|||
} as unknown as PortfolioService, |
|||
{ |
|||
get: jest.fn().mockResolvedValue({ currency: 'USD', marketPrice: 200 }) |
|||
} as unknown as SymbolService |
|||
); |
|||
}); |
|||
|
|||
describe('getMarketDataForUser', () => { |
|||
const getMarketDataForUser = ({ |
|||
endDate = parseDate('2010-07-19') |
|||
}: { endDate?: Date } = {}) => { |
|||
return benchmarksService.getMarketDataForUser({ |
|||
endDate, |
|||
dataSource: DataSource.COINGECKO, |
|||
dateRange: 'max', |
|||
startDate: parseDate('2010-07-18'), |
|||
symbol: 'bitcoin', |
|||
userId: 'user-id', |
|||
userSettings: { baseCurrency: 'USD' } |
|||
}); |
|||
}; |
|||
|
|||
it('returns the dates of the market data without a shift', async () => { |
|||
await expect(getMarketDataForUser()).resolves.toEqual({ |
|||
marketData: [ |
|||
{ date: '2010-07-18', value: 0 }, |
|||
{ date: '2010-07-19', value: 100 } |
|||
] |
|||
}); |
|||
}); |
|||
|
|||
it('returns the end date after the market data without a shift', async () => { |
|||
await expect( |
|||
getMarketDataForUser({ endDate: parseDate('2010-07-20') }) |
|||
).resolves.toEqual({ |
|||
marketData: [ |
|||
{ date: '2010-07-18', value: 0 }, |
|||
{ date: '2010-07-19', value: 100 }, |
|||
{ date: '2010-07-20', value: 300 } |
|||
] |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,45 @@ |
|||
import { isBrowserUserAgent } from './user-agent.helper'; |
|||
|
|||
describe('isBrowserUserAgent', () => { |
|||
it('should tell that the user agent of a browser is one', () => { |
|||
// Chrome on macOS
|
|||
expect( |
|||
isBrowserUserAgent( |
|||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' |
|||
) |
|||
).toBe(true); |
|||
|
|||
// Chrome on Android
|
|||
expect( |
|||
isBrowserUserAgent( |
|||
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36' |
|||
) |
|||
).toBe(true); |
|||
|
|||
// Firefox on Windows
|
|||
expect( |
|||
isBrowserUserAgent( |
|||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0' |
|||
) |
|||
).toBe(true); |
|||
|
|||
// Safari on iPhone
|
|||
expect( |
|||
isBrowserUserAgent( |
|||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1' |
|||
) |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it('should tell that the user agent of another client is not one', () => { |
|||
expect(isBrowserUserAgent('curl/8.7.1')).toBe(false); |
|||
expect(isBrowserUserAgent('node')).toBe(false); |
|||
expect(isBrowserUserAgent('python-requests/2.32.3')).toBe(false); |
|||
expect(isBrowserUserAgent('Dart/3.5 (dart:io)')).toBe(false); |
|||
}); |
|||
|
|||
it('should tell that a missing user agent is not one', () => { |
|||
expect(isBrowserUserAgent()).toBe(false); |
|||
expect(isBrowserUserAgent('')).toBe(false); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,14 @@ |
|||
const PREFIX_OF_BROWSER_USER_AGENT = 'Mozilla/'; |
|||
|
|||
/** |
|||
* Tells if the user agent is the one of a browser. Every major browser starts |
|||
* its user agent with the same prefix for compatibility, while other clients, |
|||
* such as scripts and native apps, give the name of their library instead. |
|||
* The value is for analytics only, because a client can send any user agent. |
|||
*/ |
|||
export function isBrowserUserAgent(userAgent?: string) { |
|||
return ( |
|||
typeof userAgent === 'string' && |
|||
userAgent.startsWith(PREFIX_OF_BROWSER_USER_AGENT) |
|||
); |
|||
} |
|||
@ -1,6 +1,16 @@ |
|||
import type { Product } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export type ResolvedProduct = Omit<Product, 'categories' | 'platforms'> & { |
|||
export interface ResolvedRegion { |
|||
emojiFlag?: string; |
|||
name: string; |
|||
} |
|||
|
|||
export type ResolvedProduct = Omit< |
|||
Product, |
|||
'categories' | 'origin' | 'platforms' | 'regions' |
|||
> & { |
|||
categories?: string[]; |
|||
origin?: ResolvedRegion; |
|||
platforms?: string[]; |
|||
regions?: ResolvedRegion[]; |
|||
}; |
|||
|
|||
File diff suppressed because it is too large
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue