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
//