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) } } }