273 lines
8.8 KiB
Go
273 lines
8.8 KiB
Go
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 := seriesID(t, icsDetailSample)
|
|
if id == "" {
|
|
t.Fatal("series ID 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 := seriesID(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 '#'")
|
|
}
|
|
}
|
|
|
|
// TestEventOccurrenceDaysExpandsRRule verifies that a recurring event
|
|
// (RRULE) is placed on every in-window occurrence day, not just its base
|
|
// date — the core of the "recurring series events missing" fix.
|
|
func TestEventOccurrenceDaysExpandsRRule(t *testing.T) {
|
|
const raw = "BEGIN:VCALENDAR\r\n" +
|
|
"VERSION:2.0\r\n" +
|
|
"PRODID:-//nidus//test//EN\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:rec1@nidus.test\r\n" +
|
|
"DTSTAMP:20260101T000000Z\r\n" +
|
|
"DTSTART:20260803T090000Z\r\n" + // a Monday
|
|
"DTEND:20260803T100000Z\r\n" +
|
|
"RRULE:FREQ=WEEKLY;COUNT=4\r\n" +
|
|
"SUMMARY:weekly meeting\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
ev := mustParseICS(t, raw)
|
|
loc := time.UTC
|
|
|
|
// Window covering the whole of August 2026 (the 4 weekly occurrences:
|
|
// Aug 3, 10, 17, 24 are all within range).
|
|
gridStart := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
|
|
gridEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, loc)
|
|
|
|
dayIndex := make(map[string]int)
|
|
for d, i := gridStart, 0; !d.After(gridEnd); d, i = d.AddDate(0, 0, 1), i+1 {
|
|
dayIndex[d.Format(dateLayout)] = i
|
|
}
|
|
|
|
got := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
|
|
|
|
wantDates := []string{"2026-08-03", "2026-08-10", "2026-08-17", "2026-08-24"}
|
|
for _, ws := range wantDates {
|
|
if _, ok := got[dayIndex[ws]]; !ok {
|
|
t.Errorf("expected recurring occurrence on %s, got days %v", ws, got)
|
|
}
|
|
}
|
|
if len(got) != 4 {
|
|
t.Errorf("expected exactly 4 in-window occurrence days, got %d (%v)", len(got), got)
|
|
}
|
|
}
|
|
|
|
// TestEventOccurrenceDaysSingleEvent verifies a non-recurring event occupies
|
|
// only its own day(s) (no spurious expansion).
|
|
func TestEventOccurrenceDaysSingleEvent(t *testing.T) {
|
|
const raw = "BEGIN:VCALENDAR\r\n" +
|
|
"VERSION:2.0\r\n" +
|
|
"PRODID:-//nidus//test//EN\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:single1@nidus.test\r\n" +
|
|
"DTSTAMP:20260101T000000Z\r\n" +
|
|
"DTSTART:20260805T090000Z\r\n" +
|
|
"DTEND:20260805T100000Z\r\n" +
|
|
"SUMMARY:one off\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
ev := mustParseICS(t, raw)
|
|
loc := time.UTC
|
|
gridStart := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
|
|
gridEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, loc)
|
|
|
|
dayIndex := make(map[string]int)
|
|
for d, i := gridStart, 0; !d.After(gridEnd); d, i = d.AddDate(0, 0, 1), i+1 {
|
|
dayIndex[d.Format(dateLayout)] = i
|
|
}
|
|
|
|
got := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected exactly 1 occurrence day, got %d (%v)", len(got), got)
|
|
}
|
|
if _, ok := got[dayIndex["2026-08-05"]]; !ok {
|
|
t.Errorf("expected event on 2026-08-05, got days %v", got)
|
|
}
|
|
}
|
|
|
|
// seriesID returns the stable ID that addICSEvents/icsEventForDisplay
|
|
// advertise for the (single) series in raw — GroupSeries(raw)[0].ID().
|
|
// ICS-subscription grid cells and detail links are keyed by the series,
|
|
// not by individual VEVENTs, so this is what the href/ID in assertions
|
|
// must match.
|
|
func seriesID(t *testing.T, raw string) string {
|
|
t.Helper()
|
|
_ = t
|
|
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
|
|
if err != nil {
|
|
t.Fatalf("seriesID: ical decode: %v", err)
|
|
}
|
|
series := icssub.GroupSeries(cal)
|
|
if len(series) == 0 {
|
|
t.Fatal("seriesID: no series")
|
|
}
|
|
return series[0].ID()
|
|
}
|
|
|
|
// 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]
|
|
}
|