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"