Files
member-console/internal/server/operator_domains_render_test.go
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- Restructure operator sidebar into a flat task list with indented
  children; fold plan topology into plan ladders
- Expand member catalog non-plan section to all published non-tier
  products; require recurring Stripe-mapped prices for purchase
- Add operator domains placements and terminal-claims ledger; redirect
  /domains to the FedWiki Sites Domains anchor
- Apply canonical vocabulary and chrome/form conventions; migrate seeded
  FedWiki Sites display name
2026-08-23 17:12:42 -05:00

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