Files
member-console/internal/server/operator_overview_render_test.go
T
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00

951 lines
39 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.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"bytes"
"context"
"errors"
"html/template"
"io"
"io/fs"
"log/slog"
"regexp"
"strconv"
"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/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/google/uuid"
)
// Render coverage for the operator landing surface's overview regions ("At a
// glance" + "System"). Template-level only: it executes operator.html against
// hand-built OperatorPageData, so it needs no database and runs in every
// environment. The loader that fills that data (loadOverview) is exercised
// separately by the pure-function tests at the bottom of this file.
// overviewTemplate parses the operator template set the same way
// NewOperatorPartialsHandler does — partials first, then the shell — so this
// test fails on the same parse errors production would hit.
func overviewTemplate(t *testing.T) *template.Template {
t.Helper()
sub, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub partials: %v", err)
}
tmpl := template.New("operator").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,
"pageTitle": pageTitle,
"supportURL": config.SupportURL,
"helpIcon": helpIcon,
})
if tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html"))); err != nil {
t.Fatalf("ParseFS partials: %v", err)
}
if tmpl, err = tmpl.ParseFS(sub, "operator.html"); err != nil {
t.Fatalf("ParseFS operator.html: %v", err)
}
// The operator surface's root crumb (design D18); see server.go's own
// override for why this must run after ParseUIPartials.
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": OperatorSurfaceRoot})
return tmpl
}
func renderOperator(t *testing.T, data OperatorPageData) string {
t.Helper()
var buf bytes.Buffer
if err := overviewTemplate(t).ExecuteTemplate(&buf, "operator.html", data); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
return buf.String()
}
// landingRegion slices the rendered document down to the landing surface
// itself. The navbar, sidebar, toast container and confirmation modal are
// outside <main> (or outside #operator-body) and carry their own markup —
// including an <h5> in the modal — so assertions about this page's structure
// must not see them.
func landingRegion(t *testing.T, out string) string {
t.Helper()
const openTag = `<div id="operator-body">`
start := strings.Index(out, openTag)
if start < 0 {
t.Fatalf("rendered output has no #operator-body region")
}
end := strings.Index(out, "</main>")
if end < start {
t.Fatalf("rendered output has no </main> after #operator-body")
}
return out[start+len(openTag) : end]
}
// populatedOverview is a representative landing payload: all three tiles
// available, and an Integrations card mixing a configured provider, an
// unconfigured one, and Stripe (configured, draining queue work) — the
// same mix the "Integrations card lists every provider with one status"
// scenario describes (operator-panel-navigation).
func populatedOverview() OperatorPageData {
return OperatorPageData{
IAPosition: "runtime:landing",
Overview: OverviewData{
Stats: []OverviewStat{
{Label: "People", Value: "1,204", Caption: "212 joined in the last 30 days", Href: "/operator/persons", Available: true},
{Label: "Team organizations", Value: "38", Caption: "Personal orgs excluded", Href: "/operator/organizations", Available: true},
{Label: "Monthly recurring", Value: "$2,295.00", Caption: "205 active subscriptions; 9 trialing", Href: "/operator/billing/subscriptions", Available: true},
},
Integrations: OverviewIntegrationsCard{
Rows: []OverviewIntegrationRow{
{IntegrationRow: IntegrationRow{
Key: "fedwiki", DisplayName: "FedWiki", Kind: "provisioning", Status: "active",
SurfacePath: "/operator/integrations/fedwiki", Configured: true,
}},
{IntegrationRow: IntegrationRow{
Key: "discourse", DisplayName: "Discourse", Kind: "provisioning", Status: "active",
SettingsPath: "/operator/integrations/discourse/settings", Configured: false,
}},
{
IntegrationRow: IntegrationRow{
Key: "stripe", DisplayName: "Stripe", Kind: "payment", Status: "active",
SurfacePath: "/operator/integrations/stripe", Configured: true,
},
StripeFacts: &OverviewStripeFacts{ModeLabel: "Test mode", Queued: 4, Attention: 0, Available: true},
},
},
},
},
Activity: []ActivityEvent{{
EventType: "grant_issued",
Timestamp: "Jan 2, 2026 3:04 PM",
OrgID: "org-1",
OrgName: "Example Org",
Summary: "Issued Example Plan (promo)",
Href: "/operator/organizations/org-1",
}},
// ActivityNav pages the feed like the composite's Tier changes
// (overview-consistency D5, round 2); the fixture's one row is the
// whole (unpaged) window's length.
ActivityNav: ListNav{
BasePath: "/operator",
ParamPrefix: "act_",
Page: 1,
Total: 1,
PerPage: embeddedListDefaultPerPage,
DefaultPerPage: embeddedListDefaultPerPage,
PerPageOptions: perPageOptions,
Target: "#activity-panel",
},
}
}
// The landing surface's lookup is a wide bar: one input group spanning the
// content column, with the magnifier, the control and an outline "Look up"
// on one line (design D20). Round 3 rendered it stacked and filled, a
// width and a weight no other search had (maintainer, 2026-09-04).
func TestOperatorOverviewLookupIsAWideBar(t *testing.T) {
data := populatedOverview()
data.LookupForm = lookupForm("")
out := landingRegion(t, renderOperator(t, data))
for _, want := range []string{
`class="app-form app-form-bar-wide"`,
`role="search"`,
`<div class="input-group">`,
`<span class="input-group-text"><svg`,
`class="btn btn-outline-secondary"><span class="spinner-border`,
`>Look up</button>`,
`class="form-label visually-hidden"`,
} {
if !strings.Contains(out, want) {
t.Errorf("the lookup is missing %q:\n%s", want, out)
}
}
for _, banned := range []string{"btn-primary", "app-form-box", "app-form-fields", "(optional)"} {
if strings.Contains(out, banned) {
t.Errorf("the lookup rendered %q, which no search bar carries:\n%s", banned, out)
}
}
}
func TestOperatorOverviewRendersStatTiles(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
for _, want := range []string{
`<h2 class="h5 mb-0">At a glance</h2>`,
// Each tile is a readout part inside a linked card
// (overview-consistency D3, round 2): the card is the anchor,
// marked by the chevron glyph, and the value is at the console's
// one readout size. The label is the eyebrow, never a link.
`<a class="card h-100 text-decoration-none text-body" href="/operator/persons">`,
`<div class="card-body d-flex align-items-center justify-content-between gap-3">`,
`<dt class="small fw-semibold text-uppercase text-body-secondary">People</dt>`,
`<svg class="app-chevron-icon"`,
`<dd class="fs-3 fw-semibold mb-0">1,204</dd>`, // grouped value, formatted in Go
`<small class="text-body-secondary">212 joined in the last 30 days</small>`,
"Team organizations", // deliberately created orgs, not personal ones
`href="/operator/organizations"`, // tile drills into its section
"Monthly recurring", // the money tile (design D3, round 2 restore)
`<dd class="fs-3 fw-semibold mb-0">$2,295.00</dd>`,
`href="/operator/billing/subscriptions"`,
} {
if !strings.Contains(out, want) {
t.Errorf("landing surface missing %q", want)
}
}
// The retired tile markup: the label carrying the link style, a number
// bigger than the page title, and an anchor inside the card's own
// anchor.
for _, banned := range []string{"link-primary", "display-6"} {
if strings.Contains(out, banned) {
t.Errorf("landing surface still carries the retired tile markup %q", banned)
}
}
// The headline row is reserved for the three daily reads
// (operator-panel-navigation "Headline row carries only the three daily
// reads"): open-invoice, delivery, and catalog counts must not render
// here.
for _, forbidden := range []string{"Open invoices", "Delivering grants", "Catalog products"} {
if strings.Contains(out, forbidden) {
t.Errorf("landing surface still renders the removed tile %q", forbidden)
}
}
// A tile with no Href must not render as a link (an unresolvable count
// destination renders a static card, never an empty link target).
if strings.Contains(out, `href=""`) {
t.Errorf("hrefless stat tile rendered an empty link target")
}
}
// TestOperatorOverviewTileChevronMarksOnlyLinkedTiles covers page-anatomy
// "A linked card is marked by a glyph and the row's hover": the chevron
// glyph marks a tile that is a link; a tile with no browse surface (no
// .Href) carries no chevron (overview-consistency D3, round 2).
func TestOperatorOverviewTileChevronMarksOnlyLinkedTiles(t *testing.T) {
data := OperatorPageData{
Overview: OverviewData{Stats: []OverviewStat{
{Label: "Linked", Value: "1", Href: "/operator/persons", Available: true},
{Label: "Unlinked", Value: "2", Available: true},
}},
}
out := landingRegion(t, renderOperator(t, data))
if n := strings.Count(out, `<svg class="app-chevron-icon"`); n != 1 {
t.Errorf("expected exactly 1 chevron glyph, one per linked tile, got %d in:\n%s", n, out)
}
if !strings.Contains(out, `<div class="card h-100">`) {
t.Errorf("the unlinked tile must render a plain, unmarked card, got:\n%s", out)
}
}
// An unavailable count must render an em dash. Rendering 0 would be an
// actively misleading answer to "how many organizations are there".
func TestOperatorOverviewUnavailableStatIsNotZero(t *testing.T) {
data := OperatorPageData{
Overview: OverviewData{Stats: []OverviewStat{
{Label: "Organizations", Caption: "Active organizations", Href: "/operator/organizations", Available: false},
}},
}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, "&mdash;") {
t.Errorf("unavailable stat did not render an em dash")
}
if !strings.Contains(out, "Count unavailable") {
t.Errorf("unavailable stat did not explain itself")
}
// The tile still renders its label and link; only the number is withheld.
if !strings.Contains(out, "Organizations") {
t.Errorf("unavailable stat dropped its label")
}
if regexp.MustCompile(`fs-3[^>]*>\s*0\s*<`).MatchString(out) {
t.Errorf("unavailable stat rendered a zero value")
}
}
// TestOperatorOverviewFeedIsARecordTable covers overview-consistency D5:
// the activity feed is a record table like every other, with a plain header
// row, body-size cells, and the Summary as the row's primary cell placed
// after the event kind.
func TestOperatorOverviewFeedIsARecordTable(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
for _, want := range []string{
`<h2 class="h5 mb-0">Recent activity</h2>`,
`<div id="activity-panel">`,
`<table class="table table-hover table-sm table-record">`,
`<td class="text-nowrap">Jan 2, 2026 3:04 PM</td>`,
`<th scope="row"><a href="/operator/organizations/org-1">Issued Example Plan (promo)</a></th>`,
// The feed pages through the list scaffold's pager, the same shape
// as the composite's Tier changes (overview-consistency D5, round
// 2), replacing the retired ?limit parameter.
"Showing 11 of 1",
} {
if !strings.Contains(out, want) {
t.Errorf("the activity feed is missing %q:\n%s", want, out)
}
}
// The header row is plain, like every other table's (F5b): the styled
// uppercase row is gone.
if !regexp.MustCompile(`(?s)<thead>\s*<tr>`).MatchString(out) {
t.Errorf("the activity feed's header row still carries classes:\n%s", out)
}
table := out[strings.Index(out, "<table"):]
if strings.Contains(table, "<small") {
t.Errorf("the activity feed still renders a cell at small size:\n%s", table)
}
// Column order: Summary, Event, Organization, Actor, When
// (overview-consistency D5, round 2: "I wanna move summary there").
cols := regexp.MustCompile(`<th scope="col">([^<]+)</th>`).FindAllStringSubmatch(out, -1)
got := make([]string, 0, len(cols))
for _, c := range cols {
got = append(got, c[1])
}
want := []string{"Summary", "Event", "Organization", "Actor", "When"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("activity columns = %v, want %v", got, want)
}
}
// TestOperatorOverviewSystemPanel covers design D3's System region: one
// Integrations card listing every registered provider, in registry order,
// each with its name link and single Status; Stripe's row alone adds its
// mode and outbox summary.
func TestOperatorOverviewSystemPanel(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
for _, want := range []string{
`<h2 class="h5 mb-0">System</h2>`,
// The card carries no title of its own: "Integrations" is a
// category, and a card title names an instance
// (overview-consistency D2). The providers are a flush list in the
// card body, the setup checklist's idiom.
`<div class="card-body">`,
`<ul class="list-group list-group-flush">`,
`<li class="list-group-item px-0 py-2">`,
"FedWiki",
`href="/operator/integrations/fedwiki"`,
`<span class="badge text-bg-success`, // FedWiki's Configured badge
"Discourse",
`href="/operator/integrations/discourse/settings"`,
`<span class="badge text-bg-warning`, // Discourse's Not configured badge
"Stripe",
`href="/operator/integrations/stripe"`,
"Test mode",
"4 queued, 0 need attention",
} {
if !strings.Contains(out, want) {
t.Errorf("system panel missing %q", want)
}
}
// The card lists providers, never a dead-letter table or operation
// identifiers (design D3: that detail lives on the Stripe provider
// page, D24).
for _, forbidden := range []string{"Dead-letter", "op_", "operation id"} {
if strings.Contains(out, forbidden) {
t.Errorf("system panel leaked delivery-queue detail %q", forbidden)
}
}
// The registry-status badge must not read as a live health/connectivity
// claim (integration-settings spec: "cannot read as a health check").
if strings.Contains(out, "read live") || strings.Contains(out, "Connected") || strings.Contains(out, "connected to") {
t.Errorf("system panel implies a live health/connectivity check")
}
if strings.Contains(out, "<h3") {
t.Errorf("the System card carries a title of its own; a card title names an instance, not a category:\n%s", out)
}
}
// The System section header's one action, "Setup", always links to
// /operator/setup — setup complete or not, banner dismissed or not
// (operator-panel-navigation "The System header always offers Setup").
// Navigation, not an opener, so outline-secondary (design D19).
func TestOperatorSystemHeaderAlwaysOffersSetup(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
if !strings.Contains(out, `<a href="/operator/setup" class="btn btn-sm btn-outline-secondary">Setup</a>`) {
t.Errorf("System header did not render its Setup action, got:\n%s", out)
}
}
// Stripe not configured reads "Not configured" without alarm styling.
func TestOperatorOverviewStripeNotConfigured(t *testing.T) {
data := populatedOverview()
data.Overview.Integrations.Rows[2] = OverviewIntegrationRow{
IntegrationRow: IntegrationRow{
Key: "stripe", DisplayName: "Stripe", Status: "active",
SurfacePath: "/operator/integrations/stripe", Configured: false,
},
StripeFacts: &OverviewStripeFacts{ModeLabel: "Test mode", Available: true},
}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, `<span class="badge text-bg-warning`) {
t.Errorf("unconfigured Stripe did not render the not-configured badge")
}
if !strings.Contains(out, `href="/operator/integrations/stripe"`) {
t.Errorf("unconfigured Stripe row dropped its provider-page link")
}
}
// Dead-lettered outbox work is the one queue bucket nothing will clear on its
// own, so Stripe's row must call it out rather than showing it as another
// neutral number.
func TestOperatorOverviewStripeDeadLetterRaisesAlarm(t *testing.T) {
data := populatedOverview()
data.Overview.Integrations.Rows[2].StripeFacts = &OverviewStripeFacts{
ModeLabel: "Test mode", Queued: 2, Attention: 3, Available: true,
}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, "text-danger") {
t.Errorf("dead-lettered work did not render with alarm styling")
}
if !strings.Contains(out, "2 queued, 3 need attention") {
t.Errorf("Stripe row did not state the queued/attention counts")
}
}
// The landing surface is the first thing a fresh deployment shows, so every
// region needs a legible empty state rather than a blank panel.
func TestOperatorOverviewEmptyStates(t *testing.T) {
out := landingRegion(t, renderOperator(t, OperatorPageData{}))
for _, want := range []string{
"Counts are unavailable.",
"No activity recorded yet.",
"No integrations registered.",
} {
if !strings.Contains(out, want) {
t.Errorf("empty landing surface missing %q", want)
}
}
// The lookup affordance is the one thing that must work on an empty
// deployment, so it renders regardless. It names itself with
// aria-label rather than a heading (overview-consistency D1).
if !strings.Contains(out, `<section aria-label="Lookup">`) {
t.Errorf("empty landing surface dropped the lookup affordance")
}
// A zero-value Setup (no steps derived) has nothing to announce.
if strings.Contains(out, `id="setup-banner"`) {
t.Errorf("empty landing surface rendered the setup banner with no steps to report")
}
}
// TestOperatorLandingRegionOrder covers operator-panel-navigation ("Landing
// surface orders attention before totals"; maintainer 2026-08-23: the counts
// an operator checks daily outrank the system cards): the lookup precedes At
// a glance, At a glance precedes System, and System precedes Recent
// activity — so attention-shaped content is still never the page's last
// region. (The setup banner's own position, ahead of the lookup while it
// shows, is covered separately in operator_setup_render_test.go.)
func TestOperatorLandingRegionOrder(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
lookupIdx := strings.Index(out, `<section aria-label="Lookup">`)
glanceIdx := strings.Index(out, `<h2 class="h5 mb-0">At a glance</h2>`)
systemIdx := strings.Index(out, `<h2 class="h5 mb-0">System</h2>`)
activityIdx := strings.Index(out, `<h2 class="h5 mb-0">Recent activity</h2>`)
for name, idx := range map[string]int{"lookup": lookupIdx, "glance": glanceIdx, "system": systemIdx, "activity": activityIdx} {
if idx < 0 {
t.Fatalf("landing surface missing the %s region", name)
}
}
if !(lookupIdx < glanceIdx && glanceIdx < systemIdx && systemIdx < activityIdx) {
t.Errorf("landing regions out of order: lookup=%d glance=%d system=%d activity=%d, want lookup < glance < system < activity",
lookupIdx, glanceIdx, systemIdx, activityIdx)
}
}
// TestOperatorActivityFeedLinksToSubject covers operator-panel-navigation
// ("Feed entries link to their subjects"): a grant/transition entry's
// summary links to its organization's detail page, an invoice/payment
// entry's summary links to the corresponding billing view, and an entry
// with no addressable surface (Href empty) renders plain text rather than a
// dead link.
func TestOperatorActivityFeedLinksToSubject(t *testing.T) {
data := OperatorPageData{
Activity: []ActivityEvent{
{EventType: "grant_issued", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Issued Example Plan", Href: "/operator/organizations/org-1"},
{EventType: "invoice_created", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Invoice $10.00", Href: "/operator/billing/invoices"},
{EventType: "payment_received", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Payment $10.00", Href: "/operator/billing/payments"},
{EventType: "grant_issued", Timestamp: "t", Summary: "No addressable subject"},
},
}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, `<a href="/operator/organizations/org-1">Issued Example Plan</a>`) {
t.Errorf("grant entry summary must link to the org detail page, got:\n%s", out)
}
if !strings.Contains(out, `<a href="/operator/billing/invoices">Invoice $10.00</a>`) {
t.Errorf("invoice entry summary must link to the billing invoices view, got:\n%s", out)
}
if !strings.Contains(out, `<a href="/operator/billing/payments">Payment $10.00</a>`) {
t.Errorf("payment entry summary must link to the billing payments view, got:\n%s", out)
}
if strings.Contains(out, `<a href="">No addressable subject</a>`) {
t.Errorf("an entry with no addressable surface must not render a dead (empty-href) link, got:\n%s", out)
}
// The Summary is the row's primary cell (overview-consistency D5), so
// an entry with no addressable surface renders it as plain text in
// that cell, at body size.
if !strings.Contains(out, `<th scope="row">No addressable subject</th>`) {
t.Errorf("an entry with no addressable surface must render plain text in the primary cell, got:\n%s", out)
}
// Organization column always links straight to the org, independent of
// the row's own Href (which for invoice/payment rows points elsewhere).
if n := strings.Count(out, `<a href="/operator/organizations/org-1">Example Org</a>`); n != 3 {
t.Errorf("expected 3 Organization-column links to the org, got %d in:\n%s", n, out)
}
}
// The landing surface is the page screenshotted for the project README, so
// its document outline has to be defensible: exactly one H1, no skipped
// levels. The older operator pages run H1 straight to H6; that pattern must
// not spread here.
func TestOperatorOverviewHeadingHierarchy(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
matches := regexp.MustCompile(`(?i)<h([1-6])[\s>]`).FindAllStringSubmatch(out, -1)
if len(matches) == 0 {
t.Fatalf("landing surface rendered no headings at all")
}
levels := make([]int, 0, len(matches))
for _, m := range matches {
n, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("unparsable heading level %q", m[1])
}
levels = append(levels, n)
}
if levels[0] != 1 {
t.Errorf("first heading is h%d, want h1", levels[0])
}
h1Count := 0
for _, l := range levels {
if l == 1 {
h1Count++
}
}
if h1Count != 1 {
t.Errorf("found %d h1 elements, want exactly 1", h1Count)
}
for i := 1; i < len(levels); i++ {
if levels[i] > levels[i-1]+1 {
t.Errorf("heading level skips from h%d to h%d at position %d (levels: %v)",
levels[i-1], levels[i], i, levels)
}
}
// Guard the specific regression: no h4/h5/h6 on this surface at all.
for _, l := range levels {
if l > 3 {
t.Errorf("landing surface uses h%d; the overview outline stops at h3 (levels: %v)", l, levels)
}
}
}
// TestOperatorOverviewCarriesNoTrail covers design D18: the operator
// overview is one of exactly two surface roots (with the member dashboard)
// that carry no location trail, so it renders no breadcrumb nav at all —
// unlike every other operator page, which is rooted at the surface.
func TestOperatorOverviewCarriesNoTrail(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
if strings.Contains(out, `aria-label="breadcrumb"`) || strings.Contains(out, "breadcrumb-item") {
t.Errorf("the operator overview must render no breadcrumb nav, got:\n%s", out)
}
}
// Strict CSP: nothing on this surface may rely on an inline script, an inline
// event handler, or a style attribute.
func TestOperatorOverviewIsCSPClean(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
if regexp.MustCompile(`(?i)<script(\s[^>]*)?>[^<]`).MatchString(out) {
t.Errorf("landing surface contains an inline <script> body")
}
if m := regexp.MustCompile(`(?i)\son(click|load|change|submit|input|focus|error)\s*=`).FindString(out); m != "" {
t.Errorf("landing surface contains an inline event handler: %q", strings.TrimSpace(m))
}
if regexp.MustCompile(`(?i)\sstyle\s*=\s*"`).MatchString(out) {
t.Errorf("landing surface contains an inline style attribute")
}
}
// Brand neutrality: this is general-purpose OSS and the operator surface is
// the page a stranger evaluating the project sees first.
func TestOperatorShellIsBrandNeutral(t *testing.T) {
out := renderOperator(t, populatedOverview())
for _, forbidden := range []string{"Wiki Cafe", "wiki.cafe", "WikiCafe"} {
if strings.Contains(out, forbidden) {
t.Errorf("operator shell names a specific deployment: %q", forbidden)
}
}
if !strings.Contains(out, "<title>Operator overview - Member Console</title>") {
t.Errorf("operator shell lost its brand-neutral title")
}
}
// TestOperatorShellFlashSuccessToast covers design D20, round 2 ("a success
// toast, not a banner"): a create-then-land page carries its confirmation
// as OperatorPageData.FlashSuccess, which the shell pre-renders into the
// shared #successToast and marks data-show-on-load so success-toast.js's
// init shows it without any htmx-triggered event (the landing GET is a
// fresh top-level navigation, not an htmx exchange). Absent FlashSuccess,
// the toast renders its default body and no auto-show marker — it is
// wired but unshown, same as the error toast beside it.
func TestOperatorShellFlashSuccessToast(t *testing.T) {
out := renderOperator(t, OperatorPageData{FlashSuccess: "Product created."})
if !strings.Contains(out, `id="successToast"`) {
t.Fatalf("shell missing #successToast, got:\n%s", out)
}
if !strings.Contains(out, `data-show-on-load="true"`) {
t.Errorf("expected the toast marked data-show-on-load when FlashSuccess is set, got:\n%s", out)
}
bodyIdx := strings.Index(out, `id="successToastBody"`)
if bodyIdx < 0 || !strings.Contains(out[bodyIdx:bodyIdx+200], "Product created.") {
t.Errorf("expected the toast body to read Product created., got:\n%s", out)
}
if strings.Contains(out, "alert-success") {
t.Errorf("expected no dismissible alert-success banner anywhere in the shell, got:\n%s", out)
}
plain := renderOperator(t, OperatorPageData{})
if strings.Contains(plain, "data-show-on-load") {
t.Errorf("expected no auto-show marker when FlashSuccess is unset, got:\n%s", plain)
}
plainBodyIdx := strings.Index(plain, `id="successToastBody"`)
if plainBodyIdx < 0 || !strings.Contains(plain[plainBodyIdx:plainBodyIdx+50], "Done.") {
t.Errorf("expected the toast's default body Done. when FlashSuccess is unset, got:\n%s", plain)
}
}
func TestFormatCount(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0"},
{7, "7"},
{999, "999"},
{1000, "1,000"},
{1204, "1,204"},
{12480, "12,480"},
{999999, "999,999"},
{1000000, "1,000,000"},
{-4200, "-4,200"},
}
for _, tc := range cases {
if got := formatCount(tc.in); got != tc.want {
t.Errorf("formatCount(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The console's card idiom is a plain bordered card. The landing surface
// shipped with a bespoke elevation/hover treatment and lost it in
// operator-overview-polish; this pins the whole rendered shell, hero
// included, so it cannot come back silently.
func TestOperatorOverviewUsesPlainCardIdiom(t *testing.T) {
out := renderOperator(t, populatedOverview())
for _, forbidden := range []string{"shadow-sm", "stat-tile"} {
if strings.Contains(out, forbidden) {
t.Errorf("landing surface carries bespoke card styling %q", forbidden)
}
}
}
func TestPeopleCaption(t *testing.T) {
if got, want := peopleCaption(212, nil), "212 joined in the last 30 days"; got != want {
t.Errorf("peopleCaption = %q, want %q", got, want)
}
if got := peopleCaption(0, errors.New("boom")); !strings.Contains(got, "search above") {
t.Errorf("failed peopleCaption = %q, want the lookup fallback", got)
}
}
func TestRecurringCaption(t *testing.T) {
if got, want := recurringCaption(205, 9, nil, 1), "205 active subscriptions; 9 trialing"; got != want {
t.Errorf("recurringCaption = %q, want %q", got, want)
}
if got, want := recurringCaption(1, 0, nil, 1), "1 active subscription"; got != want {
t.Errorf("recurringCaption (singular) = %q, want %q", got, want)
}
if got, want := recurringCaption(3, 0, nil, 2), "3 active subscriptions; plus 1 more currency"; got != want {
t.Errorf("recurringCaption (extra bucket) = %q, want %q", got, want)
}
if got := recurringCaption(0, 0, errors.New("boom"), 0); got != "Active subscriptions" {
t.Errorf("failed recurringCaption = %q, want the fallback", got)
}
}
func TestNewMoneyStatPicksLargestBucket(t *testing.T) {
buckets := []moneyBucket{{currency: "usd", cents: 50000}, {currency: "usd", cents: 90000}}
stat := newMoneyStat("Monthly recurring", nil, buckets, "caption", "/href")
if stat.Value != "$900.00" {
t.Errorf("newMoneyStat picked the wrong bucket: %+v, want $900.00", stat)
}
if !stat.Available {
t.Errorf("newMoneyStat with no error reported itself unavailable")
}
empty := newMoneyStat("Monthly recurring", nil, nil, "caption", "/href")
if empty.Value != "0" {
t.Errorf("newMoneyStat with no buckets = %q, want the bare-digit fallback \"0\"", empty.Value)
}
failed := newMoneyStat("Monthly recurring", errors.New("boom"), buckets, "caption", "/href")
if failed.Available {
t.Errorf("newMoneyStat with an error reported itself available")
}
if failed.Value != "" {
t.Errorf("newMoneyStat with an error carried a value %q", failed.Value)
}
}
func TestOverviewStripeFactsNeedsAttention(t *testing.T) {
if (OverviewStripeFacts{Queued: 99, Available: true}).NeedsAttention() {
t.Errorf("queued work must not raise an alarm on its own")
}
if !(OverviewStripeFacts{Attention: 1, Available: true}).NeedsAttention() {
t.Errorf("dead-lettered work must raise an alarm")
}
}
func TestNewOverviewStatWithholdsValueOnError(t *testing.T) {
ok := newOverviewStat("Organizations", nil, 42, "caption", "/operator/organizations")
if !ok.Available || ok.Value != "42" {
t.Errorf("successful stat = %+v, want available with value 42", ok)
}
bad := newOverviewStat("Organizations", errors.New("boom"), 42, "caption", "/operator/organizations")
if bad.Available {
t.Errorf("failed stat reported itself available")
}
if bad.Value != "" {
t.Errorf("failed stat carried a value %q; the template must have nothing to print", bad.Value)
}
}
// The headline row is reserved for the three daily reads
// (operator-panel-navigation "Headline row carries only the three daily
// reads"): pinning loadOverview's labels and order keeps a feature tile — a
// removed billing/catalog one, or a future equivalent — from slipping back
// in without failing here. The fakes embed their Querier interfaces, so
// loadOverview calling any count not stubbed below panics, which doubles as
// a check on what the loader is allowed to read.
type overviewIdentityQ struct{ identity.Querier }
func (overviewIdentityQ) CountActivePersons(context.Context) (int64, error) { return 1204, nil }
func (overviewIdentityQ) CountPersonsJoinedLast30Days(context.Context, uuid.NullUUID) (int64, error) {
return 212, nil
}
type overviewOrgQ struct{ organization.Querier }
func (overviewOrgQ) CountTeamOrganizations(context.Context) (int64, error) { return 38, nil }
// ListOrganizationsByType backs the People tile's system-person exclusion
// (systemPersonID); an empty result means no System tenant is provisioned
// in this fake, so the tile counts every active person unchanged.
func (overviewOrgQ) ListOrganizationsByType(context.Context, string) ([]organization.Organization, error) {
return nil, nil
}
func TestLoadOverviewTileSet(t *testing.T) {
h := &OperatorHandler{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
OrgQ: overviewOrgQ{},
IdentityQ: overviewIdentityQ{},
}
overview := h.loadOverview(context.Background())
stats := overview.Stats
got := make([]string, len(stats))
for i, s := range stats {
got[i] = s.Label
}
want := []string{"People", "Team organizations", "Monthly recurring"}
if len(got) != len(want) {
t.Fatalf("tile labels = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("tile %d = %q, want %q (full set %v)", i, got[i], want[i], got)
}
}
if ppl := stats[0]; ppl.Value != "1,204" || ppl.Caption != "212 joined in the last 30 days" || ppl.Href != "/operator/persons" {
t.Errorf("People tile = %+v, want 1,204 with the 30-day trend caption and the directory link", ppl)
}
if team := stats[1]; team.Value != "38" || team.Caption != "Personal orgs excluded" || team.Href != "/operator/organizations" {
t.Errorf("Team organizations tile = %+v, want 38 with the personal-orgs-excluded caption", team)
}
// A handler with no BillingQ (as this fake is) degrades the money tile
// to unavailable rather than panicking.
if recurring := stats[2]; recurring.Available {
t.Errorf("Monthly recurring tile reported itself available with no BillingQ: %+v", recurring)
}
// House style for this row: captions use no em dashes (the unavailable
// value marker is separate, spec-mandated semantics).
for _, s := range stats {
if strings.Contains(s.Caption, "—") {
t.Errorf("tile %q caption %q contains an em dash", s.Label, s.Caption)
}
}
// A handler with no IntegrationQ (as this fake is) degrades the System
// region to its empty state rather than panicking.
if len(overview.Integrations.Rows) != 0 {
t.Errorf("Integrations card = %+v, want no rows with no IntegrationQ", overview.Integrations)
}
}
// TestLoadOverviewExcludesSystemPerson covers ACC-10: the People tile
// excludes the reserved system person the directory also hides, detected
// structurally (the owner of the System tenant), never by name or email.
func TestLoadOverviewExcludesSystemPerson(t *testing.T) {
h := &OperatorHandler{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
OrgQ: systemPersonOrgQ{
systemPersonID: "11111111-1111-7111-8111-111111111111",
},
IdentityQ: systemPersonIdentityQ{
systemPersonID: "11111111-1111-7111-8111-111111111111",
total: 5,
},
}
stats := h.loadOverview(context.Background()).Stats
if stats[0].Value != "4" {
t.Errorf("People tile = %q, want 4 (5 active persons minus the excluded system person)", stats[0].Value)
}
}
type systemPersonOrgQ struct {
organization.Querier
systemPersonID string
}
func (q systemPersonOrgQ) ListOrganizationsByType(context.Context, string) ([]organization.Organization, error) {
return []organization.Organization{{OwnerPersonID: q.systemPersonID}}, nil
}
func (systemPersonOrgQ) CountTeamOrganizations(context.Context) (int64, error) { return 0, nil }
type systemPersonIdentityQ struct {
identity.Querier
systemPersonID string
total int64
}
func (q systemPersonIdentityQ) CountActivePersons(context.Context) (int64, error) {
return q.total, nil
}
func (systemPersonIdentityQ) CountPersonsJoinedLast30Days(context.Context, uuid.NullUUID) (int64, error) {
return 0, nil
}
func (q systemPersonIdentityQ) GetPersonByID(_ context.Context, personID string) (identity.Person, error) {
if personID == q.systemPersonID {
return identity.Person{PersonID: personID, Status: "active"}, nil
}
return identity.Person{}, errors.New("not found")
}
// TestLoadOverviewCaptionExcludesSystemPersonInsideWindow covers design D3:
// the caption takes the same exclude_person_id parameter the headline
// resolves, so a system person created inside the 30-day window (as it is on
// a fresh deployment) never inflates the caption the way it would if the
// query only filtered by status. The fake's CountPersonsJoinedLast30Days
// models the real query: it only excludes the row when the resolved id
// arrives, exactly like WHERE person_id <> $1.
func TestLoadOverviewCaptionExcludesSystemPersonInsideWindow(t *testing.T) {
const sysID = "11111111-1111-7111-8111-111111111111"
h := &OperatorHandler{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
OrgQ: systemPersonOrgQ{
systemPersonID: sysID,
},
IdentityQ: windowIdentityQ{
systemPersonID: sysID,
activeTotal: 14, // the system person plus thirteen members
joinedWithSystem: 14, // system person created inside the window too
joinedWithoutSystem: 13,
},
}
stats := h.loadOverview(context.Background()).Stats
if stats[0].Value != "13" {
t.Errorf("People headline = %q, want 13 (14 active minus the excluded system person)", stats[0].Value)
}
if stats[0].Caption != "13 joined in the last 30 days" {
t.Errorf("People caption = %q, want \"13 joined in the last 30 days\"", stats[0].Caption)
}
}
// TestLoadOverviewCaptionUnaffectedWhenSystemPersonOutsideWindow covers the
// spec's second scenario: a system person created more than 30 days ago
// already falls outside CountPersonsJoinedLast30Days's date filter, so
// passing its id as the exclude parameter changes nothing. The fake returns
// the same count whether or not the exclude parameter arrives, modeling a
// row the date filter alone already drops.
func TestLoadOverviewCaptionUnaffectedWhenSystemPersonOutsideWindow(t *testing.T) {
const sysID = "22222222-2222-7222-8222-222222222222"
h := &OperatorHandler{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
OrgQ: systemPersonOrgQ{
systemPersonID: sysID,
},
IdentityQ: windowIdentityQ{
systemPersonID: sysID,
activeTotal: 3, // the system person plus two members
joinedWithSystem: 2, // the system person is outside the window either way
joinedWithoutSystem: 2,
},
}
stats := h.loadOverview(context.Background()).Stats
if stats[0].Caption != "2 joined in the last 30 days" {
t.Errorf("People caption = %q, want \"2 joined in the last 30 days\"", stats[0].Caption)
}
}
// windowIdentityQ models CountPersonsJoinedLast30Days's exclude_person_id
// parameter: joinedWithoutSystem is what the fake returns when the resolved
// system person id arrives (as the real query's WHERE person_id <> $1
// would produce), joinedWithSystem when it does not.
type windowIdentityQ struct {
identity.Querier
systemPersonID string
activeTotal int64
joinedWithSystem int64
joinedWithoutSystem int64
}
func (q windowIdentityQ) CountActivePersons(context.Context) (int64, error) {
return q.activeTotal, nil
}
func (q windowIdentityQ) CountPersonsJoinedLast30Days(_ context.Context, exclude uuid.NullUUID) (int64, error) {
if exclude.Valid && exclude.UUID.String() == q.systemPersonID {
return q.joinedWithoutSystem, nil
}
return q.joinedWithSystem, nil
}
func (q windowIdentityQ) GetPersonByID(_ context.Context, personID string) (identity.Person, error) {
if personID == q.systemPersonID {
return identity.Person{PersonID: personID, Status: "active"}, nil
}
return identity.Person{}, errors.New("not found")
}