Files
nidus/internal/caldav/ics_shared_test.go
T

282 lines
9.3 KiB
Go

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)
}
}