Files
member-console/internal/server/operator_domains_render_test.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

239 lines
9.7 KiB
Go

// DB-free render coverage for the operator Domains moderation list's
// force-release affordance: offered exactly where the registry would admit
// it. A rendered control the server always refuses is the dead-end-affordance
// failure mode (issues.md 2026-07-26 — the domains walkthrough timed out on
// a placed claim's button).
//
// Also covers ux-ia-naming tasks 9.1-9.3: the placements section, the
// terminal-claims ledger section, and the name-policy / abandonment-scope
// copy, all of which render from static OperatorDomainsData fixtures here
// rather than a live database.
package server
import (
"database/sql"
"html/template"
"io/fs"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
func renderOperatorDomains(t *testing.T, data OperatorDomainsData) string {
t.Helper()
sub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("sub templates FS: %v", err)
}
tmpl, err := template.New("operator-domains").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(sub, "operator_domains.html")
if err != nil {
t.Fatalf("parse operator_domains.html: %v", err)
}
tmpl = template.Must(web.ParseUIPartials(tmpl))
rec := httptest.NewRecorder()
NewSafeTemplates(tmpl, slog.Default()).Render(rec, "operator_domains.html", data)
if rec.Code != 200 {
t.Fatalf("render = %d: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// TestOperatorDomainsForceReleaseOnlyWhereAdmissible pins the affordance to
// the registry's own guards: unplaced non-root claims get the button; placed
// claims read "in use"; operator roots read "operator root" even when they
// hold placements (roots always do — every hosted site places under one).
func TestOperatorDomainsForceReleaseOnlyWhereAdmissible(t *testing.T) {
body := renderOperatorDomains(t, OperatorDomainsData{
Claims: []OperatorDomainClaimRow{
{ClaimID: "claim-free", Root: "spare.example.org", Kind: "external",
Status: "active", Releasable: true},
{ClaimID: "claim-used", Root: "busy.example.org", Kind: "external",
Status: "active", Placements: 2, Placed: true},
{ClaimID: "claim-root", Root: "hosting.test", Kind: "operator_root",
Status: "active", Placements: 5},
},
})
if !strings.Contains(body, "/partials/operator/domains/claim-free/force-release") {
t.Error("unplaced non-root claim must offer force-release")
}
if strings.Contains(body, "/partials/operator/domains/claim-used/force-release") {
t.Error("placed claim must not offer force-release — the registry's placement guard refuses it")
}
if strings.Contains(body, "/partials/operator/domains/claim-root/force-release") {
t.Error("operator root must not offer force-release")
}
if !strings.Contains(body, ">in use<") {
t.Error("placed claim should be labeled 'in use'")
}
if !strings.Contains(body, ">operator root<") {
t.Error("operator root should keep its label even when it holds placements")
}
if got := strings.Count(body, "Force-release {{"); got != 0 {
t.Errorf("unexecuted template fragments in output: %d", got)
}
}
// TestOperatorDomainsScopeCopyPresent pins task 9.3's copy: the name-policy
// panel states its own boundary (carving inside operator roots only) and
// where external claims are governed instead, and every abandonment tally is
// labeled "abandonment scope" — never bare "scope" — with the count-wide /
// clear-narrow width rule stated inline (invariant 10, the "single most
// damaging misreading" the model card calls out).
func TestOperatorDomainsScopeCopyPresent(t *testing.T) {
body := renderOperatorDomains(t, OperatorDomainsData{
Policy: OperatorDomainPolicyView{
ClaimWindow: "1d",
PendingCap: "5",
AbandonBudget: "3",
AbandonWindow: "7d",
ScopeLabels: "2",
InitiationBudget: "10",
},
})
for _, want := range []string{
"Name policy governs carving inside operator roots only",
"External claims are governed separately, by",
"verification and the abuse apparatus",
"Abandonment scope budget",
"Abandonment scope width",
"Abandonments count against the whole scope root and everything beneath it",
"Verifying a name clears only that name's own subtree",
} {
if !strings.Contains(body, want) {
t.Errorf("operator Domains page does not say %q", want)
}
}
if em := strings.Count(body, ">Scope labels<") + strings.Count(body, ">Abandonments per scope<"); em != 0 {
t.Error("policy card still uses the old bare-scope field labels")
}
}
// TestOperatorDomainsPlacementsSectionRenders pins task 9.1: the placements
// section lists the exact names actually being served, each with the
// provider resource it is wired to and its servable state — deliberately a
// different table from the claims list above it (a claim can hold a name
// that serves nothing at all).
func TestOperatorDomainsPlacementsSectionRenders(t *testing.T) {
body := renderOperatorDomains(t, OperatorDomainsData{
Placements: []OperatorPlacementRow{
{Name: "wiki.example.org", Provider: "fedwiki", ResourceRef: "site-abc123", Servable: true},
{Name: "archived.example.org", Provider: "fedwiki", ResourceRef: "site-def456", Servable: false},
},
})
for _, want := range []string{
"Placements",
"wiki.example.org", "fedwiki", "site-abc123",
"archived.example.org", "site-def456",
} {
if !strings.Contains(body, want) {
t.Errorf("placements section does not mention %q", want)
}
}
if !strings.Contains(body, ">Servable<") {
t.Error("servable placement should be labeled 'Servable'")
}
if !strings.Contains(body, ">Unservable<") {
t.Error("unservable placement should be labeled 'Unservable'")
}
empty := renderOperatorDomains(t, OperatorDomainsData{})
if !strings.Contains(empty, "No placements") {
t.Error("empty placements list should say so, not render a broken table")
}
}
// TestOperatorDomainsTerminalClaimsSectionRenders pins task 9.2: expired,
// canceled, and released claims are reachable in a segregated section (kept
// out of the live-claims table so live claims stay prominent), each terminal
// external claim carrying an Evidence column and an Outcome column derived
// from the ledger, never from Status.
func TestOperatorDomainsTerminalClaimsSectionRenders(t *testing.T) {
body := renderOperatorDomains(t, OperatorDomainsData{
TerminalClaims: []OperatorTerminalClaimRow{
{ClaimID: "c-abandoned", Root: "squatted.example.org", Kind: "external",
Status: "expired", OrgName: "Acme Co",
Evidence: false, Outcome: "abandoned"},
{ClaimID: "c-system", Root: "outage.example.org", Kind: "external",
Status: "canceled", OrgName: "Acme Co",
Evidence: false, Outcome: "rolled back by the system"},
{ClaimID: "c-walked", Root: "proven.example.org", Kind: "external",
Status: "released", OrgName: "Acme Co",
Evidence: true, Outcome: "walked away with evidence published"},
{ClaimID: "c-member", Root: "carved.hosting.test", Kind: "member",
Status: "released", OrgName: "Acme Co",
Evidence: false, Outcome: ""},
},
})
if !strings.Contains(body, "Claim history (4)") {
t.Error("claim history summary should count the terminal claims")
}
for _, want := range []string{
"squatted.example.org", "abandoned",
"outage.example.org", "rolled back by the system",
"proven.example.org", "walked away with evidence published",
"carved.hosting.test",
} {
if !strings.Contains(body, want) {
t.Errorf("claim history does not mention %q", want)
}
}
// "canceled" must never itself read as blame — the three phrases above
// are the ones that carry blame, and the bare status badge is not one of
// them (dimension "Status vs blame").
if strings.Contains(body, "canceled by") {
t.Error("status must not be dressed up as blame in the ledger table")
}
empty := renderOperatorDomains(t, OperatorDomainsData{})
if !strings.Contains(empty, "No expired, canceled, or released claims yet") {
t.Error("empty terminal-claims list should say so, not render a broken table")
}
if !strings.Contains(empty, "Claim history (0)") {
t.Error("claim history summary should read 0 when there is none")
}
}
// TestDomainTerminalOutcome unit-tests the derivation directly (task 9.2):
// blame is read from the ledger columns in a fixed priority
// (system_canceled_at, then abandoned_at, then evidence_at), never from
// status, and only for external claims — the ledger governs external claims
// only (dimension "Name policy vs abuse policy").
func TestDomainTerminalOutcome(t *testing.T) {
valid := sql.NullTime{Valid: true}
none := sql.NullTime{}
cases := []struct {
name string
kind string
evidenceAt, abandonedAt, systemCanceledAt sql.NullTime
want string
}{
{"system rollback wins even with other columns unset", domains.KindExternal, none, none, valid, "rolled back by the system"},
{"abandoned, no evidence", domains.KindExternal, none, valid, none, "abandoned"},
{"force-cancel abandons even over evidence", domains.KindExternal, valid, valid, none, "abandoned"},
{"evidence published, no abandonment", domains.KindExternal, valid, none, none, "walked away with evidence published"},
{"ledger untouched", domains.KindExternal, none, none, none, "—"},
{"member claim carries no outcome", domains.KindMember, valid, valid, valid, ""},
{"operator root carries no outcome", domains.KindOperatorRoot, none, none, none, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := domainTerminalOutcome(tc.kind, tc.evidenceAt, tc.abandonedAt, tc.systemCanceledAt)
if got != tc.want {
t.Errorf("domainTerminalOutcome(%s, ...) = %q, want %q", tc.kind, got, tc.want)
}
})
}
}