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.
643 lines
26 KiB
Go
643 lines
26 KiB
Go
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/billing"
|
|
"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/entitlements"
|
|
"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"
|
|
)
|
|
|
|
// 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,
|
|
})
|
|
if tmpl, err = 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)
|
|
}
|
|
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: every tile
|
|
// available, a mix of healthy and unhealthy integrations, and a draining
|
|
// queue.
|
|
func populatedOverview() OperatorPageData {
|
|
return OperatorPageData{
|
|
CSRFToken: "csrf",
|
|
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},
|
|
{Label: "Open invoices", Value: "3", Caption: "$372.00 outstanding; issued and awaiting payment", Href: "/operator/billing/invoices", Available: true},
|
|
{Label: "Delivering grants", Value: "7", Caption: "Plus 80 signup defaults", Href: "/operator/grants", Available: true},
|
|
},
|
|
Providers: []OverviewProvider{
|
|
{Slug: "alpha", DisplayName: "Alpha", Kind: "provisioning", Status: "active", SurfacePath: "/operator/alpha-sites", Healthy: true, Configured: true},
|
|
{Slug: "beta", DisplayName: "Beta", Kind: "billing", Status: "disabled", MissingKeysText: "beta-api-key"},
|
|
},
|
|
Queue: OverviewQueue{Pending: 4, Retrying: 1, 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",
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestOperatorOverviewRendersStatTiles(t *testing.T) {
|
|
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
|
|
|
for _, want := range []string{
|
|
"At a glance",
|
|
"People",
|
|
`href="/operator/persons"`, // tile drills into the directory (ux-operator-scale)
|
|
"1,204", // grouped value, formatted in Go
|
|
"Team organizations", // deliberately created orgs, not personal ones
|
|
`href="/operator/organizations"`, // tile drills into its section
|
|
"Monthly recurring", // commitment as money, not a bare count
|
|
"$2,295.00", // largest-currency-bucket value
|
|
`href="/operator/billing/subscriptions"`,
|
|
"Delivering grants", // operator-issued grants; defaults ride the caption
|
|
"Plus 80 signup defaults", // composition caption
|
|
`href="/operator/grants"`,
|
|
"Open invoices", // receivables tile
|
|
`href="/operator/billing/invoices"`, // drills into the invoices page
|
|
"$372.00 outstanding", // caption leads with the balance
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("landing surface missing %q", want)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
CSRFToken: "csrf",
|
|
Overview: OverviewData{Stats: []OverviewStat{
|
|
{Label: "Organizations", Caption: "Active organizations", Href: "/operator/organizations", Available: false},
|
|
}},
|
|
}
|
|
out := landingRegion(t, renderOperator(t, data))
|
|
|
|
if !strings.Contains(out, "—") {
|
|
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(`display-6[^>]*>\s*0\s*<`).MatchString(out) {
|
|
t.Errorf("unavailable stat rendered a zero value")
|
|
}
|
|
}
|
|
|
|
func TestOperatorOverviewSystemPanel(t *testing.T) {
|
|
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
|
|
|
for _, want := range []string{
|
|
"System",
|
|
"Integrations",
|
|
"Alpha",
|
|
`href="/operator/alpha-sites"`, // provider with a surface links to it
|
|
"provisioning", // provider kind is shown
|
|
`<span class="badge text-bg-success`, // healthy provider
|
|
"Beta",
|
|
`<span class="badge text-bg-warning`, // unhealthy provider
|
|
"Not configured", // Beta's configuration-readiness signal
|
|
"Delivery queue",
|
|
"Stripe", // outbox coverage caption names what it tracks
|
|
"Pending",
|
|
"Retrying",
|
|
"Dead-letter",
|
|
"draining normally",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("system panel missing %q", want)
|
|
}
|
|
}
|
|
// A provider without a surface path must not become a dead link.
|
|
if strings.Contains(out, `href="">Beta</a>`) {
|
|
t.Errorf("surfaceless provider rendered an empty link")
|
|
}
|
|
// Alpha is fully configured; only Beta's row should carry the
|
|
// not-configured marker (ux-honest-surfaces: the signal is a negative
|
|
// marker, never a redundant positive claim).
|
|
if n := strings.Count(out, "Not configured"); n != 1 {
|
|
t.Errorf("system panel shows %d \"Not configured\" markers, want exactly 1 (Beta only)", n)
|
|
}
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// Dead-lettered outbox work is the one queue bucket nothing will clear on its
|
|
// own, so it must be called out rather than shown as another neutral number.
|
|
func TestOperatorOverviewDeadLetterRaisesAlarm(t *testing.T) {
|
|
data := populatedOverview()
|
|
data.Overview.Queue = OverviewQueue{Pending: 2, Retrying: 0, DeadLetter: 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, "exhausted its retries") {
|
|
t.Errorf("dead-letter state did not explain what an operator must do")
|
|
}
|
|
if strings.Contains(out, "draining normally") {
|
|
t.Errorf("dead-lettered queue still claimed to be draining normally")
|
|
}
|
|
}
|
|
|
|
// 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{CSRFToken: "csrf"}))
|
|
|
|
for _, want := range []string{
|
|
"Counts are unavailable.",
|
|
"No activity recorded yet.",
|
|
"No integrations registered.",
|
|
"Queue health is unavailable.",
|
|
} {
|
|
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.
|
|
if !strings.Contains(out, "Find a person or organization") {
|
|
t.Errorf("empty landing surface dropped the lookup affordance")
|
|
}
|
|
}
|
|
|
|
// 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): setup (while required
|
|
// steps are incomplete) 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.
|
|
func TestOperatorLandingRegionOrder(t *testing.T) {
|
|
data := populatedOverview()
|
|
data.Setup = SetupState{Steps: []SetupStep{{Key: "step", Required: true, Complete: false}}}
|
|
out := landingRegion(t, renderOperator(t, data))
|
|
|
|
setupIdx := strings.Index(out, `id="overview-setup-heading"`)
|
|
systemIdx := strings.Index(out, `id="overview-system-heading"`)
|
|
glanceIdx := strings.Index(out, `id="overview-glance-heading"`)
|
|
activityIdx := strings.Index(out, `id="overview-activity-heading"`)
|
|
|
|
for name, idx := range map[string]int{"setup": setupIdx, "system": systemIdx, "glance": glanceIdx, "activity": activityIdx} {
|
|
if idx < 0 {
|
|
t.Fatalf("landing surface missing the %s region", name)
|
|
}
|
|
}
|
|
if !(setupIdx < glanceIdx && glanceIdx < systemIdx && systemIdx < activityIdx) {
|
|
t.Errorf("landing regions out of order: setup=%d glance=%d system=%d activity=%d, want setup < glance < system < activity",
|
|
setupIdx, glanceIdx, systemIdx, activityIdx)
|
|
}
|
|
}
|
|
|
|
// TestOperatorLandingGlanceBeforeSystemEvenWithoutSetup covers the same
|
|
// requirement's scenario directly (the metric tiles render before the
|
|
// system-status region) for the common case where setup is not showing.
|
|
func TestOperatorLandingGlanceBeforeSystemEvenWithoutSetup(t *testing.T) {
|
|
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
|
systemIdx := strings.Index(out, `id="overview-system-heading"`)
|
|
glanceIdx := strings.Index(out, `id="overview-glance-heading"`)
|
|
if systemIdx < 0 || glanceIdx < 0 {
|
|
t.Fatalf("landing surface missing System or At a glance region")
|
|
}
|
|
if glanceIdx >= systemIdx {
|
|
t.Errorf("At a glance must render before System status (glance=%d system=%d)", glanceIdx, systemIdx)
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
CSRFToken: "csrf",
|
|
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)
|
|
}
|
|
if !strings.Contains(out, `<small>No addressable subject</small>`) {
|
|
t.Errorf("an entry with no addressable surface must render plain text, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
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 "Delivering grants" caption states its composition (signup defaults
|
|
// vs deliberate grants) without echoing the headline, which the
|
|
// grant-backed provision count equals by construction.
|
|
func TestDeliveringGrantsCaptionStatesComposition(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
defaults int64
|
|
err error
|
|
want string
|
|
}{
|
|
{"plural", 2589, nil, "Plus 2,589 signup defaults"},
|
|
{"singular", 1, nil, "Plus 1 signup default"},
|
|
{"unavailable", 0, errors.New("boom"), "Signup defaults excluded"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := deliveringGrantsCaption(tc.defaults, tc.err)
|
|
if got != tc.want {
|
|
t.Errorf("deliveringGrantsCaption(%d, %v) = %q, want %q", tc.defaults, tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Money never crosses currencies on this surface: largest bucket wins the
|
|
// headline, the caption acknowledges the rest, and formatting matches the
|
|
// member-facing price renderer.
|
|
func TestMoneyStatPicksLargestBucket(t *testing.T) {
|
|
stat := newMoneyStat("Monthly recurring", nil, []moneyBucket{
|
|
{currency: "eur", cents: 4200},
|
|
{currency: "usd", cents: 229500},
|
|
}, "caption", "/operator/billing/subscriptions")
|
|
if !stat.Available || stat.Value != "$2,295.00" {
|
|
t.Errorf("money stat = %+v, want available $2,295.00 (largest bucket)", stat)
|
|
}
|
|
|
|
empty := newMoneyStat("Monthly recurring", nil, nil, "caption", "")
|
|
if !empty.Available || empty.Value != "0" {
|
|
t.Errorf("no-subscriptions money stat = %+v, want available bare 0", empty)
|
|
}
|
|
|
|
failed := newMoneyStat("Monthly recurring", errors.New("boom"), nil, "caption", "")
|
|
if failed.Available || failed.Value != "" {
|
|
t.Errorf("failed money stat = %+v, want unavailable with no value", failed)
|
|
}
|
|
}
|
|
|
|
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(205, 0, nil, 3),
|
|
"205 active subscriptions; plus 2 more currencies"; got != want {
|
|
t.Errorf("multi-currency recurringCaption = %q, want %q", got, want)
|
|
}
|
|
if got, want := recurringCaption(0, 0, errors.New("boom"), 0),
|
|
"Active subscriptions"; got != want {
|
|
t.Errorf("failed recurringCaption = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOpenInvoicesCaption(t *testing.T) {
|
|
if got, want := openInvoicesCaption([]moneyBucket{{currency: "usd", cents: 37200}}, nil),
|
|
"$372.00 outstanding; issued and awaiting payment"; got != want {
|
|
t.Errorf("openInvoicesCaption = %q, want %q", got, want)
|
|
}
|
|
if got, want := openInvoicesCaption(nil, nil),
|
|
"Issued and awaiting payment"; got != want {
|
|
t.Errorf("empty openInvoicesCaption = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The headline row is reserved for core capability state
|
|
// (operator-overview-polish D5): pinning loadOverview's labels and order
|
|
// keeps a feature tile — the removed domain-claims 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 overviewBillingQ struct{ billing.Querier }
|
|
|
|
func (overviewBillingQ) CountLiveSubscriptionsByStatus(context.Context) (billing.CountLiveSubscriptionsByStatusRow, error) {
|
|
return billing.CountLiveSubscriptionsByStatusRow{ActiveCount: 205, TrialingCount: 9}, nil
|
|
}
|
|
func (overviewBillingQ) SumMonthlyRecurringByCurrency(context.Context) ([]billing.SumMonthlyRecurringByCurrencyRow, error) {
|
|
return []billing.SumMonthlyRecurringByCurrencyRow{{Currency: "usd", MonthlyCents: 229500}}, nil
|
|
}
|
|
func (overviewBillingQ) CountOpenInvoices(context.Context) (int64, error) { return 3, nil }
|
|
func (overviewBillingQ) SumOpenInvoiceBalanceByCurrency(context.Context) ([]billing.SumOpenInvoiceBalanceByCurrencyRow, error) {
|
|
return []billing.SumOpenInvoiceBalanceByCurrencyRow{{Currency: "usd", OutstandingCents: 37200}}, nil
|
|
}
|
|
func (overviewBillingQ) CountPublishedProducts(context.Context) (int64, error) { return 5, nil }
|
|
|
|
type overviewIdentityQ struct{ identity.Querier }
|
|
|
|
func (overviewIdentityQ) CountActivePersons(context.Context) (int64, error) { return 1204, nil }
|
|
func (overviewIdentityQ) CountPersonsJoinedLast30Days(context.Context) (int64, error) {
|
|
return 212, nil
|
|
}
|
|
|
|
type overviewOrgQ struct{ organization.Querier }
|
|
|
|
func (overviewOrgQ) CountTeamOrganizations(context.Context) (int64, error) { return 38, nil }
|
|
|
|
type overviewEntitlementsQ struct{ entitlements.Querier }
|
|
|
|
func (overviewEntitlementsQ) CountDeliveringOperatorGrants(context.Context) (int64, error) {
|
|
return 7, nil
|
|
}
|
|
func (overviewEntitlementsQ) CountDeliveringDefaultGrants(context.Context) (int64, error) {
|
|
return 80, nil
|
|
}
|
|
|
|
func TestLoadOverviewTileSet(t *testing.T) {
|
|
h := &OperatorHandler{
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
BillingQ: overviewBillingQ{},
|
|
EntitlementsQ: overviewEntitlementsQ{},
|
|
OrgQ: overviewOrgQ{},
|
|
IdentityQ: overviewIdentityQ{},
|
|
}
|
|
stats := h.loadOverview(context.Background()).Stats
|
|
|
|
got := make([]string, len(stats))
|
|
for i, s := range stats {
|
|
got[i] = s.Label
|
|
}
|
|
want := []string{"People", "Team organizations", "Monthly recurring", "Open invoices", "Delivering grants", "Catalog products"}
|
|
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 mrr := stats[2]; mrr.Value != "$2,295.00" || mrr.Href != "/operator/billing/subscriptions" ||
|
|
mrr.Caption != "205 active subscriptions; 9 trialing" {
|
|
t.Errorf("Monthly recurring tile = %+v, want $2,295.00 with the active/trialing caption", mrr)
|
|
}
|
|
if inv := stats[3]; inv.Value != "3" || inv.Caption != "$372.00 outstanding; issued and awaiting payment" {
|
|
t.Errorf("Open invoices tile = %+v, want count 3 with the outstanding caption", inv)
|
|
}
|
|
if ppl := stats[0]; ppl.Caption != "212 joined in the last 30 days" || ppl.Href != "/operator/persons" {
|
|
t.Errorf("People tile = %+v, want the 30-day trend caption and the directory link", ppl)
|
|
}
|
|
if g := stats[4]; g.Value != "7" || g.Caption != "Plus 80 signup defaults" {
|
|
t.Errorf("Delivering grants tile = %+v, want operator-issued count 7 with defaults in the caption", g)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 TestOverviewQueueNeedsAttention(t *testing.T) {
|
|
if (OverviewQueue{Pending: 99, Retrying: 12, Available: true}).NeedsAttention() {
|
|
t.Errorf("pending and retrying work must not raise an alarm on its own")
|
|
}
|
|
if !(OverviewQueue{DeadLetter: 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)
|
|
}
|
|
}
|