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
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.
791 lines
29 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|