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.
204 lines
6.9 KiB
Go
204 lines
6.9 KiB
Go
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</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&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 51–100 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)
|
||
}
|
||
}
|