package web_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/web"
)
// TestTimelineExcludeMigrationLanded asserts the new column + GIN index
// are queryable. Each task in the chain adds a column; if a future
// migration drops the chain, this test fires loudly.
func TestTimelineExcludeMigrationLanded(t *testing.T) {
_, pool := mustServer(t)
defer pool.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var col string
if err := pool.QueryRow(ctx,
`SELECT column_name FROM information_schema.columns
WHERE table_schema='projax' AND table_name='items' AND column_name='timeline_exclude'`,
).Scan(&col); err != nil {
t.Fatalf("timeline_exclude column missing: %v", err)
}
if col != "timeline_exclude" {
t.Errorf("got %q, want timeline_exclude", col)
}
var idxDef string
if err := pool.QueryRow(ctx,
`SELECT indexdef FROM pg_indexes WHERE schemaname='projax' AND indexname='items_timeline_exclude_idx'`,
).Scan(&idxDef); err != nil {
t.Fatalf("items_timeline_exclude_idx missing: %v", err)
}
if !strings.Contains(idxDef, "gin") {
t.Errorf("expected GIN index, got: %s", idxDef)
}
}
// TestTimelineExcludeSkipsTodosForFlaggedItem seeds a projax item with
// timeline_exclude=['todos'] and a calendar holding one open VTODO; the
// /timeline response should NOT include that VTODO, but should still
// include any docs/creation rows for the same item.
func TestTimelineExcludeSkipsTodosForFlaggedItem(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
// Fake CalDAV that always returns one VTODO due today.
icsTodo := `BEGIN:VCALENDAR
BEGIN:VTODO
UID:tle-1@fake
SUMMARY:Shopping list item
STATUS:NEEDS-ACTION
DUE;VALUE=DATE:` + time.Now().UTC().Format("20060102") + `
END:VTODO
END:VCALENDAR`
mux := http.NewServeMux()
mux.HandleFunc("/dav/calendars/m/Home/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(207)
if strings.Contains(string(body), "VTODO") {
_, _ = io.WriteString(w, `
/dav/calendars/m/Home/t1.ics
"t1"
`+icsTodo+`
HTTP/1.1 200 OK
`)
return
}
// VEVENT branch — empty
_, _ = io.WriteString(w, ``)
})
fake := httptest.NewServer(mux)
defer fake.Close()
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/dav/calendars/m/", "u", "p")}
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "tle-" + stamp
calURL := fake.URL + "/dav/calendars/m/Home/"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var dev, id 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, timeline_exclude)
values (array['project']::text[], 'TLE', $1, ARRAY[$2]::uuid[], ARRAY['todos'])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'caldav-list', $2, 'tracks')`,
id, calURL,
); err != nil {
t.Fatalf("seed link: %v", err)
}
h := srv.Routes()
_, body := get(t, h, "/views/timeline")
if strings.Contains(body, "Shopping list item") {
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
}
// Override: ?include_excluded=1 brings it back.
_, peekBody := get(t, h, "/views/timeline?include_excluded=1")
if !strings.Contains(peekBody, "Shopping list item") {
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
}
}
// TestTimelineExcludeBulkAction flips the array via /admin/bulk and
// verifies the change persists.
func TestTimelineExcludeBulkAction(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "tle-bk-" + stamp
var dev, id 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[], 'TLE Bulk', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// Exclude todos.
form := url.Values{}
form.Add("ids", id)
form.Set("timeline_todos", "exclude")
req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
var arr []string
if err := pool.QueryRow(ctx, `select timeline_exclude from projax.items where id=$1`, id).Scan(&arr); err != nil {
t.Fatalf("re-read: %v", err)
}
if len(arr) != 1 || arr[0] != "todos" {
t.Errorf("exclude bulk action should have set ['todos'], got %v", arr)
}
// Idempotent: applying again leaves it unchanged (no duplicate).
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.ServeHTTP(w2, req2)
if err := pool.QueryRow(ctx, `select timeline_exclude from projax.items where id=$1`, id).Scan(&arr); err != nil {
t.Fatalf("re-read 2: %v", err)
}
if len(arr) != 1 {
t.Errorf("second exclude should be idempotent, got %v", arr)
}
// Re-include.
form2 := url.Values{}
form2.Add("ids", id)
form2.Set("timeline_todos", "include")
req3 := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form2.Encode()))
req3.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w3 := httptest.NewRecorder()
h.ServeHTTP(w3, req3)
if err := pool.QueryRow(ctx, `select timeline_exclude from projax.items where id=$1`, id).Scan(&arr); err != nil {
t.Fatalf("re-read 3: %v", err)
}
if len(arr) != 0 {
t.Errorf("re-include should empty the array, got %v", arr)
}
}
// TestTimelineExcludeMCPUpdateItemRoundTrip — call update_item with
// timeline_exclude:['todos','events'], verify both the returned view and
// the DB hold the value.
func TestTimelineExcludeDetailFormShowsCheckboxes(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/i/dev")
for _, want := range []string{
`name="timeline_exclude" value="todos"`,
`name="timeline_exclude" value="events"`,
`name="timeline_exclude" value="docs"`,
`name="timeline_exclude" value="creation"`,
`data-section="timeline-behaviour"`,
} {
if !strings.Contains(body, want) {
t.Errorf("detail form missing timeline-exclude affordance %q", want)
}
}
}