feat(web): show ICS subscription events in detail view
This commit is contained in:
+9
-2
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.arnef.de/arnef/nidus/internal/carddav"
|
"git.arnef.de/arnef/nidus/internal/carddav"
|
||||||
"git.arnef.de/arnef/nidus/internal/config"
|
"git.arnef.de/arnef/nidus/internal/config"
|
||||||
"git.arnef.de/arnef/nidus/internal/db"
|
"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/store"
|
||||||
"git.arnef.de/arnef/nidus/internal/web"
|
"git.arnef.de/arnef/nidus/internal/web"
|
||||||
filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
|
filewebdav "git.arnef.de/arnef/nidus/internal/webdav"
|
||||||
@@ -103,10 +104,16 @@ func main() {
|
|||||||
authMw := auth.NewMiddleware(cfg, dbase, logger)
|
authMw := auth.NewMiddleware(cfg, dbase, logger)
|
||||||
|
|
||||||
// ---- Handlers ----
|
// ---- 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)
|
cardHandler := carddav.NewHandler(cfg, st, dbase, logger)
|
||||||
fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, 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)
|
mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,16 @@ type Backend struct {
|
|||||||
icsCache *icssub.Cache
|
icsCache *icssub.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBackend creates a CalDAV backend. dbase may be nil, in which case
|
// NewBackend creates a CalDAV backend over an existing ICS cache.
|
||||||
// calendar sharing is disabled (only a user's own calendars are visible).
|
// dbase may be nil, in which case calendar sharing is disabled (only a
|
||||||
func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Backend {
|
// user's own calendars are visible). The icsCache is shared with any
|
||||||
return &Backend{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)}
|
// 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.
|
// 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
|
// property into PROPFIND responses for calendar collections, since
|
||||||
// go-webdav's caldav.Backend interface has no extension point for
|
// go-webdav's caldav.Backend interface has no extension point for
|
||||||
// vendor-specific WebDAV properties.
|
// vendor-specific WebDAV properties.
|
||||||
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler {
|
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)
|
b := NewBackend(cfg, st, dbase, logger, icsCache)
|
||||||
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
|
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newTestBackend(t *testing.T) (*Backend, *db.DB) {
|
|||||||
t.Fatalf("CreateCalendar bob/personal: %v", err)
|
t.Fatalf("CreateCalendar bob/personal: %v", err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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 {
|
func ctxFor(username string) context.Context {
|
||||||
@@ -179,7 +179,7 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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(
|
req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
|
||||||
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
|
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
|
||||||
@@ -250,7 +250,7 @@ func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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(
|
req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader(
|
||||||
`<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
|
`<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
|
||||||
|
|||||||
+22
-26
@@ -1,8 +1,6 @@
|
|||||||
package caldav
|
package caldav
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,6 +11,7 @@ import (
|
|||||||
"github.com/emersion/go-webdav/caldav"
|
"github.com/emersion/go-webdav/caldav"
|
||||||
|
|
||||||
"git.arnef.de/arnef/nidus/internal/db"
|
"git.arnef.de/arnef/nidus/internal/db"
|
||||||
|
"git.arnef.de/arnef/nidus/internal/icssub"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultICSColor is the display color for an ICS/webcal subscription
|
// 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:
|
// 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
|
// its own UID property if it has one, otherwise a placeholder derived from
|
||||||
// position so it still round-trips consistently between requests.
|
// the object ID (so the event still has a *unique* UID in the returned
|
||||||
|
// VCALENDAR).
|
||||||
func icsObjectUID(ev ical.Event, fallback string) string {
|
func icsObjectUID(ev ical.Event, fallback string) string {
|
||||||
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
|
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
|
||||||
return p.Value
|
return p.Value
|
||||||
@@ -41,16 +41,11 @@ func icsObjectUID(ev ical.Event, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// icsObjID builds the object ID (file-name-like, ".ics" suffixed) used to
|
// icsSubscriptionCalendarObjects fetches sub's remote calendar (via
|
||||||
// address a fetched VEVENT within its subscription calendar, derived from
|
// b.icsCache) and returns one caldav.CalendarObject per VEVENT. The
|
||||||
// its UID so it stays stable across fetches of the same feed.
|
// object path is derived from icssub.EventID(ev) so the same event keeps
|
||||||
func icsObjID(uid string) string {
|
// the same path across fetches, even if its position in the document
|
||||||
sum := sha1.Sum([]byte(uid))
|
// changes.
|
||||||
return hex.EncodeToString(sum[:]) + ".ics"
|
|
||||||
}
|
|
||||||
|
|
||||||
// listICSSubscriptionCalendarObjects fetches sub's remote calendar (via
|
|
||||||
// b.icsCache) and returns one caldav.CalendarObject per VEVENT.
|
|
||||||
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
|
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
|
||||||
cal, err := b.icsCache.Get(sub.URL)
|
cal, err := b.icsCache.Get(sub.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,8 +53,8 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
var objs []caldav.CalendarObject
|
var objs []caldav.CalendarObject
|
||||||
for i, ev := range cal.Events() {
|
for _, ev := range cal.Events() {
|
||||||
obj, err := b.encodeICSObject(localName, ev, fmt.Sprintf("event-%d", i))
|
obj, err := b.encodeICSObject(localName, ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -75,24 +70,25 @@ func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
|
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
|
||||||
}
|
}
|
||||||
for i, ev := range cal.Events() {
|
for _, ev := range cal.Events() {
|
||||||
uid := icsObjectUID(ev, fmt.Sprintf("event-%d", i))
|
if icssub.EventID(ev) != objID {
|
||||||
if icsObjID(uid) != objID {
|
|
||||||
continue
|
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"))
|
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeICSObject wraps a single fetched VEVENT ev into its own
|
// encodeICSObject wraps a single fetched VEVENT ev into its own
|
||||||
// caldav.CalendarObject, encoding it as a standalone one-event calendar
|
// caldav.CalendarObject, encoding it as a standalone one-event calendar
|
||||||
// the same way every other calendar object in this backend is
|
// the same way every other calendar object in this backend is represented.
|
||||||
// represented. fallbackUID is used to derive the object ID/UID if ev has
|
func (b *Backend) encodeICSObject(localName string, ev ical.Event) (*caldav.CalendarObject, error) {
|
||||||
// no UID property of its own.
|
objID := icssub.EventID(ev)
|
||||||
func (b *Backend) encodeICSObject(localName string, ev ical.Event, fallbackUID string) (*caldav.CalendarObject, error) {
|
if objID == "" {
|
||||||
uid := icsObjectUID(ev, fallbackUID)
|
// Not addressable (no DTSTART) — skip.
|
||||||
objID := icsObjID(uid)
|
return nil, fmt.Errorf("event has no DTSTART; not addressable")
|
||||||
|
}
|
||||||
|
uid := icsObjectUID(ev, objID)
|
||||||
|
|
||||||
event := ical.NewEvent()
|
event := ical.NewEvent()
|
||||||
event.Props = ev.Props
|
event.Props = ev.Props
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
-27
@@ -7,6 +7,8 @@ package icssub
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -19,8 +21,8 @@ import (
|
|||||||
"git.arnef.de/arnef/nidus/internal/icalfix"
|
"git.arnef.de/arnef/nidus/internal/icalfix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultTTL is how long a fetched calendar is cached before being
|
// DefaultTTL is how long a fetched calendar is considered "fresh" before a
|
||||||
// re-fetched on the next access.
|
// Get will kick off a background refresh.
|
||||||
const DefaultTTL = 15 * time.Minute
|
const DefaultTTL = 15 * time.Minute
|
||||||
|
|
||||||
// fetchTimeout bounds how long a single upstream request may take, so one
|
// fetchTimeout bounds how long a single upstream request may take, so one
|
||||||
@@ -32,54 +34,143 @@ const fetchTimeout = 15 * time.Second
|
|||||||
// response.
|
// response.
|
||||||
const maxBodySize = 32 * 1024 * 1024 // 32 MiB
|
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 {
|
type entry struct {
|
||||||
fetchedAt time.Time
|
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
|
cal *ical.Calendar
|
||||||
err error
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache fetches remote ICS calendars over HTTP(S), keeping a short-lived
|
// Cache fetches remote ICS calendars over HTTP(S), keeping a shared
|
||||||
// in-memory copy per URL so repeated renders (e.g. every month-view page
|
// in-memory copy per URL. Semantics:
|
||||||
// load, or CalDAV client polling) don't re-fetch the same subscription
|
//
|
||||||
// from origin every time.
|
// - 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 {
|
type Cache struct {
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
entries map[string]entry
|
urls map[string]*entry // keyed by normalizeURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache creates a Cache with the given TTL (use DefaultTTL if unsure).
|
// NewCache creates a Cache with the given TTL (use DefaultTTL if unsure).
|
||||||
func NewCache(ttl time.Duration) *Cache {
|
func NewCache(ttl time.Duration) *Cache {
|
||||||
return &Cache{
|
return &Cache{
|
||||||
ttl: ttl,
|
ttl: ttl,
|
||||||
client: &http.Client{Timeout: fetchTimeout},
|
client: &http.Client{},
|
||||||
entries: make(map[string]entry),
|
urls: make(map[string]*entry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the parsed calendar fetched from url, using a cached copy
|
// Get returns the most recently successfully-fetched calendar for url, or
|
||||||
// if it's still within the TTL. If a fresh fetch fails but a previously
|
// the most-recent fetch error if no successful copy exists yet (a
|
||||||
// fetched copy exists, the stale copy is returned instead of the error,
|
// background refresher may already be retrying).
|
||||||
// so a transient network issue doesn't blank out the calendar entirely.
|
|
||||||
func (c *Cache) Get(url string) (*ical.Calendar, error) {
|
func (c *Cache) Get(url string) (*ical.Calendar, error) {
|
||||||
|
key := normalizeURL(url)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
e, ok := c.entries[url]
|
e := c.urls[key]
|
||||||
fresh := ok && time.Since(e.fetchedAt) < c.ttl
|
if e == nil {
|
||||||
c.mu.Unlock()
|
e = &entry{url: url}
|
||||||
if fresh {
|
c.urls[key] = e
|
||||||
return e.cal, e.err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cal, err := c.fetch(url)
|
switch {
|
||||||
c.mu.Lock()
|
case e.cal == nil && e.lastErr == nil && !e.refreshing:
|
||||||
defer c.mu.Unlock()
|
// Very first request for this URL: do a foreground fetch.
|
||||||
if err != nil && ok && e.cal != nil {
|
e.refreshing = true
|
||||||
return e.cal, nil
|
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)
|
||||||
}
|
}
|
||||||
c.entries[url] = entry{fetchedAt: time.Now(), cal: cal, err: err}
|
// Stale — return the cached value immediately; spawn at most one
|
||||||
return cal, err
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// fetch downloads and parses url, translating a "webcal://" scheme (used
|
||||||
@@ -128,3 +219,40 @@ func normalizeURL(u string) string {
|
|||||||
}
|
}
|
||||||
return u
|
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("<DTSTART-value>|<duration>|<SUMMARY>") 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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-10
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.arnef.de/arnef/nidus/internal/birthdays"
|
"git.arnef.de/arnef/nidus/internal/birthdays"
|
||||||
"git.arnef.de/arnef/nidus/internal/db"
|
"git.arnef.de/arnef/nidus/internal/db"
|
||||||
"git.arnef.de/arnef/nidus/internal/icalfix"
|
"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/store"
|
||||||
"git.arnef.de/arnef/nidus/internal/web/templates"
|
"git.arnef.de/arnef/nidus/internal/web/templates"
|
||||||
ical "github.com/emersion/go-ical"
|
ical "github.com/emersion/go-ical"
|
||||||
@@ -650,12 +651,17 @@ func (s *Server) handleEventView(w http.ResponseWriter, r *http.Request) {
|
|||||||
ref := r.PathValue("ref")
|
ref := r.PathValue("ref")
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
|
|
||||||
|
// For ICS subscriptions, ref is "!<name>" 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 "<hex>.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) {
|
if !eventIDRe.MatchString(id) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
color, form, err := s.eventForDisplay(username, ref, id)
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
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
|
// eventForDisplay resolves a calendar reference for read access, loads and
|
||||||
// decodes the event with the given id, populates the form's display fields
|
// decodes the event with the given id, populates the form's display fields
|
||||||
// (CalRef, CalendarLabel, Writable), and returns the calendar's color for
|
// (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 "!<name>").
|
||||||
func (s *Server) eventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
|
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)
|
owner, name, err := s.resolveCalRef(username, ref, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", form, err
|
return "", form, err
|
||||||
@@ -701,6 +711,35 @@ func (s *Server) eventForDisplay(username, ref, id string) (color string, form t
|
|||||||
return color, form, nil
|
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) {
|
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
username := userFromContext(r.Context())
|
username := userFromContext(r.Context())
|
||||||
ref := r.PathValue("ref")
|
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,
|
// 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
|
// which uses stale-while-revalidate so a month-view render never blocks on
|
||||||
// from origin) and places each VEVENT's occurrence onto the month grid,
|
// network I/O) and places each VEVENT's occurrence onto the month grid,
|
||||||
// the same way a stored calendar object would be. There's no per-event
|
// the same way a stored calendar object would be. Each event's ID is the
|
||||||
// edit page for these (the source is external and read-only), so each
|
// stable icssub.EventID hash, which routes through the read-only detail
|
||||||
// event's LinkURL is left pointing nowhere useful ("#").
|
// page (eventForDisplay → icsEventForDisplay).
|
||||||
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
|
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)
|
cal, err := s.icsCache.Get(entry.ICSURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1276,8 +1315,11 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalDays = 42
|
const totalDays = 42
|
||||||
for i, ev := range cal.Events() {
|
for _, ev := range cal.Events() {
|
||||||
id := fmt.Sprintf("ics-%d", i)
|
id := icssub.EventID(ev)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
form, err := eventFormFromComponent(id, ev)
|
form, err := eventFormFromComponent(id, ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -1311,7 +1353,6 @@ func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time,
|
|||||||
Summary: form.Summary,
|
Summary: form.Summary,
|
||||||
TimeText: timeText,
|
TimeText: timeText,
|
||||||
AllDay: form.AllDay,
|
AllDay: form.AllDay,
|
||||||
LinkURL: "#",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<icssub.EventID>/, 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/!<ref>/<id>"
|
||||||
|
// 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]
|
||||||
|
}
|
||||||
+10
-3
@@ -25,9 +25,16 @@ type Server struct {
|
|||||||
icsCache *icssub.Cache
|
icsCache *icssub.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs a web UI Server.
|
// NewServer constructs a web UI Server. icsCache may be nil, in which
|
||||||
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server {
|
// case a private default-TTL cache is created — prefer sharing a single
|
||||||
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger, icsCache: icssub.NewCache(icssub.DefaultTTL)}
|
// *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/"
|
// Handler returns the http.Handler serving the web UI, mounted at "/web/"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func newTestServer(t *testing.T) *Server {
|
|||||||
t.Fatalf("CreateCalendar bob/personal: %v", err)
|
t.Fatalf("CreateCalendar bob/personal: %v", err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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
|
// loginAs performs a login request against handler and returns the
|
||||||
|
|||||||
Reference in New Issue
Block a user