package caldav import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "sync/atomic" "testing" "time" "git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/icssub" "git.arnef.de/arnef/nidus/internal/store" ical "github.com/emersion/go-ical" ) // icsSample is a minimal valid ICS feed with one VEVENT. const icsSample = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:shared1@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260805T090000Z\r\n" + "DTEND:20260805T100000Z\r\n" + "SUMMARY:Shared event\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" // mustCalEvent parses raw and returns its first VEVENT (panic on error; // safe in tests). func mustCalEvent(t *testing.T, raw string) ical.Event { t.Helper() _ = t cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() if err != nil { panic("mustCalEvent: " + err.Error()) } evs := cal.Events() if len(evs) == 0 { panic("mustCalEvent: no events") } return evs[0] } // icssubSeriesID returns the stable ID the CalDAV backend advertises for the // (single) series in raw. All tests here use single-series feeds, so this is // just GroupSeries(raw)[0].ID(). func icssubSeriesID(t *testing.T, raw string) string { t.Helper() _ = t cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() if err != nil { panic("icssubSeriesID: " + err.Error()) } series := icssub.GroupSeries(cal) if len(series) == 0 { panic("icssubSeriesID: no series") } return series[0].ID() } // TestICSSubscriptionListAndGetObject verifies the full CalDAV read path // for an ICS subscription: ListCalendarObjects returns one synthetic // stand-alone object whose Path is derived from icssub.EventID (not from // the event's position), and the same object is returned via // GetCalendarObject by the same Path. func TestICSSubscriptionListAndGetObject(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(icsSample)) })) defer srv.Close() dir := t.TempDir() st, err := store.NewStore(filepath.Join(dir, "data")) if err != nil { t.Fatalf("NewStore: %v", err) } dbase, err := db.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("db.Open: %v", err) } defer dbase.Close() if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser: %v", err) } if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil { t.Fatalf("CreateICSSubscription: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour)) ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}) objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil) if err != nil { t.Fatalf("ListCalendarObjects: %v", err) } if len(objs) != 1 { t.Fatalf("expected 1 object, got %d", len(objs)) } // The Path must be derived from the event's stable identity (its UID) — // i.e. the series ID — not from the event's position in the feed. wantID := icssubSeriesID(t, icsSample) wantPath := calObjectPath("holidays", wantID) if objs[0].Path != wantPath { t.Fatalf("Path = %q, want %q (series-ID-based, not position-based)", objs[0].Path, wantPath) } // GetCalendarObject by the same Path should return an object that has // a valid VEVENT whose UID matches our source ICS (round-trip // correctness). found, err := b.GetCalendarObject(ctx, objs[0].Path, nil) if err != nil { t.Fatalf("GetCalendarObject: %v", err) } if found.Path != objs[0].Path { t.Fatalf("round-trip path mismatch: %q vs %q", found.Path, objs[0].Path) } if found.Data == nil || len(found.Data.Events()) == 0 { t.Fatalf("expected found.Data to have >=1 event, got %+v", found.Data) } uid := found.Data.Events()[0].Props.Get(ical.PropUID) if uid == nil || uid.Value != "shared1@nidus.test" { t.Fatalf("expected UID shared1@nidus.test in round-tripped event, got %+v", uid) } } // TestIcssubCacheSharedBetweenConsumersInCalDav verifies that one // *icssub.Cache shared by two different callers of the same backend does // not re-fetch the upstream twice (the second caller sees the cached // copy via the singleflight guard), proving the "return cached value and // update in the background" design is reachable from the CalDAV API. func TestIcssubCacheSharedBetweenConsumersInCalDav(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) // Sleep long enough that *if* the second Get blocked, this // test's wall clock would obviously exceed the bound below. time.Sleep(30 * time.Millisecond) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(icsSample)) })) defer srv.Close() dir := t.TempDir() st, err := store.NewStore(filepath.Join(dir, "data")) if err != nil { t.Fatalf("NewStore: %v", err) } dbase, err := db.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("db.Open: %v", err) } defer dbase.Close() if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser: %v", err) } if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil { t.Fatalf("CreateICSSubscription: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // One cache, shared by two backends on the same DB — mirrors the // production wiring (cmd/server/main.go) where the CalDAV and web UI // share one instance. shared := icssub.NewCache(time.Hour) b1 := NewBackend(&config.Config{}, st, dbase, logger, shared) b2 := NewBackend(&config.Config{}, st, dbase, logger, shared) ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}) if _, err := b1.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil { t.Fatalf("backend 1 list: %v", err) } after1 := hits.Load() if _, err := b2.ListCalendarObjects(ctx, "/cal/home/holidays/", nil); err != nil { t.Fatalf("backend 2 list: %v", err) } after2 := hits.Load() if after2 > after1+1 { t.Fatalf("expected shared cache to coalesce (hits %d → %d)", after1, after2) } } // TestIcssubCacheStaleReturnsWithBackgroundRefresh verifies the // stale-while-revalidate path through the public Cache API: a cached // entry past its TTL is still returned immediately (never blocking the // caller for the slow 30 ms fetch), while a background refresh updates // the entry for the next Get. func TestIcssubCacheStaleReturnsWithBackgroundRefresh(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) time.Sleep(50 * time.Millisecond) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(icsSample)) })) defer srv.Close() c := icssub.NewCache(2 * time.Millisecond) // very short TTL if _, err := c.Get(srv.URL); err != nil { t.Fatalf("first Get: %v", err) } firstHits := hits.Load() time.Sleep(5 * time.Millisecond) // force staleness st := time.Now() if _, err := c.Get(srv.URL); err != nil { t.Fatalf("stale Get: %v", err) } if time.Since(st) > 40*time.Millisecond { t.Fatalf("stale Get blocked on the network for %v — should have returned the cached copy", time.Since(st)) } // Wait for the background refresh to land. deadline := time.Now().Add(2 * time.Second) for hits.Load() == firstHits && time.Now().Before(deadline) { time.Sleep(5 * time.Millisecond) } if hits.Load() == firstHits { t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits) } } // TestICSSubscriptionEventIDMatchesEventInCalDav proves that the object // path the CalDAV backend advertises (in ListCalendarObjects) is exactly // the same string the EventDetailPage-style lookup would use on the web // side — i.e. both surfaces agree on what icssub.EventID(ev) produces. func TestICSSubscriptionEventIDMatchesEventInCalDav(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(icsSample)) })) defer srv.Close() dir := t.TempDir() st, err := store.NewStore(filepath.Join(dir, "data")) if err != nil { t.Fatalf("NewStore: %v", err) } dbase, err := db.Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("db.Open: %v", err) } defer dbase.Close() if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser: %v", err) } if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil { t.Fatalf("CreateICSSubscription: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour)) ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}) objs, err := b.ListCalendarObjects(ctx, "/cal/home/holidays/", nil) if err != nil || len(objs) == 0 { t.Fatalf("list: objs=%d err=%v", len(objs), err) } gotPath := objs[0].Path wantID := icssubSeriesID(t, icsSample) wantPath := calObjectPath("holidays", wantID) if gotPath != wantPath { t.Fatalf("CalDAV object path %q does not match web-side EventID-derived %q", gotPath, wantPath) } }