Files
Aether/scripts/kalender-selbsttest.py
T
marcandClaude Opus 5 688bb13bde feat(calendar): einzelnen Termin aus einer Reihe streichen
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>
2026-09-04 09:06:53 +02:00

215 lines
9.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)