fix(tests): Testsuite unabhängig von einer lokalen .env machen
Images bauen / build (backend) (push) Failing after 29s
Images bauen / build (frontend) (push) Failing after 30s
CI / backend (push) Successful in 2m6s
CI / frontend (push) Successful in 6m5s

Ohne backend/.env galt COOKIE_SECURE=true. Der Testclient spricht über http,
httpx verwarf die Secure-Cookies, und 106 Tests scheiterten mit 401. Lokal fiel
das nicht auf, weil dort eine nicht eingecheckte .env den Wert überschrieb – die
CI hat es aufgedeckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
Jonas Menzel
2026-09-09 17:25:34 +02:00
co-authored by Claude Opus 5
parent 55975338f9
commit bdf4097bd7
3 changed files with 606 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
"""CLI: Demodaten anlegen `python -m app.scripts.seed_demo [--reset]`.
Legt einen vollständigen deutschen Beispielhaushalt an: Konten, Firmen samt
Logos, wiederkehrende Posten, bestätigte Historie, Buchungen, Budgets und
Sparziele. Alle Termine liegen relativ zum heutigen Monat.
"""
import argparse
import asyncio
import sys
from app.db.session import SessionLocal, engine
from app.services.demo import has_data, seed_demo
async def main(reset: bool, resolve_logos: bool) -> int:
async with SessionLocal() as session:
if not reset and await has_data(session):
print(
"Es sind bereits Daten vorhanden. Mit --reset werden Konten, Firmen, "
"Posten, Buchungen, Budgets und Sparziele zuvor gelöscht.\n"
"Kategorien und Benutzer bleiben in jedem Fall erhalten.",
file=sys.stderr,
)
await engine.dispose()
return 1
ergebnis = await seed_demo(session, reset=reset, resolve_logos=resolve_logos)
await engine.dispose()
print(
"Demodaten angelegt:\n"
f" {ergebnis.accounts} Konten\n"
f" {ergebnis.merchants} Firmen ({ergebnis.logos} mit Logo)\n"
f" {ergebnis.recurrences} wiederkehrende Posten\n"
f" {ergebnis.occurrences} bestätigte Fälligkeiten\n"
f" {ergebnis.transactions} Buchungen\n"
f" {ergebnis.budgets} Budgets und Vorlagen\n"
f" {ergebnis.goals} Sparziele"
)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--reset",
action="store_true",
help="Vorhandene Konten, Firmen, Posten, Buchungen, Budgets und Sparziele löschen.",
)
parser.add_argument(
"--no-logos",
action="store_true",
help="Logosuche überspringen schneller und ohne jeden Netzzugriff.",
)
args = parser.parse_args()
raise SystemExit(asyncio.run(main(args.reset, not args.no_logos)))
+545
View File
@@ -0,0 +1,545 @@
"""Demodaten: ein realistischer deutscher Haushalt.
Alle Termine liegen relativ zum heutigen Monat, damit Dashboard, Kalender und
Auswertungen unmittelbar etwas zeigen unabhängig davon, wann der Seed läuft.
"""
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_start, today
from app.models import (
Account,
AmountVersion,
Budget,
BudgetTemplate,
Category,
Merchant,
Occurrence,
Recurrence,
ReserveLedger,
SavingsGoal,
Transaction,
)
from app.models.enums import (
AccountType,
BusinessDayShift,
EntryKind,
LogoStatus,
)
from app.services.logos import resolve_merchant_logo
from app.services.merchants import normalize_name
from app.services.occurrences import confirm
from app.services.recurrence import expand
from app.services.seed import seed_all
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class DemoResult:
"""Was der Seed angelegt hat."""
accounts: int = 0
merchants: int = 0
recurrences: int = 0
transactions: int = 0
occurrences: int = 0
budgets: int = 0
goals: int = 0
logos: int = 0
# Firmen mit ihrer Domain die Domain verbessert die Logosuche.
DEMO_MERCHANTS: tuple[tuple[str, str | None], ...] = (
("Netflix", "netflix.com"),
("Spotify", "spotify.com"),
("Vodafone", "vodafone.de"),
("Deutsche Telekom", "telekom.de"),
("REWE", "rewe.de"),
("dm", "dm.de"),
("Deutsche Bahn", "bahn.de"),
("Sparkasse", "sparkasse.de"),
# Bewusst ohne Eintrag im Markenkatalog: zeigt den erzeugten Avatar.
("Stadtwerke Musterstadt", None),
("HUK-COBURG", "huk.de"),
)
async def _category_map(session: AsyncSession) -> dict[str, int]:
"""Kategoriename -> ID. Bei doppelten Namen gewinnt die Unterkategorie."""
rows = (await session.execute(select(Category))).scalars().all()
namen: dict[str, int] = {}
for kategorie in rows:
if kategorie.name not in namen or kategorie.parent_id is not None:
namen[kategorie.name] = kategorie.id
return namen
async def has_data(session: AsyncSession) -> bool:
"""True, wenn schon Bewegungsdaten vorhanden sind."""
for modell in (Recurrence, Transaction, Account):
anzahl = (await session.execute(select(func.count()).select_from(modell))).scalar_one()
if anzahl:
return True
return False
async def reset_data(session: AsyncSession) -> None:
"""Entfernt alle Bewegungs- und Stammdaten außer Kategorien und Benutzern.
Die Reihenfolge folgt den Fremdschlüsseln.
"""
for modell in (
Occurrence,
AmountVersion,
ReserveLedger,
Recurrence,
Transaction,
Budget,
BudgetTemplate,
SavingsGoal,
Merchant,
Account,
):
await session.execute(delete(modell))
await session.flush()
async def seed_demo(
session: AsyncSession, *, reset: bool = False, resolve_logos: bool = True
) -> DemoResult:
"""Legt einen vollständigen Beispielhaushalt an."""
await seed_all(session)
if reset:
await reset_data(session)
ergebnis = DemoResult()
heute = today()
monat = month_start(heute)
# --- Konten ---------------------------------------------------------------
konten = {
"giro": Account(
name="Girokonto",
type=AccountType.CHECKING,
iban_last4="4711",
opening_balance=Decimal("2450.00"),
# Sechs Monate Historie, damit Vergleiche etwas zeigen.
opening_balance_date=add_months(monat, -6),
color="#3b82f6",
icon="wallet",
sort_order=0,
),
"karte": Account(
name="Kreditkarte",
type=AccountType.CREDIT_CARD,
iban_last4="8820",
opening_balance=Decimal("0.00"),
opening_balance_date=add_months(monat, -6),
color="#a855f7",
icon="credit-card",
sort_order=1,
),
"tagesgeld": Account(
name="Tagesgeld",
type=AccountType.SAVINGS,
opening_balance=Decimal("8200.00"),
opening_balance_date=add_months(monat, -6),
color="#10b981",
icon="piggy-bank",
sort_order=2,
),
}
session.add_all(konten.values())
await session.flush()
ergebnis.accounts = len(konten)
# --- Firmen ---------------------------------------------------------------
firmen: dict[str, Merchant] = {}
for name, domain in DEMO_MERCHANTS:
firma = Merchant(
name=name,
normalized_name=normalize_name(name),
domain=domain,
logo_status=LogoStatus.PENDING,
)
session.add(firma)
firmen[name] = firma
await session.flush()
ergebnis.merchants = len(firmen)
if resolve_logos:
for firma in firmen.values():
try:
if await resolve_merchant_logo(session, firma) is not None:
ergebnis.logos += 1
except Exception: # eine fehlende Grafik darf den Seed nicht stoppen
logger.exception("Logo für %s konnte nicht ermittelt werden.", firma.name)
# --- Wiederkehrende Posten ------------------------------------------------
kategorie = await _category_map(session)
giro = konten["giro"].id
karte = konten["karte"].id
posten: list[Recurrence] = []
def anlegen(**felder) -> Recurrence:
eintrag = Recurrence(**felder)
session.add(eintrag)
posten.append(eintrag)
return eintrag
anlegen(
kind=EntryKind.INCOME,
title="Gehalt",
category_id=kategorie["Gehalt"],
account_id=giro,
amount=Decimal("3450.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart=add_months(monat, -6).replace(day=28),
# Das Gehalt kommt vor dem Wochenende, nicht danach.
business_day_shift=BusinessDayShift.PREVIOUS,
notes="Monatliches Nettogehalt.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Miete",
category_id=kategorie["Miete"],
account_id=giro,
amount=Decimal("1180.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=add_months(monat, -6),
merchant_id=None,
notes="Kaltmiete zuzüglich Nebenkosten.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Strom",
category_id=kategorie["Strom"],
account_id=giro,
merchant_id=firmen["Stadtwerke Musterstadt"].id,
amount=Decimal("89.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=15",
dtstart=add_months(monat, -6).replace(day=15),
# Abschlag; die Jahresabrechnung weicht regelmäßig ab.
is_variable=True,
)
anlegen(
kind=EntryKind.EXPENSE,
title="Internet & Telefon",
category_id=kategorie["Internet"],
account_id=giro,
merchant_id=firmen["Vodafone"].id,
amount=Decimal("39.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=5",
dtstart=add_months(monat, -6).replace(day=5),
)
netflix = anlegen(
kind=EntryKind.EXPENSE,
title="Netflix Standard",
category_id=kategorie["Streaming"],
account_id=karte,
merchant_id=firmen["Netflix"].id,
amount=Decimal("13.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=17",
dtstart=add_months(monat, -6).replace(day=17),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Spotify Premium",
category_id=kategorie["Streaming"],
account_id=karte,
merchant_id=firmen["Spotify"].id,
amount=Decimal("11.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=3",
dtstart=add_months(monat, -6).replace(day=3),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Rundfunkbeitrag",
category_id=kategorie["Rundfunkbeitrag"],
account_id=giro,
amount=Decimal("55.08"),
# Vierteljährlich, jeweils Mitte des Quartalsmonats.
rrule="FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15",
dtstart=add_months(monat, -6).replace(day=15),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Kfz-Versicherung",
category_id=kategorie["Kfz"],
account_id=giro,
merchant_id=firmen["HUK-COBURG"].id,
amount=Decimal("612.00"),
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
# Vorjahr, damit eine Zahlung in der Historie liegt und die nächste im Januar.
dtstart=date(heute.year - 1, 1, 15),
# Jahresbeitrag auf zwölf Monate verteilt zurücklegen.
reserve_enabled=True,
notes="Jahresbeitrag, Zahlung im Januar.",
)
handyvertrag_start = add_months(monat, -8)
anlegen(
kind=EntryKind.EXPENSE,
title="Handyvertrag",
category_id=kategorie["Internet"],
account_id=giro,
merchant_id=firmen["Deutsche Telekom"].id,
amount=Decimal("29.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=8",
dtstart=handyvertrag_start.replace(day=8),
contract_start=handyvertrag_start,
contract_min_term_months=24,
contract_notice_period_days=90,
contract_auto_renew_months=12,
notes="Mindestlaufzeit 24 Monate, danach jährliche Verlängerung.",
)
kredit_start = add_months(monat, -9)
anlegen(
kind=EntryKind.EXPENSE,
title="Autokredit",
category_id=kategorie["Kredite"],
account_id=giro,
merchant_id=firmen["Sparkasse"].id,
amount=Decimal("289.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=kredit_start,
installments_total=36,
principal_amount=Decimal("10404.00"),
notes="36 Raten, Restschuld siehe Detailansicht.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Sparplan Tagesgeld",
category_id=kategorie["Sparen"],
account_id=giro,
amount=Decimal("250.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart=add_months(monat, -6).replace(day=28),
)
anlegen(
kind=EntryKind.INCOME,
title="Nebeneinkünfte",
category_id=kategorie["Nebeneinkünfte"],
account_id=giro,
amount=Decimal("320.00"),
# Alle zwei Monate.
rrule="FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=20",
dtstart=add_months(monat, -6).replace(day=20),
is_variable=True,
)
await session.flush()
ergebnis.recurrences = len(posten)
# Preishistorie: für jeden Posten der Anfangsbetrag ab Serienstart.
for eintrag in posten:
session.add(
AmountVersion(
recurrence_id=eintrag.id,
amount=eintrag.amount,
valid_from=eintrag.dtstart,
note="Anfangsbetrag",
)
)
# Die Versionen müssen in der Datenbank stehen, bevor sie abgefragt werden.
await session.flush()
# Netflix ist vor drei Monaten teurer geworden vergangene Monate bleiben
# dadurch betragstreu.
preiswechsel = add_months(monat, -3)
session.add(
AmountVersion(
recurrence_id=netflix.id,
amount=Decimal("13.99"),
valid_from=preiswechsel,
note="Preiserhöhung laut Schreiben",
)
)
# Der Anfangsbetrag lag darunter.
anfang = next(
version
for version in await _versions_of(session, netflix.id)
if version.valid_from == netflix.dtstart
)
anfang.amount = Decimal("11.99")
await session.flush()
# --- Vergangene Fälligkeiten bestätigen -----------------------------------
# Erst dadurch zeigen die Berichte einen Plan-Ist-Vergleich.
ergebnis.occurrences = await _confirm_past(session, posten, heute)
# --- Einmalige Buchungen --------------------------------------------------
ergebnis.transactions = await _seed_transactions(session, kategorie, konten, firmen, monat)
# --- Budgets --------------------------------------------------------------
budgets = [
Budget(
category_id=kategorie["Lebensmittel"],
period_month=monat,
limit_amount=Decimal("450.00"),
rollover=True,
),
Budget(
category_id=kategorie["Restaurant"],
period_month=monat,
limit_amount=Decimal("120.00"),
),
Budget(
category_id=kategorie["Drogerie"],
period_month=monat,
limit_amount=Decimal("60.00"),
),
]
session.add_all(budgets)
session.add(
BudgetTemplate(
category_id=kategorie["Sprit"],
valid_from=add_months(monat, -6),
limit_amount=Decimal("180.00"),
)
)
ergebnis.budgets = len(budgets) + 1
# --- Sparziele ------------------------------------------------------------
ziele = [
SavingsGoal(
name="Neues Fahrrad",
target_amount=Decimal("1800.00"),
current_amount=Decimal("620.00"),
target_date=add_months(monat, 10),
monthly_contribution=Decimal("100.00"),
account_id=konten["tagesgeld"].id,
color="#22c55e",
icon="bike",
),
SavingsGoal(
name="Notgroschen",
target_amount=Decimal("6000.00"),
current_amount=Decimal("4200.00"),
monthly_contribution=Decimal("250.00"),
account_id=konten["tagesgeld"].id,
color="#3b82f6",
icon="shield",
),
]
session.add_all(ziele)
ergebnis.goals = len(ziele)
await session.commit()
logger.info("Demodaten angelegt: %s", ergebnis)
return ergebnis
async def _versions_of(session: AsyncSession, recurrence_id: int) -> list[AmountVersion]:
stmt = select(AmountVersion).where(AmountVersion.recurrence_id == recurrence_id)
return list((await session.execute(stmt)).scalars().all())
async def _confirm_past(session: AsyncSession, posten: list[Recurrence], heute: date) -> int:
"""Bestätigt alle Fälligkeiten, die vor heute lagen.
Beim Strom weicht der Ist-Betrag ab so wie eine echte Abrechnung.
"""
anzahl = 0
for eintrag in posten:
versionen = await _versions_of(session, eintrag.id)
faellig = expand(
eintrag,
eintrag.dtstart,
heute - timedelta(days=1),
amount_versions=versionen,
)
for index, geplant in enumerate(faellig):
ist = geplant.amount
if eintrag.is_variable:
# Abwechselnd etwas darüber und darunter.
abweichung = Decimal("7.40") if index % 2 == 0 else Decimal("-4.20")
ist = max(geplant.amount + abweichung, Decimal("0.01"))
await confirm(
session,
eintrag.id,
geplant.nominal_date,
actual_amount=ist,
)
anzahl += 1
await session.flush()
return anzahl
async def _seed_transactions(
session: AsyncSession,
kategorie: dict[str, int],
konten: dict[str, Account],
firmen: dict[str, Merchant],
monat: date,
) -> int:
"""Einmalige Buchungen der letzten drei Monate."""
giro = konten["giro"].id
karte = konten["karte"].id
muster: tuple[tuple[int, int, str, str, str, str | None, int], ...] = (
# (Monatsversatz, Tag, Titel, Betrag, Kategorie, Firma, Konto)
(0, 3, "Wocheneinkauf", "84.30", "Lebensmittel", "REWE", giro),
(0, 7, "Drogerie", "23.40", "Drogerie", "dm", giro),
(0, 9, "Tanken", "72.10", "Sprit", None, karte),
(0, 11, "Wocheneinkauf", "96.85", "Lebensmittel", "REWE", giro),
(0, 12, "Abendessen", "48.60", "Restaurant", None, karte),
(0, 18, "Wocheneinkauf", "78.20", "Lebensmittel", "REWE", giro),
(0, 21, "Bahnticket", "49.00", "ÖPNV", "Deutsche Bahn", giro),
(-1, 4, "Wocheneinkauf", "91.15", "Lebensmittel", "REWE", giro),
(-1, 8, "Werkstatt Inspektion", "312.00", "Werkstatt", None, giro),
(-1, 14, "Drogerie", "31.75", "Drogerie", "dm", giro),
(-1, 16, "Tanken", "68.40", "Sprit", None, karte),
(-1, 19, "Wocheneinkauf", "88.90", "Lebensmittel", "REWE", giro),
(-1, 24, "Restaurant", "62.30", "Restaurant", None, karte),
(-2, 6, "Wocheneinkauf", "79.55", "Lebensmittel", "REWE", giro),
(-2, 13, "Tanken", "70.20", "Sprit", None, karte),
(-2, 22, "Steuererstattung", "418.00", "Erstattungen", None, giro),
)
anzahl = 0
for versatz, tag, titel, betrag, kategoriename, firmenname, konto in muster:
buchungsmonat = add_months(monat, versatz)
ist_einkunft = kategoriename == "Erstattungen"
session.add(
Transaction(
kind=EntryKind.INCOME if ist_einkunft else EntryKind.EXPENSE,
title=titel,
category_id=kategorie[kategoriename],
account_id=konto,
merchant_id=firmen[firmenname].id if firmenname else None,
amount=Decimal(betrag),
booking_date=buchungsmonat.replace(day=tag),
)
)
anzahl += 1
await session.flush()
return anzahl
__all__ = ["DemoResult", "has_data", "reset_data", "seed_demo"]
+4
View File
@@ -13,6 +13,10 @@ os.environ.setdefault(
)
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
os.environ.setdefault("ENVIRONMENT", "test")
# Der Testclient spricht über http; `Secure`-Cookies würde httpx verwerfen und
# jede geschützte Route liefe in einen 401. Ohne diese Zeile hinge die Suite an
# einer lokalen .env die CI hat genau das aufgedeckt.
os.environ.setdefault("COOKIE_SECURE", "false")
# Die Hintergrundsuche liefe außerhalb der Testtransaktion sie wird gezielt aufgerufen.
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))