Files
backend/test/bank-cards.e2e-spec.ts
T
ksv741 4796d9d3f0
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
fix: harden auth and investment synchronization
Resolve A01-A10 and C01-C07 from the backend audit.
Preserve auth failures, forced candle reconciliation and forecast freshness.
Validate MOEX responses and nested HTTP errors; remove unused legacy paths.

Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability.
Record the remaining macOS native SQLite abort in the audit.
2026-09-27 14:09:05 +03:00

791 lines
29 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import {
BankCardDto,
BankOperationDto,
BankOperationsPageDto,
BankSummaryDto,
ExpenseCategoryDto,
} from '../src/modules/banking/bank-cards/presentation/bank-card.dto';
import { PrismaService } from '../src/prisma/prisma.service';
jest.setTimeout(60000);
describe('Manual bank cards configured HTTP and real SQLite', () => {
let app: INestApplication<Server>, db: PrismaService;
const owner = randomUUID(),
stranger = randomUUID();
const token = (user = owner) =>
new JwtService().sign(
{ sub: user, type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const money = (value: string) => ({ currency: 'RUB', value });
const auth = (
method: 'get' | 'post' | 'patch' | 'delete',
path: string,
user = owner,
) =>
request(app.getHttpServer())
[method]('/api/v1/bank-cards' + path)
.set('Authorization', `Bearer ${token(user)}`);
const createCard = async (value = '1000', user = owner, extra = {}) =>
(
await auth('post', '', user)
.send({
id: randomUUID(),
name: 'Вымышленная карта',
openingDate: '2026-01-01',
openingBalance: money(value),
...extra,
})
.expect(201)
).body as BankCardDto;
const getCard = async (id: string) =>
(await auth('get', '/' + id).expect(200)).body as BankCardDto;
const getOp = async (id: string) =>
(await auth('get', '/operations/' + id).expect(200))
.body as BankOperationDto;
const op = (cardId: string, kind = 'expense', value = '100', extra = {}) => ({
id: randomUUID(),
cardId,
kind,
amount: money(value),
date: '2026-08-01',
...extra,
});
const createOp = async (body: object) =>
(await auth('post', '/operations').send(body).expect(201))
.body as BankOperationDto;
const categories = async () =>
(await auth('get', '/categories').expect(200)).body as ExpenseCategoryDto[];
const summary = async (month: string, cardId?: string) =>
(
await auth(
'get',
`/summary?month=${month}${cardId ? '&cardId=' + cardId : ''}`,
).expect(200)
).body as BankSummaryDto;
const start = async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.listen(0, '127.0.0.1');
db = app.get(PrismaService);
};
beforeAll(async () => {
await start();
await db.user.createMany({
data: [
{
id: owner,
email: owner + '@example.test',
passwordHash: 'fictional',
},
{
id: stranger,
email: stranger + '@example.test',
passwordHash: 'fictional',
},
],
});
});
afterAll(async () => {
if (app) await app.close();
});
it('requires auth and reads never seed data or need T-Bank', async () => {
for (const path of ['', '/categories', '/summary?month=2026-08'])
await request(app.getHttpServer())
.get('/api/v1/bank-cards' + path)
.expect(401);
expect((await auth('get', '').expect(200)).body).toEqual([]);
expect(await categories()).toEqual([]);
expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe(
0,
);
});
it('seeds once concurrently, replays card creation and preserves current archived state', async () => {
const body = {
id: randomUUID(),
name: ' Основная ',
openingDate: '2026-01-01',
openingBalance: money('1000'),
};
const responses = await Promise.all([
auth('post', '').send(body),
auth('post', '').send(body),
auth('post', '').send({ ...body, id: randomUUID() }),
]);
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
expect(await db.bankCard.count({ where: { userId: owner } })).toBe(2);
expect(await categories()).toHaveLength(9);
await auth('post', '')
.send({ ...body, name: 'Другое имя' })
.expect(409);
const archived = (
await auth('patch', '/' + body.id)
.send({ version: 1, archived: true })
.expect(200)
).body as BankCardDto;
const replay = (await auth('post', '').send(body).expect(201))
.body as BankCardDto;
expect(replay).toEqual(archived);
expect(replay.name).toBe('Основная');
});
it('roundtrips >2^53 and signed64 boundaries, and calculates beyond a stored value', async () => {
for (const value of [
'90071992547409.93',
'92233720368547758.07',
'-92233720368547758.08',
]) {
const card = await createCard(value);
expect(card.balance.value).toBe(value);
expect((await getCard(card.id)).openingBalance.value).toBe(value);
}
const card = await createCard('92233720368547758.07');
await createOp(op(card.id, 'topup', '0.01'));
expect((await getCard(card.id)).balance.value).toBe('92233720368547758.08');
const exact = await createCard('1');
await createOp(op(exact.id, 'expense', '0.10'));
await createOp(op(exact.id, 'expense', '0.20'));
expect((await getCard(exact.id)).balance.value).toBe('0.7');
await createOp(op(exact.id, 'expense', '1'));
expect((await getCard(exact.id)).balance.value).toBe('-0.3');
});
it('clears optional card metadata and changes opening balance without synthetic operations', async () => {
const card = await createCard('0', owner, {
bankName: 'Fake Bank',
lastFour: '1234',
});
const changed = (
await auth('patch', '/' + card.id)
.send({
version: 1,
bankName: null,
lastFour: null,
openingBalance: money('-25'),
})
.expect(200)
).body as BankCardDto;
expect(changed).toMatchObject({
bankName: null,
lastFour: null,
balance: money('-25'),
});
expect(
await db.bankCardOperation.count({ where: { cardId: card.id } }),
).toBe(0);
expect((await summary('2026-08', card.id)).net.value).toBe('0');
});
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
const card = await createCard();
const expense = await createOp(op(card.id));
const archived = (
await auth('patch', '/' + card.id)
.send({ version: card.version, archived: true })
.expect(200)
).body as BankCardDto;
for (const field of ['archived', 'openingBalance', 'openingDate', 'name']) {
await auth('patch', '/' + card.id)
.send({ version: archived.version, [field]: null })
.expect(400);
expect(await getCard(card.id)).toEqual(archived);
}
await auth('patch', '/' + card.id)
.send({ version: archived.version, archived: false })
.expect(200);
for (const field of [
'amount',
'kind',
'cardId',
'date',
'description',
'targetCardId',
'categoryId',
'originalExpenseId',
]) {
await auth('patch', '/operations/' + expense.id)
.send({ version: expense.version, [field]: null })
.expect(400);
expect(await getOp(expense.id)).toEqual(expense);
}
const before = await db.bankCardOperation.count();
for (const field of [
'amount',
'description',
'targetCardId',
'categoryId',
'originalExpenseId',
]) {
await auth('post', '/operations')
.send({ ...op(card.id), [field]: null })
.expect(400);
}
expect(await db.bankCardOperation.count()).toBe(before);
});
it('validates actual money, calendar, field and operation-shape inputs without writes', async () => {
const card = await createCard();
const before = await db.bankCardOperation.count();
for (const amount of [
money('0'),
money('-1'),
money('1.001'),
money('92233720368547758.08'),
{ currency: 'USD', value: '1' },
money('NaN'),
])
await auth('post', '/operations')
.send({ ...op(card.id), amount })
.expect(400);
for (const extra of [
{ date: '2026-02-30' },
{ date: '2099-01-01' },
{ date: '2025-12-31' },
{ description: 'x'.repeat(501) },
{ kind: 'topup', categoryId: (await categories())[0].id },
{ kind: 'transfer', targetCardId: card.id },
])
await auth('post', '/operations')
.send(op(card.id, 'expense', '1', extra))
.expect(400);
expect(await db.bankCardOperation.count()).toBe(before);
for (const extra of [
{ name: ' ' },
{ name: 'x'.repeat(101) },
{ bankName: 'x'.repeat(101) },
{ lastFour: '123' },
{ openingDate: '2026-02-30' },
{ openingBalance: money('-92233720368547758.09') },
])
await auth('post', '')
.send({
id: randomUUID(),
name: 'Card',
openingDate: '2026-01-01',
openingBalance: money('0'),
...extra,
})
.expect(400);
});
it('returns nested money field errors without exposing input or writing a card', async () => {
const before = await db.bankCard.count();
const response = await auth('post', '')
.send({
id: randomUUID(),
name: 'Card',
openingDate: '2026-01-01',
openingBalance: {
currency: 'USD',
value: 'SYNTHETIC_VALIDATION_SECRET',
},
})
.expect(400)
.expect('Content-Type', /application\/problem\+json/);
expect(response.body).toEqual({
status: 400,
code: 'VALIDATION_ERROR',
errors: [
{
pointer: '#/openingBalance/currency',
detail: expect.stringContaining('RUB') as unknown,
},
{
pointer: '#/openingBalance/value',
detail: expect.any(String) as unknown,
},
],
});
expect(JSON.stringify(response.body)).not.toContain(
'SYNTHETIC_VALIDATION_SECRET',
);
expect(await db.bankCard.count()).toBe(before);
});
it('scopes every card/category/expense link and edit to owner', async () => {
const own = await createCard(),
foreign = await createCard('0', stranger);
const foreignCategories = (
await auth('get', '/categories', stranger).expect(200)
).body as ExpenseCategoryDto[];
const foreignOp = (
await auth('post', '/operations', stranger)
.send(op(foreign.id))
.expect(201)
).body as BankOperationDto;
for (const path of [
'/' + foreign.id,
'/' + foreign.id + '/operations?month=2026-08',
'/' + foreign.id + '/expenses',
'/operations/' + foreignOp.id,
'/summary?month=2026-08&cardId=' + foreign.id,
])
await auth('get', path).expect(404);
for (const body of [
op(foreign.id),
op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }),
op(own.id, 'transfer', '1', { targetCardId: foreign.id }),
op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }),
])
await auth('post', '/operations').send(body).expect(404);
await auth('patch', '/' + foreign.id)
.send({ version: 1, archived: true })
.expect(404);
await auth('patch', '/categories/' + foreignCategories[0].id)
.send({ version: 1, name: 'No' })
.expect(404);
await auth('patch', '/operations/' + foreignOp.id)
.send({ version: 1, amount: money('2') })
.expect(404);
await auth('delete', '/operations/' + foreignOp.id + '?version=1').expect(
404,
);
expect((await getCard(own.id)).balance.value).toBe('1000');
});
it('renames default category without role loss; duplicates and stale rename conflict', async () => {
const category = (await categories()).find((c) => c.isDefault);
assert.ok(category);
await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Остальное' })
.expect(200);
await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Stale' })
.expect(409);
await auth('post', '/categories').send({ name: ' кафе ' }).expect(409);
const custom = (
await auth('post', '/categories').send({ name: 'Питомец' }).expect(201)
).body as ExpenseCategoryDto;
expect(
(await categories()).find((c) => c.id === category.id),
).toMatchObject({ name: 'Остальное', isDefault: true });
const card = await createCard();
expect((await createOp(op(card.id))).categoryId).toBe(category.id);
expect(custom.name).toBe('Питомец');
});
it('keeps both independent expenses, exactly one same-intent operation and tombstone after editing/deleting', async () => {
const card = await createCard();
const body = op(card.id);
const responses = await Promise.all([
auth('post', '/operations').send(body),
auth('post', '/operations').send(body),
auth('post', '/operations').send(op(card.id, 'expense', '200')),
]);
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
expect((await getCard(card.id)).balance.value).toBe('700');
await auth('post', '/operations')
.send({ ...body, amount: money('200') })
.expect(409);
const edited = (
await auth('patch', '/operations/' + body.id)
.send({ version: 1, amount: money('150') })
.expect(200)
).body as BankOperationDto;
expect(
(
(await auth('post', '/operations').send(body).expect(201))
.body as BankOperationDto
).amount.value,
).toBe('150');
await auth('delete', '/operations/' + body.id + '?version=1').expect(409);
await auth(
'delete',
`/operations/${body.id}?version=${edited.version}`,
).expect(204);
await auth('delete', '/operations/' + body.id + '?version=1').expect(204);
await auth('post', '/operations').send(body).expect(410);
expect((await getCard(card.id)).balance.value).toBe('800');
});
it('transfers atomically, edits both sides, validates old and new cards and opening dates', async () => {
const a = await createCard('100'),
b = await createCard('0'),
late = await createCard('0', owner, { openingDate: '2026-09-01' });
await auth('post', '/operations')
.send(op(a.id, 'transfer', '150', { targetCardId: late.id }))
.expect(400);
const transfer = await createOp(
op(a.id, 'transfer', '150', { targetCardId: b.id }),
);
expect((await getCard(a.id)).balance.value).toBe('-50');
expect((await getCard(b.id)).balance.value).toBe('150');
const history = (
await auth('get', `/${b.id}/operations?month=2026-08`).expect(200)
).body as BankOperationsPageDto;
expect(history.items[0]).toMatchObject({
id: transfer.id,
cardId: a.id,
targetCardId: b.id,
});
expect((await summary('2026-08', b.id)).expenses.value).toBe('0');
await auth('patch', '/' + b.id)
.send({ version: 1, openingDate: '2026-08-02' })
.expect(400);
const edited = (
await auth('patch', '/operations/' + transfer.id)
.send({ version: 1, amount: money('50') })
.expect(200)
).body as BankOperationDto;
expect((await getCard(a.id)).balance.value).toBe('50');
expect((await getCard(b.id)).balance.value).toBe('50');
await auth(
'delete',
`/operations/${transfer.id}?version=${edited.version}`,
).expect(204);
expect((await getCard(a.id)).balance.value).toBe('100');
expect((await getCard(b.id)).balance.value).toBe('0');
});
it('serializes refund limits, replays full refund before limit checks, excludes own value during edit', async () => {
const card = await createCard();
const expense = await createOp(op(card.id, 'expense', '1000'));
const responses = await Promise.all([
auth('post', '/operations').send(
op(card.id, 'refund', '600', {
date: '2026-09-01',
originalExpenseId: expense.id,
}),
),
auth('post', '/operations').send(
op(card.id, 'refund', '600', {
date: '2026-09-01',
originalExpenseId: expense.id,
}),
),
]);
expect(responses.map((r) => r.status).sort()).toEqual([201, 409]);
const body = op(card.id, 'refund', '400', {
date: '2026-09-01',
originalExpenseId: expense.id,
});
const refund = await createOp(body);
expect(refund.availableRefund?.value).toBe('400');
await auth('post', '/operations').send(body).expect(201);
await auth('patch', '/operations/' + refund.id)
.send({ version: refund.version, description: 'Тот же полный возврат' })
.expect(200);
const sept = await summary('2026-09', card.id);
expect(sept).toMatchObject({
expenses: money('0'),
refunds: money('1000'),
net: money('-1000'),
});
const august = await summary('2026-08', card.id);
expect(august.net.value).toBe('1000');
const choices = (await auth('get', '/' + card.id + '/expenses').expect(200))
.body as BankOperationsPageDto;
expect(choices.items[0].availableRefund?.value).toBe('0');
const current = await getOp(expense.id);
for (const patch of [
{ amount: money('999') },
{ date: '2026-09-02' },
{ cardId: (await createCard()).id },
])
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, ...patch })
.expect(409);
await auth(
'delete',
`/operations/${expense.id}?version=${current.version}`,
).expect(409);
const category = (await categories()).find((c) => !c.isDefault);
assert.ok(category);
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, categoryId: category.id })
.expect(200);
expect((await summary('2026-09', card.id)).categories[0].categoryId).toBe(
category.id,
);
});
it('rejects invalid refund references, dates, immutable kind and purchase link', async () => {
const card = await createCard(),
other = await createCard();
const purchase = await createOp(op(card.id)),
topup = await createOp(op(card.id, 'topup'));
for (const body of [
op(card.id, 'refund', '1', { originalExpenseId: topup.id }),
op(other.id, 'refund', '1', { originalExpenseId: purchase.id }),
op(card.id, 'refund', '1', {
originalExpenseId: purchase.id,
date: '2026-07-01',
}),
])
await auth('post', '/operations').send(body).expect(400);
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
await auth('patch', '/operations/' + refund.id)
.send({ version: refund.version, originalExpenseId: topup.id })
.expect(400);
await auth('patch', '/operations/' + topup.id)
.send({ version: topup.version, kind: 'expense' })
.expect(400);
});
it('moves plain operations and month totals, preserves full balance with stable pagination', async () => {
const a = await createCard(),
b = await createCard();
const first = await createOp(op(a.id, 'expense', '10'));
await createOp(op(a.id, 'expense', '20'));
await createOp(op(a.id, 'expense', '30'));
const pages: BankOperationsPageDto[] = [];
for (let page = 1; page <= 3; page++)
pages.push(
(
await auth(
'get',
`/${a.id}/operations?month=2026-08&page=${page}&limit=1`,
).expect(200)
).body as BankOperationsPageDto,
);
expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe(
3,
);
expect(pages.map((p) => p.hasNext)).toEqual([true, true, false]);
expect((await getCard(a.id)).balance.value).toBe('940');
expect((await summary('2026-08', a.id)).expenses.value).toBe('60');
await auth('patch', '/operations/' + first.id)
.send({ version: first.version, cardId: b.id, date: '2026-09-01' })
.expect(200);
expect((await getCard(a.id)).balance.value).toBe('950');
expect((await getCard(b.id)).balance.value).toBe('990');
expect((await summary('2026-09', b.id)).net.value).toBe('10');
});
it('serializes archive with writes, blocks financial changes, keeps metadata editable and all-card history', async () => {
const card = await createCard();
const expense = await createOp(op(card.id));
const total = (await summary('2026-08')).net.value;
await auth('patch', '/' + card.id)
.send({ version: 1, archived: true })
.expect(200);
for (const body of [
op(card.id),
op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }),
])
await auth('post', '/operations').send(body).expect(409);
await auth('patch', '/operations/' + expense.id)
.send({ version: 1, description: 'no' })
.expect(409);
await auth('delete', `/operations/${expense.id}?version=1`).expect(409);
await auth('patch', '/' + card.id)
.send({ version: 2, openingBalance: money('200') })
.expect(409);
await auth('patch', '/' + card.id)
.send({ version: 2, name: 'Архивная' })
.expect(200);
expect((await summary('2026-08')).net.value).toBe(total);
await auth('patch', '/' + card.id)
.send({ version: 1, archived: false })
.expect(409);
await auth('patch', '/' + card.id)
.send({ version: 3, archived: false })
.expect(200);
await createOp(op(card.id));
const racing = await createCard();
const results = await Promise.all([
auth('patch', '/' + racing.id).send({ version: 1, archived: true }),
auth('post', '/operations').send(op(racing.id)),
]);
expect(results[0].status).toBe(200);
expect([201, 409]).toContain(results[1].status);
expect((await getCard(racing.id)).balance.value).toBe(
results[1].status === 201 ? '900' : '1000',
);
});
it('serializes a refund against reducing its purchase, and rolls back rejected source locks', async () => {
const card = await createCard();
const expense = await createOp(op(card.id, 'expense', '1000'));
const results = await Promise.all([
auth('post', '/operations').send(
op(card.id, 'refund', '600', { originalExpenseId: expense.id }),
),
auth('patch', '/operations/' + expense.id).send({
version: expense.version,
amount: money('500'),
}),
]);
expect([201, 409]).toContain(results[0].status);
expect([200, 409]).toContain(results[1].status);
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
const current = await getOp(expense.id);
const invalid = op(card.id, 'refund', '1', {
originalExpenseId: expense.id,
date: '2026-07-01',
});
await auth('post', '/operations').send(invalid).expect(400);
expect((await getOp(expense.id)).version).toBe(current.version);
const refunds = await db.bankCardOperation.findMany({
where: { originalExpenseId: expense.id, deletedAt: null },
});
const source = await db.bankCardOperation.findUniqueOrThrow({
where: { id: expense.id },
});
expect(
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <=
source.amountKopecks,
).toBe(true);
});
it('serializes opening-date changes and validates archived previous transfer sides', async () => {
const card = await createCard();
const results = await Promise.all([
auth('patch', '/' + card.id).send({
version: 1,
openingDate: '2026-09-01',
}),
auth('post', '/operations').send(op(card.id)),
]);
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
const current = await getCard(card.id);
const rows = await db.bankCardOperation.findMany({
where: { cardId: card.id, deletedAt: null },
});
expect(rows.every((row) => row.date >= current.openingDate)).toBe(true);
const a = await createCard(),
b = await createCard(),
c = await createCard();
const transfer = await createOp(
op(a.id, 'transfer', '10', { targetCardId: b.id }),
);
await auth('patch', '/' + b.id)
.send({ version: 1, archived: true })
.expect(200);
await auth('patch', '/operations/' + transfer.id)
.send({ version: 1, targetCardId: c.id })
.expect(409);
expect((await getCard(b.id)).balance.value).toBe('1010');
expect((await getCard(c.id)).balance.value).toBe('1000');
});
it('deleting refunds releases the purchase and a later replay never restores either record', async () => {
const card = await createCard();
const purchaseBody = op(card.id),
purchase = await createOp(purchaseBody);
const refundBody = op(card.id, 'refund', '50', {
originalExpenseId: purchase.id,
}),
refund = await createOp(refundBody);
await auth(
'delete',
`/operations/${refund.id}?version=${refund.version}`,
).expect(204);
const current = await getOp(purchase.id);
expect(current.availableRefund?.value).toBe('100');
await auth(
'delete',
`/operations/${purchase.id}?version=${current.version}`,
).expect(204);
await auth('post', '/operations').send(refundBody).expect(410);
await auth('post', '/operations').send(purchaseBody).expect(410);
await auth('post', '/operations')
.send(op(card.id, 'refund', '1', { originalExpenseId: purchase.id }))
.expect(400);
expect((await getCard(card.id)).balance.value).toBe('1000');
});
it('preserves history, fingerprints, archive and deleted replay across app restart', async () => {
const card = await createCard();
const purchase = await createOp(op(card.id));
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
const deletedBody = op(card.id, 'topup', '20');
const deleted = await createOp(deletedBody);
await auth('delete', `/operations/${deleted.id}?version=1`).expect(204);
await auth('patch', '/' + card.id)
.send({ version: 1, archived: true })
.expect(200);
const before = await getCard(card.id);
await app.close();
await start();
expect(await getCard(card.id)).toEqual(before);
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
await auth('post', '/operations').send(deletedBody).expect(410);
expect((await categories()).find((c) => c.isDefault)?.name).toBe(
'Остальное',
);
});
it('enforces actual FK and unique constraints', async () => {
await expect(
db.expenseCategory.create({
data: {
id: randomUUID(),
userId: randomUUID(),
name: 'Invalid',
nameKey: 'invalid',
},
}),
).rejects.toMatchObject({ code: 'P2003' });
const category = (await categories())[0];
await expect(
db.expenseCategory.create({
data: {
id: randomUUID(),
userId: owner,
name: category.name,
nameKey: category.name.toLocaleLowerCase('ru-RU'),
},
}),
).rejects.toMatchObject({ code: 'P2002' });
});
it('persists payment systems, preserves omitted metadata and allows clearing', async () => {
const card = await createCard('1000', owner, { paymentSystem: 'mir' });
expect((await getCard(card.id)).paymentSystem).toBe('mir');
await auth('patch', '/' + card.id)
.send({ version: 1, name: 'Renamed' })
.expect(200);
expect((await getCard(card.id)).paymentSystem).toBe('mir');
await auth('patch', '/' + card.id)
.send({ version: 2, paymentSystem: 'visa' })
.expect(200);
expect((await getCard(card.id)).paymentSystem).toBe('visa');
await auth('patch', '/' + card.id)
.send({ version: 3, paymentSystem: 'unknown' })
.expect(400);
await auth('patch', '/' + card.id)
.send({ version: 3, paymentSystem: null })
.expect(200);
expect((await getCard(card.id)).paymentSystem).toBeNull();
expect((await createCard()).paymentSystem).toBeNull();
expect(
(await createCard('1000', owner, { paymentSystem: 'mastercard' }))
.paymentSystem,
).toBe('mastercard');
});
it('filters journal and summary inclusively across months and rejects invalid periods', async () => {
const card = await createCard();
const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02'];
for (const date of dates)
await createOp(op(card.id, 'expense', '10', { date }));
const range = 'from=2026-07-31&to=2026-08-01';
const history = (
await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200)
).body as BankOperationsPageDto;
expect(history.total).toBe(2);
expect(history.items.map((item) => item.date)).toEqual(['2026-08-01']);
expect(history.hasNext).toBe(true);
const result = (
await auth('get', `/summary?cardId=${card.id}&${range}`).expect(200)
).body as BankSummaryDto;
expect(result.net.value).toBe('20');
const single = (
await auth(
'get',
`/${card.id}/operations?from=2026-07-31&to=2026-07-31`,
).expect(200)
).body as BankOperationsPageDto;
expect(single.total).toBe(1);
for (const invalid of [
'',
'from=2026-07-31',
'to=2026-08-01',
'from=2026-08-02&to=2026-08-01',
'from=2026-02-30&to=2026-08-01',
`${range}&month=2026-08`,
]) {
await auth('get', `/${card.id}/operations?${invalid}`).expect(400);
await auth('get', `/summary?${invalid}`).expect(400);
}
});
});