import Testing import Foundation @testable import CalendarProvider @Suite("Calendarr direkt vom Server") struct CalendarrLiveTests { // MARK: - Adresse @Test("Eine eingetippte Adresse wird zu einer brauchbaren") func serverNormalisation() { // Niemand tippt „https://" mit. Und wer es tut, hängt gern noch einen // Schrägstrich an. #expect(CalendarrLiveDecoder.server(from: "calendarr.example.com")?.absoluteString == "https://calendarr.example.com") #expect(CalendarrLiveDecoder.server(from: "https://calendarr.example.com/")?.absoluteString == "https://calendarr.example.com") #expect(CalendarrLiveDecoder.server(from: " http://192.168.1.5:8080 ")?.absoluteString == "http://192.168.1.5:8080") } @Test("Unsinn ergibt keine Adresse") func serverRejectsGarbage() { #expect(CalendarrLiveDecoder.server(from: "") == nil) #expect(CalendarrLiveDecoder.server(from: " ") == nil) } @Test("Der Abruf trägt den Zeitraum in der Adresse") func eventsURLCarriesInterval() throws { let url = try CalendarrLiveDecoder.eventsURL( server: URL(string: "https://c.example.com")!, interval: DateInterval(start: Date(timeIntervalSince1970: 0), end: Date(timeIntervalSince1970: 86400))) #expect(url.absoluteString.contains("/api/caldav/events")) #expect(url.absoluteString.contains("start=1970-01-01")) #expect(url.absoluteString.contains("end=1970-01-02")) } // MARK: - Anmeldung @Test("Sonderzeichen im Passwort überstehen die Anmeldung") func passwordEscaping() { // Der Klassiker: `+` bedeutet im Formularkörper ein Leerzeichen. Ohne // eigene Kodierung meldet sich niemand mit „a+b" jemals an — und der // Fehler tritt nur bei genau diesem Passwort auf. let body = String(decoding: CalendarrLiveDecoder.formBody(username: "guido", password: "a+b c&d"), as: UTF8.self) #expect(body == "username=guido&password=a%2Bb%20c%26d") } @Test("Die Gültigkeit steht im Token selbst") func tokenExpiry() throws { // Ein JWT trägt sie im Mittelteil. Die Alternative — sieben Tage // annehmen — steht heute so im Server und morgen vielleicht nicht mehr. let payload = try JSONSerialization.data(withJSONObject: ["exp": 1_800_000_000.0]) let encoded = payload.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") let token = "kopf.\(encoded).unterschrift" #expect(CalendarrLiveDecoder.expiry(ofToken: token) == Date(timeIntervalSince1970: 1_800_000_000)) } @Test("Ein kaputtes Token ergibt kein Datum") func brokenTokenHasNoExpiry() { #expect(CalendarrLiveDecoder.expiry(ofToken: "nur-ein-wort") == nil) #expect(CalendarrLiveDecoder.expiry(ofToken: "a.b.c") == nil) } // MARK: - Termine private func json(_ object: Any) throws -> Data { try JSONSerialization.data(withJSONObject: object) } @Test("Termine kommen aus dem Umschlag heraus") func decodesEnvelope() throws { let data = try json([ "events": [[ "id": "abc", "title": "Zahnarzt", "start": "2026-08-14T09:00:00+02:00", "end": "2026-08-14T10:00:00+02:00", "allDay": false, "location": "Praxis", "color": "#FF0000", "calendar_id": "local-3", "calendar_name": "Persönlich", ]], "errors": [], ]) let events = try CalendarrLiveDecoder.events(from: data) #expect(events.count == 1) #expect(events[0].title == "Zahnarzt") #expect(events[0].location == "Praxis") #expect(events[0].calendarID == "local-3") #expect(events[0].calendarTitle == "Persönlich") #expect(events[0].colorHex == "#FF0000") #expect(!events[0].isAllDay) } @Test("Ganztägige Termine kommen ohne Uhrzeit") func decodesAllDay() throws { let data = try json(["events": [[ "id": "b", "title": "Urlaub", "start": "2026-08-14", "end": "2026-08-16", "allDay": true, "calendar_id": "local-1", "calendar_name": "Familie", ]]]) let events = try CalendarrLiveDecoder.events(from: data) #expect(events.count == 1) #expect(events[0].isAllDay) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current #expect(calendar.component(.day, from: events[0].start) == 14) #expect(calendar.component(.hour, from: events[0].start) == 0) } @Test("Ohne Ende bekommt ein Termin trotzdem eine Länge") func missingEndGetsALength() throws { // Sonst wäre er null Sekunden lang — und damit unsichtbar. let data = try json(["events": [ ["id": "c", "title": "Kurz", "start": "2026-08-14T09:00:00Z", "calendar_id": "x", "calendar_name": "X"], ["id": "d", "title": "Ganztags", "start": "2026-08-15", "allDay": true, "calendar_id": "x", "calendar_name": "X"], ]]) let events = try CalendarrLiveDecoder.events(from: data) #expect(events[0].end.timeIntervalSince(events[0].start) == 3600) #expect(events[1].end.timeIntervalSince(events[1].start) == 86400) } @Test("Die Kalenderfarbe springt ein, wenn der Termin keine hat") func calendarColourIsTheFallback() throws { let data = try json(["events": [[ "id": "e", "title": "T", "start": "2026-08-14T09:00:00Z", "calendarColor": "#00FF00", "calendar_id": "x", "calendar_name": "X", ]]]) #expect(try CalendarrLiveDecoder.events(from: data)[0].colorHex == "#00FF00") } @Test("Termine kommen sortiert an") func eventsAreSorted() throws { let data = try json(["events": [ ["id": "spät", "title": "B", "start": "2026-08-16T09:00:00Z", "calendar_id": "x", "calendar_name": "X"], ["id": "früh", "title": "A", "start": "2026-08-14T09:00:00Z", "calendar_id": "x", "calendar_name": "X"], ]]) #expect(try CalendarrLiveDecoder.events(from: data).map(\.title) == ["A", "B"]) } @Test("Ein Eintrag ohne Anfang fällt heraus, der Rest bleibt") func brokenEntriesAreSkipped() throws { // Ein einziger kaputter Termin darf nicht die ganze Liste kosten. let data = try json(["events": [ ["id": "kaputt", "title": "Ohne Anfang", "calendar_id": "x", "calendar_name": "X"], ["id": "heil", "title": "Mit Anfang", "start": "2026-08-14T09:00:00Z", "calendar_id": "x", "calendar_name": "X"], ]]) #expect(try CalendarrLiveDecoder.events(from: data).map(\.title) == ["Mit Anfang"]) } @Test("Eine Antwort ohne Umschlag geht auch") func bareArrayWorks() throws { let data = try json([["id": "f", "title": "T", "start": "2026-08-14T09:00:00Z", "calendar_id": "x", "calendar_name": "X"]]) #expect(try CalendarrLiveDecoder.events(from: data).count == 1) } @Test("Was kein JSON ist, wird als solches gemeldet") func garbageThrows() { #expect(throws: CalendarrLiveError.badResponse) { try CalendarrLiveDecoder.events(from: Data("kein json".utf8)) } } // MARK: - Zeitangaben @Test("Die verbreiteten Zeitformate werden alle gelesen") func dateFormats() { #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00Z") != nil) #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00.123Z") != nil) #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00+02:00") != nil) // Ohne Zeitzone: dann gilt die des Rechners. #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00") != nil) #expect(CalendarrLiveDecoder.date(from: "2026-08-14") != nil) #expect(CalendarrLiveDecoder.date(from: "irgendwann") == nil) } }