Files
member-console/internal/server/member_nav_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

205 lines
8.8 KiB
Go

package server_test
import (
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
)
// renderMemberShell renders a member page through the root template set
// with the given shell, the way the /, /products, and /billing handlers do.
func renderMemberShell(t *testing.T, page string, shell server.Shell) string {
t.Helper()
st := rootTemplates(t)
rec := httptest.NewRecorder()
switch page {
case "index.html":
st.Render(rec, page, dashboardData{Shell: shell})
case "products.html":
st.Render(rec, page, server.ProductsPageData{Shell: shell})
default:
st.Render(rec, page, server.BillingPageData{Shell: shell})
}
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
func memberShell(active string, operator bool) server.Shell {
return server.Shell{
Surface: server.ShellMember,
Name: "Alice Admin",
Username: "alice",
Email: "alice@example.test",
KeycloakAccountURL: "https://idp.example.test/account",
IsOperator: operator,
Active: active,
}
}
// TestShellAccountMenu pins chrome-conventions "Session controls live in
// the account menu on both surfaces" and the unchanged "The
// identity-provider handoff is marked subtly" on the member surface: the
// top bar's account menu is the only home of Identity and Access and Sign
// out, in that order around a divider, under a trigger that names the
// person; the handoff keeps its SVG marker nowrap-bound to the label and
// its plain-words tooltip; nothing session-related renders in the rail.
func TestShellAccountMenu(t *testing.T) {
for _, page := range []string{"index.html", "products.html", "billing.html"} {
t.Run(page, func(t *testing.T) {
body := renderMemberShell(t, page, memberShell("dashboard", true))
if got := strings.Count(body, `class="dropdown-menu dropdown-menu-end"`); got != 1 {
t.Fatalf("expected one account menu, got %d", got)
}
if !strings.Contains(body, `data-bs-toggle="dropdown" aria-expanded="false"><span class="app-account-label">Alice Admin</span></button>`) {
t.Error("the account menu's trigger must be a button labelled with the person's display name")
}
if !strings.Contains(body, `<h6 class="dropdown-header">Alice Admin<br><span class="fw-normal">alice@example.test</span></h6>`) {
t.Error("the menu must open with a non-interactive name-and-email header")
}
// The identity-provider handoff, unchanged in substance: one
// SVG marker glued to the label's last word, one tooltip, no
// ↗ character, no inline explanation copy.
if got := strings.Count(body, `Identity and <span class="text-nowrap">Access<svg class="external-link-icon"`); got != 1 {
t.Errorf("expected the IdP handoff icon nowrap-bound to the label exactly once, got %d", got)
}
if got := strings.Count(body, `title="Manage your account and sign-in. Opens in a new tab."`); got != 1 {
t.Errorf("expected the IdP handoff tooltip exactly once, got %d", got)
}
if strings.Contains(body, "↗") {
t.Error("the handoff marker must be the SVG icon, not the ↗ character")
}
if strings.Contains(body, "opens your identity provider") {
t.Error("inline handoff explanation copy must not render")
}
// Sign out: plain, last, and the only session-ending control.
signOut := `<a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a>`
if got := strings.Count(body, signOut); got != 1 {
t.Errorf("expected exactly one plain Sign out item, got %d", got)
}
if strings.Contains(body, "text-danger") {
t.Error("Sign out must not carry the destructive colour")
}
if strings.Contains(body, "Logout") || strings.Contains(body, "Log out") {
t.Error("the session-ending label is Sign out; Logout must not render")
}
idpIdx := strings.Index(body, `href="https://idp.example.test/account"`)
divIdx := strings.Index(body, `<hr class="dropdown-divider">`)
outIdx := strings.Index(body, signOut)
if idpIdx == -1 || divIdx == -1 || outIdx == -1 || !(idpIdx < divIdx && divIdx < outIdx) {
t.Error("the menu must read: header, Identity and Access, divider, Sign out")
}
// Nothing session-related outside the menu: exactly one
// /logout link and one account-console link on the page, and
// the old rail session block is gone.
if got := strings.Count(body, `href="/logout"`); got != 1 {
t.Errorf("expected one /logout link on the page, got %d", got)
}
if got := strings.Count(body, `https://idp.example.test/account`); got != 1 {
t.Errorf("expected one account-console link on the page, got %d", got)
}
if strings.Contains(body, `mt-2 pt-2 border-top`) {
t.Error("the rail's old session block must not render")
}
})
}
t.Run("trigger falls back to the username, then Account", func(t *testing.T) {
shell := memberShell("dashboard", false)
shell.Name = ""
body := renderMemberShell(t, "index.html", shell)
if !strings.Contains(body, `aria-expanded="false"><span class="app-account-label">alice</span></button>`) {
t.Error("with no display name the trigger must read the username")
}
shell.Username = ""
body = renderMemberShell(t, "index.html", shell)
if !strings.Contains(body, `aria-expanded="false"><span class="app-account-label">Account</span></button>`) {
t.Error("with neither name nor username the trigger must read Account")
}
})
}
// TestShellMemberRail pins chrome-conventions "One shell for both
// surfaces" and "The surface switch is a visible, mirrored rail entry" on
// the member surface: the rail is the responsive offcanvas element holding
// Dashboard, Products, Billing with the current page active, then the
// separated "Operator panel" switch for operators only; the top bar carries
// the toggler for it and no section links.
func TestShellMemberRail(t *testing.T) {
cases := []struct{ page, active string }{
{"index.html", "dashboard"}, {"products.html", "products"}, {"billing.html", "billing"},
}
hrefs := map[string]string{"dashboard": "/", "products": "/products", "billing": "/billing"}
labels := map[string]string{"dashboard": "Dashboard", "products": "Products", "billing": "Billing"}
for _, tc := range cases {
t.Run(tc.page, func(t *testing.T) {
body := renderMemberShell(t, tc.page, memberShell(tc.active, true))
for _, want := range []string{
`<aside id="app-rail" class="app-sidebar offcanvas-lg offcanvas-start`,
`<div class="offcanvas-header">`,
`data-bs-toggle="offcanvas" data-bs-target="#app-rail" aria-controls="app-rail" aria-label="Open navigation"`,
`<script defer src="/static/shell.js"></script>`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
if strings.Contains(body, "navbar-collapse") || strings.Contains(body, "d-none d-lg-flex") {
t.Error("the old collapse list and hidden rail must not render")
}
// Only the drawer's close button dismisses; entries navigate
// (Bootstrap's data-bs-dismiss cancels an anchor's navigation).
if got := strings.Count(body, `data-bs-dismiss="offcanvas"`); got != 1 {
t.Errorf("expected exactly one offcanvas dismiss control (the close button), got %d", got)
}
// The active entry, and only it, carries active + aria-current.
for key, href := range hrefs {
active := `<a class="nav-link active" aria-current="page" href="` + href + `">` + labels[key] + `</a>`
plain := `<a class="nav-link" href="` + href + `">` + labels[key] + `</a>`
if key == tc.active {
if !strings.Contains(body, active) {
t.Errorf("expected %s active on %s", labels[key], tc.page)
}
} else if !strings.Contains(body, plain) {
t.Errorf("expected %s plain on %s", labels[key], tc.page)
}
}
// The top bar holds no section links: every Dashboard/Products/
// Billing link on the page is a rail entry.
if got := strings.Count(body, `>Dashboard</a>`); got != 1 {
t.Errorf("expected one Dashboard link (the rail's), got %d", got)
}
// The surface switch: separated, last, never active.
switchEntry := `<li class="nav-item app-rail-switch"><a class="nav-link" href="/operator">Operator panel</a></li>`
if got := strings.Count(body, switchEntry); got != 1 {
t.Errorf("expected the Operator panel switch exactly once for an operator, got %d", got)
}
if strings.Index(body, `>Billing</a>`) > strings.Index(body, switchEntry) {
t.Error("the surface switch must be the rail's last entry")
}
if strings.Contains(body, `>Operator</a>`) {
t.Error("the old Operator task entry must not render")
}
})
}
t.Run("no operator role: no switch", func(t *testing.T) {
body := renderMemberShell(t, "index.html", memberShell("dashboard", false))
if strings.Contains(body, "Operator panel") || strings.Contains(body, `href="/operator"`) {
t.Error("a non-operator must not see the Operator panel switch")
}
})
}