package server import ( "bytes" "errors" "html/template" "io/fs" "net/http/httptest" "strings" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) func TestParseListParamsClamping(t *testing.T) { cases := []struct { url string wantQ string wantF string wantPage int }{ {"/operator/grants", "", "", 1}, {"/operator/grants?q=acme&state=live&page=3", "acme", "live", 3}, {"/operator/grants?page=abc", "", "", 1}, {"/operator/grants?page=-4", "", "", 1}, {"/operator/grants?page=0", "", "", 1}, } for _, tc := range cases { r := httptest.NewRequest("GET", tc.url, nil) p := ParseListParams(r, "state") if p.Q != tc.wantQ || p.Facet != tc.wantF || p.Page != tc.wantPage { t.Errorf("%s: got %+v, want q=%q facet=%q page=%d", tc.url, p, tc.wantQ, tc.wantF, tc.wantPage) } } // A list with no facet parameter never reads one. r := httptest.NewRequest("GET", "/operator/persons?state=live", nil) if p := ParseListParams(r, ""); p.Facet != "" { t.Errorf("facet must stay empty when the list declares no facet param, got %q", p.Facet) } } func TestFetchPageClampsPastTheEnd(t *testing.T) { calls := []int32{} load := func(limit, offset int32) ([]string, int64, error) { calls = append(calls, offset) if offset >= 60 { return nil, 0, nil // past the end: no rows, and no total knowledge } return []string{"row"}, 60, nil } p := &ListParams{Page: 9} rows, total, err := FetchPage(p, load) if err != nil || len(rows) != 1 || total != 60 { t.Fatalf("clamped fetch: rows=%d total=%d err=%v", len(rows), total, err) } if p.Page != 1 { t.Errorf("page must clamp to 1 after an out-of-range fetch, got %d", p.Page) } if len(calls) != 2 || calls[1] != 0 { t.Errorf("expected a re-run at offset 0, calls=%v", calls) } wantErr := errors.New("boom") _, _, err = FetchPage(&ListParams{Page: 1}, func(_, _ int32) ([]string, int64, error) { return nil, 0, wantErr }) if !errors.Is(err, wantErr) { t.Errorf("load errors must surface, got %v", err) } } func TestListNavMathAndURLs(t *testing.T) { n := ListNav{BasePath: "/operator/grants", FacetParam: "state", Q: "a b", Facet: "live", Page: 2, Total: 120} if n.Pages() != 3 || n.From() != 51 || n.To() != 100 { t.Errorf("math: pages=%d from=%d to=%d", n.Pages(), n.From(), n.To()) } if got := n.Showing(); got != "Showing 51–100 of 120" { t.Errorf("Showing() = %q", got) } if !n.HasPrev() || !n.HasNext() { t.Error("page 2 of 3 must have both prev and next") } // URLs preserve the rest of the state and escape the term. if got := n.NextURL(); got != "/operator/grants?page=3&q=a+b&state=live" { t.Errorf("NextURL = %q", got) } if got := n.FacetURL("inactive"); got != "/operator/grants?q=a+b&state=inactive" { t.Errorf("FacetURL = %q", got) } if got := n.FacetURL(""); got != "/operator/grants?q=a+b" { t.Errorf("FacetURL clear = %q", got) } if got := n.ClearSearchURL(); got != "/operator/grants?state=live" { t.Errorf("ClearSearchURL = %q", got) } empty := ListNav{BasePath: "/operator/persons", Total: 0, Page: 1} if empty.Pages() != 1 || empty.From() != 0 || empty.To() != 0 || empty.Showing() != "" { t.Errorf("empty list math: pages=%d from=%d to=%d showing=%q", empty.Pages(), empty.From(), empty.To(), empty.Showing()) } if empty.Filtered() { t.Error("empty unfiltered list must not report Filtered") } exact := ListNav{BasePath: "/x", Page: 2, Total: 100} if exact.Pages() != 2 || exact.To() != 100 || exact.HasNext() { t.Errorf("exact-multiple math: pages=%d to=%d hasNext=%v", exact.Pages(), exact.To(), exact.HasNext()) } } func TestValidFacet(t *testing.T) { opts := []FacetOption{{Value: "live", Label: "Live"}, {Value: "inactive", Label: "Inactive"}} if got := ValidFacet("live", opts); got != "live" { t.Errorf("valid value rejected: %q", got) } if got := ValidFacet("bogus", opts); got != "" { t.Errorf("unknown facet value must be ignored, got %q", got) } } // listControlsTemplates builds the operator partial set the same way the // smoke test does, so the shared list-controls defines render exactly as // they will in handlers. func listControlsTemplates(t *testing.T) *template.Template { t.Helper() partialsSub, err := fs.Sub(embeds.Templates, "templates/partials") if err != nil { t.Fatalf("fs.Sub partials: %v", err) } tmpl := template.New("t").Funcs(template.FuncMap{ "renderBody": func(string, any) (template.HTML, error) { return "", nil }, "routeURL": web.RouteURL, "fieldErr": func(_, _, _, _ string, _, _ any) string { return "" }, "stripeEntityURL": func(string, string) string { return "" }, "deploymentName": config.DeploymentName, }) tmpl, err = tmpl.ParseFS(partialsSub, "operator_*.html") if err != nil { t.Fatalf("ParseFS partials: %v", err) } return tmpl } func renderListDefine(t *testing.T, name string, nav ListNav) string { t.Helper() var buf bytes.Buffer if err := listControlsTemplates(t).ExecuteTemplate(&buf, name, nav); err != nil { t.Fatalf("ExecuteTemplate %s: %v", name, err) } return buf.String() } func TestListControlsRender(t *testing.T) { nav := ListNav{ BasePath: "/operator/grants", SearchPlaceholder: "Search organization or product", FacetParam: "state", FacetOptions: []FacetOption{{Value: "live", Label: "Live"}, {Value: "inactive", Label: "Inactive"}}, Q: "acme", Facet: "live", Page: 2, Total: 120, } out := renderListDefine(t, "listControls", nav) for _, want := range []string{ `action="/operator/grants"`, `name="q" value="acme"`, `name="state" value="live"`, // hidden input keeps the facet through a search `>Clear`, `>All`, } { if !strings.Contains(out, want) { t.Errorf("listControls missing %q in:\n%s", want, out) } } if !strings.Contains(out, `active" href="/operator/grants?q=acme">All`) && !strings.Contains(out, `small active" href="/operator/grants?q=acme&state=live">Live`) { t.Errorf("active facet pill not marked in:\n%s", out) } pager := renderListDefine(t, "listPager", nav) for _, want := range []string{ "Showing 51–100 of 120", "Page 2 of 3", ">Previous", ">Next", } { if !strings.Contains(pager, want) { t.Errorf("listPager missing %q in:\n%s", want, pager) } } if pagerEmpty := renderListDefine(t, "listPager", ListNav{BasePath: "/x", Page: 1, Total: 0}); strings.Contains(pagerEmpty, "Showing") { t.Errorf("empty list must render no Showing line, got:\n%s", pagerEmpty) } noMatch := renderListDefine(t, "listNoMatch", nav) if !strings.Contains(noMatch, "No rows match") || !strings.Contains(noMatch, "acme") { t.Errorf("listNoMatch must name the term, got:\n%s", noMatch) } if !strings.Contains(noMatch, `href="/operator/grants"`) { t.Errorf("listNoMatch must link the bare list, got:\n%s", noMatch) } }