Calendarr direkt vom Server
Bisher las Onyx nur den Schnappschuss aus dem gemeinsamen Ordner. Der ist so aktuell wie der letzte Start der Calendarr-App — wer sie eine Woche nicht öffnet, sieht wochenalte Termine, und Onyx kann nichts dafür und nichts dagegen. Der Live-Weg stand im Plan als „bewusst weggelassen, später ohne Umbau nachrüstbar". Das stimmte: das Quellenprotokoll hat gehalten. Ein einziger Aufruf genügt. /api/caldav/events fasst CalDAV-Konten, lokale Kalender, Geburtstage, iCal-Abos und Google bereits zusammen — nachgebaut wird nichts. Angemeldet wird mit Adresse, Benutzername und Passwort statt mit einem Token, und der Grund steht im Backend: auth.py:17 ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days Ein eingetragenes Token wäre nach einer Woche tot. Onyx holt sich seines deshalb selbst und erneuert es, bevor es abläuft — die Gültigkeit liest es aus dem Token, statt sieben Tage anzunehmen; die stehen heute so im Server und morgen vielleicht nicht mehr. Kommt trotzdem ein 401, wird einmal neu angemeldet und der Abruf wiederholt. Adresse und Benutzername stehen in den Einstellungen, das Passwort im Schlüsselbund — `defaults read` ist keine Stelle, an der ein Passwort mitgelesen werden soll. Geprüft wird beim Verbinden, nicht erst beim nächsten Laden: ein Tippfehler, den man am Folgetag bemerkt, ist ein vermeidbarer. Der Schnappschuss bleibt das Netz darunter. Ohne Zugangsdaten ändert sich nichts, und ohne Netzwerk hält er, was Calendarr zuletzt geschrieben hat. Nachgesehen wird am Server alle fünf Minuten statt alle zwei: ein Dateizugriff kostet nichts, eine Anfrage über das Netz schon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user