Files
fin/.claude/skills/fin-test-coverage-campaign/SKILL.md
T
EislM0203andClaude Opus 5 cb97d97d81 docs: check in the fin skill library, ignore local Claude settings
Twelve skills that had been sitting uncommitted in the working tree: the
architecture contract, accounting reference, change-control rulebook, failure
archaeology, debugging playbook, diagnostics scripts, ledger-analysis toolkit,
research frontier, run/operate runbook, build-and-env, test-coverage campaign,
and validation/QA guide.

Updates three frontier statuses to match what actually shipped today rather
than checking in a roadmap that describes closed work as open:

- A1 double-entry hardening -> partly-done. The create half is enforced; what
  remains is running the existing audit_linked_legs.py against a prod backup.
- B3 safety rails -> partly-done. Idempotency shipped, including the note that
  the index is not unique-when-not-null and why. dry_run is still open.
- D3 CSV import -> unblocked but still gated. Its stated blocker was B3's
  idempotency keys, which now exist.

Also gitignores .claude/settings.local.json, which is per-machine and was
showing up as untracked on every status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:09:22 +00:00

39 KiB
Raw Blame History

name, description
name description
fin-test-coverage-campaign The executable campaign to take fin from 25 e2e CLI tests to a real safety net — direct api-level tests for crud.py, regression tests for every historical bug family, property tests for ledger invariants, service tests (net worth, price fetcher, scheduler), Alembic migration tests, and CI. Load when asked to improve test coverage, harden crud.py or services against regressions, add CI or a test gate, write api/tests, measure coverage, or after ANY accounting bug is found or fixed (to lock down its whole family). Also load before refactoring crud.py — characterization tests come first.

fin — Test Coverage Campaign

Mission: the only tests are cli/tests/ (25 e2e tests). api/app/crud.py — 995 lines holding ALL DB mutation logic — has no direct tests. services/, router error mapping, and the 8-revision Alembic chain are untested. There is no CI. Every accounting bug in this project's history shipped because nothing failed (chronicle: fin-failure-archaeology). This campaign closes that, phase by phase, with a measurable gate at every step.

All facts and numbers date-stamped 2026-07-05 (HEAD 082b608). The authoring sandbox had no pip/venv, so every command below is verified statically unless it appears in the git history or skill siblings as executed; the gates therefore say "record the number you observe; expected ≈N". If your observation differs from the expectation, follow the branch instruction — never shrug and continue.

Read first: /CLAUDE.md (root rulebook). Alignment contracts:

  • Promotion: every phase lands as its own reviewed change through fin-change-control (§1 classification, §7 done-checklist). Tests-only diffs that touch no invariant file classify as behavior changes whose "test covering the new behavior" requirement is satisfied by the tests themselves.
  • Evidence: this campaign produces level-(a) evidence in the fin-validation-and-qa hierarchy. Follow its hypothesis-predicts-numbers discipline: write the expected number and mechanism as a comment BEFORE the first run of every new test.
  • Regression targets: Phase 2 converts the fin-failure-archaeology chronicle into named tests. Read that skill before writing Phase 2.
  • Environment: fin-build-and-env owns setup (venv, minimal deps, yfinance-stub rationale). Not duplicated here.
  • Oracles: fin-diagnostics-and-tooling and fin-ledger-analysis-toolkit (being written in parallel as of 2026-07-05) may provide audit scripts and derived invariants; check them before hand-rolling an oracle in Phase 3.

When NOT to use this skill

You actually want to… Use instead
Add one e2e test for a change you just made fin-validation-and-qa §4
Decide whether a change is allowed / gates fin-change-control
Set up the Python environment fin-build-and-env
Understand accounting math for expected values fin-accounting-reference
Look up a past bug before pinning it fin-failure-archaeology
Debug a live failure fin-debugging-playbook

Use THIS skill to execute or resume the coverage campaign, and whenever a new bug is fixed — its family gets a Phase-2-style regression test even after the campaign is "done".

Ground truth — what exists (2026-07-05)

crud.py public surface — the coverage target

35 top-level defs in api/app/crud.py (verify: grep -cE '^def ' api/app/crud.py), 33 public + 2 private:

Area Functions (count)
Accounts (5) get_accounts, get_account, create_account, update_account, deactivate_account
Categories (5) get_categories, get_category, create_category, update_category, delete_category
Transactions (9) get_transactions, get_transaction, create_transaction, update_transaction, delete_transaction, create_transfer, create_settlement, create_split_expense, create_splitwise_i_paid
Holdings (2) get_holdings, get_holding
Stock transactions (5) create_stock_transaction, get_stock_transactions, recompute_holding_from_stock_transactions, delete_stock_transaction, create_funded_buy
Spending (2) get_monthly_spending, get_spending_summary
RSU helpers, private (2) _add_months, _generate_rsu_schedule
RSU grants (5) get_rsu_grants, get_rsu_grant, create_rsu_grant, delete_rsu_grant, vest_rsu_grant

Exercised by the existing 25 cli tests (13 of 33): get_accounts, create_account, get_categories, create_category, get_transactions, get_transaction, create_transaction, delete_transaction, create_transfer, create_settlement, create_split_expense, create_splitwise_i_paid, get_monthly_spending.

Never executed by any test (20 public + 2 private): everything in the Accounts area except create/list, all category mutations, update_transaction (the one existing edit test is rejected at the router before crud runs), the entire Holdings, Stock-transactions, RSU areas, and get_spending_summary. CLAUDE.md invariant 2 (holdings recompute) is completely untested.

Also untested but NOT in crud.py: reconcile_accounts — a two-leg settlement writer that lives in api/app/routers/transactions.py (line ~100), the only paired-leg writer outside crud. It is a Phase 2 target too.

Existing suite

cli/tests/test_cli.py: 25 def test_ functions (verify: grep -c 'def test_' cli/tests/test_cli.py), inventoried per-test in fin-validation-and-qa §2. cli/tests/conftest.py (59 lines): sets a throwaway DATABASE_URL before the app.* import chain, stubs yfinance via sys.modules.setdefault, autouse drop_all+create_all per test.

Migration chain

8 revisions, linear, head h8c9d0e1f2a3 (verify: ls api/alembic/versions/). Table in fin-change-control §4.2.

Git remote — checked for Phase 6

origin  https://gitea.cloud.traunseenet.com/markuseisl/fin.git

Self-hosted Gitea, not GitHub (observed 2026-07-05 via git remote -v). GitHub Actions is off the menu; see Phase 6.


Phase 0 — Baseline (run before touching anything)

0.1 Environment

Follow fin-build-and-env §(b), then add the coverage tool:

cd /home/markuseisl/Documents/git/fin
python -m venv .venv && . .venv/bin/activate
pip install pytest fastapi httpx sqlalchemy apscheduler "pydantic>=2.0" coverage
pip install -e cli/

Do NOT install yfinance (deliberately stubbed) or alembic yet (needed only from Phase 5).

0.2 Run the suite as-found

python -m pytest cli/tests -q

Record: test count, pass/fail, wall time. Expected: 25 passed, well under 60 s (as of 2026-07-05).

Gate 0a — branch table:

  • 25 passed → continue.
  • More than 25 passed → someone added tests since 2026-07-05; re-read cli/tests/, update the ground-truth section counts, continue.
  • Any failure or error as-found → STOP the campaign. A broken baseline means either the environment is wrong (missing dep → fin-build-and-env traps table) or a real regression shipped untested. Fix or quarantine (@pytest.mark.skip with a linked reason) as its own change through fin-change-control first, and record the finding as a new entry candidate for fin-failure-archaeology. The campaign starts from green, always.

0.3 Baseline coverage

python -m coverage run -m pytest cli/tests -q
python -m coverage report --include='*/api/app/*' -m

Record (into your session notes and the final phase commit message): per-file % for crud.py, services/net_worth.py, services/price_fetcher.py, services/scheduler.py, routers/*.py, and the total.

Expected shape (predicted statically from the exercised-function map above — record what you actually see): crud.py roughly 45–65% (20 of 33 public functions never run); net_worth.py moderate (cash paths only); price_fetcher.py and scheduler.py near import-only (≈15–35%); router coverage high for transactions.py/accounts.py, near-zero for stock_transactions.py/rsu_grants.py/holdings.py/prices.py.

Gate 0b: if crud.py reports > 80% at baseline, your --include filter is wrong (probably matching fin_cli) or the suite grew — investigate before trusting any later delta. If coverage reports "no data collected", you ran pytest without coverage run wrapping it.

The recorded table is the campaign's before-photo; every later phase gate is a delta against it.


Phase 1 — Infrastructure: api/tests/ for direct crud/service tests

The cli suite tests the deployed HTTP contract; it cannot reach crud/service functions the routers don't expose (e.g. recompute_holding_from_stock_transactions mid-history, get_spending_summary edge modes, _generate_rsu_schedule), and it cannot seed price_cache directly. That is why a second, api-level suite is justified rather than growing cli/tests (which stays e2e-only per fin-validation-and-qa §4).

1.1 Create api/tests/conftest.py — exact content

Modeled on cli/tests/conftest.py mechanics: throwaway DATABASE_URL before the import chain, yfinance stub. Verified statically against app/database.py (reads DATABASE_URL at import time via db_config).

"""Direct api-layer fixtures: crud/services against a throwaway SQLite.

Same bootstrap discipline as cli/tests/conftest.py: DATABASE_URL must point at
a throwaway file BEFORE anything imports app.database (which reads it at
import time), and yfinance is stubbed so the heavy package is not required.
Unconditional overwrite: a real DATABASE_URL in the shell must never leak in.
"""

import os
import sys
import tempfile
import types
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "api"))

sys.modules.setdefault("yfinance", types.ModuleType("yfinance"))

_db_dir = tempfile.mkdtemp(prefix="fin-api-tests-")
os.environ["DATABASE_URL"] = f"sqlite:///{_db_dir}/test.db"

from app.database import Base, SessionLocal, engine  # noqa: E402
from app import models  # noqa: E402


@pytest.fixture(autouse=True)
def _fresh_schema():
    Base.metadata.drop_all(bind=engine)
    Base.metadata.create_all(bind=engine)
    yield


@pytest.fixture
def db():
    session = SessionLocal()
    try:
        yield session
    finally:
        session.rollback()
        session.close()


@pytest.fixture
def make_account(db):
    def _make(name="Giro", type_="bank", currency="EUR"):
        acct = models.Account(name=name, type=type_, currency=currency)
        db.add(acct)
        db.commit()
        db.refresh(acct)
        return acct
    return _make


@pytest.fixture
def seed_price(db):
    """Insert a price_cache row directly — the only sanctioned way to get a
    price into tests (yfinance is a stub; never fetch)."""
    from datetime import datetime

    def _seed(symbol, price, currency="USD", source="test"):
        db.add(models.PriceCache(symbol=symbol, price=price,
                                 currency=currency, source=source,
                                 fetched_at=datetime.utcnow()))
        db.commit()
    return _seed

Coexistence note (verified statically): when both suites run in one pytest invocation, both conftests set throwaway URLs but app.database is imported once — the engine binds to whichever conftest imports first. Both URLs are throwaway temp dirs, so this is safe; both autouse fixtures drop/create the same engine's schema per test. If you ever see one suite's data in the other, that is a missing autouse fixture, not a conftest bug.

Rule inherited from fin-change-control §2.1: tests use throwaway databases only — the temp-dir-then-import pattern above is the load-bearing part. Do not "simplify" it.

1.2 One trivial proving test — api/tests/test_crud_accounts.py

from app import crud, schemas


def test_create_account_with_opening_balance(db):
    acct = crud.create_account(db, schemas.AccountCreate(
        name="Giro", type="bank", currency="EUR", initial_balance=250.0))
    # create_account writes one opening 'income' transaction of 250
    txns = crud.get_transactions(db, account_id=acct.id)
    assert len(txns) == 1
    assert txns[0].amount == 250.0
    assert txns[0].type == "income"
    assert txns[0].description == "Opening balance"

1.3 Gate 1

python -m pytest api/tests -q          # expect: 1 passed
python -m pytest cli/tests -q          # expect: still 25 passed
python -m pytest cli/tests api/tests -q  # expect: 26 passed — coexistence proven

Branches:

  • ModuleNotFoundError: app in api/tests → the sys.path.insert line is missing or the file moved; do not fix by installing the api package or editing cli's conftest.
  • cli/tests suddenly failing → your api conftest leaked state (most likely you made the DATABASE_URL assignment conditional, or imported app.* above the env assignment). Revert to the exact code above.
  • Combined run failing while individual runs pass → fixture-ordering conflict; check both autouse fixtures still drop/create and that neither conftest caches Session objects at module scope.

Land Phase 1 as its own change (conftest + one test). Classification: behavior-change gate per fin-change-control §1.


Phase 2 — Regression lockdown: the chronicle becomes tests

Convert fin-failure-archaeology's bug families into named tests. Naming convention: test_regress_<sha>_<slug> so the pinned incident is greppable. For each family below: the exact scenario, the predicted numbers (write them as comments per fin-validation-and-qa §1), and the function it pins. Put crud- level tests in api/tests/test_regressions.py (split by area when it grows); anything asserting HTTP status codes belongs in cli/tests or an api-level TestClient test.

Family A — spending/income inflation (e36d719, migration h8c9d0e1f2a3)

Pins: create_funded_buy, get_monthly_spending, router reconcile_accounts.

  1. test_regress_e36d719_funded_buy_not_spending — EUR bank account + investment account; crud.create_funded_buy with FundedBuyRequest(type="buy", shares=5, price_per_share=100, fee=0, total_cost=500, currency="USD", funded_by_account_id=bank.id, …) and eur_usd_rate=1.25. Predict: bank leg type == "transfer", amount == -400.0 (−500/1.25), description "Buy 5 <TICKER>"; get_monthly_spending for that month → total == 0.0 and income_total == 0.0.
  2. test_regress_e36d719_funded_sell_not_income — funded sell 5 @ 100, fee 2: bank leg +498/1.25 == 398.4, type transfer; income_total 0.
  3. test_regress_e36d719_reconcile_not_income — via TestClient POST /api/transactions/reconcile (bank + receivable account): two settlement legs, bank +amount, receivable −amount; spending total 0, income_total 0.
  4. test_regress_a9f045f_e61b254_payable_counts_receivable_excluded — expense −30 on a payable account counts (total 30); expense −30 on a receivable account is excluded (total 0). The asymmetry is intentional — do not "fix" it (settled question, archaeology top table).
  5. test_opening_balance_not_income — create_account(initial_balance=500) → get_monthly_spending(current month)["income_total"] == 0.0 (the description != 'Opening balance' filter in crud).

Family B — orphan legs / linked-group deletes (a06f921, a1f109b, e36d719)

Pins: delete_transaction, create_transfer, create_settlement, create_split_expense, create_splitwise_i_paid.

  1. test_regress_a06f921_delete_either_transfer_leg_removes_both — create transfer, delete leg 1 → 0 rows remain; repeat deleting leg 2 first.
  2. test_regress_e36d719_split_expense_delete_any_of_three — three parametrized cases: deleting the expense row, the from-leg, or the to-leg each removes all 3 rows (the expense links via linked_transaction_id to the from-leg; delete follows links in both directions).
  3. test_regress_a1f109b_legacy_fallback_picks_adjacent_leg — two transfers between the same two accounts on the same date; null out linked_transaction_id on all four rows (simulating pre-migration legacy rows, direct UPDATE via the db fixture); delete one leg → exactly its closest-id partner dies with it, the other transfer's 2 rows survive.
  4. test_zero_sum_after_any_group_delete — after each delete above, no remaining row's linked_transaction_id references a missing id.

Family C — FX missing means excluded, never mis-converted (6b7d19b, 81091fc)

Pins: services/net_worth._to_eur via calculate_net_worth.

  1. test_regress_6b7d19b_missing_gbp_rate_excludes_not_converts — GBP bank account with balance 100 (one income txn); seed only EUR/USD = 1.10. Predict: that account's balance_eur == 0.0, total_eur == 0.0, and caplog contains "No EUR/GBP rate". Negative control in the same test: seed EUR/GBP = 0.85 → balance_eur == pytest.approx(117.647, rel=1e-4) (100 / 0.85).
  2. test_eur_usd_fallback_is_reported_not_used_for_conversion — no price rows at all, one EUR account with 100: eur_usd_rate == 1.0 (reported fallback, warning logged) but total_eur == 100.0 untouched. This pins the current behavior; if the 1.0 report is ever removed, this test is the notification.

Family D — holdings recompute (c558999, 1480088, bc256f6)

Pins: create_stock_transaction, delete_stock_transaction, recompute_holding_from_stock_transactions, vest_rsu_grant. This is CLAUDE.md invariant 2's first-ever test coverage.

  1. test_regress_c558999_avg_cost_fold — buy 10 @ 100 fee 5 → holding shares == 10, avg_cost_basis_per_share == 100.5; then sell 4 → shares == 6, avg stays 100.5 (weighted-average sell reduces cost proportionally).
  2. test_regress_c558999_oversell_rejected_422 — sell 20 with 10 held: crud.create_stock_transaction raises HTTPException with status_code == 422 and the holding is unchanged (rollback happened).
  3. test_regress_1480088_delete_mid_history_recomputes — buy 10, buy 10, sell 5; delete the second buy → holding recomputed over the remaining history to shares == 5 (10 − 5); predict the resulting avg cost by hand in the comment first. Then: buy 10, sell 8, delete the buy → delete_stock_transaction raises 422 ("Cannot sell 8"), the buy row still exists, holding unchanged.
  4. test_delete_last_stock_txn_deletes_holding — one buy, delete it → holding row gone (get_holding returns None).
  5. test_regress_bc256f6_vest_delete_reverts_grant — RSU account, grant (gross 100, withholding 0.5), seed price for the ticker, vest_rsu_grant → net_shares == 50 (floor(100 × 0.5)), grant has vested_at and vested_stock_transaction_id; delete that stock transaction → grant back to pending (vested_at is None, link cleared), holding gone. This was the undeletable-grant deadlock.
  6. test_vest_without_cached_price_is_422 — no price row → vest_rsu_grant raises 422 mentioning "No cached price". Never let a vest invent a price.

Family E — atomic multi-row writes (bc256f6, c67d34f)

Pins: create_split_expense, create_splitwise_i_paid, cross-linking.

  1. test_regress_bc256f6_split_expense_three_rows_linked — total 100, partner 40: expense amount == -60.0 typed expense; transfer legs −40/+40; the expense's linked_transaction_id == from_leg.id; both transfer legs cross-linked.
  2. test_regress_3e7f8ec_splitwise_net_share_per_category — two splitwise_i_paid in one month with different categories (100/45 and 60/30) → spending total 55 + 30 == 85.0, per-category totals 55 and 30. Extends the existing cli test to the multi-category merge path in get_monthly_spending.

Family F — funded-buy currency handling (b3062b7)

  1. test_regress_b3062b7_usd_funding_account_no_double_conversion — USD bank account funds a USD buy, total_cost 500: bank leg amount == -500.0 exactly (currency match branch — no division by the rate).
  2. test_funded_buy_zero_rate_guard — eur_usd_rate=0 and no exchange_rate_used, EUR funding account: bank amount falls back to cash_native un-divided (the used_rate > 0 guard). Pins current behavior.

Gate 2

python -m coverage run -m pytest cli/tests api/tests -q
python -m coverage report --include='*/api/app/*' -m
  • Test count: record it; expected ≈ 25 + 20–28 new.
  • Coverage floor: crud.py ≥ 75%, and coverage report -m shows no crud function with an entirely-red body except the RSU-schedule helpers (_generate_rsu_schedule / _add_months — Phase 3/4 material) and pure getters you consciously deferred. List any deferred function by name in the commit body.
  • Branch: a regression test that passes without any product-code change is correct here (the bugs are already fixed — you are pinning). But if a regression test FAILS, you have found a live regression: stop the phase, handle it as a bug through fin-debugging-playbook + fin-change-control, and add the incident to fin-failure-archaeology.

Land Phase 2 as one change (or one per family if review size demands).


Phase 3 — Invariant/property tests

Encode the ledger's algebraic invariants so unknown future bugs in the families above fail loudly. Derivations for these invariants live in fin-accounting-reference (and, when it lands, fin-ledger-analysis-toolkit — check it before re-deriving).

Invariants to encode (file: api/tests/test_properties.py)

  • I1 — two-leg zero-sum: every same-currency transfer/settlement group written by create_transfer/create_settlement/reconcile sums to 0 across its legs. Documented exceptions, assert them explicitly instead: cross- currency transfers (amounts differ by design), splitwise_pay pairs (−total / +partner_share — intentionally non-zero, invariant 6).
  • I2 — balance formula equivalence: for any bank account, calculate_net_worth's balance_native equals a test-local Python fold of sum(t.amount - t.fee) over its transactions (CLAUDE.md invariant 5, computed by an independent implementation, not by re-querying the same SQL).
  • I3 — holdings fold: after any valid random buy/sell sequence, recompute_holding_from_stock_transactions equals a reference weighted- average fold implemented in the test (~15 lines — keep it independent).
  • I4 — spending invariance under asset movement: take any month's get_monthly_spending result, then add N random transfers/settlements/ funded buys in that month → total and income_total unchanged.
  • I5 — delete closure: after deleting any randomly chosen transaction, no surviving row's linked_transaction_id points at a missing id, and no account's balance changed except those in the deleted group.

Solution menu — ranked, with obligations

Option 1 (preferred): hypothesis — pip install hypothesis (dev-only; do not add to api/requirements.txt, which is the Docker image's dependency list). Obligations: define an action strategy (st.sampled_from over account/txn constructors with st.floats(allow_nan=False, allow_infinity=False, min_value=0.01, max_value=1e6) rounded to cents); @settings(deadline=None) (SQLite file IO makes per-example deadlines flaky); set hypothesis.settings(database=None) or gitignore .hypothesis/ so shrink artifacts never enter the repo; promote every found counterexample to an explicit @example(...) so it reruns forever; derandomize=True in the CI profile (Phase 6) for reproducibility.

Option 2: hand-rolled random-action generator — no new dependency. Obligations you now own manually: seed from os.environ.get("FIN_TEST_SEED") or random.randrange(2**32), ALWAYS print the seed in the failure message (assert ok, f"seed={seed}: …"), document replay (FIN_TEST_SEED=<n> python -m pytest …), and cap runtime (≤200 actions, ≤20 iterations) so the suite stays under a minute. No shrinking — expect ugly failures.

Option 3: exhaustive small-case enumeration — enumerate all sequences of ≤4 actions from a fixed small action set (itertools.product). Deterministic, dependency-free, but tiny state space; acceptable as a floor for I1/I5 only.

Choose 1 if the environment allows new dev dependencies, else 2. Record the choice and seed policy in the test module docstring.

Gate 3

  • At least I1–I5 implemented (any option).
  • Prove the oracle bites: temporarily flip a sign in one reference fold (e.g. I3's fee handling) and observe the property test fail with a reproducible seed/example; revert. A property test that never failed during development is unverified.
  • Full suite still green; runtime recorded — if the combined suite now exceeds ~90 s, cut iteration counts, don't delete invariants.

Phase 4 — Services: net worth, price fetcher, scheduler

File: api/tests/test_services.py (split later if large). NO live network, ever: yfinance stays a stub; httpx.get gets monkeypatched.

4.1 services/net_worth.py — synthetic price_cache rows

Using the seed_price fixture:

  • USD cash: bank account (currency USD) with +110 income; seed EUR/USD = 1.10 → balance_eur == 100.0, total_eur == 100.0.
  • Investment holding: create via crud.create_stock_transaction (buy 10 VWCE @ 100), seed VWCE = 100 (USD) and EUR/USD = 1.25 → account balance_native == 1000.0, balance_eur == 800.0.
  • GBP-priced holding: seed TICK = 50 with currency="GBP" and EUR/GBP = 0.85 → balance_eur == 10*50/0.85.
  • Missing ticker price → holding contributes 0 (price_map default), no crash.
  • manual_override_balance on an investment account wins over holdings and is converted via the account currency.
  • Unknown account type: insert an Account with type="weird" directly via the db fixture (the schema Literal blocks it at the API; crud/services must still not crash) → account skipped, warning logged.
  • Inactive accounts excluded from the total.

4.2 services/price_fetcher.py — mocked httpx and a rigged yfinance stub

The conftest already made yfinance an empty module; give it behavior per-test: import yfinance then monkeypatch.setattr(yfinance, "Ticker", FakeTicker, raising=False) (the module-scope import yfinance as yf in price_fetcher resolves to the same stub object). Build FakeTicker with a fast_info namespace (last_price, currency) and an info dict.

Mandatory obligation: monkeypatch.setattr(time, "sleep", lambda s: None) (patch app.services.price_fetcher.time.sleep) in every retry-path test — the real schedule sleeps 1+2+4 s per candidate across 9 candidates.

  • fetch_eur_usd success: monkeypatch httpx.get to return a fake response (raise_for_status no-op, json() → {"rates": {"USD": 1.1}}) → returns 1.1, upserts EUR/USD row with source frankfurter.
  • fetch_eur_usd failure with cache: httpx raises; pre-seed EUR/USD = 1.08 → returns 1.08, fetched_at NOT bumped.
  • test_regress_81091fc_no_fake_rate_on_cold_failure: httpx raises, no cache → returns None and price_cache stays EMPTY (the 1.0-fallback bug).
  • test_regress_bc8c8d5_pence_division: FakeTicker reports fast_info.currency == "GBp", price 12345 → cached price 123.45, currency GBP.
  • Suffix discovery: bare ticker returns no data (last_price is None, info without regularMarketPrice), TICK.DE succeeds → row cached under the ORIGINAL symbol TICK.
  • All candidates fail → returns cached value if present, else 0.0; no row written.
  • get_tracked_tickers: union of holdings with shares > 0 and pending grants; vested grants and zero-share holdings excluded.

4.3 services/scheduler.py — call the job function directly

Never create_scheduler(...).start() in tests. Call fetch_prices_and_snapshot(database_url) with the conftest's throwaway URL (os.environ["DATABASE_URL"]), monkeypatching app.services.scheduler.fetch_all_prices (it is imported into the scheduler module's namespace — patch it THERE) to lambda db: 0:

  • One NetWorthSnapshot row for date.today() with total_eur matching seeded accounts and parseable breakdown_json.
  • Run twice same day → still exactly one row (update path), values refreshed.
  • create_scheduler unit check: returns a scheduler with job ids {"price_refresh", "daily_snapshot"} and correct triggers — WITHOUT calling .start(). Note (verified statically): SQLAlchemyJobStore(url=…) in create_scheduler touches the DB URL at construction; if this creates an unwanted apscheduler_jobs table in the shared test DB, point it at its own temp file via a distinct URL argument — the function takes the URL as a parameter precisely so you can.

Gate 4

coverage report --include='*/api/app/services/*' -m — floors: net_worth.py ≥ 90%, price_fetcher.py ≥ 80%, scheduler.py ≥ 70% (the create_scheduler wiring may stay partially red). No single test over 2 s (if one is, you forgot the time.sleep patch). Full suite green.


Phase 5 — Migration tests: the 8-revision chain

Now pip install alembic (first phase that needs it). File: api/tests/test_migrations.py.

Key mechanics (verified statically against api/alembic/env.py): env.py resolves the URL as DATABASE_URL env var → sqlalchemy.url → app default, and inserts the api dir into sys.path itself. fileConfig is skipped when the Config has no file. So a programmatic Config works from any cwd:

import tempfile
from pathlib import Path

import sqlalchemy as sa
from alembic import command
from alembic.config import Config

REPO_ROOT = Path(__file__).resolve().parents[2]


def _migrated_engine(monkeypatch, tmp_path):
    url = f"sqlite:///{tmp_path}/mig.db"
    # env.py reads DATABASE_URL FIRST — without this the conftest's shared
    # test-DB URL would be migrated instead. Always a fresh file per test.
    monkeypatch.setenv("DATABASE_URL", url)
    cfg = Config()
    cfg.set_main_option("script_location", str(REPO_ROOT / "api" / "alembic"))
    command.upgrade(cfg, "head")
    return sa.create_engine(url)

Tests:

  1. test_chain_is_linear_with_8_revisions — via alembic.script.ScriptDirectory(str(REPO_ROOT / "api" / "alembic")): exactly one head, id h8c9d0e1f2a3, walk from base counts 8 revisions, no branchpoints. (If this fails because a NEW revision exists: update the expected head/count AND the table in fin-change-control §4.2 — that is maintenance, not a bug.)
  2. test_empty_to_head_matches_metadata — migrate a fresh temp DB, then compare against Base.metadata: build {table: sorted(column names)} from sa.inspect(engine) (excluding alembic_version) and from Base.metadata.tables; assert equal. First run: record any diff verbatim. Expected: empty. If NOT empty, you have found model/migration drift — the exact failure mode fin-change-control §4.3 warns about (create_all hides it in every other test). STOP, file it as a schema-change fix through change control; only allowlist a difference with an inline comment naming the reason.
  3. test_downgrade_one_runs — after upgrade, command.downgrade(cfg, "-1") then command.upgrade(cfg, "head") completes. This asserts the downgrade runs, not that it restores data (h8c9d0e1f2a3's downgrade is an honest no-op by design).
  4. Stretch (optional, document if skipped): alembic.autogenerate.compare_metadata against the migrated DB expecting [] — catches type/index drift the name-comparison misses, but is noisier on SQLite; adopt only if the noise is manageable.

Gate 5: all migration tests green; the recorded schema diff is empty or each entry is allowlisted-with-reason. Full suite still green (migration tests must not have touched the shared conftest DB — if cli/tests now fails with an alembic_version artifact, a test forgot the monkeypatch.setenv).


Phase 6 — CI

Checked first, per protocol: git remote -v → sole remote is https://gitea.cloud.traunseenet.com/markuseisl/fin.git — self-hosted Gitea (observed 2026-07-05). GitHub Actions does not apply.

Hard constraints for ANY option: no docker required, no live yfinance or frankfurter calls (the suite is already offline-by-construction — keep it that way), total runtime target < 3 minutes.

Menu — ranked

Option 1: Gitea Actions (GitHub-Actions-compatible syntax). Preconditions you must VERIFY before choosing it — do not assume: (a) Actions is enabled on the instance and repo (repo Settings → Actions, or ask the owner — it is off by default on older Gitea), (b) an act_runner is registered. If either fails → Option 2. Workflow .gitea/workflows/tests.yml:

name: tests
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    container: python:3.12-slim
    steps:
      - uses: actions/checkout@v4
      - run: pip install pytest fastapi httpx sqlalchemy apscheduler "pydantic>=2.0" coverage alembic hypothesis
      - run: pip install -e cli/
      - run: python -m coverage run -m pytest cli/tests api/tests -q
      - run: python -m coverage report --include='*/api/app/*' --fail-under=80

(Verified statically — could not be executed from the sandbox; the dependency list mirrors Phase 0/5. Adjust hypothesis to your Phase 3 choice. If the runner lacks internet for pip, bake a runner image — that is fin-run-and-operate territory.) Obligations: pin --fail-under to your actual Gate-6 floor, not aspiration; add the badge/URL to README only once it has passed on a real push.

Option 2: committed pre-push hook (works with zero server support). scripts/git-hooks/pre-push:

#!/bin/sh
set -e
python -m pytest cli/tests api/tests -q

plus a one-time install command documented in README: git config core.hooksPath scripts/git-hooks. Obligations: keep it under a minute; document that git push --no-verify bypasses it (an emergency valve, not a habit); this protects only machines that ran the config line.

Option 3: documented gate only — the fin-change-control §7 checklist already mandates the pytest run; add the combined cli/tests api/tests command there and to CLAUDE.md. Weakest — no automation — acceptable only as an interim while Option 1's preconditions are pending.

Gate 6: state which option you chose and WHY (what you found when verifying Option 1's preconditions); demonstrate one real failing run blocking a push (introduce a deliberate test failure on a scratch branch, observe the block, revert). Record the evidence in the commit message.


Promotion protocol (routes through fin-change-control)

  1. One phase = one reviewed change (Phase 2 may split by family). Never bundle test phases with product-code fixes — if a phase uncovers a product bug, the fix is a separate change with its own class and gate.
  2. Classification per fin-change-control §1: Phases 1–5 are tests-only → behavior-change gate (compile + full suite green; the tests are their own coverage). Phase 6 adds repo config → same, plus README update for the hook install line. Nothing here is deploy-affecting: no phase may touch api/requirements.txt, Dockerfiles, or compose (dev-only deps like coverage/hypothesis/alembic-for-tests live in the venv / CI config, not the image).
  3. Every phase commit body records the gate numbers: test count before/after, coverage table delta, runtime. Commit style per fin-change-control §6.
  4. CLAUDE.md gains a single test-run line (python -m pytest cli/tests api/tests -q) only after Phase 2 has landed and stayed green across at least one subsequent unrelated change — not before (stability first, advertisement second). Update fin-validation-and-qa §2's inventory pointers at the same time.
  5. Campaign completion floors (measure, don't eyeball): crud.py ≥ 90%, api/app total ≥ 80%, every CLAUDE.md invariant (1, 2, 5, 6) named in at least one test docstring, migration chain tested, CI or hook active. Record the final coverage table in the last phase's commit.

Fenced-off wrong paths — do not do these

  • Async test rewrite (pytest-asyncio, AsyncSession, httpx.AsyncClient against the app): the engine is synchronous by design (CLAUDE.md). Any "modernization" here is architecture change, not testing.
  • Pointing any test at the prod DB or a copy of it inside the test tree: prod /data/fin.db is sacred (fin-change-control §2). Throwaway temp-file SQLite only, created by the conftest pattern above.
  • Live yfinance/frankfurter in tests or CI: the yfinance stub and httpx monkeypatching are load-bearing. A test that "just fetches one real price" makes the suite flaky, slow, and network-dependent — the exact failure CI must never have.
  • Editing cli/tests/conftest.py's sys.path/env bootstrapping casually: the set-DATABASE_URL-before-import dance is what keeps real DBs out of reach. Extend by copying the pattern (Phase 1), never by "cleaning it up".
  • Base.metadata.create_all as a migration-test substitute: it creates a schema that matches the models by construction and proves nothing about the migration chain — the drift it hides is the point of Phase 5 (fin-change-control §4.3).
  • Refactoring crud.py "for testability" before characterization tests exist: no extracting pure functions, no dependency injection, no splitting the file until Phase 2's floor is met. The current shape is testable as-is (the db fixture proves it); refactors before pinning behavior are how accounting semantics silently change.
  • Chasing 100%: the last percent (defensive except arms in fetch_all_prices, log-only branches) buys nothing. The floors above are the contract; spend surplus effort on Phase 3 invariants instead.
  • Skipping/xfailing a red test to make a gate: with no CI history, a skipped test is a deleted test (fin-validation-and-qa §5: fix or delete, never skip).

Provenance and maintenance

Authored 2026-07-05 against HEAD 082b608; the authoring sandbox had no pip/venv/docker, so all run-commands are verified statically (files read in full: api/app/crud.py, cli/tests/conftest.py, cli/tests/test_cli.py, api/app/services/{net_worth,price_fetcher,scheduler}.py, api/app/routers/transactions.py, api/app/{main,database}.py, api/app/schemas.py, api/alembic/env.py; sibling skills: change-control, failure-archaeology, validation-and-qa, build-and-env).

Recount the volatile facts before trusting them:

grep -cE '^def ' api/app/crud.py                    # 35 defs (33 public) as of 2026-07-05
wc -l api/app/crud.py                               # 995
grep -c 'def test_' cli/tests/test_cli.py           # 25
ls api/tests/ 2>/dev/null                           # campaign progress: which phases landed
grep -rc 'def test_' api/tests/ 2>/dev/null         # api-suite size
ls api/alembic/versions/                            # 8 revisions, head h8c9d0e1f2a3
git remote -v                                       # Gitea (Phase 6 premise)
ls .gitea/workflows scripts/git-hooks 2>/dev/null   # which Phase-6 option landed
git log --oneline -5                                # HEAD moved? re-audit archaeology for new families

Maintenance rules: when a phase lands, update the ground-truth counts and mark the phase done in this file (same change). When a new bug is fixed anywhere in fin, add its regression test using the Phase 2 naming scheme and register the incident in fin-failure-archaeology — the campaign's steady state is that those two updates always travel together. If crud.py's function inventory changes, regenerate the area table with grep -nE '^def |^# ---' api/app/crud.py and re-derive the exercised/never-executed split from the current test suites, not from memory.