diff --git a/backend/internal/calendar/api.go b/backend/internal/calendar/api.go index 8b43207..3ebd5c4 100644 --- a/backend/internal/calendar/api.go +++ b/backend/internal/calendar/api.go @@ -31,6 +31,15 @@ func NewAPIHandler(store *Store) *APIHandler { // GET /api/v1/calendars func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) + // Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst + // steht ein neues Konto vor einer leeren Ansicht und muss erst erraten, + // dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich + // vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht + // zeigt. + if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil { + response.Error(w, http.StatusInternalServerError, "list calendars failed") + return + } cals, err := h.store.ListCalendars(r.Context(), user.ID) if err != nil { response.Error(w, http.StatusInternalServerError, "list calendars failed") diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go index 64099fc..1e69a85 100644 --- a/backend/pkg/response/response.go +++ b/backend/pkg/response/response.go @@ -3,6 +3,7 @@ package response import ( "encoding/json" "net/http" + "reflect" ) type envelope struct { @@ -13,7 +14,31 @@ type envelope struct { func JSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - json.NewEncoder(w).Encode(envelope{Data: data}) + json.NewEncoder(w).Encode(envelope{Data: leereListeStattNull(data)}) +} + +// leereListeStattNull macht aus einer nicht belegten Liste eine leere. +// +// In Go ist eine Liste ohne Einträge oft nil, und `encoding/json` schreibt +// dafür `null` statt `[]`. Jede Store-Funktion, die ihre Liste mit +// `var out []T` beginnt, liefert das bei leerem Ergebnis – und jede +// Oberfläche, die darüber läuft, bricht dann ab. +// +// Genau so ist die Kalenderansicht bei einem Konto ohne Kalender +// weiß geblieben: `for (const cal of calendars)` über `null`. +// +// Der Ort dafür ist hier und nicht in den Stores: Es gibt Dutzende +// Listenendpunkte, und der nächste wird wieder mit `var out []T` anfangen. +// Eine Regel, an die man sich erinnern muss, ist keine. +func leereListeStattNull(data any) any { + if data == nil { + return data + } + v := reflect.ValueOf(data) + if v.Kind() == reflect.Slice && v.IsNil() { + return reflect.MakeSlice(v.Type(), 0, 0).Interface() + } + return data } func Error(w http.ResponseWriter, status int, msg string) { diff --git a/backend/pkg/response/response_test.go b/backend/pkg/response/response_test.go new file mode 100644 index 0000000..544e06a --- /dev/null +++ b/backend/pkg/response/response_test.go @@ -0,0 +1,68 @@ +package response + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +// Eine leere Liste muss als [] herauskommen, nicht als null. Jede +// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die +// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben. +func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) { + type eintrag struct { + Name string `json:"name"` + } + var leer []eintrag + + w := httptest.NewRecorder() + JSON(w, 200, leer) + + if got := w.Body.String(); !contains(got, `"data":[]`) { + t.Fatalf("erwartet \"data\":[], bekam %s", got) + } +} + +func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) { + w := httptest.NewRecorder() + JSON(w, 200, []string{"a", "b"}) + + var out struct { + Data []string `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if len(out.Data) != 2 || out.Data[0] != "a" { + t.Fatalf("Inhalt verändert: %v", out.Data) + } +} + +// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst +// verwandelte die Regel jede fehlende Antwort in eine leere Liste. +func TestJSONObjekteBleibenObjekte(t *testing.T) { + w := httptest.NewRecorder() + JSON(w, 200, map[string]int{"n": 1}) + if got := w.Body.String(); !contains(got, `"n":1`) { + t.Fatalf("Objekt verändert: %s", got) + } + + w = httptest.NewRecorder() + JSON(w, 200, nil) + if got := w.Body.String(); contains(got, "[]") { + t.Fatalf("nil sollte keine Liste werden: %s", got) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index 12f230c..b377bc4 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -147,7 +147,10 @@ export default function CalendarPage() { const loadCalendars = useCallback(async () => { setError(null) try { - setCalendars(await calendarApi.list()) + // ?? []: Eine Liste, die als null ankommt, legte die ganze Seite lahm + // (das ist einmal passiert). Der Server schickt das nicht mehr – aber + // eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen. + setCalendars(await calendarApi.list() ?? []) } catch (e) { setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen') } finally {