feat(events): support date range filtering
This commit is contained in:
@@ -8,7 +8,13 @@ describe('UpcomingEventsController', () => {
|
|||||||
it('passes the authenticated user and account to the service', async () => {
|
it('passes the authenticated user and account to the service', async () => {
|
||||||
const events = { get: jest.fn() };
|
const events = { get: jest.fn() };
|
||||||
const controller = new UpcomingEventsController(events as never);
|
const controller = new UpcomingEventsController(events as never);
|
||||||
await controller.get({ user: { sub: 'user' } } as never, 'account');
|
await controller.get({ user: { sub: 'user' } } as never, 'account', {
|
||||||
expect(events.get).toHaveBeenCalledWith('user', 'account');
|
from: '2026-09-01',
|
||||||
|
to: '2026-09-30',
|
||||||
|
});
|
||||||
|
expect(events.get).toHaveBeenCalledWith('user', 'account', {
|
||||||
|
from: '2026-09-01',
|
||||||
|
to: '2026-09-30',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AccessPayload } from '../auth/auth.types';
|
import { AccessPayload } from '../auth/auth.types';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
|
||||||
import { UpcomingEventsDto } from './upcoming-events.dto';
|
import {
|
||||||
|
UpcomingEventsDto,
|
||||||
|
UpcomingEventsQueryDto,
|
||||||
|
} from './upcoming-events.dto';
|
||||||
import { UpcomingEventsService } from './upcoming-events.service';
|
import { UpcomingEventsService } from './upcoming-events.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
@@ -17,8 +20,11 @@ export class UpcomingEventsController {
|
|||||||
|
|
||||||
@Get('all/events')
|
@Get('all/events')
|
||||||
@ApiOkResponse({ type: UpcomingEventsDto })
|
@ApiOkResponse({ type: UpcomingEventsDto })
|
||||||
all(@Req() request: Request & { user: AccessPayload }) {
|
all(
|
||||||
return this.events.getAll(request.user.sub);
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Query() query: UpcomingEventsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.events.getAll(request.user.sub, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/events')
|
@Get(':accountId/events')
|
||||||
@@ -26,7 +32,8 @@ export class UpcomingEventsController {
|
|||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: UpcomingEventsQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.events.get(request.user.sub, accountId);
|
return this.events.get(request.user.sub, accountId, query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
||||||
import { AccountReferenceDto } from './account-reference.dto';
|
import { AccountReferenceDto } from './account-reference.dto';
|
||||||
import { MoneyDto } from './portfolio.dto';
|
import { MoneyDto } from './portfolio.dto';
|
||||||
|
|
||||||
|
export class UpcomingEventsQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'date',
|
||||||
|
description:
|
||||||
|
'Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpcomingEventDto {
|
export class UpcomingEventDto {
|
||||||
@ApiPropertyOptional({ type: AccountReferenceDto })
|
@ApiPropertyOptional({ type: AccountReferenceDto })
|
||||||
account?: AccountReferenceDto;
|
account?: AccountReferenceDto;
|
||||||
|
|||||||
@@ -50,6 +50,102 @@ describe('UpcomingEventsService', () => {
|
|||||||
tbank.getDividends.mockResolvedValue([]);
|
tbank.getDividends.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => {
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'S',
|
||||||
|
ticker: 'SHARE',
|
||||||
|
instrumentType: 'share',
|
||||||
|
quantity: { units: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
figi: 'B',
|
||||||
|
ticker: 'BOND',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantity: { units: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
moex.bondEvents.mockResolvedValue([
|
||||||
|
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
|
||||||
|
{ type: 'offer', date: '2099-02-28' },
|
||||||
|
{ type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' },
|
||||||
|
{ type: 'offer', date: '2099-01-31' },
|
||||||
|
]);
|
||||||
|
tbank.getDividends.mockImplementation(
|
||||||
|
(_token: string, _id: string, from: string) =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
dividendNet: { units: 5, currency: 'RUB' },
|
||||||
|
recordDate: {
|
||||||
|
seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const february = { from: '2099-02-01', to: '2099-02-28' };
|
||||||
|
const first = await service.get('user', 'account', february);
|
||||||
|
expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([
|
||||||
|
{ type: 'coupon', date: february.from },
|
||||||
|
{ type: 'dividend', date: february.from },
|
||||||
|
{ type: 'offer', date: february.to },
|
||||||
|
]);
|
||||||
|
expect(first.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'RUB', value: '16' },
|
||||||
|
]);
|
||||||
|
const march = await service.get('user', 'account', {
|
||||||
|
from: '2099-03-01',
|
||||||
|
to: '2099-03-01',
|
||||||
|
});
|
||||||
|
expect(march.events.map((event) => event.type)).toEqual([
|
||||||
|
'dividend',
|
||||||
|
'maturity',
|
||||||
|
]);
|
||||||
|
expect(march.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'RUB', value: '10' },
|
||||||
|
]);
|
||||||
|
await service.get('user', 'account', february);
|
||||||
|
expect(tbank.getDividends).toHaveBeenCalledTimes(2);
|
||||||
|
expect(tbank.getDividends).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'token',
|
||||||
|
'S',
|
||||||
|
february.from,
|
||||||
|
february.to,
|
||||||
|
);
|
||||||
|
expect(tbank.getDividends).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'token',
|
||||||
|
'S',
|
||||||
|
'2099-03-01',
|
||||||
|
'2099-03-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults the end to 30 days after the selected start', async () => {
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'B',
|
||||||
|
ticker: 'BOND',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantity: { units: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
moex.bondEvents.mockResolvedValue([
|
||||||
|
{ type: 'offer', date: '2099-01-01' },
|
||||||
|
{ type: 'offer', date: '2099-01-31' },
|
||||||
|
{ type: 'offer', date: '2099-02-01' },
|
||||||
|
]);
|
||||||
|
const result = await service.get('user', 'account', { from: '2099-01-01' });
|
||||||
|
expect(result.events.map((event) => event.date)).toEqual([
|
||||||
|
'2099-01-01',
|
||||||
|
'2099-01-31',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('projects exact amounts and preserves registry dates and offers', async () => {
|
it('projects exact amounts and preserves registry dates and offers', async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const day = (offset: number) =>
|
const day = (offset: number) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../common/logging/request-context.service';
|
||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
TbankInstrument,
|
TbankInstrument,
|
||||||
TbankPortfolio,
|
TbankPortfolio,
|
||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto';
|
import {
|
||||||
|
UpcomingEventDto,
|
||||||
|
UpcomingEventsDto,
|
||||||
|
UpcomingEventsQueryDto,
|
||||||
|
} from './upcoming-events.dto';
|
||||||
|
|
||||||
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
||||||
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
|
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
|
||||||
@@ -46,20 +50,29 @@ export class UpcomingEventsService {
|
|||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(userId: string, accountId: string): Promise<UpcomingEventsDto> {
|
async get(
|
||||||
|
userId: string,
|
||||||
|
accountId: string,
|
||||||
|
query: UpcomingEventsQueryDto = {},
|
||||||
|
): Promise<UpcomingEventsDto> {
|
||||||
|
const window = dateWindow(query);
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
return this.access.withAccount(userId, accountId, ({ token }) =>
|
return this.access.withAccount(userId, accountId, ({ token }) =>
|
||||||
this.forAccount(token, accountId),
|
this.forAccount(token, accountId, window),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(userId: string): Promise<UpcomingEventsDto> {
|
async getAll(
|
||||||
|
userId: string,
|
||||||
|
query: UpcomingEventsQueryDto = {},
|
||||||
|
): Promise<UpcomingEventsDto> {
|
||||||
|
const window = dateWindow(query);
|
||||||
return this.access.withSupportedAccounts(
|
return this.access.withSupportedAccounts(
|
||||||
userId,
|
userId,
|
||||||
async ({ token, accounts }) => {
|
async ({ token, accounts }) => {
|
||||||
const events: UpcomingEventDto[] = [];
|
const events: UpcomingEventDto[] = [];
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const result = await this.forAccount(token, account.id);
|
const result = await this.forAccount(token, account.id, window);
|
||||||
events.push(
|
events.push(
|
||||||
...result.events.map((event) => ({
|
...result.events.map((event) => ({
|
||||||
...event,
|
...event,
|
||||||
@@ -85,9 +98,9 @@ export class UpcomingEventsService {
|
|||||||
private async forAccount(
|
private async forAccount(
|
||||||
token: string,
|
token: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
|
window: { from: string; to: string },
|
||||||
): Promise<UpcomingEventsDto> {
|
): Promise<UpcomingEventsDto> {
|
||||||
const portfolio = await this.tbank.getPortfolio(token, accountId);
|
const portfolio = await this.tbank.getPortfolio(token, accountId);
|
||||||
const window = dateWindow();
|
|
||||||
const supportedPositions = (portfolio.positions ?? []).filter(supported);
|
const supportedPositions = (portfolio.positions ?? []).filter(supported);
|
||||||
const events: UpcomingEventDto[][] = [];
|
const events: UpcomingEventDto[][] = [];
|
||||||
for (
|
for (
|
||||||
@@ -101,7 +114,7 @@ export class UpcomingEventsService {
|
|||||||
);
|
);
|
||||||
events.push(
|
events.push(
|
||||||
...(await Promise.all(
|
...(await Promise.all(
|
||||||
batch.map((position) => this.positionEvents(token, position)),
|
batch.map((position) => this.positionEvents(token, position, window)),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,6 +140,7 @@ export class UpcomingEventsService {
|
|||||||
private async positionEvents(
|
private async positionEvents(
|
||||||
token: string,
|
token: string,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
window: { from: string; to: string },
|
||||||
): Promise<UpcomingEventDto[]> {
|
): Promise<UpcomingEventDto[]> {
|
||||||
const ticker = position.ticker?.trim();
|
const ticker = position.ticker?.trim();
|
||||||
const instrument = ticker
|
const instrument = ticker
|
||||||
@@ -147,7 +161,7 @@ export class UpcomingEventsService {
|
|||||||
};
|
};
|
||||||
const quantity = decimal(position.quantity ?? {});
|
const quantity = decimal(position.quantity ?? {});
|
||||||
if (position.instrumentType?.toLowerCase() === 'share') {
|
if (position.instrumentType?.toLowerCase() === 'share') {
|
||||||
const dividends = await this.dividends(token, position);
|
const dividends = await this.dividends(token, position, window);
|
||||||
this.logger.event('debug', 'upcoming-events.source-result', {
|
this.logger.event('debug', 'upcoming-events.source-result', {
|
||||||
count: dividends.length,
|
count: dividends.length,
|
||||||
});
|
});
|
||||||
@@ -180,13 +194,13 @@ export class UpcomingEventsService {
|
|||||||
private async dividends(
|
private async dividends(
|
||||||
token: string,
|
token: string,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
window: { from: string; to: string },
|
||||||
): Promise<TbankDividend[]> {
|
): Promise<TbankDividend[]> {
|
||||||
const instrumentId = position.instrumentUid ?? position.figi;
|
const instrumentId = position.instrumentUid ?? position.figi;
|
||||||
if (!instrumentId) return [];
|
if (!instrumentId) return [];
|
||||||
const window = dateWindow();
|
|
||||||
const dividends = await this.cached(
|
const dividends = await this.cached(
|
||||||
this.dividendCache,
|
this.dividendCache,
|
||||||
instrumentId,
|
JSON.stringify([instrumentId, window.from, window.to]),
|
||||||
dividendCacheTtlMs,
|
dividendCacheTtlMs,
|
||||||
() =>
|
() =>
|
||||||
this.tbank.getDividends(token, instrumentId, window.from, window.to),
|
this.tbank.getDividends(token, instrumentId, window.from, window.to),
|
||||||
@@ -267,13 +281,14 @@ function supported(position: Position): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateWindow() {
|
function dateWindow(query: UpcomingEventsQueryDto) {
|
||||||
const now = new Date();
|
const from = query.from ?? new Date().toISOString().slice(0, 10);
|
||||||
const from = now.toISOString().slice(0, 10);
|
const end = new Date(`${from}T00:00:00.000Z`);
|
||||||
const end = new Date(
|
end.setUTCDate(end.getUTCDate() + 30);
|
||||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 30),
|
const to = query.to ?? end.toISOString().slice(0, 10);
|
||||||
);
|
if (from > to)
|
||||||
return { from, to: end.toISOString().slice(0, 10) };
|
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
|
||||||
|
return { from, to };
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectedMoney(
|
function projectedMoney(
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { configureApp } from '../src/main';
|
||||||
|
import { TbankAccessService } from '../src/tbank/tbank-access.service';
|
||||||
|
import { TbankClient } from '../src/tbank/tbank.client';
|
||||||
|
import { MoexClient } from '../src/tbank/moex.client';
|
||||||
|
|
||||||
|
describe('Upcoming events date filter HTTP boundary', () => {
|
||||||
|
let app: INestApplication<Server>;
|
||||||
|
const getPortfolio = jest.fn();
|
||||||
|
const token = new JwtService().sign(
|
||||||
|
{ sub: 'account-owner', type: 'access' },
|
||||||
|
{ secret: process.env.JWT_SECRET },
|
||||||
|
);
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
.overrideProvider(TbankAccessService)
|
||||||
|
.useValue({
|
||||||
|
withAccount: (
|
||||||
|
_user: string,
|
||||||
|
_id: string,
|
||||||
|
handler: (ctx: unknown) => unknown,
|
||||||
|
) => handler({ token: 'unused' }),
|
||||||
|
withSupportedAccounts: (
|
||||||
|
_user: string,
|
||||||
|
handler: (ctx: unknown) => unknown,
|
||||||
|
) =>
|
||||||
|
handler({
|
||||||
|
token: 'unused',
|
||||||
|
accounts: [
|
||||||
|
{ id: 'account', name: 'Main' },
|
||||||
|
{ id: 'second', name: 'Second' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.overrideProvider(TbankClient)
|
||||||
|
.useValue({
|
||||||
|
getPortfolio,
|
||||||
|
getInstrumentByFigi: () => Promise.resolve(null),
|
||||||
|
})
|
||||||
|
.overrideProvider(MoexClient)
|
||||||
|
.useValue({
|
||||||
|
bondEvents: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
|
||||||
|
{ type: 'offer', date: '2099-02-28' },
|
||||||
|
{ type: 'offer', date: '2099-03-01' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
configureApp(app);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
getPortfolio.mockReset().mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'B',
|
||||||
|
ticker: 'BOND',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
quantity: { units: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(() => app.close());
|
||||||
|
|
||||||
|
describe.each(['account', 'all'])('%s account scope', (scope) => {
|
||||||
|
it('returns only the requested inclusive period and matching totals', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/api/v1/brokerage-accounts/${scope}/events?from=2099-02-01&to=2099-02-28`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
accountId: scope,
|
||||||
|
events:
|
||||||
|
scope === 'all'
|
||||||
|
? [
|
||||||
|
{ date: '2099-02-01', account: { id: 'account' } },
|
||||||
|
{ date: '2099-02-01', account: { id: 'second' } },
|
||||||
|
{ date: '2099-02-28', account: { id: 'account' } },
|
||||||
|
{ date: '2099-02-28', account: { id: 'second' } },
|
||||||
|
]
|
||||||
|
: [{ date: '2099-02-01' }, { date: '2099-02-28' }],
|
||||||
|
incomeTotalsByCurrency: [
|
||||||
|
{ currency: 'RUB', value: scope === 'all' ? '12' : '6' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'from=not-a-date',
|
||||||
|
'from=2099-02-30',
|
||||||
|
'to=2099-13-01',
|
||||||
|
'from=2099-02-01T00:00:00Z',
|
||||||
|
'from=2099-02-28&to=2099-02-01',
|
||||||
|
])('rejects invalid dates before upstream access: %s', async (query) => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/brokerage-accounts/${scope}/events?${query}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(400);
|
||||||
|
expect(getPortfolio).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user