import Testing import Foundation @testable import Certain // Reine Logik, kein Netz. Das ist alles, was sich ohne laufenden Server // prüfen lässt – und zugleich das, was beim blinden Schreiben am ehesten // still danebengeht. // // Hinweis: `@testable import Certain` muss auf den Namen deines App-Ziels // zeigen. Heisst es anders, hier anpassen. // MARK: - Geldbeträge @Suite("Geldbetrag") struct GeldbetragTests { @Test("Komma und Punkt meinen dasselbe") func trennerSindAustauschbar() { #expect(Geldbetrag.ausEingabe("12,50", waehrung: "CHF")?.cents == 1250) #expect(Geldbetrag.ausEingabe("12.50", waehrung: "CHF")?.cents == 1250) #expect(Geldbetrag.ausEingabe("12", waehrung: "CHF")?.cents == 1200) #expect(Geldbetrag.ausEingabe("0,05", waehrung: "CHF")?.cents == 5) } @Test("Bei beiden Zeichen ist das hintere der Dezimaltrenner") func tausenderUndDezimal() { #expect(Geldbetrag.ausEingabe("1.234,50", waehrung: "EUR")?.cents == 123450) #expect(Geldbetrag.ausEingabe("1,234.50", waehrung: "EUR")?.cents == 123450) } @Test("Drei Stellen hinter dem Trenner sind Tausender, keine Rappen") func dreiStellenSindKeinBruch() { // "1.234" meint tausendzweihundertvierunddreissig, nicht 1,23. #expect(Geldbetrag.ausEingabe("1.234", waehrung: "EUR")?.cents == 123400) } @Test("Vorzeichen bleibt erhalten") func negativ() { #expect(Geldbetrag.ausEingabe("-5", waehrung: "EUR")?.cents == -500) #expect(Geldbetrag.ausEingabe("-12,34", waehrung: "EUR")?.cents == -1234) } @Test("Unsinn ergibt keinen Betrag") func unsinn() { #expect(Geldbetrag.ausEingabe("", waehrung: "EUR") == nil) #expect(Geldbetrag.ausEingabe(" ", waehrung: "EUR") == nil) // Der wichtige Fall: Ohne diese Prüfung käme hier 0,00 heraus und die // App buchte stillschweigend einen Nullbetrag. #expect(Geldbetrag.ausEingabe("abc", waehrung: "EUR") == nil) #expect(Geldbetrag.ausEingabe(",", waehrung: "EUR") == nil) } @Test("Hin und zurück durchs Eingabefeld") func rundlauf() { let betrag = Geldbetrag(cents: 123450, waehrung: "CHF") #expect(betrag.alsEingabe == "1234,50") #expect(Geldbetrag.ausEingabe(betrag.alsEingabe, waehrung: "CHF")?.cents == 123450) let krumm = Geldbetrag(cents: 5, waehrung: "CHF") #expect(krumm.alsEingabe == "0,05") #expect(Geldbetrag.ausEingabe(krumm.alsEingabe, waehrung: "CHF")?.cents == 5) } @Test("Die Formatierung nennt Betrag und Währung") func formatierung() { let text = Geldbetrag(cents: 1250, waehrung: "CHF").formatiert() // Kein Vergleich auf die genaue Zeichenkette: Trennzeichen und Stellung // des Währungszeichens hängen an der Sprache des Geräts. #expect(text.contains("12")) #expect(text.contains("50")) } @Test("Plus nur, wenn ausdrücklich gewünscht") func vorzeichenstil() { let positiv = Geldbetrag(cents: 500, waehrung: "EUR") #expect(positiv.formatiert(vorzeichen: .immer).hasPrefix("+")) #expect(positiv.formatiert().hasPrefix("+") == false) } @Test("Summieren geht nur in derselben Währung") func summieren() { let a = Geldbetrag(cents: 100, waehrung: "EUR") let b = Geldbetrag(cents: 250, waehrung: "EUR") #expect((a + b).cents == 350) } } // MARK: - Zeitangaben @Suite("Zeitangaben") struct ZeitangabenTests { @Test("Ein Tagesdatum überlebt Hin- und Rückweg") func tagesdatum() throws { let tag = try #require(Tagesdatum("2026-07-14")) #expect(tag.text == "2026-07-14") #expect(Tagesdatum(tag.datum).text == "2026-07-14") } @Test("Tage lassen sich vergleichen") func reihenfolge() throws { let frueher = try #require(Tagesdatum("2026-01-31")) let spaeter = try #require(Tagesdatum("2026-02-01")) #expect(frueher < spaeter) } @Test("Kaputte Tagesangaben ergeben nichts") func kaputterTag() { #expect(Tagesdatum("14.07.2026") == nil) #expect(Tagesdatum("") == nil) #expect(Tagesdatum("2026-13-45") == nil) } @Test("Zeitstempel mit und ohne Sekundenbruchteile") func zeitpunkte() { // Das Backend schickt beide Formen, je nach Feld. #expect(Zeitpunkt("2026-07-14T10:11:12Z") != nil) #expect(Zeitpunkt("2026-07-14T10:11:12.345Z") != nil) #expect(Zeitpunkt("2026-07-14T10:11:12+02:00") != nil) // Python schreibt ohne Zone, wenn keine gesetzt ist. #expect(Zeitpunkt("2026-07-14T10:11:12") != nil) // Mikrosekunden, wie SQLite sie ablegt. #expect(Zeitpunkt("2026-07-14T10:11:12.345678") != nil) #expect(Zeitpunkt("übermorgen") == nil) } } // MARK: - Server-Adresse @Suite("Server-Adresse") struct AdressTests { @Test("Ohne Schema wird https ergänzt") func schemaErgaenzen() throws { let adresse = try #require(AppZustand.deuteAdresse("budget.beispiel.com")) #expect(adresse.absoluteString == "https://budget.beispiel.com") } @Test("Ein angehängter Pfad fliegt raus") func pfadWeg() throws { // Der Nutzer kopiert gern die Adresse aus der Browserzeile, samt Pfad. // Die API hängt aber immer unter /api an der Wurzel. let adresse = try #require(AppZustand.deuteAdresse("https://budget.beispiel.com/buchungen?x=1")) #expect(adresse.absoluteString == "https://budget.beispiel.com") } @Test("http bleibt http") func schemaBleibt() throws { let adresse = try #require(AppZustand.deuteAdresse("http://192.168.1.5:8000")) #expect(adresse.absoluteString == "http://192.168.1.5:8000") } @Test("Unsinn ergibt keine Adresse") func unsinn() { #expect(AppZustand.deuteAdresse("") == nil) #expect(AppZustand.deuteAdresse(" ") == nil) } } // MARK: - Symbole @Suite("Symbole") struct SymbolTests { @Test("Bekannte Namen werden übersetzt") func bekannt() { #expect(Symbole.sf("wallet") == "wallet.bifold") #expect(Symbole.sf("shopping-cart") == "cart") } @Test("Unbekanntes wird zum Platzhalter, nie zu nichts") func unbekannt() { // Eine leere Stelle in der Liste sähe aus wie ein Fehler. #expect(Symbole.sf("gibtesnicht") == Symbole.platzhalter) #expect(Symbole.sf(nil) == Symbole.platzhalter) } } // MARK: - Fehlerantworten @Suite("Fehlerantworten") struct FehlerTests { @Test("detail als Zeichenkette") func alsText() { let daten = Data(#"{"detail":"Nicht gefunden"}"#.utf8) #expect(Fehlerantwort.ausDaten(daten) == "Nicht gefunden") } @Test("detail als Liste, wie bei 422") func alsListe() { // Genau hier gehen Meldungen verloren, wenn der Decoder nur eine // Zeichenkette kennt. let daten = Data(#"{"detail":[{"loc":["body","amount_cents"],"msg":"muss groesser als 0 sein","type":"value_error"}]}"#.utf8) #expect(Fehlerantwort.ausDaten(daten) == "muss groesser als 0 sein") } @Test("Was gar kein Fehlerkörper ist, ergibt leeren Text statt eines Absturzes") func unbrauchbar() { #expect(Fehlerantwort.ausDaten(Data("".utf8)) == "") #expect(Fehlerantwort.ausDaten(Data()) == "") } } // MARK: - Modelle gegen echte Antworten @Suite("Modelle") struct ModellTests { @Test("Ein Konto, wie der Server es schickt") func konto() throws { // Abgenommen von einer echten Antwort aus GET /api/accounts. let roh = """ {"id":3,"name":"Revolut CHF","type":"girokonto","currency":"CHF", "icon":"wallet","color":"#3f51b5","opening_balance_cents":0, "opening_date":"2026-01-01","credit_limit_cents":null, "is_locked":false,"is_archived":false,"position":2, "savings_category_id":null, "identifiers":[{"id":7,"kind":"iban","value":"CH93..."}], "balance_cents":-1043,"balance_base_cents":-1043, "bank_balance_cents":null,"bank_balance_at":null, "bank_balance_vergleichbar":false} """ let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8)) #expect(konto.name == "Revolut CHF") #expect(konto.type == .girokonto) #expect(konto.saldo.cents == -1043) #expect(konto.identifiers.first?.kind == .iban) #expect(konto.weichtVonDerBankAb == false) } @Test("Ein Banksaldo, der abweicht, wird gemeldet") func saldoWeichtAb() throws { let roh = """ {"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet", "color":"#6b7480","opening_balance_cents":0,"opening_date":null, "credit_limit_cents":null,"is_locked":false,"is_archived":false, "position":0,"savings_category_id":null,"identifiers":[], "balance_cents":1000,"balance_base_cents":1000, "bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z", "bank_balance_vergleichbar":true} """ let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8)) #expect(konto.weichtVonDerBankAb) } @Test("Ein nicht vergleichbarer Banksaldo loest keinen Hinweis aus") func saldoNichtVergleichbar() throws { // Ein verfuegbarer Saldo enthaelt Vorgemerktes - dass er abweicht, // ist normal und keine Meldung wert. let roh = """ {"id":1,"name":"Giro","type":"girokonto","currency":"EUR","icon":"wallet", "color":"#6b7480","opening_balance_cents":0,"opening_date":null, "credit_limit_cents":null,"is_locked":false,"is_archived":false, "position":0,"savings_category_id":null,"identifiers":[], "balance_cents":1000,"balance_base_cents":1000, "bank_balance_cents":1200,"bank_balance_at":"2026-07-14T06:00:00Z", "bank_balance_vergleichbar":false} """ let konto = try JSONDecoder().decode(Konto.self, from: Data(roh.utf8)) #expect(konto.weichtVonDerBankAb == false) } @Test("Einstellungen mit Lücken fallen auf sinnvolle Werte zurück") func einstellungen() { let leer = Einstellungen([]) #expect(leer.basiswaehrung == "EUR") #expect(leer.budgetStarttag == 1) let gesetzt = Einstellungen([ Einstellung(key: "base_currency", value: "CHF"), Einstellung(key: "budget_start_day", value: "25"), Einstellung(key: "active_currencies", value: "EUR, CHF ,USD"), ]) #expect(gesetzt.basiswaehrung == "CHF") #expect(gesetzt.budgetStarttag == 25) #expect(gesetzt.aktiveWaehrungen == ["EUR", "CHF", "USD"]) } @Test("Ein unsinniger Starttag wird eingefangen") func starttagGrenzen() { // Das Backend begrenzt auf 1..31; die App darf sich nicht darauf // verlassen, dass in der Einstellung nichts anderes steht. #expect(Einstellungen([Einstellung(key: "budget_start_day", value: "99")]) .budgetStarttag == 31) #expect(Einstellungen([Einstellung(key: "budget_start_day", value: "0")]) .budgetStarttag == 1) #expect(Einstellungen([Einstellung(key: "budget_start_day", value: "weiss nicht")]) .budgetStarttag == 1) } @Test("Die Serverkennung erkennt einen fremden Server") func serverkennung() throws { let echt = try JSONDecoder().decode( Serverkennung.self, from: Data(#"{"name":"Certain Finance","version":"0.1.0","git_sha":"abc"}"#.utf8)) #expect(echt.istCertain) let fremd = try JSONDecoder().decode( Serverkennung.self, from: Data(#"{"name":"Nextcloud","version":"29","git_sha":null}"#.utf8)) #expect(fremd.istCertain == false) } } // MARK: - Überträge zusammenfassen @Suite("Übertragszeilen") struct UebertragszeilenTests { /// Eine Buchung, wie der Server sie schickt – nur die Felder, auf die es /// hier ankommt. private func buchung(_ id: Int, konto: Int, cents: Int, art: Buchungsart = .expense) throws -> Buchung { let roh = """ {"id": \(id), "type": "\(art.rawValue)", "date": "2026-10-15", "time": null, "account_id": \(konto), "to_account_id": null, "account_currency": "EUR", "amount_cents": \(cents), "to_amount_cents": null, "fx_rate": null, "amount_eur_cents": \(cents), "amount_base_cents": \(cents), "category_id": null, "budget_treatment": null, "budget_category_id": null, "subscription_id": null, "budget_period": "2026-10", "budget_period_manuell": false, "import_batch_id": null, "herkunft": "bank", "payee": "Test", "description": null, "notes": null, "tag_ids": []} """ return try JSONDecoder().decode(Buchung.self, from: Data(roh.utf8)) } private func verknuepfung(_ anderes: Int, raus: Bool, gegenkonto: Int?) -> Uebertragsverknuepfung { Uebertragsverknuepfung(linkId: 1, otherId: anderes, richtung: raus ? "raus" : "rein", otherAccountId: gegenkonto, automatisch: true) } @Test("Zwei verknüpfte Buchungen ergeben eine Zeile, nicht zwei") func paarWirdZusammengefasst() throws { let raus = try buchung(1, konto: 10, cents: -5000) let rein = try buchung(2, konto: 20, cents: 5000, art: .income) let zeilen = Buchungszeile.zeilen(aus: [raus, rein], paare: [ "1": verknuepfung(2, raus: true, gegenkonto: 20), "2": verknuepfung(1, raus: false, gegenkonto: 10), ]) #expect(zeilen.count == 1) #expect(zeilen[0].istUebertrag) #expect(zeilen[0].vonKonto == 10) #expect(zeilen[0].nachKonto == 20) } @Test("Fehlt das zweite Bein, verschwindet die Zeile nicht") func einzelnesBeinBleibtStehen() throws { // Genau der Fall eines Kontofilters: Die Gegenbuchung liegt auf einem // Konto, das gerade nicht gezeigt wird. let raus = try buchung(1, konto: 10, cents: -5000) let zeilen = Buchungszeile.zeilen(aus: [raus], paare: [ "1": verknuepfung(2, raus: true, gegenkonto: 20), ]) #expect(zeilen.count == 1) #expect(zeilen[0].istUebertrag) // Das Gegenkonto steht in der Verknüpfung, auch ohne die Buchung. #expect(zeilen[0].nachKonto == 20) } @Test("Die Richtung kommt aus der Verknüpfung, nicht aus dem Vorzeichen") func richtungZaehlt() throws { // Manche Banken liefern beide Beine positiv. Wer am Vorzeichen // ablesen wollte, wohin das Geld geht, läge hier falsch herum. let rein = try buchung(2, konto: 20, cents: 5000, art: .income) let zeilen = Buchungszeile.zeilen(aus: [rein], paare: [ "2": verknuepfung(1, raus: false, gegenkonto: 10), ]) #expect(zeilen[0].vonKonto == 10) #expect(zeilen[0].nachKonto == 20) } @Test("Ohne Verknüpfung bleibt jede Buchung ihre eigene Zeile") func ohneVerknuepfung() throws { let eins = try buchung(1, konto: 10, cents: -1000) let zwei = try buchung(2, konto: 10, cents: -2000) let zeilen = Buchungszeile.zeilen(aus: [eins, zwei], paare: [:]) #expect(zeilen.count == 2) #expect(zeilen.allSatisfy { !$0.istUebertrag }) } } // MARK: - Was der Client selbst rechnet @Suite("Budget-Anteile") struct BudgetAnteilTests { private func kategorie(geplant: Int, ist: Int) throws -> Budgetkategorie { let roh = """ {"category_id": 1, "name": "Test", "icon": "home", "color": "#3f51b5", "planned_cents": \(geplant), "actual_cents": \(ist), "delta_cents": \(geplant - ist), "over": \(ist > geplant), "modus": "fest", "zuschlag_cents": 0, "aus_vertraegen_cents": 0, "vertraege_anzahl": 0, "good": false} """ return try JSONDecoder().decode(Budgetkategorie.self, from: Data(roh.utf8)) } @Test("Ohne Plan gibt es keinen Anteil, keinen vollen Balken") func ohnePlanKeinBalken() throws { #expect(try kategorie(geplant: 0, ist: 0).anteilVerbraucht == 0) // Ausgegeben ohne Plan: Ein voller Balken sähe aus wie "genau // aufgebraucht" und wäre die falsche Auskunft. #expect(try kategorie(geplant: 0, ist: 5000).anteilVerbraucht == 0) } @Test("Überzogen wird bei eins gekappt") func gekappt() throws { #expect(try kategorie(geplant: 10000, ist: 25000).anteilVerbraucht == 1) #expect(try kategorie(geplant: 10000, ist: 5000).anteilVerbraucht == 0.5) } } // MARK: - Nachschlagewerk @Suite("Nachschlag") struct NachschlagTests { private func gruppe() throws -> Kategoriegruppe { let roh = """ {"id": 1, "name": "Fixkosten", "kind": "expense", "icon": "landmark", "color": "#3f51b5", "position": 0, "is_builtin": true, "categories": [{"id": 7, "group_id": 1, "name": "Wohnen", "type": "expense", "icon": "home", "color": "#3f51b5", "position": 0, "is_builtin": true, "is_archived": false, "excluded_from_stats": false, "spending_is_good": false, "keyword_count": 0}]} """ return try JSONDecoder().decode(Kategoriegruppe.self, from: Data(roh.utf8)) } @Test("Eine ID findet ihren Namen") func kategorieNachId() throws { let nachschlag = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: []) #expect(nachschlag.kategorie(7)?.name == "Wohnen") #expect(nachschlag.kategorie(999) == nil) #expect(nachschlag.kategorie(nil) == nil) } @Test("Ein unbekanntes Konto ergibt Text, nicht nichts") func unbekanntesKonto() throws { // Eine leere Zeile in der Liste wäre schlimmer als das Wort // "Unbekannt" – sie sähe aus wie ein Fehler der App. #expect(!Nachschlag.leer.kontoname(42).isEmpty) } @Test("Der Zwischenspeicher gibt dasselbe Nachschlagewerk zurück") func ueberlebtDenSpeicher() throws { let original = Nachschlag(gruppen: [try gruppe()], schlagwoerter: [], konten: []) let roh = try JSONEncoder().encode(original) let zurueck = try JSONDecoder().decode(Nachschlag.self, from: roh) // Die Nachschlagetabellen werden beim Lesen neu gebaut, nicht // mitgeschrieben – genau das prüft dieser Test. #expect(zurueck.kategorie(7)?.name == "Wohnen") } } // MARK: - Die Modelle gegen echte Serverantworten /// Nur da, um über `Bundle(for:)` an die Prüfdatei zu kommen. Swift Testing /// kennt keine Testklasse, an der sich das Bündel ablesen liesse. private final class Markierung {} /// Entschlüsselt jedes Modell aus einer echten Antwort des Backends. /// /// Das ist die Prüfung, die beim blinden Schreiben wirklich etwas fängt: Ein /// vertippter Feldname übersetzt anstandslos und fällt erst auf, wenn eine /// Liste beim Nutzer leer bleibt. `Tests/Fixtures/antworten.json` kommt aus /// `backend/scripts/erzeuge_ios_fixture.py`; nach jeder Schema-Änderung neu /// erzeugen. @Suite("Serverantworten") struct ServerantwortenTests { private static let antworten: [String: Data] = { guard let ort = Bundle(for: Markierung.self) .url(forResource: "antworten", withExtension: "json"), let roh = try? Data(contentsOf: ort), let oben = try? JSONSerialization.jsonObject(with: roh) as? [String: Any] else { return [:] } return oben.compactMapValues { try? JSONSerialization.data(withJSONObject: $0, options: .fragmentsAllowed) } }() private func lies(_ typ: A.Type, _ name: String) throws -> A { let daten = try #require(Self.antworten[name], "Prüfdatei kennt '\(name)' nicht – neu erzeugen?") return try JSONDecoder().decode(A.self, from: daten) } @Test("Die Prüfdatei liegt im Testbündel") func dateiIstDa() { // Ohne diesen Test wären alle folgenden still übersprungen, wenn die // Datei nicht ins Ziel kopiert wird. #expect(!Self.antworten.isEmpty) } @Test("Konten, Kategorien und Schlagwörter") func stammdaten() throws { let konten = try lies([Konto].self, "konten") // Nicht auf eine genaue Zahl pruefen: Die Pruefdatei waechst mit // jeder Stufe, und ein Test, der dann rot wird, sagt nichts aus. #expect(konten.count >= 2) #expect(konten.contains { $0.isLocked }) let gruppen = try lies([Kategoriegruppe].self, "kategoriegruppen") #expect(gruppen.contains { !$0.categories.isEmpty }) #expect(try !lies([Schlagwort].self, "schlagwoerter").isEmpty) } @Test("Buchungen samt Tags, Übertrag und Vertragszuordnung") func buchungen() throws { let alle = try lies([Buchung].self, "buchungen") #expect(alle.count >= 5) // Jede Spielart einmal – sonst prüft die Datei die Felder nicht, die // nur in einem Fall belegt sind. #expect(alle.contains { !$0.tagIds.isEmpty }) #expect(alle.contains { $0.type == .transfer && $0.toAccountId != nil }) #expect(alle.contains { $0.subscriptionId != nil }) #expect(alle.contains { $0.notes?.isEmpty == false }) } @Test("Die Paarliste fasst zwei getrennte Buchungen zusammen") func verknuepfungen() throws { let paare = try lies([String: Uebertragsverknuepfung].self, "uebertragsverknuepfungen") #expect(paare.count == 2) let alle = try lies([Buchung].self, "buchungen") let zeilen = Buchungszeile.zeilen(aus: alle, paare: paare) // Die Paarliste fuehrt jede Verknuepfung zweimal auf, einmal je // Richtung - also halb so viele Paare wie Eintraege. Genau so viele // Zeilen fallen beim Zusammenfassen weg. #expect(zeilen.count == alle.count - paare.count / 2) let uebertraege = zeilen.filter(\.istUebertrag) #expect(!uebertraege.isEmpty) } @Test("Budget-Monat, Vorlage und Verträge") func budget() throws { let monat = try lies(Budgetmonat.self, "budget") #expect(!monat.groups.isEmpty) #expect(monat.groups.contains { !$0.categories.isEmpty }) #expect(monat.totals.plannedExpense > 0) // prev/next tragen die Blätterung – ohne sie steht die Ansicht still. #expect(!monat.prev.isEmpty && !monat.next.isEmpty) let vorlage = try lies(Budgetvorlage.self, "budgetvorlage") // Betriebsart und Zuschlag kamen mit Backend-Ergaenzung C dazu. #expect(vorlage.lines.contains { $0.modus == "fest" }) let abos = try lies(Aboliste.self, "abos") #expect(abos.summary.anzahlAktiv >= 1) #expect(abos.subscriptions.contains { $0.intervalMonths == 12 }) } @Test("Alle vier Dashboard-Karten") func dashboard() throws { let staende = try lies(Kontostaende.self, "kontostaende") #expect(staende.accounts.count >= 2) #expect(staende.gebunden != 0) #expect(try !lies(Budgetfortschritt.self, "budgetfortschritt").rows.isEmpty) #expect(try lies(EinnahmenAusgaben.self, "einnahmenAusgaben").income > 0) let letzte = try lies(LetzteBuchungen.self, "letzteBuchungen") #expect(!letzte.transactions.isEmpty) // Ein Übertrag hat keine Richtung – sign ist dort null. #expect(letzte.transactions.contains { $0.vorzeichen == .keines }) } @Test("Benutzer, Einstellungen und Serverkennung") func zugang() throws { #expect(try lies(Serverkennung.self, "version").istCertain) #expect(try !lies(Benutzer.self, "ichSelbst").username.isEmpty) let roh = try lies([Einstellung].self, "einstellungen") #expect(!Einstellungen(roh).basiswaehrung.isEmpty) } } // MARK: - Was zum Server hinausgeht @Suite("Eingaben") struct EingabenTests { /// Die Felder, die wirklich im JSON stehen. private func felder(_ wert: A) throws -> [String: Any] { let roh = try JSONEncoder().encode(wert) return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any]) } @Test("Eine Teiländerung schickt nur, was gesetzt ist") func teilaenderungBleibtTeilweise() throws { // Der Fall, der stillschweigend Daten frisst: Ginge ein nicht // angefasstes Feld als null hinaus, loeschte jedes Speichern den // Empfänger, die Notiz und die Kategorie gleich mit. let nurNotiz = try felder(BuchungAendern(notes: "Kassenbon fehlt")) #expect(nurNotiz.keys.sorted() == ["notes"]) #expect(nurNotiz["notes"] as? String == "Kassenbon fehlt") } @Test("Eine leere Tag-Liste geht mit, denn sie bedeutet etwas") func leereTagListeGehtMit() throws { // Mitgeschickt heisst ERSETZEN. Eine leere Liste nimmt alle Tags ab; // sie wegzulassen liesse sie stehen. Die zwei Faelle duerfen nicht // zusammenfallen. let ohneTags = try felder(BuchungAendern(tagIds: [])) #expect(ohneTags["tag_ids"] as? [Int] == []) let unberuehrt = try felder(BuchungAendern(payee: "Laden")) #expect(unberuehrt["tag_ids"] == nil) } @Test("Die Feldnamen sind die des Backends") func feldnamenPassen() throws { let anlegen = try felder(BuchungAnlegen( type: .expense, date: #require(Tagesdatum("2026-10-15")), accountId: 3, amountCents: 4250, categoryId: 7, payee: "Supermarkt")) #expect(anlegen["account_id"] as? Int == 3) #expect(anlegen["amount_cents"] as? Int == 4250) #expect(anlegen["category_id"] as? Int == 7) #expect(anlegen["date"] as? String == "2026-10-15") #expect(anlegen["type"] as? String == "expense") } @Test("Konto und Vertrag ändern sich ebenso nur in dem, was gesetzt ist") func weitereTeilaenderungen() throws { #expect(try felder(KontoAendern(isArchived: true)).keys.sorted() == ["is_archived"]) #expect(try felder(AboAendern(isActive: false)).keys.sorted() == ["is_active"]) let kreditrahmen = try felder(KontoAendern(creditLimitCents: 200000)) #expect(kreditrahmen["credit_limit_cents"] as? Int == 200000) } @Test("Die Rhythmen sind genau die, die das Backend annimmt") func rhythmenPassenZumBackend() { // INTERVALL_MONATE in backend/app/schemas.py. Weicht die App ab, // antwortet der Server mit 422 auf etwas, das die Auswahl anbot. #expect(Zahlungsrhythmus.allCases.map(\.rawValue) == [1, 2, 3, 6, 12]) } } // MARK: - Arbeitsliste @Suite("Arbeitsliste") struct ArbeitslisteTests { private func eintrag(stand: String, konto: Int?, kategorie: Int?, gegenkonto: Int? = nil) throws -> Eingangseintrag { let roh = """ {"id": 1, "source": "shortcut", "status": "\(stand)", "account_id": \(konto.map(String.init) ?? "null"), "date": "2026-10-15", "amount_cents": -1290, "currency": "EUR", "payee": "Baeckerei", "note": null, "category_id": \(kategorie.map(String.init) ?? "null"), "transaction_id": null, "transfer_account_id": \(gegenkonto.map(String.init) ?? "null"), "fx_rate": null, "counter_amount_cents": null, "counter_currency": null, "created_at": "2026-10-15T14:18:15.780705"} """ return try JSONDecoder().decode(Eingangseintrag.self, from: Data(roh.utf8)) } @Test("Ohne Konto laesst sich nichts buchen") func ohneKontoKeinBuchen() throws { // Das Backend lehnt es mit 400 ab. Den Knopf trotzdem anzubieten // hiesse, dem Nutzer eine Fehlermeldung als Antwort zu geben. #expect(try eintrag(stand: "pending", konto: nil, kategorie: nil) .laesstSichBuchen == false) #expect(try eintrag(stand: "pending", konto: 3, kategorie: nil) .laesstSichBuchen) } @Test("Was schon gebucht ist, steht nicht mehr zum Buchen an") func gebuchtesIstErledigt() throws { #expect(try eintrag(stand: "booked", konto: 3, kategorie: 7) .laesstSichBuchen == false) // Unklares schon: Es wartet auf einen Blick, nicht auf ein Konto. #expect(try eintrag(stand: "unclear", konto: 3, kategorie: nil) .laesstSichBuchen) } @Test("Ein Uebertrag braucht keine Kategorie") func uebertragBrauchtKeineKategorie() throws { // Ohne transfer_account_id liesse sich ein Uebertrag nicht von einer // Ausgabe unterscheiden - und die App fragte ewig nach einer // Kategorie, die es nie geben wird. #expect(try eintrag(stand: "booked", konto: 3, kategorie: nil) .brauchtKategorie) #expect(try eintrag(stand: "booked", konto: 3, kategorie: nil, gegenkonto: 4) .brauchtKategorie == false) } @Test("Ein Vorschlag ist dasselbe Paar, egal herum") func paarIstUngeordnet() throws { let roh = """ {"verknuepft": 0, "vorschlaege": [ {"a": {"id": 9, "date": "2026-10-15", "type": "expense", "amount_cents": 7500, "currency": "EUR", "account_id": 1, "account_name": "Giro", "payee": "Dauerauftrag", "description": null, "category_id": null}, "b": {"id": 10, "date": "2026-10-15", "type": "income", "amount_cents": 7500, "currency": "EUR", "account_id": 3, "account_name": "Zweitkonto", "payee": "Dauerauftrag", "description": null, "category_id": 4}}]} """ let stand = try JSONDecoder().decode(Uebertragsvorschlaege.self, from: Data(roh.utf8)) let paar = try #require(stand.vorschlaege.first) // Die ID muss unabhaengig von der Reihenfolge sein, sonst zeigt die // Liste dasselbe Paar als zwei Eintraege. #expect(paar.id == "9-10") // Eine zugeordnete Buchung faellt durchs Verbinden aus dem Budget. #expect(paar.hatKategorie) } } extension ServerantwortenTests { @Test("Eingang, Dubletten und Uebertragsvorschlaege") func arbeitsliste() throws { let eingang = try lies([Eingangseintrag].self, "eingang") #expect(!eingang.isEmpty) #expect(eingang.allSatisfy { $0.currency.isEmpty == false }) let dubletten = try lies(Dublettengruppen.self, "dubletten") let gruppe = try #require(dubletten.gruppen.first) #expect(gruppe.zeilen.count >= 2) let vorschlaege = try lies(Uebertragsvorschlaege.self, "uebertragsvorschlaege") #expect(vorschlaege.verknuepft == 0, "beim Fragen darf nichts verknuepft werden") #expect(vorschlaege.vorschlaege.count == 1) // Das Paar stand frueher zweimal drin, einmal je Richtung. let ids = Set(vorschlaege.vorschlaege.map(\.id)) #expect(ids.count == vorschlaege.vorschlaege.count) } } // MARK: - Nebenraeume @Suite("Nebenraeume") struct NebenraeumeTests { private func felder(_ wert: A) throws -> [String: Any] { let roh = try JSONEncoder().encode(wert) return try #require(JSONSerialization.jsonObject(with: roh) as? [String: Any]) } @Test("Eine Teilaenderung an Kategorie und Tag bleibt teilweise") func teilaenderungen() throws { // Dieselbe Falle wie bei Buchung und Konto: Ein nicht angefasstes // Feld als null zu schicken loeschte Symbol, Farbe und Gruppe mit. #expect(try felder(KategorieAendern(isArchived: true)).keys.sorted() == ["is_archived"]) #expect(try felder(TagAendern(color: "#2a78d6")).keys.sorted() == ["color"]) } @Test("Die Feldnamen der Kategorie sind die des Backends") func kategorieFeldnamen() throws { let neu = try felder(KategorieAnlegen(groupId: 2, name: "Spartopf", spendingIsGood: true)) #expect(neu["group_id"] as? Int == 2) #expect(neu["spending_is_good"] as? Bool == true) #expect(neu["excluded_from_stats"] as? Bool == false) } @Test("Ein Abruf ohne zugeordnetes Konto sagt das auch") func abrufOhneZuordnung() throws { // Sonst meldet die App "0 Umsaetze geholt" und der Nutzer sucht den // Fehler bei der Bank statt bei der fehlenden Zuordnung. let roh = """ {"geholt": 0, "neu": 0, "saldi": {}, "ohne_zuordnung": true} """ let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8)) #expect(ergebnis.ohneZuordnung == true) #expect(ergebnis.meldung != String(format: String(localized: "bank.abrufErgebnis"), 0, 0)) } @Test("Ein voller Abruf liest sich trotz der vielen Felder") func vollerAbruf() throws { let roh = """ {"uebertraege": 1, "geholt": 42, "neu": 7, "gebucht": 5, "vorgemerkt": 2, "doppelt": 0, "saldi": {}, "ab": "2026-10-01", "ohne_zuordnung": false} """ let ergebnis = try JSONDecoder().decode(Abrufergebnis.self, from: Data(roh.utf8)) #expect(ergebnis.geholt == 42 && ergebnis.neu == 7) #expect(ergebnis.uebertraege == 1) } } extension ServerantwortenTests { @Test("Erkennungswoerter, zweiter Faktor und Bankverbindungen") func nebenraeume() throws { let woerter = try lies([Erkennungswort].self, "schlagwoerter_der_kategorie") #expect(!woerter.isEmpty) #expect(woerter.allSatisfy { !$0.word.isEmpty }) // Leer, solange niemand oft genug korrigiert hat - der Typ muss sich // trotzdem lesen lassen. _ = try lies([MarkiertesWort].self, "markierteSchlagwoerter") let faktor = try lies(ZweiterFaktorStand.self, "zweiterFaktor") #expect(faktor.enabled == false) // Bleibt leer: Eine Anbindung laesst sich ohne echten Anbieter nicht // herstellen. Die Felder von Bankverbindung deckt dieser Test NICHT // ab - wer sie aendert, prueft von Hand. #expect(try lies([Bankverbindung].self, "bankverbindungen").isEmpty) } }