mirror of https://github.com/ghostfolio/ghostfolio
12 changed files with 644 additions and 9 deletions
@ -0,0 +1,205 @@ |
|||
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
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 { |
|||
MCP_MAX_ACTIVITIES, |
|||
THROTTLE_AI_ACTIVITIES_LIMIT, |
|||
THROTTLE_DAILY_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 { 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: [], 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 day', () => { |
|||
expect(getMetadataOfMethod<string>(HAS_PERMISSION_KEY)).toBe( |
|||
permissions.importActivitiesWithAi |
|||
); |
|||
|
|||
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_DAILY_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 ${MCP_MAX_ACTIVITIES} rows before a model call`, async () => { |
|||
await expect( |
|||
aiController.getActivitiesFromFile( |
|||
createResponse(), |
|||
createCsvFile(MCP_MAX_ACTIVITIES + 1) |
|||
) |
|||
).rejects.toThrow(HttpException); |
|||
|
|||
expect(aiService.getActivitiesFromFile).not.toHaveBeenCalled(); |
|||
|
|||
expect(Logger.prototype.warn).toHaveBeenCalledWith( |
|||
`Rejected a CSV file with ${MCP_MAX_ACTIVITIES + 1} rows` |
|||
); |
|||
}); |
|||
|
|||
it(`Accepts a CSV file with ${MCP_MAX_ACTIVITIES} rows`, async () => { |
|||
const file = createCsvFile(MCP_MAX_ACTIVITIES); |
|||
|
|||
await aiController.getActivitiesFromFile(createResponse(), file); |
|||
|
|||
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
file: { buffer: file.buffer, mediaType: 'text/csv' } |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('Passes the file with its media type and the user of the request', async () => { |
|||
await expect( |
|||
aiController.getActivitiesFromFile(createResponse(), pdfFile) |
|||
).resolves.toEqual({ activities: [], rowsWithoutMatch: [] }); |
|||
|
|||
expect(aiService.getActivitiesFromFile).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
user, |
|||
file: { buffer: pdfFile.buffer, mediaType: 'application/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); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,83 @@ |
|||
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 a line with a line break in quotes as one line', () => { |
|||
expect( |
|||
getDataRowCountOfCsvFile( |
|||
Buffer.from('Date,Comment\n2026-01-15,"First\nsecond"\n') |
|||
) |
|||
).toBe(1); |
|||
}); |
|||
|
|||
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,52 @@ |
|||
import { isUtf8 } from 'node:buffer'; |
|||
import { parse as parseCsv } from 'papaparse'; |
|||
|
|||
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 is counted as a data row, hence |
|||
* the number is never too low. |
|||
*/ |
|||
export function getDataRowCountOfCsvFile(buffer: Buffer) { |
|||
const { data } = parseCsv(buffer.toString('utf8'), { skipEmptyLines: true }); |
|||
|
|||
return Math.max(data.length - 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,18 @@ |
|||
import { Type as ActivityType } from '@ghostfolio/prisma/enums'; |
|||
|
|||
/** |
|||
* An activity as Ghostfolio AI reads it from a file, with the asset and the |
|||
* account as printed |
|||
*/ |
|||
export interface AiActivityRow { |
|||
accountName?: string | null; |
|||
currency?: string | null; |
|||
date: string; |
|||
fee: number; |
|||
isin?: string | null; |
|||
name?: string | null; |
|||
quantity: number; |
|||
symbol?: string | null; |
|||
type: ActivityType; |
|||
unitPrice: number; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
import type { CreateOrderDto } from '@ghostfolio/common/dtos'; |
|||
|
|||
import { AiActivityRow } from '../ai-activity-row.interface'; |
|||
|
|||
export interface AiActivitiesResponse { |
|||
activities: CreateOrderDto[]; |
|||
rowsWithoutMatch: AiActivityRow[]; |
|||
} |
|||
Loading…
Reference in new issue