Die Nebenraeume haengen unter Einstellungen, nicht als eigene Reiter: Man braucht sie selten, und eine Leiste mit zehn Reitern findet niemand mehr. - Kategorien: anlegen, aendern, archivieren, loeschen - samt Gruppen. Eine Kategorie oeffnet ihre Erkennungswoerter, die sich dort hinzufuegen und entfernen lassen. Darunter steht, was sonst niemand merkt: Sie wirken nur auf kuenftige Importe. - Woerter, die oft korrigiert werden, stehen als Pruefmeldung in den Einstellungen - behalten oder entfernen. - Schlagwoerter: anlegen, umbenennen, umfaerben, loeschen. Neue bekommen reihum eine Farbe, damit nicht alle grau sind. - Einstellungen: Erscheinungsbild, Hauptwaehrung und Budget-Starttag lassen sich jetzt aendern. Ohne Sichern-Knopf - es ist je Zeile genau ein Wert, und das Backend nimmt ihn einzeln entgegen. Dazu der Hinweis, dass sie dem Konto gehoeren und nicht dem Geraet. - Bank: abrufen und Salden auffrischen. Verbinden bleibt am Rechner, die Bank leitet fest dorthin zurueck. Der letzte Fehler der Verbindung steht dabei, sonst ruft man vergeblich ab. - Zweiter Faktor: einrichten in drei Schritten. Auf dem Telefon fuehrt der Weg ueber die otpauth-Adresse direkt in die Authenticator-App statt ueber einen QR-Code, den man sich selbst zeigen muesste. Die Rettungscodes erscheinen einmal; das Blatt laesst sich nicht wegwischen und erst schliessen, wenn man bestaetigt hat, sie gesichert zu haben. Abschalten des zweiten Faktors bleibt bewusst draussen: Es verlangt das Kontopasswort, und die App speichert keines. Ein Vollzugang auf diesem Geraet soll den Schutz des Kontos nicht aufheben koennen. 59 Tests gruen. Texte in Deutsch und Englisch, zeilengleich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
821 lines
34 KiB
Swift
821 lines
34 KiB
Swift
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("<html>".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<A: Decodable>(_ 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<A: Encodable>(_ 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<A: Encodable>(_ 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)
|
||
}
|
||
}
|