package web_test import ( "context" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/m/projax/caldav" "github.com/m/projax/web" ) // fakeCalDAVServer is a minimal in-memory CalDAV server: a PROPFIND on // /dav/calendars/m/ returns a fixed two-calendar list, REPORT on each // calendar returns whichever VTODOs the test seeded into todos[url], // and PUT to a calendar URL captures the body so the test can assert // on what projax wrote. Mirrors the pattern in dashboard_events_test.go // but tailored to the Phase 5j flows. type fakeCalDAVServer struct { mu sync.Mutex srv *httptest.Server calendars []caldav.Calendar todos map[string][]string // calendarURL → list of VTODO ICS docs puts map[string]string // url → body of the latest PUT to that url } func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer { t.Helper() f := &fakeCalDAVServer{ todos: map[string][]string{}, puts: map[string]string{}, } mux := http.NewServeMux() mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) { if r.Method == "PROPFIND" { f.mu.Lock() cs := f.calendars f.mu.Unlock() w.WriteHeader(207) _, _ = io.WriteString(w, propfindMultistatus(cs)) return } http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed) }) // Per-calendar handler. Keyed by URL PATH so both the registration // loop and the test's seed lookup (`fake.todos[calURL]`) resolve to // the same map entry regardless of how the httptest host gets baked // into the full URL. for _, c := range cals { path := urlPathOf(c.URL) mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "REPORT": f.mu.Lock() body := buildReportMultistatus(path, f.todos[path]) f.mu.Unlock() w.WriteHeader(207) _, _ = io.WriteString(w, body) case "PUT": body, _ := io.ReadAll(r.Body) f.mu.Lock() f.puts[r.URL.String()] = string(body) f.todos[path] = append(f.todos[path], string(body)) f.mu.Unlock() w.Header().Set("ETag", `"fresh"`) w.WriteHeader(http.StatusCreated) default: http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed) } }) } f.srv = httptest.NewServer(mux) f.calendars = make([]caldav.Calendar, len(cals)) // Rewrite URLs to point at the httptest server's host. for i, c := range cals { f.calendars[i] = caldav.Calendar{ URL: f.srv.URL + urlPathOf(c.URL), HRef: urlPathOf(c.URL), DisplayName: c.DisplayName, Color: c.Color, } } t.Cleanup(f.srv.Close) return f } func urlPathOf(absURL string) string { u, _ := url.Parse(absURL) return u.Path } // propfindMultistatus builds the PROPFIND response for the slice of // calendars. Includes the collection itself + each calendar entry, plus // an "inbox" non-calendar that ListCalendars must filter out. func propfindMultistatus(cals []caldav.Calendar) string { var b strings.Builder b.WriteString(``) b.WriteString(`/dav/calendars/m/HTTP/1.1 200 OK`) for _, c := range cals { b.WriteString(`` + urlPathOf(c.URL) + `` + c.DisplayName + `HTTP/1.1 200 OK`) } b.WriteString(``) return b.String() } // buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT // multistatus body, one per VTODO. func buildReportMultistatus(calPath string, vtodos []string) string { if len(vtodos) == 0 { return `` } var b strings.Builder b.WriteString(``) for i, ics := range vtodos { b.WriteString(`` + calPath + "t" + itoa(i) + `.ics"e` + itoa(i) + `"`) b.WriteString(ics) b.WriteString(`HTTP/1.1 200 OK`) } b.WriteString(``) return b.String() } func itoa(n int) string { if n == 0 { return "0" } var buf [20]byte i := len(buf) neg := false if n < 0 { neg = true n = -n } for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) } // seedItemUnderDev inserts a fresh projax item under dev and returns // its id + primary path. Callers defer cleanup. func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id, primaryPath string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var dev string if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { t.Fatalf("dev: %v", err) } if err := pool.QueryRow(ctx, `insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[]) returning id`, title, slug, dev, ).Scan(&id); err != nil { t.Fatalf("seed item: %v", err) } return id, "dev." + slug } // TestDetailLinkExistingCalendar walks the original ask end-to-end: // 1. Fake CalDAV server exposes 3 calendars + zero VTODOs. // 2. Seed an unlinked projax item under dev. // 3. GET /i/{path} — assert the "link existing"