feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -1,373 +1,197 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
|
||||
TbankAccessService: class TbankAccessService {},
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
|
||||
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
|
||||
import type {
|
||||
TbankAccountContext,
|
||||
TbankConnectionContext,
|
||||
} from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
const access = {
|
||||
withConnection: jest.fn(),
|
||||
withAccountForConnection: jest.fn(),
|
||||
describe('persistent portfolio projection', () => {
|
||||
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
|
||||
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
|
||||
const metadata = { savedMany: jest.fn(), request: jest.fn() };
|
||||
const repository = {
|
||||
read: jest.fn(),
|
||||
reserve: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
fail: jest.fn(),
|
||||
};
|
||||
const tbank = {
|
||||
getPortfolio: jest.fn(),
|
||||
getPositions: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
};
|
||||
const requestContext = { set: jest.fn() };
|
||||
const logger = { event: jest.fn() };
|
||||
const metadata = new InstrumentMetadataService(
|
||||
tbank as never,
|
||||
logger as never,
|
||||
);
|
||||
const service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
requestContext as never,
|
||||
metadata,
|
||||
);
|
||||
let generation: string;
|
||||
|
||||
let service: PortfolioService;
|
||||
const membershipAt = new Date();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service.invalidate('user');
|
||||
generation = 'generation-1';
|
||||
access.withConnection.mockImplementation(
|
||||
(
|
||||
_userId: string,
|
||||
handler: (context: TbankConnectionContext) => Promise<unknown>,
|
||||
) => handler({ token: 'token', generation }),
|
||||
jest.resetAllMocks();
|
||||
service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
{ set: jest.fn() } as never,
|
||||
metadata as never,
|
||||
repository as never,
|
||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ event: jest.fn() } as never,
|
||||
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
);
|
||||
access.withAccountForConnection.mockImplementation(
|
||||
(
|
||||
connection: TbankConnectionContext,
|
||||
_accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
||||
) =>
|
||||
handler({
|
||||
...connection,
|
||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||
}),
|
||||
);
|
||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||
access.local.mockResolvedValue({
|
||||
revision: 'private',
|
||||
membershipAt,
|
||||
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
|
||||
});
|
||||
access.tokenForAttempt.mockResolvedValue('synthetic');
|
||||
repository.reserve.mockResolvedValue(null);
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [],
|
||||
work: [],
|
||||
});
|
||||
metadata.savedMany.mockResolvedValue(new Map());
|
||||
metadata.request.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('resolves account-scoped access before loading upstream portfolio', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
|
||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
||||
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
|
||||
repository.reserve.mockResolvedValueOnce({
|
||||
launch: true,
|
||||
work: { id: 'work', attemptId: 'attempt' },
|
||||
});
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
figi: 'A',
|
||||
instrumentUid: 'uid',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'rub', units: 3 },
|
||||
averagePositionPrice: { currency: 'RUB', units: 2 },
|
||||
quantity: { units: '2' },
|
||||
currentPrice: { currency: 'rub', units: '3', nano: 1 },
|
||||
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
figi: 'B',
|
||||
instrumentType: 'new-kind',
|
||||
quantity: { nano: -500000000 },
|
||||
currentPrice: { currency: 'USD', units: 4 },
|
||||
currentPrice: { currency: 'USD', units: '4' },
|
||||
},
|
||||
{ figi: 'ZERO', quantity: {} },
|
||||
{ figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } },
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [
|
||||
{ currency: 'RUB', units: 1 },
|
||||
{ currency: 'rub', nano: 500000000 },
|
||||
{ currency: 'USD' },
|
||||
{ currency: 'rub', units: '1' },
|
||||
{ currency: 'EUR', units: '0' },
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByFigi
|
||||
.mockResolvedValueOnce({
|
||||
ticker: 'ONE',
|
||||
name: 'Первая позиция',
|
||||
isin: 'RU000A0JX0J2',
|
||||
brand: { logoName: 'RU000A0JX0J2x160.png' },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('unavailable'));
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [
|
||||
metadata.savedMany.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
JSON.stringify(['uid', 'A']),
|
||||
{ data: { name: 'Known', ticker: 'A' } },
|
||||
],
|
||||
]),
|
||||
);
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
displayName: 'Первая позиция',
|
||||
type: 'STOCK',
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '6' },
|
||||
absoluteReturn: { currency: 'RUB', value: '2' },
|
||||
isin: 'RU000A0JX0J2',
|
||||
ticker: 'ONE',
|
||||
logoName: 'RU000A0JX0J2x160.png',
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
displayName: 'TWO',
|
||||
type: 'OTHER',
|
||||
quantity: '-0.5',
|
||||
currentValue: { currency: 'USD', value: '-2' },
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
accountId: 'a',
|
||||
name: 'A',
|
||||
type: 'BROKERAGE',
|
||||
openedOn: null,
|
||||
portfolio: null,
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: 'RUB', value: '1.5' }],
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '7.5' },
|
||||
{ currency: 'USD', value: '-2' },
|
||||
],
|
||||
work: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
quantity: { units: 1 },
|
||||
currentPrice: { currency: 'RUB' },
|
||||
},
|
||||
{
|
||||
quantity: { units: 1 },
|
||||
currentPrice: { units: 10 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [{ currency: 'USD' }, { units: 2 }],
|
||||
});
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
totalsByCurrency: [],
|
||||
cashBalances: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses instrument UID before FIGI when resolving a name', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'FIGI',
|
||||
instrumentUid: 'UID',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [{ displayName: 'Имя по UID', logoName: null }],
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
|
||||
const positions = Array.from({ length: 60 }, (_, index) => ({
|
||||
figi: `BATCH-${index}`,
|
||||
instrumentType: index % 2 ? 'bond' : 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'RUB', units: 10 },
|
||||
}));
|
||||
tbank.getPortfolio.mockResolvedValue({ positions });
|
||||
let active = 0;
|
||||
tbank.getInstrumentByFigi.mockImplementation(
|
||||
async (_token: string, figi: string) => {
|
||||
active += 1;
|
||||
try {
|
||||
if (active > 50)
|
||||
throw new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
await Promise.resolve();
|
||||
return {
|
||||
name: `Название ${figi}`,
|
||||
ticker: figi,
|
||||
isin: `ISIN-${figi}`,
|
||||
brand: { logoName: `${figi}.png` },
|
||||
};
|
||||
} finally {
|
||||
active -= 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = await service.get('user', 'account');
|
||||
expect(result.positions).toMatchObject(
|
||||
positions.map((position, index) => ({
|
||||
displayName: `Название BATCH-${index}`,
|
||||
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
|
||||
ticker: `BATCH-${index}`,
|
||||
isin: `ISIN-BATCH-${index}`,
|
||||
logoName: `BATCH-${index}.png`,
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '20' },
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
|
||||
try {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: Array.from({ length: 60 }, (_, index) => ({
|
||||
figi: `BUDGET-${index}`,
|
||||
instrumentUid: `budget-uid-${index}`,
|
||||
instrumentType: index % 2 ? 'bond' : 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'RUB', units: 10 },
|
||||
})),
|
||||
});
|
||||
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
|
||||
const pending = service.get('user', 'account');
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
const result = await pending;
|
||||
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
|
||||
expect(result.positions).toHaveLength(60);
|
||||
expect(result.positions[59]).toMatchObject({
|
||||
displayName: 'BUDGET-59',
|
||||
currentValue: { currency: 'RUB', value: '20' },
|
||||
});
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
service.invalidate('user');
|
||||
await service.get('user', 'account');
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mask an invalid token with a snapshot', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
access.withAccountForConnection.mockRejectedValueOnce(
|
||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reuse a snapshot or fallback across connection generations', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
generation = 'generation-2';
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
access.withAccountForConnection.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return a snapshot when the current connection is absent', async () => {
|
||||
await service.get('user', 'account');
|
||||
access.withConnection.mockRejectedValueOnce(
|
||||
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a late previous-generation portfolio snapshot', async () => {
|
||||
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
|
||||
const previous = new Promise<{ positions: never[] }>((resolve) => {
|
||||
finishPrevious = resolve;
|
||||
});
|
||||
tbank.getPortfolio
|
||||
.mockReturnValueOnce(previous)
|
||||
.mockResolvedValueOnce({ positions: [] })
|
||||
.mockResolvedValueOnce({ positions: [] });
|
||||
tbank.getPositions
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
|
||||
|
||||
const previousRequest = service.get('user', 'account');
|
||||
generation = 'generation-2';
|
||||
const current = await service.get('user', 'account');
|
||||
finishPrevious?.({ positions: [] });
|
||||
await previousRequest;
|
||||
const afterLateCompletion = await service.get('user', 'account');
|
||||
|
||||
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
|
||||
expect(afterLateCompletion.cashBalances).toEqual([
|
||||
{ currency: 'USD', value: '3' },
|
||||
await service.read('user', 'a');
|
||||
for (
|
||||
let index = 0;
|
||||
index < 10 && !repository.publish.mock.calls.length;
|
||||
index += 1
|
||||
)
|
||||
await immediate();
|
||||
const snapshot = (
|
||||
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
|
||||
)[0][2];
|
||||
expect(
|
||||
snapshot.positions.map((position) => [
|
||||
position.displayName,
|
||||
position.type,
|
||||
position.quantity,
|
||||
]),
|
||||
).toEqual([
|
||||
['Known', 'STOCK', '2'],
|
||||
['B', 'OTHER', '-0.5'],
|
||||
]);
|
||||
expect(snapshot.positions[0].absoluteReturn).toEqual({
|
||||
currency: 'RUB',
|
||||
value: '2.000000002',
|
||||
});
|
||||
expect(snapshot.totalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '7.000000002' },
|
||||
{ currency: 'USD', value: '-2' },
|
||||
]);
|
||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||
});
|
||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||
const snapshot: PortfolioDto = {
|
||||
lastSuccessfulAt: membershipAt.toISOString(),
|
||||
isStale: false,
|
||||
positions: [],
|
||||
cashBalances: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
|
||||
};
|
||||
const account = {
|
||||
accountId: 'a',
|
||||
name: 'A',
|
||||
type: 'BROKERAGE',
|
||||
openedOn: null,
|
||||
portfolio: {
|
||||
payload: snapshot,
|
||||
fetchedAt: membershipAt,
|
||||
dataRevision: 1,
|
||||
},
|
||||
};
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
|
||||
work: [],
|
||||
});
|
||||
const partial = await service.read('user', 'all', true);
|
||||
expect(partial.data).toBeNull();
|
||||
expect(partial.meta.availability).toBe('partial');
|
||||
const saved = await service.saved('user', 'all', true);
|
||||
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
||||
snapshot.totalsByCurrency,
|
||||
);
|
||||
expect(saved.accounts[1].snapshot).toBeNull();
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [
|
||||
account,
|
||||
{
|
||||
...account,
|
||||
accountId: 'b',
|
||||
portfolio: { ...account.portfolio, fetchedAt: new Date(0) },
|
||||
},
|
||||
],
|
||||
work: [],
|
||||
});
|
||||
const complete = await service.read('user', 'all', true);
|
||||
expect(complete.data?.totalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '2.000000002' },
|
||||
]);
|
||||
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||
expect(complete.data?.isStale).toBe(true);
|
||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps pending data unavailable to internal financial consumers', async () => {
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt: null,
|
||||
membershipRevision: 0,
|
||||
accounts: [],
|
||||
work: [],
|
||||
});
|
||||
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user