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 := 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/!/" // 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] }