test(analytics): verify SQL aggregates
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.6.1",
|
"version": "0.6.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"migrate": "tsx src/db/migrate.ts",
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
"migrate:prod": "node dist/db/migrate.js",
|
"migrate:prod": "node dist/db/migrate.js",
|
||||||
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
|
"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"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
42
backend/src/services/analytics.integration.test.ts
Normal file
42
backend/src/services/analytics.integration.test.ts
Normal file
@@ -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<void> {
|
||||||
|
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());
|
||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
Granularity,
|
Granularity,
|
||||||
} from '@family-budget/shared';
|
} from '@family-budget/shared';
|
||||||
|
|
||||||
|
type Queryable = Pick<typeof pool, 'query'>;
|
||||||
|
|
||||||
interface BaseParams {
|
interface BaseParams {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -61,11 +63,14 @@ function buildBaseConditions(
|
|||||||
return { conditions, values, nextIdx: idx };
|
return { conditions, values, nextIdx: idx };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryResponse> {
|
export async function getSummary(
|
||||||
|
params: BaseParams,
|
||||||
|
db: Queryable = pool,
|
||||||
|
): Promise<AnalyticsSummaryResponse> {
|
||||||
const { conditions, values } = buildBaseConditions(params, 1);
|
const { conditions, values } = buildBaseConditions(params, 1);
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const totalsResult = await pool.query(
|
const totalsResult = await db.query(
|
||||||
`${analyticsTransactions(where)},
|
`${analyticsTransactions(where)},
|
||||||
category_net AS (
|
category_net AS (
|
||||||
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
|
SELECT category_id, category_name, analytic_type, SUM(effective_amount)::bigint AS amount
|
||||||
@@ -90,7 +95,7 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
|||||||
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
const transferInflow = Number(totalsResult.rows[0].transfer_inflow);
|
||||||
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
const transferOutflow = Number(totalsResult.rows[0].transfer_outflow);
|
||||||
|
|
||||||
const topResult = await pool.query(
|
const topResult = await db.query(
|
||||||
`${analyticsTransactions(where)}
|
`${analyticsTransactions(where)}
|
||||||
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
SELECT category_id::bigint, category_name, GREATEST(-SUM(effective_amount), 0)::bigint AS amount
|
||||||
FROM analytics_transactions
|
FROM analytics_transactions
|
||||||
@@ -122,11 +127,14 @@ export async function getSummary(params: BaseParams): Promise<AnalyticsSummaryRe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[]> {
|
export async function getByCategory(
|
||||||
|
params: BaseParams,
|
||||||
|
db: Queryable = pool,
|
||||||
|
): Promise<ByCategoryItem[]> {
|
||||||
const { conditions, values } = buildBaseConditions(params, 1);
|
const { conditions, values } = buildBaseConditions(params, 1);
|
||||||
const where = 'WHERE ' + conditions.join(' AND ');
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await db.query(
|
||||||
`${analyticsTransactions(where)}
|
`${analyticsTransactions(where)}
|
||||||
SELECT
|
SELECT
|
||||||
category_id::bigint,
|
category_id::bigint,
|
||||||
@@ -154,6 +162,7 @@ export async function getByCategory(params: BaseParams): Promise<ByCategoryItem[
|
|||||||
|
|
||||||
export async function getTimeseries(
|
export async function getTimeseries(
|
||||||
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
params: BaseParams & { categoryId?: number; granularity: Granularity },
|
||||||
|
db: Queryable = pool,
|
||||||
): Promise<TimeseriesItem[]> {
|
): Promise<TimeseriesItem[]> {
|
||||||
let truncExpr: string;
|
let truncExpr: string;
|
||||||
let intervalStr: string;
|
let intervalStr: string;
|
||||||
@@ -198,7 +207,7 @@ export async function getTimeseries(
|
|||||||
|
|
||||||
const txWhere = txConditions.join(' AND ');
|
const txWhere = txConditions.join(' AND ');
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await db.query(
|
||||||
`WITH periods AS (
|
`WITH periods AS (
|
||||||
SELECT
|
SELECT
|
||||||
gs::date AS period_start,
|
gs::date AS period_start,
|
||||||
|
|||||||
Reference in New Issue
Block a user