- store.RecentDocuments(since, limit) returns dated item_links + parent item - web/dashboard.go handler aggregates VTODOs + Gitea issues + dated links across every linked item, fanout via 4-worker goroutine pool, 60s TTL cache keyed by encoded TreeFilter - Tasks card: bucketed Overdue/Today/Tomorrow/Week/NoDue, sort by bucket then due asc; ✓ button completes via existing PutTodo path + busts cache - Issues card: read-only, reuses GiteaDeps.Cache - Recent docs card: last-30d event_date links, canonical PER rendered - Filter chips on top reuse tree_filter URL params (tag/mgmt/has) - nav adds "dashboard" link; design.md §"Dashboard" documents the surface - 4 integration tests (empty render, dated-link surfacing, tag filter, cache hit)
156 lines
5.0 KiB
Go
156 lines
5.0 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
for _, want := range []string{
|
|
`id="dashboard-section"`,
|
|
`Open tasks`,
|
|
`Open issues`,
|
|
`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")
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
}
|