package web_test import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/m/projax/caldav" "github.com/m/projax/web" ) // TestTimelineRendersEmpty checks GET /timeline renders cleanly without any // linked DAV / Gitea integration and surfaces the "Nothing on this timeline // yet" copy when no dated content exists in the default window. func TestTimelineRendersEmpty(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() code, body := get(t, h, "/timeline") if code != 200 { t.Fatalf("GET /timeline → %d body=%s", code, body) } for _, want := range []string{ `id="timeline-section"`, `

Timeline

`, } { if !strings.Contains(body, want) { t.Errorf("timeline missing %q", want) } } } // TestTimelineSurfacesDatedDocs seeds a dated item_link inside the default // window and asserts the timeline page renders the corresponding PER. func TestTimelineSurfacesDatedDocs(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 := "tl-doc-" + 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[], 'TL doc', $1, ARRAY[$2]::uuid[]) 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, note, event_date) values ($1, 'document', $2, 'contains', $3, current_date)`, id, "https://example.com/tl-doc-"+stamp, "tl test "+stamp, ); err != nil { t.Fatalf("seed link: %v", err) } code, body := get(t, h, "/timeline") if code != 200 { t.Fatalf("GET /timeline → %d", code) } wantPER := "dev." + slug + "." + time.Now().UTC().Format("060102") if !strings.Contains(body, wantPER) { t.Errorf("timeline body missing PER %q", wantPER) } // Today header should fire on the seeded date. if !strings.Contains(body, "Today") { t.Errorf("timeline body missing 'Today' header for today's row") } } // TestTimelineFilterByKindNarrowsRows seeds an item-creation marker (item just // created in the seed step) and a dated doc; ?kind=doc should hide the // creation marker but keep the doc row. func TestTimelineFilterByKindNarrowsRows(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 := "tl-k-" + 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[], 'TL kind', $1, ARRAY[$2]::uuid[]) 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, event_date) values ($1, 'document', $2, 'contains', current_date)`, id, "https://example.com/tl-k-"+stamp, ); err != nil { t.Fatalf("seed link: %v", err) } // Unfiltered: both the creation marker and the dated doc should be present. _, allBody := get(t, h, "/timeline") if !strings.Contains(allBody, "added ` + href + `` + `"` + etag + `"` + `` + ics + `` + `HTTP/1.1 200 OK` } mux := http.NewServeMux() mux.HandleFunc("/dav/calendars/m/TL/", func(w http.ResponseWriter, r *http.Request) { if r.Method != "REPORT" { http.Error(w, "method", http.StatusMethodNotAllowed) return } body, _ := io.ReadAll(r.Body) w.WriteHeader(207) if strings.Contains(string(body), "VTODO") { _, _ = io.WriteString(w, ``+ multi("/dav/calendars/m/TL/td-1.ics", "t1", icsTodo)+ ``) return } _, _ = io.WriteString(w, ``+ multi("/dav/calendars/m/TL/ev-1.ics", "e1", icsEvent)+ ``) }) 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 := "tl-mix-" + stamp calURL := fake.URL + "/dav/calendars/m/TL/" 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) values (array['project']::text[], 'TL mix', $1, ARRAY[$2]::uuid[]) 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() code, body := get(t, h, "/timeline") if code != 200 { t.Fatalf("GET /timeline → %d", code) } for _, want := range []string{ "Timeline todo today", "Two-day offsite", "(2 days)", // duration hint `hx-post="/dashboard/task/edit"`, // edit affordance on timeline VTODO row `hx-post="/dashboard/task/delete"`, // delete affordance `hx-post="/dashboard/task/done"`, // complete affordance } { if !strings.Contains(body, want) { t.Errorf("timeline body missing %q", want) } } } // TestTimelineFilterByTagAppliesAcrossKinds seeds two items in different areas, // each with a dated link; ?tag=work should narrow the timeline to the work- // tagged item only. func TestTimelineFilterByTagAppliesAcrossKinds(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"), ".", "") var dev, home 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, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil { t.Fatalf("home: %v", err) } mkItem := func(parent, slug, tag string) string { var id string if err := pool.QueryRow(ctx, `insert into projax.items (kind, title, slug, parent_ids, tags) values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[]) returning id`, "X "+slug, slug, parent, tag, ).Scan(&id); err != nil { t.Fatalf("seed %s: %v", slug, err) } if _, err := pool.Exec(ctx, `insert into projax.item_links (item_id, ref_type, ref_id, rel, event_date) values ($1, 'document', $2, 'contains', current_date)`, id, "https://example.com/"+slug, ); err != nil { t.Fatalf("link %s: %v", slug, err) } return id } devID := mkItem(dev, "tl-tag-d-"+stamp, "tl-tag-work-"+stamp) homeID := mkItem(home, "tl-tag-h-"+stamp, "tl-tag-life-"+stamp) defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID) tag := "tl-tag-work-" + stamp _, body := get(t, h, "/timeline?tag="+tag) // Phase 5i Slice A: the project picker renders every item path as a //