diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts new file mode 100644 index 0000000000..3cc1d35d46 --- /dev/null +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.spec.ts @@ -0,0 +1,227 @@ +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 } 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 = {}) { + 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 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(() => { + configurationService = { + get: jest.fn().mockReturnValue([]) + } 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 + ); + }); + + 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]); + + jest + .spyOn(configurationService, 'get') + .mockReturnValue([DataSource.YAHOO]); + + await controller.importActivities(impersonation, { + activities: [createActivity({ dataSource: DataSource.GHOSTFOLIO })] + }); + + expect(importService.import).toHaveBeenCalledWith( + expect.objectContaining({ + activitiesDto: [ + expect.objectContaining({ dataSource: DataSource.YAHOO }) + ] + }) + ); + }); + + 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', async () => { + setupUser([permissions.createActivity]); + + jest + .spyOn(importService, 'import') + .mockRejectedValue( + new Error('Unique constraint failed on the fields: (dataSource)') + ); + + await expect( + controller.importActivities(impersonation, { + activities: [createActivity()] + }) + ).rejects.toThrow( + new RpcException(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) + ); + }); + }); + + 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); + }); + }); +}); diff --git a/apps/api/src/app/endpoints/mcp/mcp.controller.ts b/apps/api/src/app/endpoints/mcp/mcp.controller.ts index 1358837d16..df3cbd7141 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.controller.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.controller.ts @@ -1,10 +1,12 @@ import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; +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 { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScopeOfAccess } from '@ghostfolio/api/decorators/requires-scope-of-access.decorator'; import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { McpToolExceptionFilter } from '@ghostfolio/api/filters/mcp-tool-exception.filter'; +import { getUnmaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; @@ -14,8 +16,10 @@ import { MCP_MAX_ACCOUNTS, MCP_MAX_ACTIVITIES } from '@ghostfolio/common/config'; -import { SubscriptionType } from '@ghostfolio/common/enums'; -import { isCurrency } from '@ghostfolio/common/helper'; +import { + isValidCurrencyCode, + isValidDateAfter1970 +} from '@ghostfolio/common/helper'; import { Activity } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { scopes } from '@ghostfolio/common/scopes'; @@ -25,7 +29,6 @@ import { HttpException, UseFilters } from '@nestjs/common'; import { Payload, RpcException } from '@nestjs/microservices'; import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { McpController, Tool } from '@rekog/mcp-nest'; -import { isValid, parseISO } from 'date-fns'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { z } from 'zod'; @@ -98,20 +101,21 @@ const GET_ACTIVITIES_PARAMETERS = z.object({ .describe(`The number of activities to get, at most ${MCP_MAX_ACTIVITIES}`) }); -const IMPORT_ACTIVITIES_PARAMETERS = z.object({ +export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ activities: z .array( z.object({ accountId: z .string() + .min(1) .optional() .describe('The identifier of the account of the activity'), comment: z.string().optional().describe('The comment of the activity'), currency: z .string() - .refine(isCurrency) + .refine(isValidCurrencyCode) .describe( - 'The currency of the fee and of the unit price, as an ISO 4217 code' + 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' ), dataSource: z .enum(DataSource) @@ -119,19 +123,13 @@ const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .describe('The data source of the asset profile'), date: z .string() - .refine((date) => { - return isValid(parseISO(date)); - }) + .refine(isValidDateAfter1970) .describe( 'The date of the activity, as an ISO 8601 date or date and time' ), fee: z.number().min(0).describe('The fee of the activity'), quantity: z.number().min(0).describe('The quantity of the activity'), - symbol: z.string().describe('The symbol of the asset profile'), - tags: z - .array(z.string()) - .optional() - .describe('The identifiers of the tags of the activity'), + symbol: z.string().min(1).describe('The symbol of the asset profile'), type: z.enum(ActivityType).describe('The type of the activity'), unitPrice: z.number().min(0).describe('The unit price of the activity') }) @@ -266,6 +264,12 @@ export class GhostfolioMcpController { return { content: [{ text: prompt, type: 'text' as const }] }; } + /** + * The transport gives the tool to every client, because it filters the list + * of the tools by the scopes of request.user, which a request of an access + * never has. The guard refuses the call itself, hence the description names + * the permission which the access needs. + */ @RequiresScopeOfAccess(scopes.activityCreate) @Tool({ annotations: { @@ -274,7 +278,7 @@ export class GhostfolioMcpController { readOnlyHint: false, title: 'Import activities' }, - description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the activities which are skipped, because an equal activity exists already. At most ${MCP_MAX_ACTIVITIES} activities are imported per call.`, + description: `Imports activities into the portfolio and gives the number of the imported activities and the number of the skipped activities. An activity is skipped if an equal activity is in the portfolio already, hence send each activity one time only: two equal activities of the same call are both imported. The access needs the permission "Restricted view and manage". At most ${MCP_MAX_ACTIVITIES} activities are imported per call. An error does not remove the activities of the same call which are imported already, hence get the activities after an error before you import them again.`, name: 'import-activities', parameters: IMPORT_ACTIVITIES_PARAMETERS }) @@ -291,34 +295,37 @@ export class GhostfolioMcpController { ); } - let maxActivitiesToImport = this.configurationService.get( - 'MAX_ACTIVITIES_TO_IMPORT' + const ghostfolioDataSources = this.configurationService.get( + 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' ); - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - user.subscription?.type === SubscriptionType.Premium - ) { - maxActivitiesToImport = Number.MAX_SAFE_INTEGER; - } + const activitiesDto = activities.map((activity) => { + return { + ...activity, + dataSource: getUnmaskedGhostfolioDataSource({ + ghostfolioDataSources, + dataSource: activity.dataSource + }) + }; + }); let importedActivities: Activity[]; try { importedActivities = await this.importService.import({ - maxActivitiesToImport, + activitiesDto, user, accountsWithBalancesDto: [], - activitiesDto: activities, assetProfilesWithMarketDataDto: [], platformsDto: [], tagsDto: [] }); } catch (error) { - // The message of the import names the activity which is not valid and - // is written for the caller, hence it is passed on + // The message of a validation names the activity which is not valid and + // is written for the caller, hence it is passed on. Every other error + // can carry internals of the application. throw new RpcException( - error instanceof Error + error instanceof ImportValidationError ? error.message : getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR) ); diff --git a/apps/api/src/app/import/errors/import-validation.error.ts b/apps/api/src/app/import/errors/import-validation.error.ts new file mode 100644 index 0000000000..a228c99337 --- /dev/null +++ b/apps/api/src/app/import/errors/import-validation.error.ts @@ -0,0 +1,7 @@ +export class ImportValidationError extends Error { + public constructor(message: string) { + super(message); + + this.name = 'ImportValidationError'; + } +} diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index cd378d07df..79fde28003 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -2,8 +2,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { SubscriptionType } from '@ghostfolio/common/enums'; import { ImportResponse } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -34,7 +32,6 @@ export class ImportController { private readonly logger = new Logger(ImportController.name); public constructor( - private readonly configurationService: ConfigurationService, private readonly importService: ImportService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -59,21 +56,9 @@ export class ImportController { ); } - let maxActivitiesToImport = this.configurationService.get( - 'MAX_ACTIVITIES_TO_IMPORT' - ); - - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription?.type === SubscriptionType.Premium - ) { - maxActivitiesToImport = Number.MAX_SAFE_INTEGER; - } - try { const activities = await this.importService.import({ isDryRun, - maxActivitiesToImport, accountsWithBalancesDto: importData.accounts ?? [], activitiesDto: importData.activities, assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], @@ -104,16 +89,9 @@ export class ImportController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - let maxActivitiesToImport = this.configurationService.get( - 'MAX_ACTIVITIES_TO_IMPORT' - ); - - if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription?.type === SubscriptionType.Premium - ) { - maxActivitiesToImport = Number.MAX_SAFE_INTEGER; - } + const maxActivitiesToImport = this.importService.getMaxActivitiesToImport({ + user: this.request.user + }); const activities = await this.importService.getDividends({ dataSource, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index ca6966a9fb..882557e01a 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -24,6 +24,7 @@ import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { getAssetProfileIdentifier, isValidCustomAssetProfileSymbol, @@ -42,13 +43,14 @@ import { } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { Account, DataSource, Prisma } from '@prisma/client'; +import { Account, DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; import { isISIN } from 'class-validator'; import { isSameSecond, parseISO } from 'date-fns'; import { omit, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; +import { ImportValidationError } from './errors/import-validation.error'; import { ImportDataDto } from './import-data.dto'; import { AssetProfileToCreate } from './interfaces/asset-profile-to-create.interface'; @@ -186,12 +188,22 @@ export class ImportService { } } + public getMaxActivitiesToImport({ user }: { user: UserWithSettings }) { + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user.subscription?.type === SubscriptionType.Premium + ) { + return Number.MAX_SAFE_INTEGER; + } + + return this.configurationService.get('MAX_ACTIVITIES_TO_IMPORT'); + } + public async import({ accountsWithBalancesDto, activitiesDto, assetProfilesWithMarketDataDto, isDryRun = false, - maxActivitiesToImport, platformsDto, tagsDto, user @@ -200,7 +212,6 @@ export class ImportService { activitiesDto: ImportDataDto['activities']; assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; isDryRun?: boolean; - maxActivitiesToImport: number; platformsDto: ImportDataDto['platforms']; tagsDto: ImportDataDto['tags']; user: UserWithSettings; @@ -210,6 +221,7 @@ export class ImportService { const ghostfolioDataSources = this.configurationService.get( 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' ); + const maxActivitiesToImport = this.getMaxActivitiesToImport({ user }); const platformIdMapping: { [oldPlatformId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; @@ -224,7 +236,7 @@ export class ImportService { dataSource === DataSource.MANUAL && !isValidCustomAssetProfileSymbol(symbol) ) { - throw new Error( + throw new ImportValidationError( `assetProfiles.${index}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` ); } else if ( @@ -236,7 +248,7 @@ export class ImportService { ghostfolioDataSources }); - throw new Error( + throw new ImportValidationError( `assetProfiles.${index}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")` ); } @@ -261,7 +273,7 @@ export class ImportService { activity.dataSource === DataSource.MANUAL && !isValidCustomAssetProfileSymbol(activity.symbol) ) { - throw new Error( + throw new ImportValidationError( `activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` ); } else if ( @@ -273,7 +285,7 @@ export class ImportService { dataSource: activity.dataSource }); - throw new Error( + throw new ImportValidationError( `activities.${index}.symbol ("${activity.symbol}") is not valid for the data source ("${maskedDataSource}")` ); } @@ -358,7 +370,7 @@ export class ImportService { } } else { if (!canCreatePlatform) { - throw new Error( + throw new ImportValidationError( `Insufficient permissions to create platform ("${platform.name}")` ); } @@ -388,7 +400,7 @@ export class ImportService { if (!existingTagOfUser) { if (!canCreateOwnTag) { - throw new Error( + throw new ImportValidationError( `Insufficient permissions to create custom tag ("${tag.name}")` ); } @@ -729,12 +741,22 @@ export class ImportService { } } - const assetProfiles = await this.dataProviderService.validateActivities({ - activitiesDto, - assetProfilesWithMarketDataDto, - maxActivitiesToImport, - subscription: user.subscription - }); + let assetProfiles: { + [assetProfileIdentifier: string]: Partial; + }; + + try { + assetProfiles = await this.dataProviderService.validateActivities({ + activitiesDto, + assetProfilesWithMarketDataDto, + maxActivitiesToImport, + subscription: user.subscription + }); + } catch (error) { + // The messages of the validation name the activity which is not valid + // and are written for the caller + throw new ImportValidationError(error.message); + } const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ activitiesDto, diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index f3ed752295..fff8eaac1d 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -65,3 +65,15 @@ export function getMaskedGhostfolioDataSource({ ? DataSource.GHOSTFOLIO : dataSource; } + +export function getUnmaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources +}: { + dataSource?: DataSource; + ghostfolioDataSources: string[]; +}) { + return dataSource === DataSource.GHOSTFOLIO && ghostfolioDataSources?.[0] + ? (ghostfolioDataSources[0] as DataSource) + : dataSource; +} diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts index 4ab0794ec1..7b537cd523 100644 --- a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts @@ -1,4 +1,7 @@ -import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; +import { + decodeDataSource, + getUnmaskedGhostfolioDataSource +} from '@ghostfolio/api/helper/data-source.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { @@ -27,23 +30,19 @@ export class TransformDataSourceInRequestInterceptor< if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (request.body?.activities) { - const dataSourceGhostfolioDataProvider = this.configurationService.get( + const ghostfolioDataSources = this.configurationService.get( 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' - )?.[0]; + ); request.body.activities = request.body.activities.map((activity) => { if (DataSource[activity.dataSource]) { - if ( - activity.dataSource === 'GHOSTFOLIO' && - dataSourceGhostfolioDataProvider - ) { - return { - ...activity, - dataSource: dataSourceGhostfolioDataProvider - }; - } else { - return activity; - } + return { + ...activity, + dataSource: getUnmaskedGhostfolioDataSource({ + ghostfolioDataSources, + dataSource: activity.dataSource + }) + }; } else { return { ...activity, diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 0a17d2058d..78c42a779a 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -13,7 +13,9 @@ import { isCurrency, isCurrencySymbol, isSplitRatio, + isValidCurrencyCode, isValidCustomAssetProfileSymbol, + isValidDateAfter1970, isValidGranteeOfAccess, resolveUserSettings } from '@ghostfolio/common/helper'; @@ -371,6 +373,28 @@ describe('Helper', () => { }); }); + describe('Is valid currency code', () => { + it('Currency code in lower case', () => { + expect(isValidCurrencyCode('usd')).toEqual(false); + }); + + it('Currency code in upper case', () => { + expect(isValidCurrencyCode('USD')).toEqual(true); + }); + + it('Derived currency', () => { + expect(isValidCurrencyCode('GBp')).toEqual(true); + }); + + it('Empty currency code', () => { + expect(isValidCurrencyCode('')).toEqual(false); + }); + + it('Unknown currency code', () => { + expect(isValidCurrencyCode('XYZ')).toEqual(false); + }); + }); + describe('Is valid custom asset profile symbol', () => { it('Empty symbol', () => { expect(isValidCustomAssetProfileSymbol('')).toEqual(false); @@ -399,6 +423,36 @@ describe('Helper', () => { }); }); + describe('Is valid date after 1970', () => { + it('Date', () => { + expect(isValidDateAfter1970('2024-01-01')).toEqual(true); + }); + + it('Date and time', () => { + expect(isValidDateAfter1970('2024-01-01T12:00:00.000Z')).toEqual(true); + }); + + it('Date before 1970', () => { + expect(isValidDateAfter1970('0000-01-01')).toEqual(false); + }); + + it('Date of 1970', () => { + expect(isValidDateAfter1970('1970-01-01T00:00:00.000Z')).toEqual(false); + }); + + it('Date with an expanded year', () => { + expect(isValidDateAfter1970('+010000-01-01')).toEqual(true); + }); + + it('Empty date', () => { + expect(isValidDateAfter1970('')).toEqual(false); + }); + + it('Free-text date', () => { + expect(isValidDateAfter1970('yesterday')).toEqual(false); + }); + }); + describe('Is valid grantee of access', () => { const granteeUserId = 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d'; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index b6b097be94..a813e55c62 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -15,7 +15,9 @@ import { getDate, getMonth, getYear, + isAfter, isMatch, + isValid, parse, parseISO, subDays @@ -668,10 +670,27 @@ export function isUserSettingOfAuthenticatedUser(aKey: string) { ); } +export function isValidCurrencyCode(aCurrency: string) { + if (!aCurrency) { + return false; + } + + return ( + isDerivedCurrency(aCurrency) || + (aCurrency === aCurrency.toUpperCase() && isISO4217CurrencyCode(aCurrency)) + ); +} + export function isValidCustomAssetProfileSymbol(aSymbol: string) { return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); } +export function isValidDateAfter1970(aDate: string) { + const date = parseISO(aDate); + + return isValid(date) && isAfter(date, new Date(0)); +} + /** * A private access is granted to a user, while a public access and an access * of a client of the model context protocol are credentials on their own and diff --git a/libs/common/src/lib/validator-constraints/is-after-1970.ts b/libs/common/src/lib/validator-constraints/is-after-1970.ts index 9dc0b04c04..61214e848b 100644 --- a/libs/common/src/lib/validator-constraints/is-after-1970.ts +++ b/libs/common/src/lib/validator-constraints/is-after-1970.ts @@ -1,8 +1,10 @@ +import { isValidDateAfter1970 } from '@ghostfolio/common/helper'; + import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; -import { format, isAfter } from 'date-fns'; +import { format } from 'date-fns'; @ValidatorConstraint({ name: 'isAfter1970' }) export class IsAfter1970Constraint implements ValidatorConstraintInterface { @@ -10,7 +12,7 @@ export class IsAfter1970Constraint implements ValidatorConstraintInterface { return `date must be after ${format(new Date(0), 'yyyy')}`; } - public validate(aDate: Date) { - return isAfter(aDate, new Date(0)); + public validate(aDate: string) { + return isValidDateAfter1970(aDate); } } diff --git a/libs/common/src/lib/validators/is-currency-code.ts b/libs/common/src/lib/validators/is-currency-code.ts index 52d99816b3..8124b426c9 100644 --- a/libs/common/src/lib/validators/is-currency-code.ts +++ b/libs/common/src/lib/validators/is-currency-code.ts @@ -1,4 +1,4 @@ -import { isDerivedCurrency } from '@ghostfolio/common/helper'; +import { isValidCurrencyCode } from '@ghostfolio/common/helper'; import { registerDecorator, @@ -6,7 +6,6 @@ import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; -import { isISO4217CurrencyCode } from 'class-validator'; export function IsCurrencyCode(validationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { @@ -27,14 +26,6 @@ export class IsExtendedCurrencyConstraint implements ValidatorConstraintInterfac } public validate(currency: string) { - // Return true if currency is a derived currency or a standard ISO 4217 code - return ( - isDerivedCurrency(currency) || - (this.isUpperCase(currency) && isISO4217CurrencyCode(currency)) - ); - } - - private isUpperCase(aString: string) { - return aString === aString?.toUpperCase(); + return isValidCurrencyCode(currency); } }