From 1cbef453b09d5aac22bd8c605dd82e12b7bcdbb3 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 19 Aug 2026 09:12:43 +0300 Subject: [PATCH] test(analytics): verify SQL aggregates --- backend/package.json | 3 +- .../services/analytics.integration.test.ts | 42 +++++++++++++++++++ backend/src/services/analytics.ts | 21 +++++++--- 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 backend/src/services/analytics.integration.test.ts diff --git a/backend/package.json b/backend/package.json index 4ad50a7..8771077 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.6.1", + "version": "0.6.2", "private": true, "scripts": { "dev": "tsx watch src/app.ts", @@ -9,6 +9,7 @@ "migrate": "tsx src/db/migrate.ts", "migrate:prod": "node dist/db/migrate.js", "test:analytics": "tsx src/services/analyticsSemantics.test.ts", + "test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts", "test:llm": "tsx src/scripts/testLlm.ts" }, "dependencies": { diff --git a/backend/src/services/analytics.integration.test.ts b/backend/src/services/analytics.integration.test.ts new file mode 100644 index 0000000..4e04d72 --- /dev/null +++ b/backend/src/services/analytics.integration.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { pool } from '../db/pool'; +import { getByCategory, getSummary, getTimeseries } from './analytics'; + +async function testQueries(): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const account = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-1', 'RUB') RETURNING id"); + const otherAccount = await client.query("INSERT INTO accounts (bank, account_number, currency) VALUES ('TEST', 'analytics-test-2', 'RUB') RETURNING id"); + const categories = await client.query("INSERT INTO categories (name, type) VALUES ('Тест расход', 'expense'), ('Тест доход', 'income'), ('Тест перевод', 'transfer') RETURNING id, type"); + const accountId = Number(account.rows[0].id); + const otherAccountId = Number(otherAccount.rows[0].id); + const categoryId = Object.fromEntries(categories.rows.map((row) => [row.type, Number(row.id)])); + const insert = (account: number, at: string, amount: number, category: number, fingerprint: string) => client.query( + "INSERT INTO transactions (account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) VALUES ($1, $2, $3, 0, 'test', CASE WHEN $3 < 0 THEN 'expense' ELSE 'income' END, $4, $5, TRUE)", + [account, at, amount, fingerprint, category], + ); + + await insert(accountId, '2026-07-01T12:00:00+03:00', -10_000, categoryId.expense, 'analytics-test-1'); + await insert(accountId, '2026-07-02T12:00:00+03:00', 4_000, categoryId.expense, 'analytics-test-2'); + await insert(accountId, '2026-07-03T12:00:00+03:00', 20_000, categoryId.income, 'analytics-test-3'); + await insert(accountId, '2026-07-04T12:00:00+03:00', -5_000, categoryId.income, 'analytics-test-4'); + await insert(accountId, '2026-07-05T12:00:00+03:00', -3_000, categoryId.transfer, 'analytics-test-5'); + await insert(otherAccountId, '2026-07-01T12:00:00+03:00', -99_000, categoryId.expense, 'analytics-test-6'); + await insert(accountId, '2026-08-01T12:00:00+03:00', -7_000, categoryId.expense, 'analytics-test-7'); + + const params = { from: '2026-07-01', to: '2026-07-31', accountId, onlyConfirmed: true }; + const summary = await getSummary(params, client); + assert.deepEqual({ expense: summary.totalExpense, income: summary.totalIncome, net: summary.net, transferOut: summary.transferOutflow }, { expense: 6_000, income: 15_000, net: 9_000, transferOut: 3_000 }); + assert.equal((await getSummary({ ...params, from: '2026-08-01', to: '2026-08-31' }, client)).totalExpense, 7_000); + assert.deepEqual((await getByCategory(params, client)).map((item) => item.amount), [6_000]); + const timeseries = await getTimeseries({ ...params, granularity: 'month' }, client); + assert.equal(timeseries[0].expenseAmount, 6_000); + assert.equal(timeseries[0].incomeAmount, 15_000); + } finally { + await client.query('ROLLBACK'); + client.release(); + } +} + +testQueries().then(() => console.log('analytics SQL: OK')).finally(() => pool.end()); diff --git a/backend/src/services/analytics.ts b/backend/src/services/analytics.ts index dffeef5..660e3e8 100644 --- a/backend/src/services/analytics.ts +++ b/backend/src/services/analytics.ts @@ -8,6 +8,8 @@ import type { Granularity, } from '@family-budget/shared'; +type Queryable = Pick; + interface BaseParams { from: string; to: string; @@ -61,11 +63,14 @@ function buildBaseConditions( return { conditions, values, nextIdx: idx }; } -export async function getSummary(params: BaseParams): Promise { +export async function getSummary( + params: BaseParams, + db: Queryable = pool, +): Promise { const { conditions, values } = buildBaseConditions(params, 1); const where = 'WHERE ' + conditions.join(' AND '); - const totalsResult = await pool.query( + const totalsResult = await db.query( `${analyticsTransactions(where)}, category_net AS ( SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount @@ -90,7 +95,7 @@ export async function getSummary(params: BaseParams): Promise { +export async function getByCategory( + params: BaseParams, + db: Queryable = pool, +): Promise { const { conditions, values } = buildBaseConditions(params, 1); const where = 'WHERE ' + conditions.join(' AND '); - const { rows } = await pool.query( + const { rows } = await db.query( `${analyticsTransactions(where)} SELECT category_id::bigint, @@ -154,6 +162,7 @@ export async function getByCategory(params: BaseParams): Promise { let truncExpr: string; let intervalStr: string; @@ -198,7 +207,7 @@ export async function getTimeseries( const txWhere = txConditions.join(' AND '); - const { rows } = await pool.query( + const { rows } = await db.query( `WITH periods AS ( SELECT gs::date AS period_start,