Files
member-console/internal/server/list_scale_test.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

204 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 51100 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</a>`,
`>All</a>`,
} {
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&amp;state=live">Live</a>`) {
t.Errorf("active facet pill not marked in:\n%s", out)
}
pager := renderListDefine(t, "listPager", nav)
for _, want := range []string{
"Showing 51100 of 120",
"Page 2 of 3",
">Previous</a>",
">Next</a>",
} {
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)
}
}