Beim Löschen eines Serientermins gibt es jetzt zwei Wege statt einem: "Nur diesen Termin" und "Ganze Reihe". Das sind zwei verschiedene Absichten, und sie sind nicht ineinander umkehrbar -- eine einzelne Ja/Nein-Rückfrage musste sich bisher für eine davon entscheiden, und die andere war gar nicht erreichbar. "Nur diesen" wird als EXDATE in der Reihe vermerkt, so wie das iCalendar-Format eine Ausnahme ausdrückt: Die Reihe behält ihre Regel und ihre UID, jeder CalDAV-Client versteht es ohne Zutun. Bewusst nicht über ein Aufspalten in zwei Reihen gelöst -- das änderte die UID, und alle Geräte hielten den Rest für einen neuen Termin. Ein zweites Streichen desselben Termins bleibt folgenlos (sonst liefe die Datei mit der Zeit mit doppelten EXDATE-Zeilen voll), und bei einem Einzeltermin ohne Wiederholung sind beide Wege dasselbe -- der Server löscht dann einfach, statt den Aufrufer mit einer Unterscheidung zu behelligen, die es dort nicht gibt. Selbsttest um sechs Prüfungen erweitert (25 grün): Reihe steht, Streichen liefert 204, der gestrichene Termin fehlt und der Rest bleibt, EXDATE steht im .ics, zweimal Streichen ist harmlos, ganze Reihe löschen räumt alles weg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
9.7 KiB
Python
Executable File
215 lines
9.7 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── 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)
|