Files
certain/ios/Tests/GrundlagenTests.swift
T
ScarriffleandClaude Opus 5 6b86a6bf91 feat: Erste Stufe der iOS-App - Anmeldung und Kontenliste
Neuer Ordner ios/ mit dem Geruest: Netzwerkschicht, Modelle, Offline-Ablage,
Texte in Deutsch und Englisch, Navigation, der komplette Anmeldeweg (Server
waehlen, anmelden, zweiter Faktor, Pflicht-Passwortwechsel) und als erste
Fachansicht die Kontenliste.

Die Anmeldung laeuft einmal ueber das Sitzungs-Cookie, holt sich dabei eine
App-Marke und legt sie in den Schluesselbund. Danach traegt nur noch der
Header. Kein Passwort auf dem Geraet, kein Ablauf nach 30 Tagen.

Der Code ist nie uebersetzt worden - hier gibt es kein Xcode. Geprueft ist
dafuer jedes Modellfeld gegen echte Antworten des laufenden Backends; dabei
kam heraus, dass created_at ohne Zeitzone und mit Mikrosekunden kommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 16:20:03 +02:00

305 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}