Files
fin/cli/tests/test_cli.py
T
EislM0203andClaude Opus 5.5 ec21078bf8 fix: balance adjustments counted as spending and income — add 'adjustment' type
POST /api/accounts/{id}/adjust-balance wrote its delta as 'expense' (down)
or 'income' (up), so every downward correction surfaced as uncategorized
spending in get_monthly_spending, and every upward one as income. Opening
balances were written as 'income' too, kept out of the income total only by
a description != 'Opening balance' filter.

Backend:
- adjust_balance (routers/accounts.py) and create_account (crud.py) now
  write type='adjustment' with the signed amount. Balances are unaffected:
  SUM(amount - fee) ignores type.
- get_monthly_spending drops the description filter; spending and income
  select purely by type.
- TransactionBase gains 'adjustment' for reads; TransactionCreate does not,
  so only the server can write one.
- Migration j0e1f2a3b4c5 retypes historical rows: unlinked income/expense
  rows with the exact generated descriptions. Downgrade restores the
  typing from the sign of the amount.

Tests: cli/tests/test_adjustment.py covers spending/income exclusion, the
balance landing on target, and the migration + downgrade on a scratch DB.
test_cli's opening-balance assertion updated to the new type. 65 passed.

Docs: CLAUDE.md type list and invariant 6; spec in
docs/superpowers/specs/2026-09-30-adjustment-transaction-type.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:45 +00:00

309 lines
11 KiB
Python

import json
import pytest
from fin_cli.client import FinClientError
def _bank_account(client, name="Checking", initial_balance=None):
data = {"name": name, "type": "bank", "currency": "EUR"}
if initial_balance is not None:
data["initial_balance"] = initial_balance
return client.create_account(data)
def _account(client, name, type_):
return client.create_account({"name": name, "type": type_, "currency": "EUR"})
class TestAuth:
def test_auth_off_allows_requests(self, client):
assert client.list_accounts() == []
def test_health_ok(self, client):
assert client.health() == {"status": "ok"}
def test_auth_on_rejects_missing_token(self, client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
with pytest.raises(FinClientError) as exc:
client.list_accounts()
assert exc.value.status_code == 401
def test_auth_on_rejects_wrong_token(self, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
with pytest.raises(FinClientError) as exc:
make_client(token="wrong").list_accounts()
assert exc.value.status_code == 401
def test_auth_on_accepts_correct_token(self, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
assert make_client(token="sekret").list_accounts() == []
def test_health_bypasses_auth(self, client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
assert client.health() == {"status": "ok"}
def test_bearer_scheme_is_case_insensitive(self, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
http_client = make_client(token="sekret")
http_client._http.headers["Authorization"] = "bearer sekret"
assert http_client.list_accounts() == []
def test_openapi_gated_when_auth_on(self, client, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
with pytest.raises(FinClientError) as exc:
client.openapi_spec()
assert exc.value.status_code == 401
spec = make_client(token="sekret").openapi_spec()
assert "/api/accounts" in spec["paths"]
def test_openapi_open_when_auth_off(self, client):
spec = client.openapi_spec()
assert "/api/accounts" in spec["paths"]
class TestAccounts:
def test_create_and_list(self, client):
created = _bank_account(client, "Giro")
assert created["name"] == "Giro"
assert created["type"] == "bank"
accounts = client.list_accounts()
assert [a["id"] for a in accounts] == [created["id"]]
def test_initial_balance_creates_opening_transaction(self, client):
acct = _bank_account(client, "Giro", initial_balance=250.0)
txns = client.list_transactions(account_id=acct["id"])
assert len(txns) == 1
assert txns[0]["amount"] == 250.0
assert txns[0]["type"] == "adjustment"
class TestTransactions:
def test_add_and_list_with_limit(self, client):
acct = _bank_account(client)
for day in (1, 2, 3):
client.create_transaction(
{
"account_id": acct["id"],
"amount": -10.0 * day,
"date": f"2026-06-0{day}",
"type": "expense",
}
)
all_rows = client.list_transactions(account_id=acct["id"])
assert len(all_rows) == 3
limited = client.list_transactions(account_id=acct["id"], limit=2)
assert len(limited) == 2
# newest first
assert limited[0]["date"] == "2026-06-03"
def test_limit_must_be_positive(self, client):
with pytest.raises(FinClientError) as exc:
client.list_transactions(limit=0)
assert exc.value.status_code == 422
class TestTransfer:
def test_two_legs_created_and_linked(self, client):
a = _bank_account(client, "A", initial_balance=100)
b = _bank_account(client, "B")
legs = client.create_transfer(
{
"from_account_id": a["id"],
"to_account_id": b["id"],
"amount": 40.0,
"date": "2026-06-15",
}
)
assert len(legs) == 2
by_account = {leg["account_id"]: leg for leg in legs}
assert by_account[a["id"]]["amount"] == -40.0
assert by_account[a["id"]]["linked_account_id"] == b["id"]
assert by_account[b["id"]]["amount"] == 40.0
assert by_account[b["id"]]["linked_account_id"] == a["id"]
def test_deleting_one_leg_removes_whole_group(self, client):
a = _bank_account(client, "A")
b = _bank_account(client, "B")
legs = client.create_transfer(
{
"from_account_id": a["id"],
"to_account_id": b["id"],
"amount": 40.0,
"date": "2026-06-15",
}
)
client.delete_transaction(legs[0]["id"])
assert client.list_transactions() == []
def test_editing_one_leg_is_rejected(self, client):
a = _bank_account(client, "A")
b = _bank_account(client, "B")
legs = client.create_transfer(
{
"from_account_id": a["id"],
"to_account_id": b["id"],
"amount": 40.0,
"date": "2026-06-15",
}
)
with pytest.raises(FinClientError) as exc:
client.update_transaction(legs[0]["id"], {"amount": -30.0})
assert exc.value.status_code == 422
class TestSettlement:
def test_bank_to_splitwise_two_legs_linked(self, client):
bank = _bank_account(client, "Giro")
sw = _account(client, "Splitwise", "splitwise")
legs = client.create_settlement(
{
"bank_account_id": bank["id"],
"splitwise_account_id": sw["id"],
"amount": 30.0,
"date": "2026-06-20",
"direction": "bank_to_splitwise",
}
)
assert len(legs) == 2
by_account = {leg["account_id"]: leg for leg in legs}
assert by_account[bank["id"]]["amount"] == -30.0
assert by_account[bank["id"]]["linked_account_id"] == sw["id"]
assert by_account[sw["id"]]["amount"] == 30.0
assert by_account[sw["id"]]["linked_account_id"] == bank["id"]
assert all(leg["type"] == "settlement" for leg in legs)
class TestSplitExpense:
def test_three_rows_and_user_share_spending(self, client):
bank = _bank_account(client, "Giro")
recv = _account(client, "Partner owes me", "receivable")
rows = client.create_split_expense(
{
"bank_account_id": bank["id"],
"receivable_account_id": recv["id"],
"total_amount": 100.0,
"partner_share": 40.0,
"date": "2026-06-21",
}
)
assert len(rows) == 3
expense = [r for r in rows if r["type"] == "expense"]
transfers = [r for r in rows if r["type"] == "transfer"]
assert len(expense) == 1 and len(transfers) == 2
# user's own share on the bank account
assert expense[0]["amount"] == -60.0
by_account_amounts = sorted(t["amount"] for t in transfers)
assert by_account_amounts == [-40.0, 40.0]
# spending counts only the user's share; the transfer legs are excluded
spending = client.get_spending_month("2026-06")
assert spending["total"] == pytest.approx(60.0)
class TestSplitwisePaid:
def test_spending_counts_net_share(self, client):
bank = _bank_account(client, "Giro")
sw = _account(client, "Splitwise", "splitwise")
legs = client.create_splitwise_i_paid(
{
"bank_account_id": bank["id"],
"splitwise_account_id": sw["id"],
"total_amount": 100.0,
"partner_share": 45.0,
"date": "2026-06-22",
}
)
by_account = {leg["account_id"]: leg for leg in legs}
assert by_account[bank["id"]]["amount"] == -100.0
assert by_account[bank["id"]]["splitwise_partner_share"] == 45.0
assert by_account[sw["id"]]["amount"] == 45.0
# CLAUDE.md invariant 6: spending = amount + splitwise_partner_share
spending = client.get_spending_month("2026-06")
assert spending["total"] == pytest.approx(55.0)
class TestNetWorth:
def test_current_reflects_bank_balance(self, client):
_bank_account(client, "Giro", initial_balance=100.0)
nw = client.get_net_worth()
assert nw["total_eur"] == pytest.approx(100.0)
assert len(nw["accounts"]) == 1
def test_fee_reduces_balance(self, client):
# CLAUDE.md invariant 5: balance = SUM(amount - fee)
acct = _bank_account(client, "Giro", initial_balance=100.0)
client.create_transaction(
{
"account_id": acct["id"],
"amount": -50.0,
"fee": 2.0,
"date": "2026-06-10",
"type": "expense",
}
)
nw = client.get_net_worth()
assert nw["total_eur"] == pytest.approx(48.0)
class TestSpending:
def test_monthly_totals(self, client):
acct = _bank_account(client)
cat = client.create_category(
{"name": "Dining", "color": "#f97316", "icon": "🍽️"}
)
client.create_transaction(
{
"account_id": acct["id"],
"amount": -50.0,
"date": "2026-06-10",
"type": "expense",
"category_id": cat["id"],
}
)
spending = client.get_spending_month("2026-06")
assert spending["total"] == pytest.approx(50.0)
cats = {c["category_name"]: c for c in spending["categories"]}
assert cats["Dining"]["total"] == pytest.approx(50.0)
class TestCliWiring:
"""Smoke test that the typer app is wired to the client correctly."""
def test_accounts_list_json(self, client, monkeypatch):
from typer.testing import CliRunner
import fin_cli.output
from fin_cli.main import app
_bank_account(client, "Giro")
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
result = CliRunner().invoke(app, ["accounts", "list", "--json"])
assert result.exit_code == 0, result.output
rows = json.loads(result.output)
assert rows[0]["name"] == "Giro"
def test_error_exits_nonzero(self, client, monkeypatch):
from typer.testing import CliRunner
import fin_cli.output
from fin_cli.main import app
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
result = CliRunner().invoke(app, ["tx", "rm", "99999"])
assert result.exit_code == 1
def test_tx_add_refuses_transfer_type(self, client, monkeypatch):
from typer.testing import CliRunner
import fin_cli.output
from fin_cli.main import app
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
result = CliRunner().invoke(
app,
["tx", "add", "--account", "1", "--amount", "-5",
"--date", "2026-06-01", "--type", "transfer"],
)
assert result.exit_code == 1
assert client.list_transactions() == []