Beim Anlegen war die Kalenderauswahl schon da (der oberste ist die
Vorgabe), beim Bearbeiten war sie festgestellt -- mit der Begruendung,
ein Umhaengen sei Loeschen und Neuanlegen unter neuer Kennung. Das
stimmt so nicht: In CalDAV wechselt dabei nur die Sammlung, UID und
Inhalt bleiben. Genau das macht der neue Endpunkt
POST /api/v1/calendars/{id}/events/{uid}/move {"calendar_id": …}
Er aendert nur die Zugehoerigkeit der Zeile und erhoeht das ctag auf
**beiden** Seiten. Mit nur einem holte ein Client die andere Haelfte
erst beim naechsten Vollabgleich, und der Termin stuende
zwischenzeitlich doppelt oder gar nicht im Telefon.
Bewusst ein eigener Endpunkt und kein weiteres Feld im PATCH: Ein Umzug
laesst sich auch ohne jede Aenderung am Termin ausloesen, und dafuer den
ganzen Termin mitschicken zu muessen waere eine Einladung, nebenbei
etwas zu ueberschreiben. Im Formular laeuft deshalb erst das PATCH und
dann der Umzug -- andersherum stuende der Termin nach einem Fehler
verschoben, aber unveraendert da, die verwirrendere Haelfte.
Dazu die Zieh-Geste: einen Termin auf einen Kalender in der
Seitenleiste ziehen, am Handy nach kurzem Gedrueckthalten. Ueber
Pointer-Events statt ueber die Drag-and-Drop-Schnittstelle des
Browsers -- die gibt es auf Android nicht, und genau dort soll die
Geste ja auch funktionieren. Ist die Kalenderliste zugeklappt (am Handy
der Regelfall), erscheint waehrend des Ziehens eine Leiste unten in
Daumennaehe, sonst liefe die Geste ins Leere.
Zwei Faelle, die sonst aergern: Wer in einen ausgeblendeten Kalender
zieht, bekommt ihn eingeblendet -- der Termin waere sonst scheinbar
spurlos verschwunden. Und der Klick, der einem Ziehen hinterherkommt,
wird verschluckt, statt den Termindialog zu oeffnen.
Eine Reihe zieht vollstaendig um; sie ist eine einzige Datei, kein
Buendel von Einzelterminen. Das sagt das Formular auch an.
Selbsttest um elf Pruefungen erweitert (36 gruen): Umzug angenommen,
Kennung bleibt, aus der Quelle weg und im Ziel da, ctag auf beiden
Seiten gestiegen, Umzug auf sich selbst folgenlos, fremder Kalender
404, Reihe vollstaendig umgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
269 lines
12 KiB
Python
Executable File
269 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Selbsttest der Kalender-Schnittstelle gegen eine laufende Instanz.
|
||
|
||
python3 scripts/kalender-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
|
||
|
||
Voreinstellung: http://localhost:8090, admin/admin123 (Entwicklungsumgebung).
|
||
|
||
Prüft die Stellen, an denen bereits einmal etwas schiefging – allen voran das
|
||
Ganztags-Datum, das in einer Zeitzone östlich von UTC einen Tag zurücksprang –
|
||
und die Wiederholungsmuster, deren Fehler man sonst erst Wochen später
|
||
bemerkt ("jeder 1. Freitag" darf nicht im Monat ohne fünften Freitag
|
||
ausfallen).
|
||
|
||
**Achtung: legt Termine im ersten Kalender des Kontos an** und löscht am Ende
|
||
*alle* Termine darin. Nicht gegen einen Kalender laufen lassen, in dem echte
|
||
Termine stehen.
|
||
"""
|
||
import http.cookiejar
|
||
import json
|
||
import sys
|
||
import urllib.request
|
||
|
||
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090"
|
||
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
|
||
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
|
||
ok_count = 0
|
||
fail = []
|
||
|
||
|
||
def api(method, path, body=None, headers=None, raw=False):
|
||
url = BASE + path
|
||
data = None
|
||
hdrs = dict(headers or {})
|
||
if body is not None:
|
||
data = json.dumps(body).encode()
|
||
hdrs["Content-Type"] = "application/json"
|
||
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
||
try:
|
||
with OPENER.open(req) as r:
|
||
payload = r.read().decode()
|
||
if raw:
|
||
return r.status, payload
|
||
return r.status, (json.loads(payload).get("data") if payload else None)
|
||
except urllib.error.HTTPError as e:
|
||
return e.code, None
|
||
|
||
|
||
def check(name, cond, detail=""):
|
||
global ok_count
|
||
if cond:
|
||
ok_count += 1
|
||
print(f" \033[32mok\033[0m {name}")
|
||
else:
|
||
fail.append(name)
|
||
print(f" \033[31mFEHL\033[0m {name} {detail}")
|
||
|
||
|
||
jar = http.cookiejar.CookieJar()
|
||
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||
|
||
# ── Anmelden ────────────────────────────────────────────────────────────
|
||
st, data = api("POST", "/api/v1/auth/login",
|
||
{"username": USER, "password": PASSWORT})
|
||
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
|
||
CSRF = {"X-CSRF-Token": data["csrf_token"]}
|
||
|
||
st, cals = api("GET", "/api/v1/calendars")
|
||
CAL = cals[0]["id"]
|
||
EV = f"/api/v1/calendars/{CAL}/events"
|
||
print(f"Kalender: {cals[0]['name']}\n")
|
||
|
||
|
||
def create(**body):
|
||
st, d = api("POST", EV, body, CSRF)
|
||
assert st == 201, f"Anlegen fehlgeschlagen ({st}): {body.get('summary')}"
|
||
return d
|
||
|
||
|
||
def events(frm, to):
|
||
st, d = api("GET", f"{EV}?from={frm}&to={to}")
|
||
return d or []
|
||
|
||
|
||
def starts(summary, frm="2026-01-01T00:00:00Z", to="2027-12-31T00:00:00Z"):
|
||
return [e["start"][:10] for e in events(frm, to) if e["summary"] == summary]
|
||
|
||
|
||
print("── Ganztägig: Datum darf nicht verrutschen ──")
|
||
e = create(summary="T ganztag", start="2026-09-03T00:00:00Z",
|
||
end="2026-09-03T00:00:00Z", all_day=True)
|
||
check("ein Tag bleibt der 3.", e["start"][:10] == "2026-09-03", e["start"])
|
||
check("Ende ist derselbe Tag", e["end"][:10] == "2026-09-03", e["end"])
|
||
|
||
e2 = create(summary="T mehrtags", start="2026-09-10T00:00:00Z",
|
||
end="2026-09-12T00:00:00Z", all_day=True)
|
||
check("mehrtägig 10.–12.", e2["start"][:10] == "2026-09-10" and e2["end"][:10] == "2026-09-12",
|
||
f'{e2["start"]}..{e2["end"]}')
|
||
|
||
print("\n── Uhrzeit: unverändert durchreichen ──")
|
||
e3 = create(summary="T uhrzeit", start="2026-09-15T12:30:00Z",
|
||
end="2026-09-15T13:30:00Z", all_day=False)
|
||
check("Zeitpunkt unverändert", e3["start"] == "2026-09-15T12:30:00Z", e3["start"])
|
||
check("nicht als ganztägig gelesen", e3["all_day"] is False)
|
||
|
||
print("\n── Farbe: Name für fremde Clients, Hex für uns ──")
|
||
e4 = create(summary="T farbe", start="2026-09-16T10:00:00Z",
|
||
end="2026-09-16T11:00:00Z", all_day=False, color="#ea4335")
|
||
check("Hex kommt unverändert zurück", e4.get("color") == "#ea4335", e4.get("color"))
|
||
st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True)
|
||
block = [b for b in ics.split("BEGIN:VEVENT") if "T farbe" in b]
|
||
check("COLOR als CSS3-Name im .ics", block and "COLOR:tomato" in block[0],
|
||
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
|
||
check("X-AETHER-COLOR mit genauem Wert",
|
||
block and "#ea4335" in "".join(l for l in block[0].splitlines() if "X-AETHER-COLOR" in l),
|
||
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
|
||
|
||
print("\n── Wöchentlich an bestimmten Tagen ──")
|
||
# 7. Sept 2026 ist ein Montag. Reihe Mo+Fr, 3 Wochen.
|
||
create(summary="T wochentage", start="2026-09-07T08:00:00Z",
|
||
end="2026-09-07T09:00:00Z", all_day=False,
|
||
recurrence={"freq": "WEEKLY", "interval": 1, "count": 6,
|
||
"byday": ["MO", "FR"]})
|
||
got = starts("T wochentage")
|
||
check("Mo+Fr, 6 Termine", got == ["2026-09-07", "2026-09-11", "2026-09-14",
|
||
"2026-09-18", "2026-09-21", "2026-09-25"], got)
|
||
|
||
print("\n── Monatlich: am festen Tag vs. am n-ten Wochentag ──")
|
||
# 4. Sept 2026 ist der 1. Freitag des Monats.
|
||
create(summary="T monat tag", start="2026-09-04T08:00:00Z",
|
||
end="2026-09-04T09:00:00Z", all_day=False,
|
||
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "DAY"})
|
||
got = starts("T monat tag")
|
||
check("am 4. jedes Monats", got == ["2026-09-04", "2026-10-04", "2026-11-04", "2026-12-04"], got)
|
||
|
||
create(summary="T monat wochentag", start="2026-09-04T10:00:00Z",
|
||
end="2026-09-04T11:00:00Z", all_day=False,
|
||
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "WEEKDAY"})
|
||
got = starts("T monat wochentag")
|
||
# jeweils der 1. Freitag: 4.9. / 2.10. / 6.11. / 4.12.
|
||
check("am 1. Freitag jedes Monats", got == ["2026-09-04", "2026-10-02", "2026-11-06", "2026-12-04"], got)
|
||
|
||
print("\n── Jährlich: beide Muster ──")
|
||
create(summary="T jahr tag", start="2026-09-04T08:00:00Z",
|
||
end="2026-09-04T09:00:00Z", all_day=False,
|
||
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "DAY"})
|
||
check("am 4. September", starts("T jahr tag") == ["2026-09-04", "2027-09-04"], starts("T jahr tag"))
|
||
|
||
create(summary="T jahr wochentag", start="2026-09-04T12:00:00Z",
|
||
end="2026-09-04T13:00:00Z", all_day=False,
|
||
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "WEEKDAY"})
|
||
# 1. Freitag im September: 2026-09-04, 2027-09-03
|
||
check("am 1. Freitag im September",
|
||
starts("T jahr wochentag") == ["2026-09-04", "2027-09-03"], starts("T jahr wochentag"))
|
||
|
||
print("\n── Regel bleibt beim Bearbeiten erhalten ──")
|
||
ser = [e for e in events("2026-09-01T00:00:00Z", "2026-12-31T00:00:00Z")
|
||
if e["summary"] == "T monat wochentag"][0]
|
||
check("Regel wird als bearbeitbar gemeldet", ser.get("recurrence", {}).get("mode") == "WEEKDAY",
|
||
ser.get("recurrence"))
|
||
check("Reihenbeginn mitgeliefert", ser.get("series_start", "")[:10] == "2026-09-04",
|
||
ser.get("series_start"))
|
||
st, _ = api("PATCH", f"{EV}/{ser['uid']}",
|
||
{"summary": "T monat wochentag", "start": "2026-09-04T10:00:00Z",
|
||
"end": "2026-09-04T11:00:00Z", "all_day": False,
|
||
"recurrence": {"freq": "MONTHLY", "interval": 2, "count": 3, "mode": "WEEKDAY"}}, CSRF)
|
||
check("Bearbeiten erlaubt (200)", st == 200, st)
|
||
got = starts("T monat wochentag")
|
||
check("jetzt jeden 2. Monat, 1. Freitag", got == ["2026-09-04", "2026-11-06", "2027-01-01"], got)
|
||
|
||
print("\n── Aus einer Reihe einen einzelnen Termin streichen ──")
|
||
create(summary="T ausnahme", start="2026-10-05T08:00:00Z",
|
||
end="2026-10-05T09:00:00Z", all_day=False,
|
||
recurrence={"freq": "WEEKLY", "interval": 1, "count": 4})
|
||
check("Reihe steht", starts("T ausnahme") == ["2026-10-05", "2026-10-12",
|
||
"2026-10-19", "2026-10-26"],
|
||
starts("T ausnahme"))
|
||
serie = [e for e in events("2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z")
|
||
if e["summary"] == "T ausnahme"][1] # der zweite Termin
|
||
st, _ = api("DELETE", f"{EV}/{serie['uid']}?occurrence={serie['start']}", None, CSRF)
|
||
check("nur diesen streichen (204)", st == 204, st)
|
||
got = starts("T ausnahme")
|
||
check("der 12. fehlt, Rest bleibt",
|
||
got == ["2026-10-05", "2026-10-19", "2026-10-26"], got)
|
||
|
||
st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True)
|
||
blk = [b for b in ics.split("BEGIN:VEVENT") if "T ausnahme" in b]
|
||
check("als EXDATE im .ics vermerkt",
|
||
blk and any("EXDATE" in l and "20261012" in l for l in blk[0].splitlines()),
|
||
[l for l in (blk[0].splitlines() if blk else []) if "EXDATE" in l])
|
||
|
||
st, _ = api("DELETE", f"{EV}/{serie['uid']}?occurrence={serie['start']}", None, CSRF)
|
||
check("zweimal streichen bleibt harmlos", st == 204 and starts("T ausnahme") ==
|
||
["2026-10-05", "2026-10-19", "2026-10-26"], starts("T ausnahme"))
|
||
|
||
st, _ = api("DELETE", f"{EV}/{serie['uid']}", None, CSRF)
|
||
check("ganze Reihe löschen", st == 204 and starts("T ausnahme") == [], starts("T ausnahme"))
|
||
|
||
print("\n── Umhängen in einen anderen Kalender ──")
|
||
|
||
|
||
def ctag(cal_id):
|
||
_, cals_now = api("GET", "/api/v1/calendars")
|
||
return next(c["ctag"] for c in cals_now or [] if c["id"] == cal_id)
|
||
|
||
|
||
st, zweit = api("POST", "/api/v1/calendars",
|
||
{"name": "T zweitkalender", "color": "#34a853"}, CSRF)
|
||
check("zweiter Kalender angelegt", st == 201 and zweit, st)
|
||
CAL2 = zweit["id"]
|
||
EV2 = f"/api/v1/calendars/{CAL2}/events"
|
||
|
||
umzug = create(summary="T umzug", start="2026-11-05T09:00:00Z",
|
||
end="2026-11-05T10:00:00Z", all_day=False)
|
||
vorher_quelle, vorher_ziel = ctag(CAL), ctag(CAL2)
|
||
st, verschoben = api("POST", f"{EV}/{umzug['uid']}/move", {"calendar_id": CAL2}, CSRF)
|
||
check("Umzug angenommen", st == 200, st)
|
||
check("Kennung bleibt dieselbe", verschoben and verschoben["uid"] == umzug["uid"])
|
||
check("aus der Quelle verschwunden", starts("T umzug") == [], starts("T umzug"))
|
||
_, im_ziel = api("GET", f"{EV2}?from=2026-01-01T00:00:00Z&to=2027-12-31T00:00:00Z")
|
||
check("im Ziel angekommen", [e["summary"] for e in im_ziel or []] == ["T umzug"], im_ziel)
|
||
# Beide Seiten müssen sich melden: Ein CalDAV-Client gleicht sonst nur eine
|
||
# davon ab und hat den Termin doppelt oder gar nicht.
|
||
check("ctag beider Kalender gestiegen",
|
||
ctag(CAL) > vorher_quelle and ctag(CAL2) > vorher_ziel,
|
||
f"{vorher_quelle}->{ctag(CAL)}, {vorher_ziel}->{ctag(CAL2)}")
|
||
|
||
st, _ = api("POST", f"{EV2}/{umzug['uid']}/move", {"calendar_id": CAL2}, CSRF)
|
||
check("Umzug auf sich selbst ist folgenlos", st == 200, st)
|
||
|
||
st, _ = api("POST", f"{EV2}/{umzug['uid']}/move",
|
||
{"calendar_id": "11111111-1111-1111-1111-111111111111"}, CSRF)
|
||
check("unbekannter Zielkalender: 404", st == 404, st)
|
||
|
||
# Eine Reihe ist **eine** Datei – sie zieht vollständig um, nicht in Stücken.
|
||
serie2 = create(summary="T umzug reihe", start="2026-11-02T08:00:00Z",
|
||
end="2026-11-02T09:00:00Z", all_day=False,
|
||
recurrence={"freq": "WEEKLY", "interval": 1, "count": 3})
|
||
st, _ = api("POST", f"{EV}/{serie2['uid']}/move", {"calendar_id": CAL2}, CSRF)
|
||
_, ziel2 = api("GET", f"{EV2}?from=2026-01-01T00:00:00Z&to=2027-12-31T00:00:00Z")
|
||
reihe_im_ziel = sorted(e["start"][:10] for e in ziel2 or [] if e["summary"] == "T umzug reihe")
|
||
check("Reihe zieht vollständig um",
|
||
st == 200 and reihe_im_ziel == ["2026-11-02", "2026-11-09", "2026-11-16"],
|
||
reihe_im_ziel)
|
||
check("Reihe nicht mehr in der Quelle", starts("T umzug reihe") == [], starts("T umzug reihe"))
|
||
|
||
api("DELETE", f"/api/v1/calendars/{CAL2}", None, CSRF)
|
||
_, rest_cals = api("GET", "/api/v1/calendars")
|
||
check("Testkalender wieder entfernt",
|
||
all(c["name"] != "T zweitkalender" for c in rest_cals or []),
|
||
[c["name"] for c in rest_cals or []])
|
||
|
||
print("\n── Zeitfenster begrenzt wirklich ──")
|
||
win = [e for e in events("2026-09-14T00:00:00Z", "2026-09-20T00:00:00Z")
|
||
if e["summary"] == "T wochentage"]
|
||
check("nur die eine Woche", [e["start"][:10] for e in win] == ["2026-09-14", "2026-09-18"],
|
||
[e["start"][:10] for e in win])
|
||
|
||
print("\n── Aufräumen ──")
|
||
seen = {e["uid"] for e in events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")}
|
||
for uid in seen:
|
||
api("DELETE", f"{EV}/{uid}", None, CSRF)
|
||
rest = events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")
|
||
check("Kalender wieder leer", rest == [], rest)
|
||
|
||
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
|
||
if fail:
|
||
print("Fehlgeschlagen: " + ", ".join(fail))
|
||
sys.exit(1 if fail else 0)
|