Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf7414b800 | ||
|
|
344e2291b6 |
@@ -28,8 +28,9 @@ jobs:
|
||||
docker version
|
||||
docker buildx version
|
||||
- run: npm ci
|
||||
- run: npx --no-install prisma generate
|
||||
- run: npm run verify
|
||||
- run: node test/sqlite.cjs --all
|
||||
- run: npm run test:e2e
|
||||
- name: Login with dedicated Nexus writer
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '47 5 * * *'
|
||||
|
||||
@@ -40,7 +40,6 @@ jobs:
|
||||
needs: commits
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
gitea.event.action != 'edited' &&
|
||||
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
@@ -68,8 +67,7 @@ jobs:
|
||||
verify:
|
||||
needs: [commits, security]
|
||||
if: >-
|
||||
!cancelled() && gitea.event.action != 'edited' &&
|
||||
needs.security.result == 'success' &&
|
||||
!cancelled() && needs.security.result == 'success' &&
|
||||
gitea.event_name != 'schedule'
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
@@ -83,10 +81,12 @@ jobs:
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Generate Prisma client before typechecks
|
||||
run: npx --no-install prisma generate
|
||||
- name: Run all backend gates
|
||||
run: npm run verify
|
||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||
run: node test/sqlite.cjs --all
|
||||
run: npm run test:e2e
|
||||
- name: Analyze develop with SonarQube
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ EXPOSE 3000
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
FROM dependencies AS build
|
||||
# The client is already generated at the shared dependencies stage (same revision).
|
||||
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
|
||||
RUN npm run build && npm prune --omit=dev --omit=optional
|
||||
|
||||
FROM base AS production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
@@ -14,7 +14,6 @@ cp .env.example .env
|
||||
nvm use --silent
|
||||
npm ci
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
@@ -61,7 +60,7 @@ Compose topology принадлежит workspace, эксплуатационн
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Он запускает генерацию Prisma client, zero-warning lint,
|
||||
Он запускает zero-warning lint,
|
||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||
@@ -81,13 +80,11 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||
Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
|
||||
и `verify` на metadata-only событии не запускаются.
|
||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
|
||||
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
|
||||
на уже собранных артефактах. Используется только checkout
|
||||
Job `verify` запускает `npm ci`, генерацию Prisma client,
|
||||
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
|
||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||
|
||||
@@ -218,10 +215,7 @@ nvm use --silent
|
||||
npm run openapi:generate
|
||||
```
|
||||
|
||||
Команда собирает backend и записывает `openapi.json` в корень репозитория
|
||||
атомарно: через временный файл и rename, без частичной записи при сбое.
|
||||
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
|
||||
с ошибкой при расхождении, не перезаписывая его.
|
||||
Команда собирает backend и записывает `openapi.json` в корень репозитория.
|
||||
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
||||
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
||||
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
||||
@@ -254,14 +248,10 @@ breaking changes относительно предыдущего выпуска.
|
||||
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
||||
|
||||
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
||||
три последовательных и два параллельных запуска двух обычных e2e suites,
|
||||
проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
|
||||
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
|
||||
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
|
||||
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
|
||||
Логи остаются в каталоге из вывода. Для одной suite:
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в
|
||||
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
|
||||
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
|
||||
расходиться с parent/src.
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`.
|
||||
|
||||
| Риск | Проверка реальной DB |
|
||||
| --- | --- |
|
||||
|
||||
Generated
+21
-10
@@ -21,7 +21,7 @@
|
||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"better-sqlite3": "12.11.1",
|
||||
"better-sqlite3": "13.0.3",
|
||||
"cheerio": "1.2.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
@@ -66,6 +66,7 @@
|
||||
"globals": "17.12.0",
|
||||
"jest": "30.5.1",
|
||||
"release-it": "21.0.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.4.12",
|
||||
"ts-loader": "9.6.2",
|
||||
@@ -3768,6 +3769,20 @@
|
||||
"better-sqlite3": "^12.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/adapter-better-sqlite3/node_modules/better-sqlite3": {
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
|
||||
@@ -6267,17 +6282,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.11.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||
"hasInstallScript": true,
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
"node-addon-api": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
@@ -13623,7 +13636,6 @@
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
@@ -13635,7 +13647,6 @@
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
||||
+6
-5
@@ -11,11 +11,10 @@
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||
"format": "npm run lint:fix",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint . --max-warnings=0",
|
||||
@@ -32,7 +31,7 @@
|
||||
"test:container": "node test/container-smoke.cjs",
|
||||
"test:durability": "node test/bank-cards-durability.cjs",
|
||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||
"verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build",
|
||||
"verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||
"release:preview": "release-it --dry-run --ci",
|
||||
"release:prepare": "release-it --ci",
|
||||
"commitlint": "commitlint",
|
||||
@@ -51,7 +50,7 @@
|
||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"better-sqlite3": "12.11.1",
|
||||
"better-sqlite3": "13.0.3",
|
||||
"cheerio": "1.2.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
@@ -96,6 +95,7 @@
|
||||
"globals": "17.12.0",
|
||||
"jest": "30.5.1",
|
||||
"release-it": "21.0.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.4.12",
|
||||
"ts-loader": "9.6.2",
|
||||
@@ -144,6 +144,7 @@
|
||||
"better-sqlite3@12.11.1": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"protobufjs@7.6.5": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"better-sqlite3@13.0.3": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const { readFile, rename, rm, writeFile } = require('node:fs/promises');
|
||||
const { resolve } = require('node:path');
|
||||
|
||||
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
|
||||
const target = resolve(process.cwd(), 'openapi.json');
|
||||
const checkOnly = process.argv.includes('--check');
|
||||
const { writeFile } = require('node:fs/promises');
|
||||
|
||||
// Schema generation needs no deployment configuration or real credentials.
|
||||
Object.assign(process.env, {
|
||||
@@ -24,48 +19,23 @@ const { NestFactory } = require('@nestjs/core');
|
||||
const { AppModule } = require('../dist/src/app.module.js');
|
||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||
|
||||
async function render() {
|
||||
async function generate() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
preview: true,
|
||||
logger: false,
|
||||
});
|
||||
try {
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
|
||||
await writeFile(
|
||||
'openapi.json',
|
||||
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only: compares the runtime document with the committed artifact and never writes.
|
||||
async function check(content) {
|
||||
const current = await readFile(target, 'utf8').catch(() => null);
|
||||
if (current !== content) {
|
||||
console.error(
|
||||
'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log('openapi.json matches the runtime schema.');
|
||||
}
|
||||
|
||||
// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output.
|
||||
async function update(content) {
|
||||
const temp = `${target}.${process.pid}.tmp`;
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
|
||||
await writeFile(temp, content);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
|
||||
await rename(temp, target);
|
||||
} finally {
|
||||
await rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
.then((content) => (checkOnly ? check(content) : update(content)))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
generate().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ const allowedFields = new Set([
|
||||
'grpcCode',
|
||||
'headersReceived',
|
||||
'method',
|
||||
'networkMs',
|
||||
'operation',
|
||||
'queueWaitMs',
|
||||
'requestId',
|
||||
|
||||
@@ -165,8 +165,6 @@ describe('TbankClient operations', () => {
|
||||
operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED,
|
||||
queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false,
|
||||
}));
|
||||
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
|
||||
expect(typeof fields.networkMs).toBe('number');
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
|
||||
@@ -64,7 +64,6 @@ type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||
type RpcDiagnostics = {
|
||||
channelState?: string;
|
||||
headersReceived?: boolean;
|
||||
networkMs?: number;
|
||||
queueWaitMs?: number;
|
||||
trackingId?: string;
|
||||
};
|
||||
@@ -525,13 +524,10 @@ export class TbankClient {
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
const response = await this.queue.run(() => {
|
||||
const sentAt = performance.now();
|
||||
diagnostics.queueWaitMs = Math.round(sentAt - startedAt);
|
||||
diagnostics.queueWaitMs = Math.round(performance.now() - startedAt);
|
||||
return this.request<Response>(
|
||||
token, client, method, request, diagnostics,
|
||||
).finally(() => {
|
||||
diagnostics.networkMs = Math.round(performance.now() - sentAt);
|
||||
});
|
||||
);
|
||||
});
|
||||
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||
operation: method,
|
||||
|
||||
@@ -91,101 +91,6 @@ export class BankOperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||
const userId = rows[0]?.userId;
|
||||
if (!userId) return [];
|
||||
const cardIds = new Set(
|
||||
rows.flatMap((row) =>
|
||||
[row.cardId, row.targetCardId].filter(
|
||||
(id): id is string => id !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
const sourceIds = new Set(
|
||||
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
|
||||
);
|
||||
const [cards, sources] = await Promise.all([
|
||||
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: { userId, id: { in: [...sourceIds] } },
|
||||
}),
|
||||
]);
|
||||
const categoryIds = new Set(
|
||||
rows
|
||||
.map(
|
||||
(row) =>
|
||||
sources.find((source) => source.id === row.originalExpenseId)
|
||||
?.categoryId ?? row.categoryId,
|
||||
)
|
||||
.filter((id): id is string => id !== null),
|
||||
);
|
||||
const expenseIds = new Set(
|
||||
rows.flatMap((row) => {
|
||||
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||
return row.kind === BankOperationKind.Expense || source
|
||||
? [source?.id ?? row.id]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const [categories, refunds] = await Promise.all([
|
||||
tx.expenseCategory.findMany({
|
||||
where: { userId, id: { in: [...categoryIds] } },
|
||||
}),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId: { in: [...expenseIds] },
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
||||
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
||||
return rows.map((row) => {
|
||||
const card = cardsById.get(row.cardId) as { name: string };
|
||||
const target = row.targetCardId ? cardsById.get(row.targetCardId) : null;
|
||||
const source = row.originalExpenseId
|
||||
? sourcesById.get(row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const relatedRefunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? refunds.filter(
|
||||
(refund) =>
|
||||
refund.originalExpenseId === (source?.id ?? row.id) &&
|
||||
(!source || refund.id !== row.id),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
cardId: row.cardId,
|
||||
cardName: card.name,
|
||||
targetCardId: row.targetCardId,
|
||||
targetCardName: target?.name ?? null,
|
||||
categoryId,
|
||||
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
relatedRefunds.reduce(
|
||||
(sum, refund) => sum + refund.amountKopecks,
|
||||
ZERO_KOPECKS,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async validateOperation(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
@@ -454,7 +359,9 @@ export class BankOperationsService {
|
||||
take: limit,
|
||||
});
|
||||
return {
|
||||
items: await this.pageViews(tx, rows),
|
||||
items: await Promise.all(
|
||||
rows.map((row) => this.operationView(tx, row)),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
hasNext: page * limit < total,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
|
||||
import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
|
||||
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -61,7 +61,6 @@ export class BrokerageAccountsService {
|
||||
: null,
|
||||
freshForMs: 3_600_000,
|
||||
work: null,
|
||||
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
|
||||
dependencies: saved.accounts.map((account) => account.meta),
|
||||
}),
|
||||
sync: hourly
|
||||
|
||||
@@ -98,7 +98,6 @@ export class IncomeAnalyticsService {
|
||||
work: work.id
|
||||
? { id: work.id, state: work.state, errorCode: work.errorCode }
|
||||
: null,
|
||||
lastAttempt: current.meta.lastAttempt,
|
||||
coverage: current.meta.coverage,
|
||||
dependencies,
|
||||
}),
|
||||
|
||||
@@ -280,18 +280,16 @@ export class CatalogDiscoveryService {
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||
const [details, prices] = await Promise.all([
|
||||
Promise.all(
|
||||
instruments.map((item) =>
|
||||
input.instrumentType === 'share'
|
||||
? this.tbank.getShare(token, item.uid)
|
||||
: this.tbank.getBond(token, item.uid),
|
||||
),
|
||||
const details = await Promise.all(
|
||||
instruments.map((item) =>
|
||||
input.instrumentType === 'share'
|
||||
? this.tbank.getShare(token, item.uid)
|
||||
: this.tbank.getBond(token, item.uid),
|
||||
),
|
||||
instruments.length
|
||||
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
|
||||
: [],
|
||||
]);
|
||||
);
|
||||
const prices = instruments.length
|
||||
? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
|
||||
: [];
|
||||
const fetchedAt = new Date();
|
||||
const data = instruments.flatMap((item, index) => {
|
||||
const detail = details.at(index);
|
||||
|
||||
@@ -93,7 +93,7 @@ export class StockScreenerItemDto {
|
||||
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
|
||||
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
|
||||
@ApiProperty({ nullable: true, minimum: MIN_TICKER_LENGTH, type: 'integer' }) declare lot: null | number;
|
||||
@ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number;
|
||||
}
|
||||
|
||||
export class StockScreenerPageDto {
|
||||
|
||||
@@ -435,7 +435,6 @@ export class BrokerageOperationsService {
|
||||
saved = await this.repository.readPage(input);
|
||||
}
|
||||
const now = new Date();
|
||||
const hourly = await this.hourlySync.hourly(userId);
|
||||
const membership = investmentResourceMeta({
|
||||
resourceKey: 'accounts/membership',
|
||||
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
||||
@@ -504,7 +503,6 @@ export class BrokerageOperationsService {
|
||||
fetchedAt,
|
||||
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
|
||||
work,
|
||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||
dependencies: [membership],
|
||||
coverage: {
|
||||
requested: targets.map(serialize),
|
||||
|
||||
@@ -43,17 +43,16 @@ type HourlyAttempt = {
|
||||
|
||||
export function hourlyAttempt(
|
||||
hourly: HourlyAttempt | null,
|
||||
section: HourlySection['section'] | readonly HourlySection['section'][],
|
||||
section: HourlySection['section'],
|
||||
accountId?: string,
|
||||
): InvestmentAttemptDto {
|
||||
if (!hourly) {
|
||||
return { state: 'none', startedAt: null, finishedAt: null, problems: [] };
|
||||
}
|
||||
const sections = Array.isArray(section) ? section : [section];
|
||||
const problems = hourly.sections
|
||||
.filter(
|
||||
(entry) =>
|
||||
sections.includes(entry.section) &&
|
||||
entry.section === section &&
|
||||
(accountId === undefined || entry.accountId === accountId),
|
||||
)
|
||||
.map((entry) => ({
|
||||
@@ -183,8 +182,7 @@ export class TbankSyncRepository {
|
||||
|
||||
// The timer only proposes work; this reservation is the durable fence. The
|
||||
// attempt start (createdAt) is the basis of the next hourly term, including
|
||||
// failed and interrupted attempts. After downtime the next tick is due at
|
||||
// once, so no catch-up queue forms.
|
||||
// failed and interrupted attempts.
|
||||
reserveHourly(userId: string, runId: string, hourMs: number) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const connection = await tx.tbankConnection.findFirst({
|
||||
@@ -202,16 +200,7 @@ export class TbankSyncRepository {
|
||||
});
|
||||
const now = new Date();
|
||||
if (previous) {
|
||||
// Terms are hourMs steps from the attempt start; a walk still running
|
||||
// at a term consumes it. A dead process's attempt has no known end, so
|
||||
// it consumes only its own term.
|
||||
const started = previous.createdAt.valueOf();
|
||||
const ended =
|
||||
previous.state === 'running' ? started : previous.updatedAt.valueOf();
|
||||
const dueAt =
|
||||
started +
|
||||
Math.max(hourMs, Math.ceil((ended - started) / hourMs) * hourMs);
|
||||
const due = now.valueOf() >= dueAt;
|
||||
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
|
||||
if (previous.state === 'running') {
|
||||
if (previous.processRunId === runId) return null;
|
||||
// A dead process cannot finish its attempt; close it from its
|
||||
|
||||
+1
-12
@@ -3,20 +3,9 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
export const API_PREFIX = 'api/v1';
|
||||
|
||||
// Nest Swagger cannot express additionalProperties on a class, so closed request
|
||||
// bodies are restored here after generation; the runtime allowlist stays in the controller.
|
||||
const CLOSED_SCHEMAS = ['StockScreenerCriteriaDto'];
|
||||
|
||||
export function createOpenApiDocument(app: INestApplication) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
return SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder().setTitle('WealthLedger API').setVersion('v1').build(),
|
||||
);
|
||||
const schemas = document.components?.schemas ?? {};
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
if (CLOSED_SCHEMAS.includes(name) && 'properties' in schema) {
|
||||
schema.additionalProperties = false;
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { AppModule } from '../src/app.module';
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('All accounts HTTP boundary', () => {
|
||||
@@ -32,8 +31,6 @@ describe('All accounts HTTP boundary', () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { AppModule } from '../src/app.module';
|
||||
import { loggingOptions } from '../src/common/logging/logging.module';
|
||||
import { configureApp } from '../src/main';
|
||||
import { AuthService } from '../src/modules/auth/application/auth.service';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
type Authentication = { accessToken: string; user: { email: string } };
|
||||
@@ -34,10 +33,7 @@ describe('Authentication (e2e)', () => {
|
||||
},
|
||||
assignResponse: true,
|
||||
}),
|
||||
})
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
configureApp(app);
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
BankSummaryDto,
|
||||
ExpenseCategoryDto,
|
||||
} from '../src/modules/banking/bank-cards/presentation/bank-card.dto';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
jest.setTimeout(60000);
|
||||
@@ -78,10 +77,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
||||
const start = async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
}).compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
@@ -373,8 +369,6 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
||||
expect((await getCard(own.id)).balance.value).toBe('1000');
|
||||
});
|
||||
it('renames default category without role loss; duplicates and stale rename conflict', async () => {
|
||||
// Defaults are seeded by the first write of the user; this case must not depend on earlier cases.
|
||||
await createCard();
|
||||
const category = (await categories()).find((c) => c.isDefault);
|
||||
assert.ok(category);
|
||||
await auth('patch', '/categories/' + category.id)
|
||||
@@ -731,19 +725,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
||||
expect(await getCard(card.id)).toEqual(before);
|
||||
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
|
||||
await auth('post', '/operations').send(deletedBody).expect(410);
|
||||
// The rename is this case's own prerequisite, not an effect of an earlier case.
|
||||
const defaultCategory = (await categories()).find((c) => c.isDefault);
|
||||
assert.ok(defaultCategory);
|
||||
await auth('patch', '/categories/' + defaultCategory.id)
|
||||
.send({ version: defaultCategory.version, name: 'Остальное' })
|
||||
.expect(200);
|
||||
expect((await categories()).find((c) => c.isDefault)?.name).toBe(
|
||||
'Остальное',
|
||||
);
|
||||
});
|
||||
it('enforces actual FK and unique constraints', async () => {
|
||||
// Seeds the owner's default categories so the unique-name check has a real row to collide with.
|
||||
await createCard();
|
||||
await expect(
|
||||
db.expenseCategory.create({
|
||||
data: {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { configureApp } from '../src/main';
|
||||
import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service';
|
||||
import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service';
|
||||
import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
|
||||
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
|
||||
describe('Bond HTTP contract', () => {
|
||||
@@ -39,8 +38,6 @@ describe('Bond HTTP contract', () => {
|
||||
.useValue(bonds)
|
||||
.overrideProvider(BondIncomeService)
|
||||
.useValue(bonds)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
|
||||
@@ -122,13 +122,6 @@ describe('OpenAPI contract (integration)', () => {
|
||||
});
|
||||
|
||||
it('generates the runtime contract without server startup or deployment configuration', async () => {
|
||||
const schemas = createOpenApiDocument(app).components?.schemas;
|
||||
expect(schemas?.StockScreenerCriteriaDto).toMatchObject({
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(schemas?.StockScreenerItemDto).toMatchObject({
|
||||
properties: { lot: { type: 'integer', minimum: 1, nullable: true } },
|
||||
});
|
||||
expect(
|
||||
createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto,
|
||||
).toMatchObject({
|
||||
|
||||
@@ -18,7 +18,7 @@ writeFileSync(sentinel, 'fictional user database: must never be opened');
|
||||
const before = readFileSync(sentinel);
|
||||
const databases = new Set();
|
||||
async function check(name, overrides = {}, signal) {
|
||||
const child = spawn(process.execPath, ['test/sqlite.cjs'], {
|
||||
const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -16,7 +16,6 @@ import { StockCatalogService } from '../src/modules/investments/instruments/stoc
|
||||
import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service';
|
||||
import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
|
||||
import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
|
||||
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
|
||||
describe('Stock HTTP contract', () => {
|
||||
@@ -42,8 +41,6 @@ describe('Stock HTTP contract', () => {
|
||||
.useValue(stocks)
|
||||
.overrideProvider(StockScreenerService)
|
||||
.useValue(screener)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TbankAccessService } from '../src/integrations/tbank/tbank-access.servi
|
||||
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
|
||||
import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
|
||||
@@ -32,8 +31,6 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
|
||||
@@ -385,83 +385,6 @@ describe('hourly attempt lifecycle (SQLite)', () => {
|
||||
expect(next?.state).not.toBe('running');
|
||||
});
|
||||
|
||||
describe('hourly term eligibility', () => {
|
||||
const at = (h: number, m = 0) => new Date(Date.UTC(2026, 0, 1, h, m));
|
||||
const seed = (
|
||||
state: 'complete' | 'failed' | 'running',
|
||||
start: Date,
|
||||
end: Date,
|
||||
processRunId = 'other-process',
|
||||
) =>
|
||||
prisma.tbankSyncWork.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: USER,
|
||||
revision: REVISION,
|
||||
scopeKey: 'hourly',
|
||||
attemptId: randomUUID(),
|
||||
processRunId,
|
||||
priority: 0,
|
||||
intent: {},
|
||||
progress: {},
|
||||
state,
|
||||
createdAt: start,
|
||||
updatedAt: end,
|
||||
},
|
||||
});
|
||||
const reserve = async (when: Date) => {
|
||||
jest.setSystemTime(when);
|
||||
return repository.reserveHourly(USER, 'this-process', 3_600_000);
|
||||
};
|
||||
|
||||
it.each([
|
||||
['short attempt', 'complete', at(10), at(10, 5), at(10, 59), at(11)],
|
||||
['failed attempt', 'failed', at(10), at(10, 5), at(10, 59), at(11)],
|
||||
['walk past one term', 'complete', at(10), at(11, 10), at(11, 59), at(12)],
|
||||
['walk past two terms', 'complete', at(10), at(12, 10), at(12, 59), at(13)],
|
||||
['walk ending on a term', 'complete', at(10), at(11), at(10, 59), at(11)],
|
||||
] as const)('%s', async (_name, state, start, end, early, due) => {
|
||||
await seed(state, start, end);
|
||||
expect(await reserve(early)).toBeNull();
|
||||
const reserved = await reserve(due);
|
||||
expect(reserved?.work.state).toBe('running');
|
||||
expect(reserved?.work.createdAt).toEqual(due);
|
||||
});
|
||||
|
||||
it('starts a new revision at once', async () => {
|
||||
await seed('complete', at(10), at(10, 5));
|
||||
await prisma.tbankConnection.deleteMany({ where: { userId: USER } });
|
||||
await prisma.tbankConnection.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
revision: 'next-revision',
|
||||
encryptedToken: tokens.encrypt('synthetic'),
|
||||
connectedAt: at(10, 10),
|
||||
},
|
||||
});
|
||||
expect((await reserve(at(10, 10)))?.work.revision).toBe('next-revision');
|
||||
});
|
||||
|
||||
it('closes a dead process attempt without early retry and runs one catch-up after downtime', async () => {
|
||||
await seed('running', at(10), at(10, 1), 'dead-process');
|
||||
expect(await reserve(at(10, 30))).toBeNull();
|
||||
expect((await hourlyRow())?.errorCode).toBe('INTERRUPTED');
|
||||
expect(await reserve(at(10, 59))).toBeNull();
|
||||
expect((await reserve(at(14, 30)))?.work.createdAt).toEqual(at(14, 30));
|
||||
expect(await reserve(at(14, 31))).toBeNull();
|
||||
expect(
|
||||
await prisma.tbankSyncWork.count({
|
||||
where: { userId: USER, scopeKey: 'hourly' },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('does not reserve while this process still runs the attempt', async () => {
|
||||
await seed('running', at(10), at(10, 1), 'this-process');
|
||||
expect(await reserve(at(12, 30))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overlap a running attempt with a second walk', async () => {
|
||||
operations.request.mockImplementation(async () => {
|
||||
const row = await child('running', null, 'operations');
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '../src/modules/investments/income/income-analytics.dto';
|
||||
import { moscowDate } from '../src/modules/investments/investment-calendar';
|
||||
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
// Financial proof uses real persisted rows/coverage and the configured HTTP boundary.
|
||||
@@ -60,8 +59,6 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
@@ -269,9 +266,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
||||
await repo.fail(fence, false);
|
||||
const after = await read();
|
||||
expect(after.data).toEqual(before.data);
|
||||
// Without an hourly walk the durable attempt is absent; the failed
|
||||
// source work stays a technical observation, not the hourly attempt.
|
||||
expect(after.meta.lastAttempt.state).toBe('none');
|
||||
expect(after.meta.lastAttempt.state).toBe('failed');
|
||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||
});
|
||||
it('retains current-month observed completeness and does not renew source timestamps', async () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { TbankClient } from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const uid = '11111111-1111-4111-8111-111111111111';
|
||||
@@ -46,8 +45,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
} from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto';
|
||||
import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto';
|
||||
import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service';
|
||||
import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -104,8 +102,6 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TbankClient)
|
||||
.useValue(upstream)
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
@@ -330,74 +326,6 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => {
|
||||
await put().expect(200);
|
||||
await seedMembership();
|
||||
await requestPortfolio('account');
|
||||
await settled();
|
||||
const connection = await prisma.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId: 'owner' },
|
||||
});
|
||||
const startedAt = new Date('2026-10-10T10:00:00.000Z');
|
||||
const finishedAt = new Date('2026-10-10T10:05:00.000Z');
|
||||
await prisma.tbankSyncWork.create({
|
||||
data: {
|
||||
id: 'hourly-attempt',
|
||||
userId: 'owner',
|
||||
revision: connection.revision,
|
||||
scopeKey: 'hourly',
|
||||
intent: {},
|
||||
state: 'complete',
|
||||
attemptId: 'hourly-attempt',
|
||||
processRunId: 'test',
|
||||
priority: 0,
|
||||
progress: {
|
||||
result: 'partial',
|
||||
sections: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'account',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
{
|
||||
section: 'operations',
|
||||
accountId: 'account',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: startedAt,
|
||||
updatedAt: finishedAt,
|
||||
},
|
||||
});
|
||||
const accounts = await get('brokerage-accounts').expect(200);
|
||||
const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta;
|
||||
expect(accountsMeta.lastAttempt.state).toBe('partial');
|
||||
expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString());
|
||||
expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString());
|
||||
expect(accountsMeta.lastAttempt.problems).toEqual([
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'account',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
]);
|
||||
const income = await get(
|
||||
'brokerage-accounts/account/income-analytics?sync=observe',
|
||||
).expect(200);
|
||||
const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta;
|
||||
expect(incomeMeta.lastAttempt.state).toBe('partial');
|
||||
expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString());
|
||||
expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString());
|
||||
expect(incomeMeta.lastAttempt.problems).toEqual([
|
||||
{
|
||||
section: 'operations',
|
||||
accountId: 'account',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => {
|
||||
await put().expect(200);
|
||||
const connection = await prisma.tbankConnection.update({
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
TbankOperationsByCursorRequest,
|
||||
} from '../src/integrations/tbank/tbank.client';
|
||||
import { configureApp } from '../src/main';
|
||||
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
const evidenceDirectory = mkdtempSync(join(tmpdir(), 'wealthledger-read-evidence-'));
|
||||
@@ -216,8 +215,6 @@ describe('T-Bank persisted read budget', () => {
|
||||
})
|
||||
.overrideProvider(NcrRatingsClient)
|
||||
.useValue({ rating: () => counted('ncrRating', null) })
|
||||
.overrideProvider(TbankHourlyScheduler)
|
||||
.useValue({ tick: jest.fn() })
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
app.use((_req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
Reference in New Issue
Block a user