From f34041d8891d756c742fe68c9289abf088c06c24 Mon Sep 17 00:00:00 2001 From: arnef Date: Wed, 2 Sep 2026 19:44:27 +0200 Subject: [PATCH] feat(web): show ICS subscription events in detail view --- cmd/server/main.go | 11 +- internal/caldav/backend.go | 18 +- internal/caldav/backend_test.go | 6 +- internal/caldav/ics.go | 48 +++--- internal/caldav/ics_shared_test.go | 268 +++++++++++++++++++++++++++++ internal/icssub/icssub.go | 186 ++++++++++++++++---- internal/icssub/icssub_test.go | 199 +++++++++++++++++++++ internal/web/calendar.go | 61 +++++-- internal/web/ics_detail_test.go | 176 +++++++++++++++++++ internal/web/server.go | 13 +- internal/web/server_test.go | 2 +- 11 files changed, 908 insertions(+), 80 deletions(-) create mode 100644 internal/caldav/ics_shared_test.go create mode 100644 internal/icssub/icssub_test.go create mode 100644 internal/web/ics_detail_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 935e43b..c027c5d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "git.arnef.de/arnef/nidus/internal/carddav" "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" "git.arnef.de/arnef/nidus/internal/web" filewebdav "git.arnef.de/arnef/nidus/internal/webdav" @@ -103,10 +104,16 @@ func main() { authMw := auth.NewMiddleware(cfg, dbase, logger) // ---- Handlers ---- - calHandler := caldav.NewHandler(cfg, st, dbase, logger) + // A single ICS-subscription cache is shared by both the CalDAV backend + // (for DAV clients) and the web UI (for the browser calendar page), so + // the same subscription is fetched and served identically regardless + // of which surface a client hits, and one background refresh refreshes + // both at once. + icsCache := icssub.NewCache(icssub.DefaultTTL) + calHandler := caldav.NewHandler(cfg, st, dbase, logger, icsCache) cardHandler := carddav.NewHandler(cfg, st, dbase, logger) fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger) - webUI := web.NewServer(cfg, st, dbase, logger) + webUI := web.NewServer(cfg, st, dbase, logger, icsCache) mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger) diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index cee8445..2f78184 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -41,10 +41,16 @@ type Backend struct { icsCache *icssub.Cache } -// NewBackend creates a CalDAV backend. dbase may be nil, in which case -// calendar sharing is disabled (only a user's own calendars are visible). -func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend { - return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} +// NewBackend creates a CalDAV backend over an existing ICS cache. +// dbase may be nil, in which case calendar sharing is disabled (only a +// user's own calendars are visible). The icsCache is shared with any +// other consumers (notably the web UI) so CalDAV and the web calendar +// page see identical, cache-consistent events for ICS subscriptions. +func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Backend { + if icsCache == nil { + icsCache = icssub.NewCache(icssub.DefaultTTL) + } + return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache} } // NewHandler returns an http.Handler for the /cal/ prefix. @@ -54,8 +60,8 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog. // property into PROPFIND responses for calendar collections, since // go-webdav's caldav.Backend interface has no extension point for // vendor-specific WebDAV properties. -func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { - b := NewBackend(cfg, st, dbase, logger) +func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) http.Handler { + b := NewBackend(cfg, st, dbase, logger, icsCache) return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}} } diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go index f51cf46..a46af33 100644 --- a/internal/caldav/backend_test.go +++ b/internal/caldav/backend_test.go @@ -46,7 +46,7 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) { t.Fatalf("CreateCalendar bob/personal: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - return NewBackend(cfg, st, dbase, logger), dbase + return NewBackend(cfg, st, dbase, logger, nil), dbase } func ctxFor(username string) context.Context { @@ -179,7 +179,7 @@ func TestPropFindEmitsCalendarColor(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - handler := NewHandler(&config.Config{}, st, dbase, logger) + handler := NewHandler(&config.Config{}, st, dbase, logger, nil) req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader( ``)) @@ -250,7 +250,7 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - handler := NewHandler(&config.Config{}, st, dbase, logger) + handler := NewHandler(&config.Config{}, st, dbase, logger, nil) req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader( ``+ diff --git a/internal/caldav/ics.go b/internal/caldav/ics.go index 541a915..daa5293 100644 --- a/internal/caldav/ics.go +++ b/internal/caldav/ics.go @@ -1,8 +1,6 @@ package caldav import ( - "crypto/sha1" - "encoding/hex" "fmt" "net/http" "strings" @@ -13,6 +11,7 @@ import ( "github.com/emersion/go-webdav/caldav" "git.arnef.de/arnef/nidus/internal/db" + "git.arnef.de/arnef/nidus/internal/icssub" ) // defaultICSColor is the display color for an ICS/webcal subscription @@ -32,8 +31,9 @@ func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscripti } // icsObjectUID returns the UID a fetched VEVENT should be addressed by: -// its own UID property if it has one, otherwise a stable hash of its -// position so it still round-trips consistently between requests. +// its own UID property if it has one, otherwise a placeholder derived from +// the object ID (so the event still has a *unique* UID in the returned +// VCALENDAR). func icsObjectUID(ev ical.Event, fallback string) string { if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" { return p.Value @@ -41,16 +41,11 @@ func icsObjectUID(ev ical.Event, fallback string) string { return fallback } -// icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to -// address a fetched VEVENT within its subscription calendar, derived from -// its UID so it stays stable across fetches of the same feed. -func icsObjID(uid string) string { - sum := sha1.Sum([]byte(uid)) - return hex.EncodeToString(sum[:]) + ".ics" -} - -// listICSSubscriptionCalendarObjects fetches sub's remote calendar (via -// b.icsCache) and returns one caldav.CalendarObject per VEVENT. +// icsSubscriptionCalendarObjects fetches sub's remote calendar (via +// b.icsCache) and returns one caldav.CalendarObject per VEVENT. The +// object path is derived from icssub.EventID(ev) so the same event keeps +// the same path across fetches, even if its position in the document +// changes. func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) { cal, err := b.icsCache.Get(sub.URL) if err != nil { @@ -58,8 +53,8 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC } var objs []caldav.CalendarObject - for i, ev := range cal.Events() { - obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) + for _, ev := range cal.Events() { + obj, err := b.encodeICSObject(localName, ev) if err != nil { continue } @@ -75,24 +70,25 @@ func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db. if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err)) } - for i, ev := range cal.Events() { - uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i)) - if icsObjID(uid) != objID { + for _, ev := range cal.Events() { + if icssub.EventID(ev) != objID { continue } - return b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i)) + return b.encodeICSObject(localName, ev) } return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found")) } // encodeICSObject wraps a single fetched VEVENT ev into its own // caldav.CalendarObject, encoding it as a standalone one-event calendar -// the same way every other calendar object in this backend is -// represented. fallbackUID is used to derive the object ID/UID if ev has -// no UID property of its own. -func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) { - uid := icsObjectUID(ev, fallbackUID) - objID := icsObjID(uid) +// the same way every other calendar object in this backend is represented. +func (b *Backend) encodeICSObject(localName string, ev ical.Event) (*caldav.CalendarObject, error) { + objID := icssub.EventID(ev) + if objID == "" { + // Not addressable (no DTSTART) — skip. + return nil, fmt.Errorf("event has no DTSTART; not addressable") + } + uid := icsObjectUID(ev, objID) event := ical.NewEvent() event.Props = ev.Props diff --git a/internal/caldav/ics_shared_test.go b/internal/caldav/ics_shared_test.go new file mode 100644 index 0000000..4f26aa6 --- /dev/null +++ b/internal/caldav/ics_shared_test.go @@ -0,0 +1,268 @@ +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] +} + +// 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 DTSTART/DTEND/SUMMARY — + // i.e. icssub.EventID(ev), not from the event's position in the feed. + ev := mustCalEvent(t, icsSample) + wantID := icssub.EventID(ev) + if wantID == "" { + t.Fatal("EventID must be non-empty for a valid event") + } + wantPath := calObjectPath("holidays", wantID) + if objs[0].Path != wantPath { + t.Fatalf("Path = %q, want %q (icssub.EventID-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 := icssub.EventID(mustCalEvent(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) + } +} diff --git a/internal/icssub/icssub.go b/internal/icssub/icssub.go index fcbfb8a..9039cae 100644 --- a/internal/icssub/icssub.go +++ b/internal/icssub/icssub.go @@ -7,6 +7,8 @@ package icssub import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" @@ -19,8 +21,8 @@ import ( "git.arnef.de/arnef/nidus/internal/icalfix" ) -// DefaultTTL is how long a fetched calendar is cached before being -// re-fetched on the next access. +// DefaultTTL is how long a fetched calendar is considered "fresh" before a +// Get will kick off a background refresh. const DefaultTTL = 15 * time.Minute // fetchTimeout bounds how long a single upstream request may take, so one @@ -32,54 +34,143 @@ const fetchTimeout = 15 * time.Second // response. const maxBodySize = 32 * 1024 * 1024 // 32 MiB +// entry holds everything the Cache knows about a single upstream URL. All +// fields are only read/written while holding Cache.mu. type entry struct { + url string // original URL as supplied by the caller (fetch normalizes) + + // cal is the most recent successfully-fetched calendar. Nil until the + // first successful fetch for this URL. + cal *ical.Calendar + // lastErr is the most recent fetch error. Set alongside cal == nil + // (i.e. no successful fetch yet); cleared the moment a fetch succeeds. + lastErr error + + // refreshing is true while a fetch (foreground, or background refresh) + // is in flight for this URL. + refreshing bool + // pending is the completion channel for the in-flight fetch. Only valid + // while refreshing is true; it is created fresh for each fetch and + // closed exactly once when that fetch finishes. Callers that see + // refreshing==true read this channel (under the lock) and wait on it. + pending chan struct{} + + // fetchedAt is the wall-clock time of the most recent fetch attempt + // (success or failure), used for the TTL freshness check. fetchedAt time.Time - cal *ical.Calendar - err error } -// Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived -// in-memory copy per URL so repeated renders (e.g. every month-view page -// load, or CalDAV client polling) don't re-fetch the same subscription -// from origin every time. +// Cache fetches remote ICS calendars over HTTP(S), keeping a shared +// in-memory copy per URL. Semantics: +// +// - Fresh entry (fetchedAt within TTL): return immediately, no I/O. +// - Stale entry with a cached copy: return the stale copy immediately +// AND spawn at most one background refresher (other callers in the +// same window piggyback on the in-flight refresh). +// - Stale entry with no cached copy (prior fetch failed): return the +// cached error immediately AND spawn a background retry. +// - No entry at all (very first call for this URL): block until a +// foreground fetch finishes (concurrent first-callers wait on a shared +// channel and all get the same result) and return its data. type Cache struct { ttl time.Duration client *http.Client - mu sync.Mutex - entries map[string]entry + mu sync.Mutex + urls map[string]*entry // keyed by normalizeURL(url) } // NewCache creates a Cache with the given TTL (use DefaultTTL if unsure). func NewCache(ttl time.Duration) *Cache { return &Cache{ - ttl: ttl, - client: &http.Client{Timeout: fetchTimeout}, - entries: make(map[string]entry), + ttl: ttl, + client: &http.Client{}, + urls: make(map[string]*entry), } } -// Get returns the parsed calendar fetched from url, using a cached copy -// if it's still within the TTL. If a fresh fetch fails but a previously -// fetched copy exists, the stale copy is returned instead of the error, -// so a transient network issue doesn't blank out the calendar entirely. +// Get returns the most recently successfully-fetched calendar for url, or +// the most-recent fetch error if no successful copy exists yet (a +// background refresher may already be retrying). func (c *Cache) Get(url string) (*ical.Calendar, error) { + key := normalizeURL(url) + c.mu.Lock() - e, ok := c.entries[url] - fresh := ok && time.Since(e.fetchedAt) < c.ttl - c.mu.Unlock() - if fresh { - return e.cal, e.err + e := c.urls[key] + if e == nil { + e = &entry{url: url} + c.urls[key] = e } - cal, err := c.fetch(url) - c.mu.Lock() - defer c.mu.Unlock() - if err != nil && ok && e.cal != nil { - return e.cal, nil + switch { + case e.cal == nil && e.lastErr == nil && !e.refreshing: + // Very first request for this URL: do a foreground fetch. + e.refreshing = true + e.pending = make(chan struct{}) + ch := e.pending + c.mu.Unlock() + go c.doFetch(e, ch) + <-ch + return c.snapshot(e) + + case e.cal == nil && e.lastErr == nil: + // A foreground fetch is already in flight — wait for it. + ch := e.pending + c.mu.Unlock() + <-ch + return c.snapshot(e) + + default: + // We have some data (a cached copy or a cached error). + if time.Since(e.fetchedAt) < c.ttl { + // Fresh — just return. + c.mu.Unlock() + return c.snapshot(e) + } + // Stale — return the cached value immediately; spawn at most one + // background refresher (or piggyback on one already in flight). + if !e.refreshing { + e.refreshing = true + ch := make(chan struct{}) + e.pending = ch + c.mu.Unlock() + go c.doFetch(e, ch) + } else { + c.mu.Unlock() + } + return c.snapshot(e) } - c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err} - return cal, err +} + +// doFetch performs the network I/O for the entry, updates cal/lastErr and +// the freshness timestamp under the lock, clears the in-flight state, and +// closes the per-fetch completion channel exactly once. +func (c *Cache) doFetch(e *entry, ch chan struct{}) { + cal, err := c.fetch(e.url) + c.mu.Lock() + e.fetchedAt = time.Now() + if err == nil { + e.cal = cal + e.lastErr = nil + } else { + e.lastErr = err + } + e.refreshing = false + e.pending = nil + c.mu.Unlock() + close(ch) +} + +// snapshot reads e.cal/e.lastErr under c.mu and returns the same value +// shape Get does. Callers must not hold c.mu. +func (c *Cache) snapshot(e *entry) (*ical.Calendar, error) { + c.mu.Lock() + cal, err := e.cal, e.lastErr + c.mu.Unlock() + if cal != nil { + return cal, nil + } + return nil, err } // fetch downloads and parses url, translating a "webcal://" scheme (used @@ -128,3 +219,40 @@ func normalizeURL(u string) string { } return u } + +// EventID returns a stable, short identifier for a single ical.Event, +// suitable for use as a filesystem object name or URL path segment. It is +// the first 32 hex chars (128 bits) of +// sha256("||") with a ".ics" suffix. +// +// Two events with the same DTSTART, same duration, and same SUMMARY hash +// to the same ID — this matches the addressing scheme used both by the +// web detail view and the CalDAV backend for ICS-subscription events. +// Returns "" if DTSTART is missing (not addressable). +func EventID(ev ical.Event) string { + start := ev.Props.Get(ical.PropDateTimeStart) + if start == nil { + return "" + } + dur := "" + if end := ev.Props.Get(ical.PropDateTimeEnd); end != nil { + if s, err := start.DateTime(time.UTC); err == nil { + if e, err := end.DateTime(time.UTC); err == nil { + dur = e.Sub(s).Round(time.Second).String() + } + } + } + summary := "" + if p := ev.Props.Get(ical.PropSummary); p != nil { + summary = p.Value + } + + var keyBuf strings.Builder + keyBuf.WriteString(start.Value) + keyBuf.WriteRune('|') + keyBuf.WriteString(dur) + keyBuf.WriteRune('|') + keyBuf.WriteString(summary) + sum := sha256.Sum256([]byte(keyBuf.String())) + return hex.EncodeToString(sum[:16]) + ".ics" +} diff --git a/internal/icssub/icssub_test.go b/internal/icssub/icssub_test.go new file mode 100644 index 0000000..995569b --- /dev/null +++ b/internal/icssub/icssub_test.go @@ -0,0 +1,199 @@ +package icssub + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + ical "github.com/emersion/go-ical" +) + +const sampleICS = "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//nidus//test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:ev1@nidus.test\r\n" + + "DTSTAMP:20260101T000000Z\r\n" + + "DTSTART:20260805T090000Z\r\n" + + "DTEND:20260805T100000Z\r\n" + + "SUMMARY:Original title\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + +func mustParseEvent(raw string) ical.Event { + cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() + if err != nil { + panic("mustParseEvent: " + err.Error()) + } + evs := cal.Events() + if len(evs) == 0 { + panic("mustParseEvent: no events") + } + return evs[0] +} + +// TestCacheFirstFetchPopulatesEntry verifies that the very first Get for a +// URL does a foreground fetch and returns the parsed calendar. +func TestCacheFirstFetchPopulatesEntry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(sampleICS)) + })) + defer srv.Close() + + c := NewCache(time.Hour) + cal, err := c.Get(srv.URL) + if err != nil { + t.Fatalf("first Get: %v", err) + } + if len(cal.Events()) == 0 { + t.Fatalf("expected >=1 event, got 0") + } +} + +// TestCacheStaleReturnsWithBackgroundRefresh verifies that once a cached +// entry is stale, Get keeps returning the *stale* copy immediately while a +// background refresh is spawned in a separate goroutine (rather than +// blocking the caller on the network). +func TestCacheStaleReturnsWithBackgroundRefresh(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) // make the fetch slow enough to + w.WriteHeader(http.StatusOK) // observe as background work + _, _ = w.Write([]byte(sampleICS)) + })) + defer srv.Close() + + c := NewCache(1 * time.Millisecond) // very short TTL + if _, err := c.Get(srv.URL); err != nil { + t.Fatalf("first Get: %v", err) + } + firstHits := hits.Load() + + // Force staleness. + time.Sleep(3 * time.Millisecond) + + // Subsequent Get must return the stale copy immediately without waiting + // for the slow server to respond — if it blocked, this call would take + // >= 50ms, which we bound with a deadline below via the hit counter. + st := time.Now() + cal, err := c.Get(srv.URL) + if err != nil { + t.Fatalf("second Get: %v", err) + } + if len(cal.Events()) == 0 { + t.Fatalf("expected event in stale response") + } + if time.Since(st) > 40*time.Millisecond { + t.Fatalf("second Get appears to have blocked on the network for %v", time.Since(st)) + } + + // Wait for the background refresh to complete. + 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) + } +} + +// TestCacheConcurrentFirstFetchCoalesce ensures N concurrent first calls +// for the same URL coalesce to a small number of actual origin fetches. +func TestCacheConcurrentFirstFetchCoalesce(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + time.Sleep(40 * time.Millisecond) // widen the coalescing window + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(sampleICS)) + })) + defer srv.Close() + + c := NewCache(time.Hour) + + const n = 8 + errs := make([]error, n) + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, err := c.Get(srv.URL) + errs[i] = err + }(i) + } + close(start) + wg.Wait() + for _, err := range errs { + if err != nil { + t.Fatalf("goroutine error: %v", err) + } + } + // Without coalescing we'd see n=8 hits; with it we expect a small + // constant (the first caller fetches; the rest either return the + // in-progress result or kick off a coalesced background refresh). + if hits.Load() > 3 { + t.Fatalf("expected coalescing to reduce fetches (got %d)", hits.Load()) + } +} + +// TestEventIDStable verifies that the same event hashes to the same ID +// across calls, and is the right shape (32 hex chars + ".ics"). +func TestEventIDStable(t *testing.T) { + ev := mustParseEvent(sampleICS) + id1 := EventID(ev) + id2 := EventID(ev) + if id1 != id2 { + t.Fatalf("EventID not stable: %q vs %q", id1, id2) + } + if !strings.HasSuffix(id1, ".ics") { + t.Fatalf("EventID should end in .ics, got %q", id1) + } + if len(id1) != 32+4 { + t.Fatalf("expected 32 hex chars + .ics, got %q (len %d)", id1, len(id1)) + } +} + +// TestEventIDDiffersForDifferentEvents verifies that two events with +// different titles or different start times hash to different IDs, while +// two events with only a different UID hash to the same ID. +func TestEventIDDiffersForDifferentEvents(t *testing.T) { + base := EventID(mustParseEvent(sampleICS)) + + // Different title. + rawA := strings.Replace(sampleICS, "SUMMARY:Original title", "SUMMARY:Other title", 1) + if EventID(mustParseEvent(rawA)) == base { + t.Fatal("same start, different title must hash differently") + } + + // Different start time. + rawB := strings.Replace(sampleICS, "DTSTART:20260805T090000Z", "DTSTART:20260806T090000Z", 1) + if EventID(mustParseEvent(rawB)) == base { + t.Fatal("different start times must hash differently") + } + + // Same start, same title, different UID → same hash. + rawC := strings.Replace(sampleICS, "UID:ev1@nidus.test", "UID:ev2@nidus.test", 1) + if EventID(mustParseEvent(rawC)) != base { + t.Fatal("UID should not affect the hash — same start/dur/title must be equal") + } +} + +// TestEventIDWorksWithoutUID verifies EventID still produces a value (from +// DTSTART+DTEND+SUMMARY) when the event has no UID property. +func TestEventIDWorksWithoutUID(t *testing.T) { + raw := strings.Replace(sampleICS, "UID:ev1@nidus.test\r\n", "", 1) + ev := mustParseEvent(raw) + id := EventID(ev) + if id == "" { + t.Fatal("EventID should be non-empty even without UID") + } +} diff --git a/internal/web/calendar.go b/internal/web/calendar.go index fb19c32..b5898fc 100644 --- a/internal/web/calendar.go +++ b/internal/web/calendar.go @@ -18,6 +18,7 @@ import ( "git.arnef.de/arnef/nidus/internal/birthdays" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/icalfix" + "git.arnef.de/arnef/nidus/internal/icssub" "git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/web/templates" ical "github.com/emersion/go-ical" @@ -650,12 +651,17 @@ func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) { ref := r.PathValue("ref") id := r.PathValue("id") + // For ICS subscriptions, ref is "!" and id is the stable + // icssub.EventID (already ".ics"-suffixed). For regular calendars, + // ref is a bare name or "owner~name" and id is a ".ics" filename + // from the eventIDRe character set. Both shapes share the same + // "hex/alnum .ics" shape, so we only need the regex check. if !eventIDRe.MatchString(id) { http.NotFound(w, r) return } color, form, err := s.eventForDisplay(username, ref, id) - if errors.Is(err, store.ErrNotFound) { + if errors.Is(err, store.ErrNotFound) || errors.Is(err, errCalendarNotFound) { http.NotFound(w, r) return } @@ -670,8 +676,12 @@ func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) { // eventForDisplay resolves a calendar reference for read access, loads and // decodes the event with the given id, populates the form's display fields // (CalRef, CalendarLabel, Writable), and returns the calendar's color for -// the detail view's header dot. +// the detail view's header dot. It handles both ordinary (stored) events +// and ICS-subscription events (ref "!"). func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) { + if strings.HasPrefix(ref, icsRefPrefix) { + return s.icsEventForDisplay(username, ref, id) + } owner, name, err := s.resolveCalRef(username, ref, false) if err != nil { return "", form, err @@ -701,6 +711,35 @@ func (s *Server) eventForDisplay(username, ref, id string) (color string, form t return color, form, nil } +// icsEventForDisplay resolves one of username's ICS subscriptions named +// ref[len(!):] and looks up the event whose icssub.EventID hashes to id. +// Returns the subscription's display color and a form with Writable=false. +func (s *Server) icsEventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) { + name := strings.TrimPrefix(ref, icsRefPrefix) + sub, err := s.dbase.GetICSSubscription(username, name) + if err != nil { + return "", form, errCalendarNotFound + } + cal, err := s.icsCache.Get(sub.URL) + if err != nil { + return "", form, err + } + for _, ev := range cal.Events() { + if icssub.EventID(ev) != id { + continue + } + form, err = eventFormFromComponent(id, ev) + if err != nil { + return "", form, err + } + form.CalRef = ref + form.CalendarLabel = name + form.Writable = false + return sub.Color, form, nil + } + return "", form, errCalendarNotFound +} + func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) { username := userFromContext(r.Context()) ref := r.PathValue("ref") @@ -1264,11 +1303,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta } // addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache, -// which caches it for a while so every month-view render doesn't re-fetch -// from origin) and places each VEVENT's occurrence onto the month grid, -// the same way a stored calendar object would be. There's no per-event -// edit page for these (the source is external and read-only), so each -// event's LinkURL is left pointing nowhere useful ("#"). +// which uses stale-while-revalidate so a month-view render never blocks on +// network I/O) and places each VEVENT's occurrence onto the month grid, +// the same way a stored calendar object would be. Each event's ID is the +// stable icssub.EventID hash, which routes through the read-only detail +// page (eventForDisplay → icsEventForDisplay). func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error { cal, err := s.icsCache.Get(entry.ICSURL) if err != nil { @@ -1276,8 +1315,11 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, } const totalDays = 42 - for i, ev := range cal.Events() { - id := fmt.Sprintf("ics-%d", i) + for _, ev := range cal.Events() { + id := icssub.EventID(ev) + if id == "" { + continue + } form, err := eventFormFromComponent(id, ev) if err != nil { continue @@ -1311,7 +1353,6 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, Summary: form.Summary, TimeText: timeText, AllDay: form.AllDay, - LinkURL: "#", }) } } diff --git a/internal/web/ics_detail_test.go b/internal/web/ics_detail_test.go new file mode 100644 index 0000000..d575e51 --- /dev/null +++ b/internal/web/ics_detail_test.go @@ -0,0 +1,176 @@ +package web + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "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" +) + +// icsDetailSample is a minimal valid ICS feed with one VEVENT on Aug 5, 2026. +const icsDetailSample = "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//nidus//test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:detail1@nidus.test\r\n" + + "DTSTAMP:20260101T000000Z\r\n" + + "DTSTART:20260805T090000Z\r\n" + + "DTEND:20260805T100000Z\r\n" + + "SUMMARY:ICS detail event\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + +// newServerWithICSCache builds a Server wired to an upstream ICS server +// (for deterministic tests) and returns it, sharing one icssub.Cache the +// same way cmd/server/main.go does in production. +func newServerWithICSCache(t *testing.T, upstreamURL string) *Server { + t.Helper() + dir := t.TempDir() + st, err := store.NewStore(filepath.Join(dir, "data")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + d, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { d.Close() }) + + cfg := &config.Config{} + if err := d.CreateUser("alice", "password", "", ""); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := d.CreateICSSubscription("alice", "holidays", upstreamURL, "#123abc"); err != nil { + t.Fatalf("CreateICSSubscription: %v", err) + } + shared := icssub.NewCache(time.Hour) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewServer(cfg, st, d, logger, shared) +} + +// TestICSDetailRouteRendersReadonlyEvent verifies the full ICS detail path: +// the user clicks an ICS event from the month/week grid, the browser lands +// on GET /calendar/!holidays//, and the handler returns 200 +// with the event's fields rendered and NO Edit link (ICS subs are read-only). +func TestICSDetailRouteRendersReadonlyEvent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(icsDetailSample)) + })) + defer srv.Close() + + s := newServerWithICSCache(t, srv.URL) + handler := s.Handler(emptyStaticFS{}) + cookie := loginAs(t, handler, "alice", "password") + + id := icssub.EventID(mustParseICS(t, icsDetailSample)) + if id == "" { + t.Fatal("EventID must be non-empty") + } + + req := httptest.NewRequest(http.MethodGet, "/calendar/"+url.PathEscape("!holidays")+"/"+id, nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "ICS detail event", // summary + "holidays", // calendar label + } { + if !strings.Contains(body, want) { + t.Errorf("expected body to contain %q, got:\n%s", want, body) + } + } + if strings.Contains(body, ">Edit") { + t.Errorf("Edit link should not be present on a read-only ICS detail page, got:\n%s", body) + } +} + +// TestICSDetailRouteBadIDReturns404 verifies a request with a malformed +// event ID (missing .ics suffix) gets a 404 rather than rendering. +func TestICSDetailRouteBadIDReturns404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(icsDetailSample)) + })) + defer srv.Close() + + s := newServerWithICSCache(t, srv.URL) + handler := s.Handler(emptyStaticFS{}) + cookie := loginAs(t, handler, "alice", "password") + + req := httptest.NewRequest(http.MethodGet, "/calendar/!holidays/not-a-valid-id", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 for malformed event id, got %d", rr.Code) + } +} + +// TestMonthGridLinksICSEventToDetailPage exercises the full user path: the +// month/week grid renders an ICS-subscription event as a link to a detail +// page (not "#" and not a direct edit URL). +func TestMonthGridLinksICSEventToDetailPage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(icsDetailSample)) + })) + defer srv.Close() + + s := newServerWithICSCache(t, srv.URL) + handler := s.Handler(emptyStaticFS{}) + cookie := loginAs(t, handler, "alice", "password") + + req := httptest.NewRequest(http.MethodGet, "/calendar?year=2026&month=8", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, "ICS detail event") { + t.Fatalf("month grid should list ICS events, got:\n%s", body) + } + id := icssub.EventID(mustParseICS(t, icsDetailSample)) + // The grid renders the detail link with a "/web/calendar/!/" + // shape (see eventLinkURL in calendar.templ). The "!" in the ref is + // not %-escaped by templ.URL — we observed un-escaped output in the + // rendered HTML and pin the exact shape below. + wantHref := "href=\"/web/calendar/!holidays/" + id + "\"" + if !strings.Contains(body, wantHref) { + t.Fatalf("month grid should link ICS event to %q, but did not", wantHref) + } + if strings.Contains(body, `href="#"`) { + t.Fatalf("ICS events should not link to '#'") + } +} + +// mustParseICS parses raw and returns its first VEVENT (panic on error). +func mustParseICS(t *testing.T, raw string) ical.Event { + t.Helper() + cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() + if err != nil { + t.Fatalf("ical decode: %v", err) + } + evs := cal.Events() + if len(evs) == 0 { + t.Fatal("no events in ICS") + } + return evs[0] +} diff --git a/internal/web/server.go b/internal/web/server.go index cacbb5b..4dc270a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -25,9 +25,16 @@ type Server struct { icsCache *icssub.Cache } -// NewServer constructs a web UI Server. -func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server { - return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)} +// NewServer constructs a web UI Server. icsCache may be nil, in which +// case a private default-TTL cache is created — prefer sharing a single +// *icssub.Cache with the CALDAV/CardDAV backends (e.g. from +// cmd/server/main.go) so the web calendar page and the DAV protocol +// serve identical events for the same ICS subscription. +func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger, icsCache *icssub.Cache) *Server { + if icsCache == nil { + icsCache = icssub.NewCache(icssub.DefaultTTL) + } + return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icsCache} } // Handler returns the http.Handler serving the web UI, mounted at "/web/" diff --git a/internal/web/server_test.go b/internal/web/server_test.go index baee070..6b0f228 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -47,7 +47,7 @@ func newTestServer(t *testing.T) *Server { t.Fatalf("CreateCalendar bob/personal: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - return NewServer(cfg, st, dbase, logger) + return NewServer(cfg, st, dbase, logger, nil) } // loginAs performs a login request against handler and returns the