from datetime import timedelta from sqlalchemy import select from factories import make_account, make_category, make_txn, month_back from fintracker.analytics import today_local from fintracker.models import METRICS_JOB, AccountRole, JobStatus, SyncJob from fintracker.worker.scheduler import _process_queued_jobs async def _queue_and_run(client, auth_headers): """POST /metrics/refresh, then let the worker take the job, as it does every 5 s.""" r = await client.post("/api/v1/metrics/refresh", headers=auth_headers) assert r.status_code == 202, r.text await _process_queued_jobs() return r.json() async def test_refresh_populates_every_metric_endpoint(client, auth_headers): card = await make_account(name="Карта", balance="200000") await make_account(name="Вклад", balance="100000", role=AccountRole.savings) await make_account(name="Кредитка", balance="-5000", role=AccountRole.debt) food = await make_category("Еда") m = month_back(1) await make_txn(m + timedelta(days=1), income="150000", income_account_id=card) await make_txn( m + timedelta(days=2), outcome="30000", outcome_account_id=card, primary_category_id=food, ) await make_txn(today_local() - timedelta(days=1), outcome="500", outcome_account_id=card) before = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() assert before == {"last_refresh": None, "consistent": True, "refreshing": False} job = await _queue_and_run(client, auth_headers) assert job["status"] == "queued" status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() assert status["last_refresh"]["trigger"] == "manual" assert status["last_refresh"]["error"] is None assert status["last_refresh"]["finished_at"] is not None assert status["last_refresh"]["failed_step"] is None assert status["last_refresh"]["step_timings"] # every step is timed assert status["consistent"] is True assert status["refreshing"] is False series = (await client.get("/api/v1/networth/series", headers=auth_headers)).json() assert series assert series[-1]["d"] == str(today_local()) assert series[-1]["total_rub"].startswith("295000") assert isinstance(series[-1]["by_currency"]["RUB"], str) breakdown = (await client.get("/api/v1/networth/breakdown", headers=auth_headers)).json() assert breakdown["d"] == str(today_local()) assert breakdown["debt_rub"].startswith("-5000") assert {a["name"] for a in breakdown["accounts"]} == {"Карта", "Вклад", "Кредитка"} assert all(isinstance(a["balance_rub"], str) for a in breakdown["accounts"]) monthly = (await client.get("/api/v1/cashflow/monthly", headers=auth_headers)).json() last_month = next(row for row in monthly if row["month"] == str(m)) assert last_month["income_rub"].startswith("150000") assert last_month["expense_rub"].startswith("30000") assert last_month["savings_rate"].startswith("0.8") spending = ( await client.get( "/api/v1/spending/categories", headers=auth_headers, params={"month": m.strftime("%Y-%m")}, ) ).json() assert spending[0]["category_name"] == "Еда" assert spending[0]["root_category_name"] == "Еда" assert spending[0]["amount_rub"].startswith("30000") runway = (await client.get("/api/v1/runway", headers=auth_headers)).json() assert runway["liquid_reserve_rub"].startswith("300000") # 30000 baseline in the previous month, 0 in the two before it -> 10000 average assert runway["avg_baseline_3m_rub"].startswith("10000") assert runway["runway_months"].startswith("30") quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json() assert isinstance(quality, list) async def test_spending_rejects_a_bad_month(client, auth_headers): r = await client.get( "/api/v1/spending/categories", headers=auth_headers, params={"month": "2026/01"} ) assert r.status_code == 400 r = await client.get( "/api/v1/spending/categories", headers=auth_headers, params={"month": "2026-13"} ) assert r.status_code == 400 async def test_empty_database_reports_no_transactions(client, auth_headers): await _queue_and_run(client, auth_headers) quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json() assert [row["check_name"] for row in quality] == ["no_transactions"] assert (await client.get("/api/v1/runway", headers=auth_headers)).json()[ "runway_months" ] is None assert (await client.get("/api/v1/networth/series", headers=auth_headers)).json() == [] assert (await client.get("/api/v1/spending/categories", headers=auth_headers)).json() == [] async def test_refresh_returns_before_the_work_is_done(client, auth_headers): """The 202 is honest: nothing has been rebuilt until the worker picks the job up.""" r = await client.post("/api/v1/metrics/refresh", headers=auth_headers) assert r.status_code == 202 assert r.json()["source"] == METRICS_JOB status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() assert status["last_refresh"] is None assert status["refreshing"] is True await _process_queued_jobs() status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() assert status["refreshing"] is False assert status["last_refresh"] is not None async def test_a_waiting_refresh_is_shared_not_duplicated(client, auth_headers): first = (await client.post("/api/v1/metrics/refresh", headers=auth_headers)).json() second = (await client.post("/api/v1/metrics/refresh", headers=auth_headers)).json() assert first["id"] == second["id"] from fintracker.db import get_sessionmaker async with get_sessionmaker()() as session: jobs = ( (await session.execute(select(SyncJob).where(SyncJob.source == METRICS_JOB))) .scalars() .all() ) assert len(jobs) == 1 async def test_a_refresh_that_fails_part_way_is_reported_as_inconsistent( client, auth_headers, monkeypatch ): from fintracker.db import get_sessionmaker from fintracker.metrics import refresh as refresh_module async def fine(session): pass async def boom(session): raise RuntimeError("step blew up") monkeypatch.setattr("fintracker.analytics.register_steps", lambda: None) monkeypatch.setattr(refresh_module, "STEPS", [("fine", fine), ("boom", boom), ("never", fine)]) await _queue_and_run(client, auth_headers) status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() last = status["last_refresh"] assert last["failed_step"] == "boom" assert "step blew up" in last["error"] assert list(last["step_timings"]) == ["fine"] # 'never' did not run, 'boom' did not finish assert status["consistent"] is False async with get_sessionmaker()() as session: job = ( await session.execute(select(SyncJob).where(SyncJob.source == METRICS_JOB)) ).scalar_one() assert job.status == JobStatus.error # a later clean run makes the tables one snapshot again monkeypatch.setattr(refresh_module, "STEPS", [("fine", fine)]) await _queue_and_run(client, auth_headers) status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() assert status["consistent"] is True assert status["last_refresh"]["failed_step"] is None