package web_test import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/m/projax/gitea" "github.com/m/projax/web" ) // TestDashboardRendersWithoutDeps asserts that GET /dashboard renders cleanly // when CalDAV + Gitea are both disabled (no integrations wired). The handler // should still render the three card scaffolds and "Nothing" copy. func TestDashboardRendersWithoutDeps(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() code, body := get(t, h, "/dashboard") if code != 200 { t.Fatalf("GET /dashboard → %d body=%s", code, body) } // Empty-card collapse (phase 3g) replaces full card chrome with a // one-line "No open tasks." style note when there is no filter active // AND zero rows. So the body should contain the collapsed strings. for _, want := range []string{ `id="dashboard-section"`, `No open tasks`, `No open issues`, `No recent documents`, } { if !strings.Contains(body, want) { t.Errorf("dashboard missing %q", want) } } } // TestDashboardRecentDocsSurfacesDatedLinks seeds an item + a dated item_link // (event_date today), then asserts the dashboard's Recent Documents card // surfaces the row. func TestDashboardRecentDocsSurfacesDatedLinks(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 := "dash-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[], 'Dash 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/dash-doc-"+stamp, fmt.Sprintf("dash test %s", stamp), ); err != nil { t.Fatalf("seed link: %v", err) } code, body := get(t, h, "/dashboard") if code != 200 { t.Fatalf("GET /dashboard → %d", code) } wantPER := "dev." + slug + "." + time.Now().UTC().Format("060102") if !strings.Contains(body, wantPER) { t.Errorf("dashboard body missing PER %q (event_date today should surface)", wantPER) } } // TestDashboardFilterByTagNarrowsCard seeds two items in different areas, each // with a dated link, then asserts /dashboard?tag=dev only shows the dev one. func TestDashboardFilterByTagNarrowsCard(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 } devSlug := "filter-dev-" + stamp homeSlug := "filter-home-" + stamp devID := mkItem(dev, devSlug, "dev") homeID := mkItem(home, homeSlug, "home") defer func() { for _, id := range []string{devID, homeID} { _, _ = pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) } }() code, body := get(t, h, "/dashboard?tag=dev") if code != 200 { t.Fatalf("GET /dashboard?tag=dev → %d", code) } if !strings.Contains(body, "dev."+devSlug) { t.Errorf("expected dev row in filtered dashboard") } if strings.Contains(body, "home."+homeSlug) { t.Errorf("home row should be filtered out when ?tag=dev") } } // TestDashboardRefreshBustsCache asserts that ?refresh=1 invalidates the // cache entry for the matching filter key: the response no longer says // "cached" even when called within the 60s TTL of a preceding fetch. func TestDashboardRefreshBustsCache(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() // Prime the cache. _, _ = get(t, h, "/dashboard") // Second hit shows cached label. _, cachedBody := get(t, h, "/dashboard") if !strings.Contains(cachedBody, "cached") { t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:600]) } // Third hit with ?refresh=1 should be fresh again. code, body := get(t, h, "/dashboard?refresh=1") if code != 200 { t.Fatalf("GET /dashboard?refresh=1 → %d", code) } if strings.Contains(body, "cached") { t.Errorf("refresh=1 should bust cache — body still contains 'cached'") } if !strings.Contains(body, "fresh") { t.Errorf("refresh=1 response should be 'fresh'") } } // TestDashboardCollapsesEmptyCardsWhenNoFilter checks the 3g empty-collapse // behaviour: when there are zero rows AND no filter active, cards render as // one-line "No open tasks" muted notes instead of the full card chrome. func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() code, body := get(t, h, "/dashboard") if code != 200 { t.Fatalf("GET /dashboard → %d", code) } if !strings.Contains(body, "card-collapsed") { t.Errorf("expected at least one card-collapsed inline note (no rows + no filter)") } // Card chrome should NOT appear for the collapsed sections. if strings.Contains(body, `class="card card-tasks"`) { t.Errorf("card-tasks should be collapsed when no tasks and no filter") } } // TestDashboardFilterKeepsFullCardChrome inverse of the above: with a filter // active the cards stay rendered even when empty, so m can tell whether the // filter is hiding data or there genuinely isn't any. func TestDashboardFilterKeepsFullCardChrome(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz") if code != 200 { t.Fatalf("GET /dashboard?tag=… → %d", code) } if !strings.Contains(body, `class="card card-tasks"`) { t.Errorf("filter active should keep card-tasks chrome rendered") } } // TestDashboardStaleCardSurfacesDormantMaiProject seeds a mai-managed item // linked to a fake Gitea repo whose updated_at is 90 days ago. With no open // tasks or issues, the stale card must list this item. func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") slug := "stale-fix-" + stamp repoRef := "fake-org/" + slug // Fake Gitea server returning 90-days-old updated_at for the repo above // and an empty issue list. /repos/.../issues is called by collectIssues // even when 0 issues — the handler still needs to return []. old := time.Now().AddDate(0, 0, -90).UTC().Format(time.RFC3339) mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/fake-org/"+slug+"/issues", func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "[]") }) mux.HandleFunc("/api/v1/repos/fake-org/"+slug, func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{"full_name":"fake-org/`+slug+`","updated_at":"`+old+`","empty":false}`) }) fake := httptest.NewServer(mux) defer fake.Close() srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok")) 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, management) values (array['project']::text[], 'stale', $1, ARRAY[$2]::uuid[], ARRAY['mai']) 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, 'gitea-repo', $2, 'tracks')`, id, repoRef, ); err != nil { t.Fatalf("seed link: %v", err) } h := srv.Routes() code, body := get(t, h, "/dashboard") if code != 200 { t.Fatalf("GET /dashboard → %d", code) } if !strings.Contains(body, "card-stale") { t.Fatalf("expected stale card to render — body lacks 'card-stale'") } if !strings.Contains(body, "/i/dev."+slug) { t.Errorf("expected stale list to include /i/dev.%s", slug) } } // TestDashboardStaleCardSkipsRecentRepo asserts the inverse: an item whose // linked repo has a recent updated_at is NOT flagged as stale. func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") slug := "fresh-fix-" + stamp repoRef := "fake-org/" + slug recent := time.Now().AddDate(0, 0, -3).UTC().Format(time.RFC3339) mux := http.NewServeMux() mux.HandleFunc("/api/v1/repos/fake-org/"+slug+"/issues", func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "[]") }) mux.HandleFunc("/api/v1/repos/fake-org/"+slug, func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{"full_name":"fake-org/`+slug+`","updated_at":"`+recent+`","empty":false}`) }) fake := httptest.NewServer(mux) defer fake.Close() srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok")) 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, management) values (array['project']::text[], 'fresh', $1, ARRAY[$2]::uuid[], ARRAY['mai']) 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, 'gitea-repo', $2, 'tracks')`, id, repoRef, ); err != nil { t.Fatalf("seed link: %v", err) } h := srv.Routes() _, body := get(t, h, "/dashboard") if strings.Contains(body, "/i/dev."+slug) { t.Errorf("recent repo should NOT surface in stale card — body contains /i/dev.%s", slug) } } // TestDashboardCacheHitOnSecondLoad asserts the in-memory TTL cache returns // the same payload (and marks Cached=true) on the second request within 60s. func TestDashboardCacheHitOnSecondLoad(t *testing.T) { srv, pool := mustServer(t) defer pool.Close() h := srv.Routes() _, _ = get(t, h, "/dashboard") code, body := get(t, h, "/dashboard") if code != 200 { t.Fatalf("second GET /dashboard → %d", code) } if !strings.Contains(body, "cached") { n := len(body) if n > 500 { n = 500 } t.Errorf("second load should hit cache (look for 'cached' label) — body:\n%s", body[:n]) } }