fix(api): leere Listen als [] statt null -- Kalender blieb weiss

Ein Konto ohne Kalender bekam auf GET /api/v1/calendars die Antwort
`null`, und die Ansicht brach ab:

    for (const cal of calendars)   ->  "e is not iterable"

Nicht der Kalender ist schuld, sondern eine Eigenheit, die jeden
Listenendpunkt betrifft: In Go ist eine Liste ohne Eintraege oft nil,
und encoding/json schreibt dafuer null statt []. Jede Store-Funktion,
die mit `var out []T` anfaengt, liefert das bei leerem Ergebnis --
Favoriten, Papierkorb, Versionen, Freigaben, Etiketten. Der Kalender war
nur der, der daran zerbricht.

Deshalb die Regel dort, wo alle Antworten durchlaufen: response.JSON
macht aus einer nil-Liste eine leere. Einzelne Objekte und nil bleiben
unberuehrt. In den Stores waere es die falsche Stelle -- es gibt
Dutzende Listenendpunkte, und der naechste faengt wieder mit
`var out []T` an. Eine Regel, an die man sich erinnern muss, ist keine.

Dazu zwei Dinge, die derselbe Fall ans Licht brachte:

  - Der Web-Weg legte, anders als CalDAV, keinen Standardkalender an.
    Ein neues Konto stand vor einer leeren Ansicht und musste erraten,
    dass es selbst einen anlegen soll -- waehrend ein CalDAV-Client, der
    sich vorher verbunden hatte, bereits einen sah, den die Oberflaeche
    nicht zeigte.
  - Die Ansicht faengt eine unerwartete Antwort jetzt selbst ab (?? []).
    Der Server schickt kein null mehr; eine Seite sollte daran trotzdem
    nicht zerbrechen.

Drei Tests in pkg/response: leere Liste wird [], befuellte bleibt
unveraendert, Objekte und nil bleiben, was sie sind.

Zur Korrektur meiner frueheren Erklaerung: Die fehlenden Cache-Header
waren ein echtes Problem, aber nicht die Ursache dieses Fehlers. Der
gemeldete Bundle-Name stimmte diesmal mit dem ausgelieferten ueberein --
daran war zu sehen, dass es kein alter Stand sein konnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-07 19:56:54 +02:00
1 parent 55dcf517f2
commit fbaf24bdbd
4 files changed
+107 -2

No files matched your search

+9
View File
@@ -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")
+26 -1
View File
@@ -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) {
+68
View File
@@ -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
}
+4 -1
View File
@@ -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 {