mirror of https://github.com/ghostfolio/ghostfolio
35 changed files with 3912 additions and 133 deletions
@ -0,0 +1,185 @@ |
|||||
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
||||
|
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; |
||||
|
import { SubscriptionType } from '@ghostfolio/common/enums'; |
||||
|
import { permissions } from '@ghostfolio/common/permissions'; |
||||
|
|
||||
|
import { Test } from '@nestjs/testing'; |
||||
|
import { Role } from '@prisma/client'; |
||||
|
|
||||
|
import { UserService } from './user.service'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/activities/activities.service', () => { |
||||
|
return { ActivitiesService: class ActivitiesService {} }; |
||||
|
}); |
||||
|
jest.mock('@ghostfolio/api/services/i18n/i18n.service', () => { |
||||
|
return { I18nService: class I18nService {} }; |
||||
|
}); |
||||
|
|
||||
|
describe('UserService', () => { |
||||
|
let configuration: Record<string, boolean>; |
||||
|
let getSubscription: jest.Mock; |
||||
|
let isReadOnlyMode: boolean; |
||||
|
let userService: UserService; |
||||
|
let userFromDatabase: { |
||||
|
accounts: []; |
||||
|
createdAt: Date; |
||||
|
id: string; |
||||
|
provider: 'ANONYMOUS'; |
||||
|
role: Role; |
||||
|
settings: { |
||||
|
settings: { |
||||
|
isExperimentalFeatures: boolean; |
||||
|
isGhostfolioAiEnabled: boolean; |
||||
|
}; |
||||
|
}; |
||||
|
subscriptions: []; |
||||
|
}; |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
configuration = { |
||||
|
ENABLE_FEATURE_READ_ONLY_MODE: false, |
||||
|
ENABLE_FEATURE_SUBSCRIPTION: false |
||||
|
}; |
||||
|
isReadOnlyMode = false; |
||||
|
getSubscription = jest.fn().mockResolvedValue({ |
||||
|
type: SubscriptionType.Premium, |
||||
|
offer: {} |
||||
|
}); |
||||
|
userFromDatabase = { |
||||
|
accounts: [], |
||||
|
createdAt: new Date('2024-01-01T00:00:00Z'), |
||||
|
id: 'user-id', |
||||
|
provider: 'ANONYMOUS', |
||||
|
role: Role.USER, |
||||
|
settings: { |
||||
|
settings: { |
||||
|
isExperimentalFeatures: true, |
||||
|
isGhostfolioAiEnabled: true |
||||
|
} |
||||
|
}, |
||||
|
subscriptions: [] |
||||
|
}; |
||||
|
|
||||
|
const module = await Test.createTestingModule({ |
||||
|
providers: [ |
||||
|
UserService, |
||||
|
{ |
||||
|
provide: ConfigurationService, |
||||
|
useValue: { |
||||
|
get: (key: string) => { |
||||
|
return configuration[key]; |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
provide: PrismaService, |
||||
|
useValue: { |
||||
|
user: { |
||||
|
findUnique: jest.fn().mockImplementation(() => { |
||||
|
return Promise.resolve(userFromDatabase); |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
provide: PropertyService, |
||||
|
useValue: { |
||||
|
getByKey: jest.fn().mockImplementation((key: string) => { |
||||
|
return Promise.resolve( |
||||
|
key === PROPERTY_IS_READ_ONLY_MODE ? isReadOnlyMode : undefined |
||||
|
); |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
provide: SubscriptionService, |
||||
|
useValue: { getSubscription } |
||||
|
} |
||||
|
] |
||||
|
}) |
||||
|
.useMocker(() => { |
||||
|
return {}; |
||||
|
}) |
||||
|
.compile(); |
||||
|
|
||||
|
userService = module.get(UserService); |
||||
|
}); |
||||
|
|
||||
|
describe('Ghostfolio AI permissions', () => { |
||||
|
it.each([Role.USER, Role.ADMIN])( |
||||
|
'allows self-hosted %s users with experimental features to enable AI', |
||||
|
async (role) => { |
||||
|
userFromDatabase.role = role; |
||||
|
|
||||
|
const user = await userService.user({ id: userFromDatabase.id }); |
||||
|
|
||||
|
expect(user.permissions).toContain(permissions.enableGhostfolioAi); |
||||
|
expect(user.settings.settings.isGhostfolioAiEnabled).toBe(true); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it.each([ |
||||
|
{ isExperimentalFeatures: false, role: Role.USER }, |
||||
|
{ isExperimentalFeatures: true, role: Role.DEMO }, |
||||
|
{ isExperimentalFeatures: true, role: Role.INACTIVE } |
||||
|
])( |
||||
|
'withholds self-hosted AI access for $role with experimental features $isExperimentalFeatures', |
||||
|
async ({ isExperimentalFeatures, role }) => { |
||||
|
userFromDatabase.role = role; |
||||
|
userFromDatabase.settings.settings.isExperimentalFeatures = |
||||
|
isExperimentalFeatures; |
||||
|
|
||||
|
const user = await userService.user({ id: userFromDatabase.id }); |
||||
|
|
||||
|
expect(user.permissions).not.toContain(permissions.enableGhostfolioAi); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('withholds hosted Basic AI access and clears the AI setting', async () => { |
||||
|
configuration.ENABLE_FEATURE_SUBSCRIPTION = true; |
||||
|
getSubscription.mockResolvedValue({ type: SubscriptionType.Basic }); |
||||
|
|
||||
|
const user = await userService.user({ id: userFromDatabase.id }); |
||||
|
|
||||
|
expect(user.permissions).not.toContain(permissions.enableGhostfolioAi); |
||||
|
expect(user.settings.settings.isGhostfolioAiEnabled).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it.each([ |
||||
|
{ expected: true, isExperimentalFeatures: true, role: Role.USER }, |
||||
|
{ expected: false, isExperimentalFeatures: false, role: Role.USER }, |
||||
|
{ expected: false, isExperimentalFeatures: true, role: Role.DEMO } |
||||
|
])( |
||||
|
'preserves hosted Premium AI access $expected for $role with experimental features $isExperimentalFeatures', |
||||
|
async ({ expected, isExperimentalFeatures, role }) => { |
||||
|
configuration.ENABLE_FEATURE_SUBSCRIPTION = true; |
||||
|
userFromDatabase.role = role; |
||||
|
userFromDatabase.settings.settings.isExperimentalFeatures = |
||||
|
isExperimentalFeatures; |
||||
|
|
||||
|
const user = await userService.user({ id: userFromDatabase.id }); |
||||
|
|
||||
|
expect(user.permissions.includes(permissions.enableGhostfolioAi)).toBe( |
||||
|
expected |
||||
|
); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it.each([false, true])( |
||||
|
'respects self-hosted read-only mode set to %s', |
||||
|
async (readOnlyMode) => { |
||||
|
configuration.ENABLE_FEATURE_READ_ONLY_MODE = true; |
||||
|
isReadOnlyMode = readOnlyMode; |
||||
|
|
||||
|
const user = await userService.user({ id: userFromDatabase.id }); |
||||
|
|
||||
|
expect(user.permissions.includes(permissions.enableGhostfolioAi)).toBe( |
||||
|
!readOnlyMode |
||||
|
); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,326 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; |
||||
|
|
||||
|
import { Test } from '@nestjs/testing'; |
||||
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; |
||||
|
|
||||
|
import { CoinGeckoService } from './coingecko.service'; |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/configuration/configuration.service', |
||||
|
() => { |
||||
|
return { ConfigurationService: class ConfigurationService {} }; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => { |
||||
|
return { FetchService: class FetchService {} }; |
||||
|
}); |
||||
|
|
||||
|
describe('CoinGeckoService', () => { |
||||
|
let configuration: Record<string, string | number>; |
||||
|
let fetchService: { fetch: jest.Mock }; |
||||
|
let service: CoinGeckoService; |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
configuration = { |
||||
|
API_KEY_COINGECKO_DEMO: '', |
||||
|
API_KEY_COINGECKO_PRO: '', |
||||
|
REQUEST_TIMEOUT: 5000 |
||||
|
}; |
||||
|
fetchService = { fetch: jest.fn() }; |
||||
|
|
||||
|
const module = await Test.createTestingModule({ |
||||
|
providers: [ |
||||
|
CoinGeckoService, |
||||
|
{ |
||||
|
provide: ConfigurationService, |
||||
|
useValue: { |
||||
|
get: (key: string) => { |
||||
|
return configuration[key]; |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
{ provide: FetchService, useValue: fetchService } |
||||
|
] |
||||
|
}).compile(); |
||||
|
|
||||
|
service = module.get(CoinGeckoService); |
||||
|
await module.init(); |
||||
|
}); |
||||
|
|
||||
|
describe('search', () => { |
||||
|
it('offers EUR assets alongside existing USD assets for each search result', async () => { |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { |
||||
|
coins: [ |
||||
|
{ id: 'bitcoin', name: 'Bitcoin' }, |
||||
|
{ id: 'wrapped-bitcoin', name: 'Wrapped Bitcoin' } |
||||
|
] |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const { items } = await service.search({ query: 'bitcoin' }); |
||||
|
const url = new URL(fetchService.fetch.mock.calls[0][0]); |
||||
|
|
||||
|
expect(url.pathname).toBe('/api/v3/search'); |
||||
|
expect(url.searchParams.get('query')).toBe('bitcoin'); |
||||
|
expect(items).toHaveLength(4); |
||||
|
expect(items).toEqual( |
||||
|
expect.arrayContaining([ |
||||
|
expect.objectContaining({ |
||||
|
currency: 'USD', |
||||
|
name: 'Bitcoin', |
||||
|
symbol: 'bitcoin' |
||||
|
}), |
||||
|
expect.objectContaining({ |
||||
|
assetClass: AssetClass.LIQUIDITY, |
||||
|
assetSubClass: AssetSubClass.CRYPTOCURRENCY, |
||||
|
currency: 'EUR', |
||||
|
dataSource: DataSource.COINGECKO, |
||||
|
name: 'Bitcoin', |
||||
|
symbol: 'bitcoin-EUR' |
||||
|
}), |
||||
|
expect.objectContaining({ |
||||
|
currency: 'USD', |
||||
|
name: 'Wrapped Bitcoin', |
||||
|
symbol: 'wrapped-bitcoin' |
||||
|
}), |
||||
|
expect.objectContaining({ |
||||
|
currency: 'EUR', |
||||
|
name: 'Wrapped Bitcoin', |
||||
|
symbol: 'wrapped-bitcoin-EUR' |
||||
|
}) |
||||
|
]) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('searches the coin ID when the query includes the EUR suffix', async () => { |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { coins: [{ id: 'bitcoin', name: 'Bitcoin' }] }; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const { items } = await service.search({ query: 'bitcoin-EUR' }); |
||||
|
const url = new URL(fetchService.fetch.mock.calls[0][0]); |
||||
|
|
||||
|
expect(url.searchParams.get('query')).toBe('bitcoin'); |
||||
|
expect(items).toEqual( |
||||
|
expect.arrayContaining([ |
||||
|
expect.objectContaining({ currency: 'EUR', symbol: 'bitcoin-EUR' }) |
||||
|
]) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getAssetProfile', () => { |
||||
|
it.each([ |
||||
|
{ currency: 'USD', id: 'bitcoin', symbol: 'bitcoin' }, |
||||
|
{ currency: 'EUR', id: 'bitcoin', symbol: 'bitcoin-EUR' }, |
||||
|
{ |
||||
|
currency: 'USD', |
||||
|
id: 'wrapped-bitcoin', |
||||
|
symbol: 'wrapped-bitcoin' |
||||
|
}, |
||||
|
{ |
||||
|
currency: 'EUR', |
||||
|
id: 'wrapped-bitcoin', |
||||
|
symbol: 'wrapped-bitcoin-EUR' |
||||
|
} |
||||
|
])( |
||||
|
'loads $symbol with its $currency currency', |
||||
|
async ({ currency, id, symbol }) => { |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { name: 'Bitcoin' }; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const profile = await service.getAssetProfile({ symbol }); |
||||
|
|
||||
|
expect(fetchService.fetch).toHaveBeenCalledWith( |
||||
|
`https://api.coingecko.com/api/v3/coins/${id}`, |
||||
|
expect.any(Object) |
||||
|
); |
||||
|
expect(profile).toEqual({ |
||||
|
currency, |
||||
|
symbol, |
||||
|
assetClass: AssetClass.LIQUIDITY, |
||||
|
assetSubClass: AssetSubClass.CRYPTOCURRENCY, |
||||
|
dataSource: DataSource.COINGECKO, |
||||
|
name: 'Bitcoin' |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getHistorical', () => { |
||||
|
it.each([ |
||||
|
{ currency: 'usd', marketPrice: 70000, symbol: 'bitcoin' }, |
||||
|
{ currency: 'eur', marketPrice: 65000, symbol: 'bitcoin-EUR' } |
||||
|
])( |
||||
|
'loads $symbol history in $currency', |
||||
|
async ({ currency, marketPrice, symbol }) => { |
||||
|
const from = new Date('2024-01-01T00:00:00Z'); |
||||
|
const to = new Date('2024-01-02T00:00:00Z'); |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { |
||||
|
prices: [ |
||||
|
[from.getTime(), marketPrice], |
||||
|
[to.getTime(), marketPrice + 100] |
||||
|
] |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const history = await service.getHistorical({ from, symbol, to }); |
||||
|
const url = new URL(fetchService.fetch.mock.calls[0][0]); |
||||
|
|
||||
|
expect(url.pathname).toBe('/api/v3/coins/bitcoin/market_chart/range'); |
||||
|
expect(url.searchParams.get('vs_currency')).toBe(currency); |
||||
|
expect(url.searchParams.get('from')).toBe(`${from.getTime() / 1000}`); |
||||
|
expect(url.searchParams.get('to')).toBe(`${to.getTime() / 1000}`); |
||||
|
expect(history).toEqual({ |
||||
|
'2024-01-01': { marketPrice }, |
||||
|
'2024-01-02': { marketPrice: marketPrice + 100 } |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getQuotes', () => { |
||||
|
it('keeps USD and EUR prices separate while requesting each coin ID once', async () => { |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { |
||||
|
bitcoin: { eur: 65000, usd: 70000 }, |
||||
|
ethereum: { eur: 3000, usd: 3200 } |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const quotes = await service.getQuotes({ |
||||
|
symbols: ['bitcoin', 'bitcoin-EUR', 'ethereum-EUR'] |
||||
|
}); |
||||
|
const url = new URL(fetchService.fetch.mock.calls[0][0]); |
||||
|
|
||||
|
expect(fetchService.fetch).toHaveBeenCalledTimes(1); |
||||
|
expect(url.pathname).toBe('/api/v3/simple/price'); |
||||
|
expect(url.searchParams.get('ids').split(',').sort()).toEqual([ |
||||
|
'bitcoin', |
||||
|
'ethereum' |
||||
|
]); |
||||
|
expect(url.searchParams.get('vs_currencies').split(',').sort()).toEqual([ |
||||
|
'eur', |
||||
|
'usd' |
||||
|
]); |
||||
|
expect(Object.keys(quotes).sort()).toEqual([ |
||||
|
'bitcoin', |
||||
|
'bitcoin-EUR', |
||||
|
'ethereum-EUR' |
||||
|
]); |
||||
|
expect(quotes).toMatchObject({ |
||||
|
bitcoin: { |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.COINGECKO, |
||||
|
marketPrice: 70000, |
||||
|
marketState: 'open' |
||||
|
}, |
||||
|
'bitcoin-EUR': { |
||||
|
currency: 'EUR', |
||||
|
dataSource: DataSource.COINGECKO, |
||||
|
marketPrice: 65000, |
||||
|
marketState: 'open' |
||||
|
}, |
||||
|
'ethereum-EUR': { |
||||
|
currency: 'EUR', |
||||
|
marketPrice: 3000 |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('continues requesting USD for legacy coin IDs', async () => { |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { bitcoin: { usd: 70000 }, 'wrapped-bitcoin': { usd: 69900 } }; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const quotes = await service.getQuotes({ |
||||
|
symbols: ['bitcoin', 'wrapped-bitcoin'] |
||||
|
}); |
||||
|
const url = new URL(fetchService.fetch.mock.calls[0][0]); |
||||
|
|
||||
|
expect(url.searchParams.get('ids')).toBe('bitcoin,wrapped-bitcoin'); |
||||
|
expect(url.searchParams.get('vs_currencies')).toBe('usd'); |
||||
|
expect(quotes).toMatchObject({ |
||||
|
bitcoin: { currency: 'USD', marketPrice: 70000 }, |
||||
|
'wrapped-bitcoin': { currency: 'USD', marketPrice: 69900 } |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it.each([undefined, null, '65000'])( |
||||
|
'skips EUR prices that are unavailable or non-numeric (%s)', |
||||
|
async (marketPrice) => { |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { bitcoin: { eur: marketPrice, usd: 70000 } }; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const quotes = await service.getQuotes({ |
||||
|
symbols: ['bitcoin', 'bitcoin-EUR', 'ethereum-EUR'] |
||||
|
}); |
||||
|
|
||||
|
expect(Object.keys(quotes)).toEqual(['bitcoin']); |
||||
|
expect(quotes.bitcoin).toMatchObject({ |
||||
|
currency: 'USD', |
||||
|
marketPrice: 70000 |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('API keys', () => { |
||||
|
it.each([ |
||||
|
{ |
||||
|
configurationKey: 'API_KEY_COINGECKO_DEMO', |
||||
|
header: 'x-cg-demo-api-key', |
||||
|
hostname: 'api.coingecko.com' |
||||
|
}, |
||||
|
{ |
||||
|
configurationKey: 'API_KEY_COINGECKO_PRO', |
||||
|
header: 'x-cg-pro-api-key', |
||||
|
hostname: 'pro-api.coingecko.com' |
||||
|
} |
||||
|
])( |
||||
|
'preserves $configurationKey for EUR requests', |
||||
|
async ({ configurationKey, header, hostname }) => { |
||||
|
configuration[configurationKey] = 'test-api-key'; |
||||
|
service.onModuleInit(); |
||||
|
fetchService.fetch.mockResolvedValue({ |
||||
|
json: async () => { |
||||
|
return { bitcoin: { eur: 65000 } }; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const quotes = await service.getQuotes({ symbols: ['bitcoin-EUR'] }); |
||||
|
const [input, options] = fetchService.fetch.mock.calls[0]; |
||||
|
const url = new URL(input); |
||||
|
|
||||
|
expect(url.hostname).toBe(hostname); |
||||
|
expect(url.searchParams.get('ids')).toBe('bitcoin'); |
||||
|
expect(url.searchParams.get('vs_currencies')).toBe('eur'); |
||||
|
expect(options.headers).toEqual({ [header]: 'test-api-key' }); |
||||
|
expect(quotes['bitcoin-EUR']).toMatchObject({ |
||||
|
currency: 'EUR', |
||||
|
marketPrice: 65000 |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,463 @@ |
|||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { |
||||
|
PROPERTY_API_KEY_OPENROUTER, |
||||
|
PROPERTY_OPENROUTER_MODEL, |
||||
|
PROPERTY_OPENROUTER_MODEL_AGENT, |
||||
|
PROPERTY_OPENROUTER_MODEL_VISION |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { NotificationService } from '@ghostfolio/ui/notifications'; |
||||
|
import { AdminService, DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; |
||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'; |
||||
|
import { ReactiveFormsModule } from '@angular/forms'; |
||||
|
import '@angular/localize/init'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar'; |
||||
|
import { MatSortModule } from '@angular/material/sort'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { MatTooltipModule } from '@angular/material/tooltip'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
import { of, Subject, throwError } from 'rxjs'; |
||||
|
|
||||
|
import { GfAdminSettingsComponent } from './admin-settings.component'; |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/client/components/admin-platform/admin-platform.component', |
||||
|
() => ({ |
||||
|
GfAdminPlatformComponent: class {} |
||||
|
}) |
||||
|
); |
||||
|
jest.mock( |
||||
|
'@ghostfolio/client/components/admin-tag/admin-tag.component', |
||||
|
() => ({ |
||||
|
GfAdminTagComponent: class {} |
||||
|
}) |
||||
|
); |
||||
|
jest.mock( |
||||
|
'@ghostfolio/client/components/data-provider-status/data-provider-status.component', |
||||
|
() => ({ |
||||
|
GfDataProviderStatusComponent: class {} |
||||
|
}) |
||||
|
); |
||||
|
jest.mock('@ghostfolio/ui/entity-logo', () => ({ |
||||
|
GfEntityLogoComponent: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/premium-indicator', () => ({ |
||||
|
GfPremiumIndicatorComponent: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/value', () => ({ GfValueComponent: class {} })); |
||||
|
jest.mock('@ghostfolio/client/services/user/user.service', () => ({ |
||||
|
UserService: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/notifications', () => ({ |
||||
|
NotificationService: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/services', () => ({ |
||||
|
AdminService: class {}, |
||||
|
DataService: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/common/helper', () => ({ |
||||
|
getDateFormatString: () => 'yyyy-MM-dd' |
||||
|
})); |
||||
|
jest.mock('@ionic/angular/standalone', () => ({ IonIcon: class {} })); |
||||
|
|
||||
|
describe('GfAdminSettingsComponent OpenRouter settings', () => { |
||||
|
let component: GfAdminSettingsComponent; |
||||
|
let element: HTMLElement; |
||||
|
let fixture: ComponentFixture<GfAdminSettingsComponent>; |
||||
|
let settings: Record<string, string>; |
||||
|
let adminService: { fetchAdminData: jest.Mock }; |
||||
|
let dataService: { putAdminSetting: jest.Mock }; |
||||
|
let notificationService: { |
||||
|
confirm: jest.Mock<void, [{ confirmFn: () => void }]>; |
||||
|
}; |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
settings = { |
||||
|
[PROPERTY_API_KEY_OPENROUTER]: 'stored-secret', |
||||
|
[PROPERTY_OPENROUTER_MODEL]: 'provider/default-model', |
||||
|
[PROPERTY_OPENROUTER_MODEL_AGENT]: 'provider/agent-model' |
||||
|
}; |
||||
|
adminService = { |
||||
|
fetchAdminData: jest |
||||
|
.fn() |
||||
|
.mockImplementation(() => of({ dataProviders: [], settings })) |
||||
|
}; |
||||
|
dataService = { putAdminSetting: jest.fn().mockReturnValue(of({})) }; |
||||
|
notificationService = { |
||||
|
confirm: jest.fn<void, [{ confirmFn: () => void }]>() |
||||
|
}; |
||||
|
|
||||
|
await TestBed.configureTestingModule({ |
||||
|
errorOnUnknownProperties: true, |
||||
|
imports: [GfAdminSettingsComponent], |
||||
|
providers: [ |
||||
|
{ provide: AdminService, useValue: adminService }, |
||||
|
{ provide: DataService, useValue: dataService }, |
||||
|
{ provide: NotificationService, useValue: notificationService }, |
||||
|
{ provide: UserService, useValue: { stateChanged: of(null) } } |
||||
|
] |
||||
|
}) |
||||
|
.overrideComponent(GfAdminSettingsComponent, { |
||||
|
set: { |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
ReactiveFormsModule, |
||||
|
MatButtonModule, |
||||
|
MatCardModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
MatMenuModule, |
||||
|
MatProgressBarModule, |
||||
|
MatSortModule, |
||||
|
MatTableModule, |
||||
|
MatTooltipModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
||||
|
styleUrls: [], |
||||
|
styles: [] |
||||
|
} |
||||
|
}) |
||||
|
.compileComponents(); |
||||
|
}); |
||||
|
|
||||
|
async function createComponent() { |
||||
|
fixture = TestBed.createComponent(GfAdminSettingsComponent); |
||||
|
component = fixture.componentInstance; |
||||
|
element = fixture.nativeElement as HTMLElement; |
||||
|
fixture.autoDetectChanges(); |
||||
|
await fixture.whenStable(); |
||||
|
} |
||||
|
|
||||
|
function getApiKeyInput(): HTMLInputElement { |
||||
|
const input = element.querySelector<HTMLInputElement>( |
||||
|
'input[formControlName="apiKey"]' |
||||
|
); |
||||
|
if (!input) { |
||||
|
throw new Error('Missing OpenRouter API key input'); |
||||
|
} |
||||
|
return input; |
||||
|
} |
||||
|
|
||||
|
it('loads saved model identifiers without placing the stored secret in the form', async () => { |
||||
|
await createComponent(); |
||||
|
|
||||
|
expect(component.hasOpenRouterApiKey).toBe(true); |
||||
|
expect(component.openRouterForm.getRawValue()).toEqual({ |
||||
|
apiKey: '', |
||||
|
model: 'provider/default-model', |
||||
|
agentModel: 'provider/agent-model', |
||||
|
visionModel: '' |
||||
|
}); |
||||
|
expect(element.textContent).not.toContain('stored-secret'); |
||||
|
}); |
||||
|
|
||||
|
it('renders a password input and accessible labels for all four settings', async () => { |
||||
|
await createComponent(); |
||||
|
|
||||
|
const keyInput = getApiKeyInput(); |
||||
|
expect(keyInput.type).toBe('password'); |
||||
|
expect(keyInput.value).toBe(''); |
||||
|
expect(keyInput.autocomplete).toBe('new-password'); |
||||
|
expect(keyInput.labels?.[0].textContent).toContain('OpenRouter API key'); |
||||
|
|
||||
|
const labels: HTMLLabelElement[] = Array.from( |
||||
|
element.querySelectorAll<HTMLLabelElement>('label[for]') |
||||
|
); |
||||
|
for (const text of [ |
||||
|
'Default model', |
||||
|
'Asset and account matching model', |
||||
|
'File reading model' |
||||
|
]) { |
||||
|
const label = labels.find(({ textContent }) => |
||||
|
textContent.includes(text) |
||||
|
); |
||||
|
expect(label).toBeDefined(); |
||||
|
expect(element.querySelector(`#${label?.htmlFor}`)).toBeTruthy(); |
||||
|
} |
||||
|
for (const label of [ |
||||
|
'Save default model', |
||||
|
'Save asset and account matching model', |
||||
|
'Save file reading model' |
||||
|
]) { |
||||
|
expect( |
||||
|
element.querySelector(`button[aria-label="${label}"]`) |
||||
|
).toBeTruthy(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('saves a trimmed replacement key and clears only the key input after success', async () => { |
||||
|
await createComponent(); |
||||
|
const keyInput = getApiKeyInput(); |
||||
|
keyInput.value = ' replacement-secret '; |
||||
|
keyInput.dispatchEvent(new Event('input')); |
||||
|
component.openRouterForm.controls.visionModel.setValue( |
||||
|
'unsaved/vision-model' |
||||
|
); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
const buttons: HTMLButtonElement[] = Array.from( |
||||
|
element.querySelectorAll<HTMLButtonElement>('button') |
||||
|
); |
||||
|
buttons |
||||
|
.find(({ textContent }) => textContent.includes('Replace API key')) |
||||
|
?.click(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledWith( |
||||
|
PROPERTY_API_KEY_OPENROUTER, |
||||
|
{ value: 'replacement-secret' } |
||||
|
); |
||||
|
expect(component.openRouterForm.controls.apiKey.value).toBe(''); |
||||
|
expect(component.openRouterForm.controls.visionModel.value).toBe( |
||||
|
'unsaved/vision-model' |
||||
|
); |
||||
|
expect(component.hasOpenRouterApiKey).toBe(true); |
||||
|
expect(component.openRouterFeedback?.isError).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it.each([ |
||||
|
['model', PROPERTY_OPENROUTER_MODEL], |
||||
|
['agentModel', PROPERTY_OPENROUTER_MODEL_AGENT], |
||||
|
['visionModel', PROPERTY_OPENROUTER_MODEL_VISION] |
||||
|
] as const)( |
||||
|
'saves a trimmed %s identifier to its own setting', |
||||
|
async (controlName, property) => { |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls[controlName].setValue( |
||||
|
' provider/new-model ' |
||||
|
); |
||||
|
|
||||
|
component.onSaveOpenRouterModel(controlName); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledTimes(1); |
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledWith(property, { |
||||
|
value: 'provider/new-model' |
||||
|
}); |
||||
|
expect(component.openRouterForm.controls[controlName].value).toBe( |
||||
|
'provider/new-model' |
||||
|
); |
||||
|
expect(component.canSaveOpenRouterModel(controlName)).toBe(false); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('clears an existing optional override so the default model can be used', async () => { |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls.agentModel.setValue(' '); |
||||
|
|
||||
|
component.onSaveOpenRouterModel('agentModel'); |
||||
|
component.onSaveOpenRouterModel('agentModel'); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledTimes(1); |
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledWith( |
||||
|
PROPERTY_OPENROUTER_MODEL_AGENT, |
||||
|
{ value: undefined } |
||||
|
); |
||||
|
expect(component.openRouterForm.controls.model.value).toBe( |
||||
|
'provider/default-model' |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('does not send a delete for an optional override that was never configured', async () => { |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls.visionModel.setValue(' '); |
||||
|
|
||||
|
component.onSaveOpenRouterModel('visionModel'); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('keeps the existing key when the replacement input is blank', async () => { |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls.apiKey.setValue(' '); |
||||
|
|
||||
|
component.onSaveOpenRouterApiKey(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).not.toHaveBeenCalled(); |
||||
|
expect(component.hasOpenRouterApiKey).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('rejects a blank default model and ignores an unchanged model', async () => { |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls.model.setValue(' '); |
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
expect(component.openRouterForm.controls.model.hasError('required')).toBe( |
||||
|
true |
||||
|
); |
||||
|
|
||||
|
component.openRouterForm.controls.model.setValue( |
||||
|
' provider/default-model ' |
||||
|
); |
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('removes the API key only after the removal is confirmed', async () => { |
||||
|
await createComponent(); |
||||
|
|
||||
|
component.onRemoveOpenRouterApiKey(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).not.toHaveBeenCalled(); |
||||
|
expect(notificationService.confirm).toHaveBeenCalledTimes(1); |
||||
|
notificationService.confirm.mock.calls[0][0].confirmFn(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledWith( |
||||
|
PROPERTY_API_KEY_OPENROUTER, |
||||
|
{ value: undefined } |
||||
|
); |
||||
|
expect(component.hasOpenRouterApiKey).toBe(false); |
||||
|
expect(component.openRouterFeedback?.isError).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('preserves failed model edits and allows retry without reporting success', async () => { |
||||
|
await createComponent(); |
||||
|
dataService.putAdminSetting.mockReturnValueOnce( |
||||
|
throwError(() => new Error('Save failed')) |
||||
|
); |
||||
|
component.openRouterForm.controls.model.setValue( |
||||
|
' provider/retry-model ' |
||||
|
); |
||||
|
|
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(component.openRouterForm.controls.model.value).toBe( |
||||
|
' provider/retry-model ' |
||||
|
); |
||||
|
expect(component.isSavingOpenRouterSetting).toBe(false); |
||||
|
expect(component.openRouterForm.enabled).toBe(true); |
||||
|
expect(component.canSaveOpenRouterModel('model')).toBe(true); |
||||
|
expect(component.openRouterFeedback?.isError).toBe(true); |
||||
|
expect(element.querySelector('[role="status"]')?.textContent).toContain( |
||||
|
'Could not save' |
||||
|
); |
||||
|
|
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledTimes(2); |
||||
|
expect(component.openRouterFeedback?.isError).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('retains a replacement key and configured status when its save fails', async () => { |
||||
|
await createComponent(); |
||||
|
dataService.putAdminSetting.mockReturnValueOnce( |
||||
|
throwError(() => new Error('Save failed')) |
||||
|
); |
||||
|
component.openRouterForm.controls.apiKey.setValue(' retry-secret '); |
||||
|
|
||||
|
component.onSaveOpenRouterApiKey(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(component.openRouterForm.controls.apiKey.value).toBe( |
||||
|
' retry-secret ' |
||||
|
); |
||||
|
expect(component.hasOpenRouterApiKey).toBe(true); |
||||
|
expect(component.isSavingOpenRouterSetting).toBe(false); |
||||
|
expect(component.openRouterFeedback?.isError).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('keeps the configured key when confirmed removal fails', async () => { |
||||
|
await createComponent(); |
||||
|
dataService.putAdminSetting.mockReturnValueOnce( |
||||
|
throwError(() => new Error('Delete failed')) |
||||
|
); |
||||
|
|
||||
|
component.onRemoveOpenRouterApiKey(); |
||||
|
notificationService.confirm.mock.calls[0][0].confirmFn(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(component.hasOpenRouterApiKey).toBe(true); |
||||
|
expect(component.isSavingOpenRouterSetting).toBe(false); |
||||
|
expect(component.openRouterFeedback?.isError).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('disables editing and blocks additional requests while a save is pending', async () => { |
||||
|
await createComponent(); |
||||
|
const pendingSave = new Subject(); |
||||
|
dataService.putAdminSetting.mockReturnValue(pendingSave); |
||||
|
component.openRouterForm.controls.apiKey.setValue('new-secret'); |
||||
|
component.openRouterForm.controls.model.setValue('provider/new-model'); |
||||
|
|
||||
|
component.onSaveOpenRouterApiKey(); |
||||
|
component.onSaveOpenRouterApiKey(); |
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
component.onRemoveOpenRouterApiKey(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(component.openRouterForm.disabled).toBe(true); |
||||
|
expect(dataService.putAdminSetting).toHaveBeenCalledTimes(1); |
||||
|
expect(notificationService.confirm).not.toHaveBeenCalled(); |
||||
|
const inputs: HTMLInputElement[] = Array.from( |
||||
|
element.querySelectorAll<HTMLInputElement>('input') |
||||
|
); |
||||
|
expect(inputs).toHaveLength(4); |
||||
|
expect(inputs.every(({ disabled }) => disabled)).toBe(true); |
||||
|
|
||||
|
pendingSave.next({}); |
||||
|
pendingSave.complete(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(component.isSavingOpenRouterSetting).toBe(false); |
||||
|
expect(component.openRouterForm.enabled).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('blocks mutations until the existing settings finish loading', async () => { |
||||
|
const pendingLoad = new Subject(); |
||||
|
adminService.fetchAdminData.mockReturnValue(pendingLoad); |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls.apiKey.setValue('new-secret'); |
||||
|
component.openRouterForm.controls.model.setValue('provider/new-model'); |
||||
|
|
||||
|
component.onSaveOpenRouterApiKey(); |
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
component.onRemoveOpenRouterApiKey(); |
||||
|
|
||||
|
expect(component.isLoading).toBe(true); |
||||
|
expect(component.openRouterForm.disabled).toBe(true); |
||||
|
expect(dataService.putAdminSetting).not.toHaveBeenCalled(); |
||||
|
expect(notificationService.confirm).not.toHaveBeenCalled(); |
||||
|
|
||||
|
pendingLoad.next({ dataProviders: [], settings }); |
||||
|
pendingLoad.complete(); |
||||
|
await fixture.whenStable(); |
||||
|
|
||||
|
expect(component.isLoading).toBe(false); |
||||
|
expect(component.isOpenRouterSettingsLoaded).toBe(true); |
||||
|
expect(component.openRouterForm.enabled).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('shows a load error and leaves mutations disabled when settings cannot be read', async () => { |
||||
|
adminService.fetchAdminData.mockReturnValue( |
||||
|
throwError(() => new Error('Load failed')) |
||||
|
); |
||||
|
await createComponent(); |
||||
|
component.openRouterForm.controls.apiKey.setValue('new-secret'); |
||||
|
component.openRouterForm.controls.model.setValue('provider/new-model'); |
||||
|
|
||||
|
component.onSaveOpenRouterApiKey(); |
||||
|
component.onSaveOpenRouterModel('model'); |
||||
|
component.onRemoveOpenRouterApiKey(); |
||||
|
|
||||
|
expect(component.isLoading).toBe(false); |
||||
|
expect(component.isOpenRouterSettingsLoaded).toBe(false); |
||||
|
expect(component.openRouterForm.disabled).toBe(true); |
||||
|
expect(dataService.putAdminSetting).not.toHaveBeenCalled(); |
||||
|
expect(notificationService.confirm).not.toHaveBeenCalled(); |
||||
|
expect(element.textContent).toContain( |
||||
|
'Could not load Ghostfolio AI settings' |
||||
|
); |
||||
|
expect(element.textContent).not.toContain('No API key is configured'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,211 @@ |
|||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import type { CreateOrderDto } from '@ghostfolio/common/dtos'; |
||||
|
import type { User } from '@ghostfolio/common/interfaces'; |
||||
|
import { Type } from '@ghostfolio/prisma/enums'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { ChangeDetectorRef } from '@angular/core'; |
||||
|
import { TestBed } from '@angular/core/testing'; |
||||
|
import { FormGroup } from '@angular/forms'; |
||||
|
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; |
||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import 'reflect-metadata'; |
||||
|
import { of } from 'rxjs'; |
||||
|
|
||||
|
import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog.component'; |
||||
|
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/client/services/user/user.service', () => ({ |
||||
|
UserService: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/account-selector', () => ({ |
||||
|
GfAccountSelectorComponent: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/services', () => ({ DataService: class {} })); |
||||
|
jest.mock('@ghostfolio/ui/symbol-autocomplete', () => ({ |
||||
|
GfSymbolAutocompleteComponent: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/tags-selector', () => ({ |
||||
|
GfTagsSelectorComponent: class {} |
||||
|
})); |
||||
|
jest.mock('@ghostfolio/ui/value', () => ({ GfValueComponent: class {} })); |
||||
|
jest.mock('@ionic/angular/standalone', () => ({ IonIcon: class {} })); |
||||
|
|
||||
|
describe('GfCreateOrUpdateActivityDialogComponent bank cash movements', () => { |
||||
|
let component: GfCreateOrUpdateActivityDialogComponent; |
||||
|
let form: FormGroup; |
||||
|
let data: CreateOrUpdateActivityDialogParams; |
||||
|
let close: jest.Mock; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
data = { |
||||
|
accounts: [ |
||||
|
{ |
||||
|
id: 'bank-account', |
||||
|
currency: 'EUR', |
||||
|
name: 'Bank', |
||||
|
createdAt: new Date(), |
||||
|
updatedAt: new Date(), |
||||
|
userId: 'user-id', |
||||
|
comment: null, |
||||
|
platformId: null |
||||
|
} |
||||
|
], |
||||
|
activity: { |
||||
|
accountId: 'bank-account', |
||||
|
assetProfile: null, |
||||
|
date: new Date(2026, 0, 1), |
||||
|
fee: 0, |
||||
|
id: null, |
||||
|
type: Type.DEPOSIT, |
||||
|
unitPrice: 100 |
||||
|
}, |
||||
|
user: { |
||||
|
id: 'user-id', |
||||
|
permissions: [], |
||||
|
settings: { baseCurrency: 'EUR', locale: 'en' }, |
||||
|
tags: [] |
||||
|
} as unknown as User |
||||
|
}; |
||||
|
close = jest.fn(); |
||||
|
|
||||
|
TestBed.configureTestingModule({ |
||||
|
providers: [ |
||||
|
{ provide: MAT_DIALOG_DATA, useValue: data }, |
||||
|
{ provide: MAT_DATE_LOCALE, useValue: 'en' }, |
||||
|
{ provide: MatDialogRef, useValue: { close } }, |
||||
|
{ provide: DateAdapter, useValue: { setLocale: jest.fn() } }, |
||||
|
{ |
||||
|
provide: ChangeDetectorRef, |
||||
|
useValue: { markForCheck: jest.fn() } |
||||
|
}, |
||||
|
{ |
||||
|
provide: DataService, |
||||
|
useValue: { |
||||
|
fetchInfo: () => ({ currencies: ['EUR', 'USD'] }), |
||||
|
fetchPortfolioHoldings: () => of({ holdings: [] }) |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
provide: ImpersonationStorageService, |
||||
|
useValue: { getId: () => null } |
||||
|
}, |
||||
|
{ provide: UserService, useValue: {} } |
||||
|
] |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function createComponent() { |
||||
|
component = TestBed.runInInjectionContext(() => { |
||||
|
return new GfCreateOrUpdateActivityDialogComponent(); |
||||
|
}); |
||||
|
component.ngOnInit(); |
||||
|
form = ( |
||||
|
component as unknown as { |
||||
|
activityForm: FormGroup; |
||||
|
} |
||||
|
).activityForm; |
||||
|
} |
||||
|
|
||||
|
async function submit() { |
||||
|
await (component as unknown as { onSubmit(): Promise<void> }).onSubmit(); |
||||
|
|
||||
|
return close.mock.calls.at(-1)?.[0] as CreateOrderDto; |
||||
|
} |
||||
|
|
||||
|
it.each([Type.DEPOSIT, Type.WITHDRAWAL])( |
||||
|
'requires an account and a positive amount for %s without requiring a security or name', |
||||
|
(type) => { |
||||
|
data.activity.type = type; |
||||
|
createComponent(); |
||||
|
|
||||
|
expect(form.valid).toBe(true); |
||||
|
expect(form.get('searchSymbol')?.valid).toBe(true); |
||||
|
expect(form.get('name')?.valid).toBe(true); |
||||
|
expect(form.get('quantity')?.value).toBe(1); |
||||
|
expect(form.get('fee')?.value).toBe(0); |
||||
|
|
||||
|
form.get('unitPrice')?.setValue(0); |
||||
|
expect(form.get('unitPrice')?.invalid).toBe(true); |
||||
|
form.get('unitPrice')?.setValue(-100); |
||||
|
expect(form.get('unitPrice')?.invalid).toBe(true); |
||||
|
form.get('unitPrice')?.setValue(100); |
||||
|
expect(form.get('unitPrice')?.valid).toBe(true); |
||||
|
|
||||
|
form.get('accountId')?.setValue(null); |
||||
|
expect(form.get('accountId')?.invalid).toBe(true); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('defaults backdated cash balance updates on and retains the preference when the account is cleared and restored', () => { |
||||
|
createComponent(); |
||||
|
const balanceControl = form.get('updateAccountBalance'); |
||||
|
|
||||
|
expect(balanceControl?.enabled).toBe(true); |
||||
|
expect(balanceControl?.value).toBe(true); |
||||
|
|
||||
|
form.get('accountId')?.setValue(null); |
||||
|
expect(balanceControl?.disabled).toBe(true); |
||||
|
expect(balanceControl?.value).toBe(true); |
||||
|
|
||||
|
form.get('accountId')?.setValue('bank-account'); |
||||
|
expect(balanceControl?.enabled).toBe(true); |
||||
|
expect(balanceControl?.value).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('submits future cash movements without updating the balance and restores the preference for a past date', async () => { |
||||
|
createComponent(); |
||||
|
const balanceControl = form.get('updateAccountBalance'); |
||||
|
const futureDate = new Date(); |
||||
|
futureDate.setFullYear(futureDate.getFullYear() + 1); |
||||
|
|
||||
|
form.get('date')?.setValue(futureDate); |
||||
|
expect(balanceControl?.disabled).toBe(true); |
||||
|
expect(balanceControl?.value).toBe(true); |
||||
|
expect((await submit()).updateAccountBalance).toBe(false); |
||||
|
|
||||
|
form.get('date')?.setValue(new Date(2026, 0, 1)); |
||||
|
expect(balanceControl?.enabled).toBe(true); |
||||
|
expect(balanceControl?.value).toBe(true); |
||||
|
expect(await submit()).toMatchObject({ |
||||
|
symbol: 'Deposit', |
||||
|
updateAccountBalance: true |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('preserves an explicit user opt-out across date and account changes', () => { |
||||
|
createComponent(); |
||||
|
const balanceControl = form.get('updateAccountBalance'); |
||||
|
|
||||
|
balanceControl?.setValue(false); |
||||
|
form.get('accountId')?.setValue(null); |
||||
|
form.get('accountId')?.setValue('bank-account'); |
||||
|
form.get('date')?.setValue(new Date(2026, 0, 2)); |
||||
|
|
||||
|
expect(balanceControl?.enabled).toBe(true); |
||||
|
expect(balanceControl?.value).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it('preserves the stored cash balance opt-out when cloning an activity', async () => { |
||||
|
data.activity.updateAccountBalance = false; |
||||
|
createComponent(); |
||||
|
|
||||
|
expect(form.get('updateAccountBalance')?.value).toBe(false); |
||||
|
expect((await submit()).updateAccountBalance).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it.each([true, false])( |
||||
|
'leaves the stored balance preference %s unchanged when editing a bank activity', |
||||
|
async (updateAccountBalance) => { |
||||
|
data.activity.id = 'bank-movement'; |
||||
|
data.activity.updateAccountBalance = updateAccountBalance; |
||||
|
createComponent(); |
||||
|
|
||||
|
expect(form.get('updateAccountBalance')?.value).toBe( |
||||
|
updateAccountBalance |
||||
|
); |
||||
|
expect(await submit()).not.toHaveProperty('updateAccountBalance'); |
||||
|
} |
||||
|
); |
||||
|
}); |
||||
@ -0,0 +1,489 @@ |
|||||
|
import type { CreateOrderDto } from '@ghostfolio/common/dtos'; |
||||
|
import type { Account } from '@ghostfolio/prisma/browser'; |
||||
|
import { DataSource, Type } from '@ghostfolio/prisma/enums'; |
||||
|
|
||||
|
import { HttpClient } from '@angular/common/http'; |
||||
|
import { TestBed } from '@angular/core/testing'; |
||||
|
import { unparse } from 'papaparse'; |
||||
|
import { of } from 'rxjs'; |
||||
|
|
||||
|
import { ImportActivitiesService } from './import-activities.service'; |
||||
|
|
||||
|
jest.mock('uuid', () => ({ |
||||
|
v4: () => '123e4567-e89b-42d3-a456-426614174000' |
||||
|
})); |
||||
|
|
||||
|
describe('ImportActivitiesService bank cash movements', () => { |
||||
|
let service: ImportActivitiesService; |
||||
|
let http: { post: jest.Mock }; |
||||
|
const userAccounts = [ |
||||
|
{ id: 'bank-account', name: 'Bank', currency: 'EUR' } |
||||
|
] as Account[]; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
http = { post: jest.fn().mockReturnValue(of({ activities: [] })) }; |
||||
|
|
||||
|
TestBed.configureTestingModule({ |
||||
|
providers: [{ provide: HttpClient, useValue: http }] |
||||
|
}); |
||||
|
|
||||
|
service = TestBed.runInInjectionContext(() => { |
||||
|
return new ImportActivitiesService(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function importedActivity() { |
||||
|
return (http.post.mock.calls[0][1] as { activities: CreateOrderDto[] }) |
||||
|
.activities[0]; |
||||
|
} |
||||
|
|
||||
|
function bankExportCsv(overrides: Record<string, unknown> = {}) { |
||||
|
return unparse([ |
||||
|
{ |
||||
|
datetime: '2026-09-10T12:48:24.596Z', |
||||
|
date: '2026-09-10', |
||||
|
account_type: 'DEFAULT', |
||||
|
category: 'CASH', |
||||
|
type: 'TRANSFER_INSTANT_INBOUND', |
||||
|
asset_class: null, |
||||
|
name: 'Example Counterparty', |
||||
|
symbol: null, |
||||
|
shares: null, |
||||
|
price: null, |
||||
|
amount: 100, |
||||
|
fee: null, |
||||
|
tax: null, |
||||
|
currency: 'EUR', |
||||
|
description: 'Incoming transfer', |
||||
|
transaction_id: 'example-id', |
||||
|
...overrides |
||||
|
} |
||||
|
]); |
||||
|
} |
||||
|
|
||||
|
it.each([ |
||||
|
['TRANSFER_INSTANT_INBOUND', 100, Type.DEPOSIT], |
||||
|
['TRANSFER_INBOUND', 100, Type.DEPOSIT], |
||||
|
['TRANSFER_INSTANT_OUTBOUND', -25, Type.WITHDRAWAL], |
||||
|
['TRANSFER_OUTBOUND', 25, Type.WITHDRAWAL] |
||||
|
])( |
||||
|
'imports bank export type %s with nullable trading fields and amount %s', |
||||
|
async (type, amount, expectedType) => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ type, amount }), |
||||
|
isDryRun: true |
||||
|
}); |
||||
|
|
||||
|
expect(http.post.mock.calls[0][0]).toBe('/api/v1/import?dryRun=true'); |
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: 'bank-account', |
||||
|
currency: 'EUR', |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
date: '2026-09-10T12:48:24.596Z', |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
type: expectedType, |
||||
|
unitPrice: Math.abs(amount as number), |
||||
|
updateAccountBalance: true |
||||
|
}); |
||||
|
expect(http.post.mock.calls[0][1].assetProfiles[0].name).toBe( |
||||
|
'Incoming transfer' |
||||
|
); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it.each([ |
||||
|
[100, Type.DEPOSIT], |
||||
|
[-25, Type.WITHDRAWAL] |
||||
|
])( |
||||
|
'classifies an unsupported cash type by its signed amount %s', |
||||
|
async (amount, type) => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ type: 'BANK_TRANSACTION', amount }) |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
type, |
||||
|
unitPrice: Math.abs(amount as number) |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it.each([ |
||||
|
[Type.INTEREST, 5, Type.DEPOSIT], |
||||
|
[Type.FEE, -2, Type.WITHDRAWAL] |
||||
|
])( |
||||
|
'classifies bank cash %s by its posted amount %s', |
||||
|
async (type, amount, expectedType) => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ type, amount }) |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: 'bank-account', |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
type: expectedType, |
||||
|
unitPrice: Math.abs(amount as number), |
||||
|
updateAccountBalance: true |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('retains non-bank fee activity fields', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: |
||||
|
'Date,Type,UnitPrice,Quantity,Fee,Symbol,Currency,Account\n2026-10-01,fee,0,0,2,Account fee,EUR,Bank' |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
fee: 2, |
||||
|
quantity: 0, |
||||
|
type: Type.FEE, |
||||
|
unitPrice: 0 |
||||
|
}); |
||||
|
expect(importedActivity()).not.toHaveProperty('updateAccountBalance'); |
||||
|
}); |
||||
|
|
||||
|
it.each(['BALANCE', 'TOTAL', 'SUMMARY'])( |
||||
|
'does not turn an informational bank cash %s row into a deposit', |
||||
|
async (type) => { |
||||
|
await expect( |
||||
|
service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ type }) |
||||
|
}) |
||||
|
).rejects.toMatchObject({ message: 'activities.0.type is not valid' }); |
||||
|
|
||||
|
expect(http.post).not.toHaveBeenCalled(); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('rejects a cash row whose zero amount has no direction', async () => { |
||||
|
await expect( |
||||
|
service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ type: 'BANK_TRANSACTION', amount: 0 }) |
||||
|
}) |
||||
|
).rejects.toMatchObject({ message: 'activities.0.type is not valid' }); |
||||
|
|
||||
|
expect(http.post).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('does not infer cash movements from signed amounts on unsupported security rows', async () => { |
||||
|
await expect( |
||||
|
service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ |
||||
|
category: 'SECURITY', |
||||
|
type: 'SPLIT', |
||||
|
amount: -100 |
||||
|
}) |
||||
|
}) |
||||
|
).rejects.toMatchObject({ message: 'activities.0.type is not valid' }); |
||||
|
|
||||
|
expect(http.post).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('does not classify an unsupported security settlement suffix as a bank movement', async () => { |
||||
|
await expect( |
||||
|
service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ |
||||
|
category: 'SECURITY', |
||||
|
type: 'TRADE_SETTLEMENT_OUTBOUND', |
||||
|
amount: -100 |
||||
|
}) |
||||
|
}) |
||||
|
).rejects.toMatchObject({ message: 'activities.0.type is not valid' }); |
||||
|
|
||||
|
expect(http.post).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it.each([Type.BUY, Type.SELL])( |
||||
|
'preserves an explicit %s over a bank alias and signed cash amount', |
||||
|
async (type) => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: bankExportCsv({ |
||||
|
action: 'TRANSFER_INSTANT_INBOUND', |
||||
|
type, |
||||
|
amount: -200, |
||||
|
symbol: 'AAPL', |
||||
|
shares: 2, |
||||
|
price: 100, |
||||
|
fee: 3 |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: undefined, |
||||
|
fee: 3, |
||||
|
quantity: 2, |
||||
|
symbol: 'AAPL', |
||||
|
type, |
||||
|
unitPrice: 100 |
||||
|
}); |
||||
|
expect(importedActivity()).not.toHaveProperty('updateAccountBalance'); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('leaves cash rows unassigned when several accounts exist', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts: [ |
||||
|
...userAccounts, |
||||
|
{ id: 'other-account', currency: 'EUR' } as Account |
||||
|
], |
||||
|
fileContent: bankExportCsv() |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: undefined, |
||||
|
updateAccountBalance: false |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('does not assign a sole account with a different currency to a bank export', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts: [{ ...userAccounts[0], currency: 'USD' }], |
||||
|
fileContent: bankExportCsv() |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: undefined, |
||||
|
updateAccountBalance: false |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('imports a deposit amount without security, quantity or fee columns', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: |
||||
|
'Date,Type,Amount,Currency,Account\n2026-10-01,deposit,100,EUR,Bank' |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: 'bank-account', |
||||
|
currency: 'EUR', |
||||
|
dataSource: DataSource.MANUAL, |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
type: Type.DEPOSIT, |
||||
|
unitPrice: 100, |
||||
|
updateAccountBalance: true |
||||
|
}); |
||||
|
expect(http.post.mock.calls[0][1].assetProfiles[0].name).toBe('Deposit'); |
||||
|
}); |
||||
|
|
||||
|
it.each(['withdrawal', 'withdraw'])( |
||||
|
'imports %s with a positive amount and an optional description', |
||||
|
async (type) => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: `Date,Type,Amount,Currency,Account,Description\n2026-10-01,${type},-25,EUR,Bank,Rent` |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
type: Type.WITHDRAWAL, |
||||
|
unitPrice: 25, |
||||
|
updateAccountBalance: true |
||||
|
}); |
||||
|
expect(http.post.mock.calls[0][1].assetProfiles[0].name).toBe('Rent'); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('does not update a balance when no bank account matches', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: |
||||
|
'Date,Type,Amount,Currency,Account\n2026-10-01,deposit,100,EUR,Unknown' |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
accountId: undefined, |
||||
|
updateAccountBalance: false |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('keeps investment activity quantities and fees while leaving balance defaults to the server', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: |
||||
|
'Date,Type,UnitPrice,Quantity,Fee,Symbol,Currency,Account\n2026-10-01,buy,100,2,3,AAPL,USD,Bank' |
||||
|
}); |
||||
|
|
||||
|
expect(importedActivity()).toMatchObject({ |
||||
|
fee: 3, |
||||
|
quantity: 2, |
||||
|
symbol: 'AAPL', |
||||
|
type: Type.BUY, |
||||
|
unitPrice: 100 |
||||
|
}); |
||||
|
expect(importedActivity()).not.toHaveProperty('updateAccountBalance'); |
||||
|
}); |
||||
|
|
||||
|
function mixedStatementCsv({ |
||||
|
investmentOverrides = {}, |
||||
|
cashOverrides = {} |
||||
|
}: { |
||||
|
investmentOverrides?: Record<string, unknown>; |
||||
|
cashOverrides?: Record<string, unknown>; |
||||
|
} = {}) { |
||||
|
return unparse([ |
||||
|
{ |
||||
|
Date: '2026-09-10', |
||||
|
Type: Type.DEPOSIT, |
||||
|
Amount: 1000, |
||||
|
Currency: 'EUR', |
||||
|
Account: null, |
||||
|
Symbol: null, |
||||
|
UnitPrice: null, |
||||
|
Quantity: null, |
||||
|
Fee: null, |
||||
|
DataSource: null, |
||||
|
UpdateAccountBalance: null, |
||||
|
...cashOverrides |
||||
|
}, |
||||
|
{ |
||||
|
Date: '2026-09-11', |
||||
|
Type: Type.BUY, |
||||
|
Amount: 201.89, |
||||
|
Currency: 'EUR', |
||||
|
Account: null, |
||||
|
Symbol: 'EXAMPLE', |
||||
|
UnitPrice: 100.945, |
||||
|
Quantity: 2, |
||||
|
Fee: 1, |
||||
|
DataSource: DataSource.YAHOO, |
||||
|
UpdateAccountBalance: null, |
||||
|
...investmentOverrides |
||||
|
} |
||||
|
]); |
||||
|
} |
||||
|
|
||||
|
it.each([Type.BUY, Type.SELL, Type.DIVIDEND, Type.INTEREST, Type.FEE])( |
||||
|
'assigns the safe sole cash account to %s in a mixed statement without forcing a balance preference', |
||||
|
async (type) => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ investmentOverrides: { Type: type } }) |
||||
|
}); |
||||
|
|
||||
|
const activities = http.post.mock.calls[0][1] |
||||
|
.activities as CreateOrderDto[]; |
||||
|
|
||||
|
expect(activities[0].updateAccountBalance).toBe(true); |
||||
|
expect(activities[1]).toMatchObject({ |
||||
|
accountId: 'bank-account', |
||||
|
fee: 1, |
||||
|
quantity: 2, |
||||
|
type, |
||||
|
unitPrice: 100.945 |
||||
|
}); |
||||
|
expect(activities[1]).not.toHaveProperty('updateAccountBalance'); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('does not assign the cash account to a liability automatically', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ |
||||
|
investmentOverrides: { |
||||
|
Type: Type.LIABILITY, |
||||
|
DataSource: DataSource.MANUAL, |
||||
|
Symbol: 'Custom asset' |
||||
|
} |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
expect(http.post.mock.calls[0][1].activities[1].accountId).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('assigns the cash account to a manual purchase in a mixed statement', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ |
||||
|
investmentOverrides: { |
||||
|
Type: Type.BUY, |
||||
|
DataSource: DataSource.MANUAL, |
||||
|
Symbol: 'Custom asset' |
||||
|
} |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
const purchase = http.post.mock.calls[0][1].activities[1] as CreateOrderDto; |
||||
|
|
||||
|
expect(purchase.accountId).toBe('bank-account'); |
||||
|
expect(purchase).not.toHaveProperty('updateAccountBalance'); |
||||
|
}); |
||||
|
|
||||
|
it('does not override an explicit unknown account in a mixed statement', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ |
||||
|
investmentOverrides: { Account: 'Unknown' } |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
expect(http.post.mock.calls[0][1].activities[1].accountId).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('does not assign an arbitrary account to mixed statements with several accounts', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts: [ |
||||
|
...userAccounts, |
||||
|
{ ...userAccounts[0], id: 'other-account' } |
||||
|
], |
||||
|
fileContent: mixedStatementCsv({ cashOverrides: { Account: 'Bank' } }) |
||||
|
}); |
||||
|
|
||||
|
expect(http.post.mock.calls[0][1].activities[1].accountId).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('does not use the cash account for an investment in another currency', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ |
||||
|
investmentOverrides: { Currency: 'USD' } |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
expect(http.post.mock.calls[0][1].activities[1].accountId).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('preserves explicit CSV balance opt-outs for cash and investment rows', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ |
||||
|
cashOverrides: { UpdateAccountBalance: false }, |
||||
|
investmentOverrides: { UpdateAccountBalance: false } |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
const activities = http.post.mock.calls[0][1] |
||||
|
.activities as CreateOrderDto[]; |
||||
|
|
||||
|
expect( |
||||
|
activities.map(({ updateAccountBalance }) => updateAccountBalance) |
||||
|
).toEqual([false, false]); |
||||
|
}); |
||||
|
|
||||
|
it('preserves an explicit CSV balance opt-in for investment rows', async () => { |
||||
|
await service.importCsv({ |
||||
|
userAccounts, |
||||
|
fileContent: mixedStatementCsv({ |
||||
|
investmentOverrides: { UpdateAccountBalance: true } |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
expect(http.post.mock.calls[0][1].activities[1].updateAccountBalance).toBe( |
||||
|
true |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,3 @@ |
|||||
|
-- AlterEnum |
||||
|
ALTER TYPE "Type" ADD VALUE 'DEPOSIT'; |
||||
|
ALTER TYPE "Type" ADD VALUE 'WITHDRAWAL'; |
||||
Loading…
Reference in new issue