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
39 KiB
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-qahierarchy. 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-archaeologychronicle into named tests. Read that skill before writing Phase 2. - Environment:
fin-build-and-envowns setup (venv, minimal deps, yfinance-stub rationale). Not duplicated here. - Oracles:
fin-diagnostics-and-toolingandfin-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-envtraps table) or a real regression shipped untested. Fix or quarantine (@pytest.mark.skipwith a linked reason) as its own change throughfin-change-controlfirst, and record the finding as a new entry candidate forfin-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: appin api/tests → thesys.path.insertline 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_URLassignment conditional, or importedapp.*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.
test_regress_e36d719_funded_buy_not_spending— EUR bank account + investment account;crud.create_funded_buywithFundedBuyRequest(type="buy", shares=5, price_per_share=100, fee=0, total_cost=500, currency="USD", funded_by_account_id=bank.id, …)andeur_usd_rate=1.25. Predict: bank legtype == "transfer",amount == -400.0(−500/1.25), description"Buy 5 <TICKER>";get_monthly_spendingfor that month →total == 0.0andincome_total == 0.0.test_regress_e36d719_funded_sell_not_income— funded sell 5 @ 100, fee 2: bank leg+498/1.25 == 398.4, typetransfer; income_total 0.test_regress_e36d719_reconcile_not_income— viaTestClientPOST/api/transactions/reconcile(bank + receivable account): twosettlementlegs, bank+amount, receivable−amount; spending total 0, income_total 0.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).test_opening_balance_not_income—create_account(initial_balance=500)→get_monthly_spending(current month)["income_total"] == 0.0(thedescription != '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.
test_regress_a06f921_delete_either_transfer_leg_removes_both— create transfer, delete leg 1 → 0 rows remain; repeat deleting leg 2 first.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 vialinked_transaction_idto the from-leg; delete follows links in both directions).test_regress_a1f109b_legacy_fallback_picks_adjacent_leg— two transfers between the same two accounts on the same date; null outlinked_transaction_idon 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.test_zero_sum_after_any_group_delete— after each delete above, no remaining row'slinked_transaction_idreferences a missing id.
Family C — FX missing means excluded, never mis-converted (6b7d19b, 81091fc)
Pins: services/net_worth._to_eur via calculate_net_worth.
test_regress_6b7d19b_missing_gbp_rate_excludes_not_converts— GBP bank account with balance 100 (one income txn); seed onlyEUR/USD = 1.10. Predict: that account'sbalance_eur == 0.0,total_eur == 0.0, andcaplogcontains"No EUR/GBP rate". Negative control in the same test: seedEUR/GBP = 0.85→balance_eur == pytest.approx(117.647, rel=1e-4)(100 / 0.85).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) buttotal_eur == 100.0untouched. 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.
test_regress_c558999_avg_cost_fold— buy 10 @ 100 fee 5 → holdingshares == 10,avg_cost_basis_per_share == 100.5; then sell 4 →shares == 6, avg stays100.5(weighted-average sell reduces cost proportionally).test_regress_c558999_oversell_rejected_422— sell 20 with 10 held:crud.create_stock_transactionraisesHTTPExceptionwithstatus_code == 422and the holding is unchanged (rollback happened).test_regress_1480088_delete_mid_history_recomputes— buy 10, buy 10, sell 5; delete the second buy → holding recomputed over the remaining history toshares == 5(10 − 5); predict the resulting avg cost by hand in the comment first. Then: buy 10, sell 8, delete the buy →delete_stock_transactionraises 422 ("Cannot sell 8"), the buy row still exists, holding unchanged.test_delete_last_stock_txn_deletes_holding— one buy, delete it → holding row gone (get_holdingreturns None).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 hasvested_atandvested_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.test_vest_without_cached_price_is_422— no price row →vest_rsu_grantraises 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.
test_regress_bc256f6_split_expense_three_rows_linked— total 100, partner 40: expenseamount == -60.0typedexpense; transfer legs −40/+40; the expense'slinked_transaction_id == from_leg.id; both transfer legs cross-linked.test_regress_3e7f8ec_splitwise_net_share_per_category— twosplitwise_i_paidin one month with different categories (100/45 and 60/30) → spending total55 + 30 == 85.0, per-category totals 55 and 30. Extends the existing cli test to the multi-category merge path inget_monthly_spending.
Family F — funded-buy currency handling (b3062b7)
test_regress_b3062b7_usd_funding_account_no_double_conversion— USD bank account funds a USD buy, total_cost 500: bank legamount == -500.0exactly (currency match branch — no division by the rate).test_funded_buy_zero_rate_guard—eur_usd_rate=0and noexchange_rate_used, EUR funding account: bank amount falls back tocash_nativeun-divided (theused_rate > 0guard). 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%, andcoverage report -mshows 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 tofin-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/settlementgroup written bycreate_transfer/create_settlement/reconcilesums to 0 across its legs. Documented exceptions, assert them explicitly instead: cross- currency transfers (amounts differ by design),splitwise_paypairs (−total / +partner_share — intentionally non-zero, invariant 6). - I2 — balance formula equivalence: for any bank account,
calculate_net_worth'sbalance_nativeequals a test-local Python fold ofsum(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_transactionsequals 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_spendingresult, then add N random transfers/settlements/ funded buys in that month →totalandincome_totalunchanged. - I5 — delete closure: after deleting any randomly chosen transaction, no
surviving row's
linked_transaction_idpoints 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), seedVWCE = 100 (USD)andEUR/USD = 1.25→ accountbalance_native == 1000.0,balance_eur == 800.0. - GBP-priced holding: seed
TICK = 50withcurrency="GBP"andEUR/GBP = 0.85→balance_eur == 10*50/0.85. - Missing ticker price → holding contributes 0 (price_map default), no crash.
manual_override_balanceon an investment account wins over holdings and is converted via the account currency.- Unknown account type: insert an
Accountwithtype="weird"directly via the db fixture (the schemaLiteralblocks 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_usdsuccess: monkeypatchhttpx.getto return a fake response (raise_for_statusno-op,json()→{"rates": {"USD": 1.1}}) → returns 1.1, upsertsEUR/USDrow with sourcefrankfurter.fetch_eur_usdfailure with cache: httpx raises; pre-seedEUR/USD = 1.08→ returns 1.08,fetched_atNOT bumped.test_regress_81091fc_no_fake_rate_on_cold_failure: httpx raises, no cache → returnsNoneandprice_cachestays EMPTY (the 1.0-fallback bug).test_regress_bc8c8d5_pence_division: FakeTicker reportsfast_info.currency == "GBp", price 12345 → cached price123.45, currencyGBP.- Suffix discovery: bare ticker returns no data (
last_price is None,infowithoutregularMarketPrice),TICK.DEsucceeds → row cached under the ORIGINAL symbolTICK. - 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
NetWorthSnapshotrow fordate.today()withtotal_eurmatching seeded accounts and parseablebreakdown_json. - Run twice same day → still exactly one row (update path), values refreshed.
create_schedulerunit check: returns a scheduler with job ids{"price_refresh", "daily_snapshot"}and correct triggers — WITHOUT calling.start(). Note (verified statically):SQLAlchemyJobStore(url=…)increate_schedulertouches the DB URL at construction; if this creates an unwantedapscheduler_jobstable 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:
test_chain_is_linear_with_8_revisions— viaalembic.script.ScriptDirectory(str(REPO_ROOT / "api" / "alembic")): exactly one head, idh8c9d0e1f2a3, walk from base counts 8 revisions, no branchpoints. (If this fails because a NEW revision exists: update the expected head/count AND the table infin-change-control§4.2 — that is maintenance, not a bug.)test_empty_to_head_matches_metadata— migrate a fresh temp DB, then compare againstBase.metadata: build{table: sorted(column names)}fromsa.inspect(engine)(excludingalembic_version) and fromBase.metadata.tables; assert equal. First run: record any diff verbatim. Expected: empty. If NOT empty, you have found model/migration drift — the exact failure modefin-change-control§4.3 warns about (create_allhides 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.test_downgrade_one_runs— after upgrade,command.downgrade(cfg, "-1")thencommand.upgrade(cfg, "head")completes. This asserts the downgrade runs, not that it restores data (h8c9d0e1f2a3's downgrade is an honest no-op by design).- Stretch (optional, document if skipped):
alembic.autogenerate.compare_metadataagainst 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)
- 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.
- 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 touchapi/requirements.txt, Dockerfiles, or compose (dev-only deps like coverage/hypothesis/alembic-for-tests live in the venv / CI config, not the image). - Every phase commit body records the gate numbers: test count before/after,
coverage table delta, runtime. Commit style per
fin-change-control§6. - 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). Updatefin-validation-and-qa§2's inventory pointers at the same time. - Campaign completion floors (measure, don't eyeball):
crud.py≥ 90%,api/apptotal ≥ 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.dbis 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_allas 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
dbfixture proves it); refactors before pinning behavior are how accounting semantics silently change. - Chasing 100%: the last percent (defensive
exceptarms infetch_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.