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)
}
}