#!/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── 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)