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>
This commit is contained in:
Scarriffle
2026-10-20 16:20:03 +02:00
co-authored by Claude Opus 5
parent 8a7545bf5b
commit 6b86a6bf91
25 changed files with 2908 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
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)
}
}