feat(web): show ICS subscription events in detail view
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user