- 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
205 lines
8.8 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|