mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
20 changed files with 663 additions and 100 deletions
@ -0,0 +1,285 @@ |
|||
import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import-validation.error'; |
|||
import { ImportService } from '@ghostfolio/api/app/import/import.service'; |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { scopes } from '@ghostfolio/common/scopes'; |
|||
import type { |
|||
ImpersonationContext, |
|||
UserWithSettings |
|||
} from '@ghostfolio/common/types'; |
|||
|
|||
import { HttpException, Logger } from '@nestjs/common'; |
|||
import { RpcException } from '@nestjs/microservices'; |
|||
import { DataSource, Type as ActivityType } from '@prisma/client'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
|
|||
import { |
|||
GhostfolioMcpController, |
|||
IMPORT_ACTIVITIES_PARAMETERS |
|||
} from './mcp.controller'; |
|||
|
|||
// The controller reads the columns of the tables from the AiService, which
|
|||
// 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() }; |
|||
}); |
|||
|
|||
function createActivity(overrides: Record<string, unknown> = {}) { |
|||
return { |
|||
currency: 'USD', |
|||
date: '2024-01-01', |
|||
fee: 0, |
|||
quantity: 1, |
|||
symbol: 'AAPL', |
|||
type: ActivityType.BUY, |
|||
unitPrice: 100, |
|||
...overrides |
|||
}; |
|||
} |
|||
|
|||
describe('GhostfolioMcpController', () => { |
|||
const impersonation = { userId: 'user-id' } as ImpersonationContext; |
|||
|
|||
let configuration: Record<string, unknown>; |
|||
let configurationService: ConfigurationService; |
|||
let controller: GhostfolioMcpController; |
|||
let importService: ImportService; |
|||
let userService: UserService; |
|||
|
|||
function setupUser(userPermissions: string[]) { |
|||
jest.spyOn(userService, 'user').mockResolvedValue({ |
|||
permissions: userPermissions |
|||
} as UserWithSettings); |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
configuration = { |
|||
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], |
|||
ENABLE_FEATURE_SUBSCRIPTION: false |
|||
}; |
|||
|
|||
configurationService = { |
|||
get: jest.fn().mockImplementation((key: string) => { |
|||
return configuration[key]; |
|||
}) |
|||
} as unknown as ConfigurationService; |
|||
|
|||
importService = { |
|||
import: jest.fn().mockResolvedValue([]) |
|||
} as unknown as ImportService; |
|||
|
|||
userService = { user: jest.fn() } as unknown as UserService; |
|||
|
|||
controller = new GhostfolioMcpController( |
|||
undefined, |
|||
undefined, |
|||
configurationService, |
|||
importService, |
|||
userService |
|||
); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
describe('Import activities', () => { |
|||
it('Requires the scope to create an activity', () => { |
|||
expect( |
|||
Reflect.getMetadata( |
|||
REQUIRES_SCOPE_KEY, |
|||
GhostfolioMcpController.prototype.importActivities |
|||
) |
|||
).toEqual([scopes.activityCreate]); |
|||
}); |
|||
|
|||
it('Refuses a user without the permission to create an activity', async () => { |
|||
setupUser([]); |
|||
|
|||
await expect( |
|||
controller.importActivities(impersonation, { |
|||
activities: [createActivity()] |
|||
}) |
|||
).rejects.toThrow(HttpException); |
|||
|
|||
expect(importService.import).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('Gives the number of the imported and of the skipped activities', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
jest |
|||
.spyOn(importService, 'import') |
|||
.mockResolvedValue([{ id: 'activity-id' } as Activity]); |
|||
|
|||
expect( |
|||
await controller.importActivities(impersonation, { |
|||
activities: [createActivity(), createActivity({ quantity: 2 })] |
|||
}) |
|||
).toEqual({ |
|||
content: [ |
|||
{ |
|||
text: 'Imported activities: 1\nSkipped duplicate activities: 1', |
|||
type: 'text' |
|||
} |
|||
] |
|||
}); |
|||
}); |
|||
|
|||
it('Resolves the mask of the data source of the Ghostfolio data provider', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; |
|||
configuration.ENABLE_FEATURE_SUBSCRIPTION = true; |
|||
|
|||
await controller.importActivities(impersonation, { |
|||
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] |
|||
}); |
|||
|
|||
expect(importService.import).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
activitiesDto: [ |
|||
expect.objectContaining({ dataSource: DataSource.YAHOO }) |
|||
] |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('Keeps the data source if the subscription is not enabled', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; |
|||
configuration.ENABLE_FEATURE_SUBSCRIPTION = false; |
|||
|
|||
await controller.importActivities(impersonation, { |
|||
activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] |
|||
}); |
|||
|
|||
expect(importService.import).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
activitiesDto: [ |
|||
expect.objectContaining({ dataSource: DataSource.GHOSTFOLIO }) |
|||
] |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('Passes on the message of a validation only', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
jest |
|||
.spyOn(importService, 'import') |
|||
.mockRejectedValue( |
|||
new ImportValidationError('activities.0.symbol ("X") is not valid') |
|||
); |
|||
|
|||
await expect( |
|||
controller.importActivities(impersonation, { |
|||
activities: [createActivity()] |
|||
}) |
|||
).rejects.toThrow( |
|||
new RpcException('activities.0.symbol ("X") is not valid') |
|||
); |
|||
}); |
|||
|
|||
it('Hides the message of an unexpected error and writes it to the log', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
const error = new Error( |
|||
'Unique constraint failed on the fields: (dataSource)' |
|||
); |
|||
|
|||
const logError = jest |
|||
.spyOn(Logger.prototype, 'error') |
|||
.mockImplementation(); |
|||
|
|||
jest.spyOn(importService, 'import').mockRejectedValue(error); |
|||
|
|||
await expect( |
|||
controller.importActivities(impersonation, { |
|||
activities: [createActivity()] |
|||
}) |
|||
).rejects.toThrow( |
|||
new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) |
|||
); |
|||
|
|||
expect(logError).toHaveBeenCalledWith(error); |
|||
}); |
|||
|
|||
it('Does not write the message of a validation to the log', async () => { |
|||
setupUser([permissions.createActivity]); |
|||
|
|||
const logError = jest |
|||
.spyOn(Logger.prototype, 'error') |
|||
.mockImplementation(); |
|||
|
|||
jest |
|||
.spyOn(importService, 'import') |
|||
.mockRejectedValue( |
|||
new ImportValidationError('activities.0.accountId ("X") is not valid') |
|||
); |
|||
|
|||
await expect( |
|||
controller.importActivities(impersonation, { |
|||
activities: [createActivity({ accountId: 'X' })] |
|||
}) |
|||
).rejects.toThrow(RpcException); |
|||
|
|||
expect(logError).not.toHaveBeenCalled(); |
|||
}); |
|||
}); |
|||
|
|||
describe('Parameters of the tool to import activities', () => { |
|||
function parse(activities: unknown[]) { |
|||
return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; |
|||
} |
|||
|
|||
it('Refuses a currency in lower case', () => { |
|||
expect(parse([createActivity({ currency: 'usd' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Accepts a currency in upper case', () => { |
|||
expect(parse([createActivity({ currency: 'USD' })])).toBe(true); |
|||
}); |
|||
|
|||
it('Refuses a date at or before the epoch', () => { |
|||
expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Refuses an empty symbol', () => { |
|||
expect(parse([createActivity({ symbol: '' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Refuses an empty identifier of an account', () => { |
|||
expect(parse([createActivity({ accountId: '' })])).toBe(false); |
|||
}); |
|||
|
|||
it('Removes a tag, because the tool takes no tag', () => { |
|||
expect( |
|||
IMPORT_ACTIVITIES_PARAMETERS.parse({ |
|||
activities: [createActivity({ tags: ['tag-id'] })] |
|||
}).activities[0] |
|||
).not.toHaveProperty('tags'); |
|||
}); |
|||
|
|||
it(`Refuses more than ${MCP_MAX_ACTIVITIES} activities`, () => { |
|||
expect( |
|||
parse( |
|||
Array.from({ length: MCP_MAX_ACTIVITIES + 1 }, () => { |
|||
return createActivity(); |
|||
}) |
|||
) |
|||
).toBe(false); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,7 @@ |
|||
export class ImportValidationError extends Error { |
|||
public constructor(message: string) { |
|||
super(message); |
|||
|
|||
this.name = 'ImportValidationError'; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue