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:
co-authored by
Claude Opus 5
parent
8a7545bf5b
commit
6b86a6bf91
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user