Files
member-console/internal/server/member_nav_test.go
T
cgalo5758 3283df19b1 Move account menu into mobile rail drawer
Below lg the top bar now holds the brand and one toggler; the account
items render in the drawer as a second, labelled list from a shared
partial, so both surfaces cannot drift. Desktop unchanged.
2026-09-09 22:57:26 -05:00

417 lines
19 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
import (
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/viper"
"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,
}
}
// topBarRegion slices a rendered member page down to the shell's top bar,
// and railRegion down to the rail element. The account menu has two homes
// now, one per width (chrome-conventions "Only one account menu renders"),
// so an assertion about either must name the region it means: a body-wide
// match would be satisfied by the wrong one.
func topBarRegion(t *testing.T, body string) string {
t.Helper()
return regionBetween(t, body, `<nav class="navbar`, "</nav>", "top bar")
}
func railRegion(t *testing.T, body string) string {
t.Helper()
return regionBetween(t, body, `<aside id="app-rail"`, "</aside>", "rail")
}
func regionBetween(t *testing.T, body, open, close, name string) string {
t.Helper()
start := strings.Index(body, open)
if start < 0 {
t.Fatalf("rendered page has no %s", name)
}
rest := body[start:]
end := strings.Index(rest, close)
if end < 0 {
t.Fatalf("rendered %s has no closing %s", name, close)
}
return rest[:end]
}
// 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))
bar := topBarRegion(t, body)
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(bar, `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(bar, `<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.
for _, region := range []struct{ name, html string }{{"top bar", bar}, {"rail", railRegion(t, body)}} {
if got := strings.Count(region.html, `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 in the %s, got %d", region.name, got)
}
if got := strings.Count(region.html, `title="Manage your account and sign-in. Opens in a new tab."`); got != 1 {
t.Errorf("expected the IdP handoff tooltip exactly once in the %s, got %d", region.name, 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")
}
// Get help renders only when support-url is configured
// (chrome-conventions "Get help appears only with a
// destination"); unset by default in this render.
if strings.Contains(body, ">Get help<") {
t.Error("Get help must not render when support-url is unset")
}
// Sign out: plain, last, and the only session-ending control.
signOut := `<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`
if got := strings.Count(bar, signOut); got != 1 {
t.Errorf("expected exactly one plain Sign out item in the bar, 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(bar, `href="https://idp.example.test/account"`)
divIdx := strings.Index(bar, `<hr class="dropdown-divider">`)
outIdx := strings.Index(bar, 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 account menu's two
// homes: one /logout control and one account-console link in
// each, and nowhere else on the page. Sign out posts at both
// widths; a link to /logout would be a GET that ends nothing.
if got := strings.Count(body, `hx-post="/logout"`); got != 2 {
t.Errorf("expected two /logout controls on the page (the bar's and the drawer's), got %d", got)
}
if strings.Contains(body, `href="/logout"`) {
t.Error("Sign out must post, never link to /logout")
}
if got := strings.Count(body, `https://idp.example.test/account`); got != 2 {
t.Errorf("expected two account-console links on the page (the bar's and the drawer's), 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")
}
})
}
// TestShellAccountMenuGetHelp covers chrome-conventions "Session controls
// live in the account menu on both surfaces", "Get help appears only with
// a destination" (ACC-35): the account menu renders a "Get help" item,
// between Identity and Access and the divider, only when support-url
// resolves to a value, opening it in a new tab like the Identity item.
func TestShellAccountMenuGetHelp(t *testing.T) {
const helpURL = "https://help.example.test/console"
viper.Set("support-url", helpURL)
t.Cleanup(func() { viper.Set("support-url", "") })
body := renderMemberShell(t, "index.html", memberShell("dashboard", true))
getHelp := `<a class="dropdown-item" href="` + helpURL + `" target="_blank" hx-boost="false" title="Get help. Opens in a new tab.">Get help</a>`
if got := strings.Count(body, getHelp); got != 1 {
t.Fatalf("expected exactly one Get help item, got %d in:\n%s", got, body)
}
idpIdx := strings.Index(body, `href="https://idp.example.test/account"`)
helpIdx := strings.Index(body, getHelp)
divIdx := strings.Index(body, `<hr class="dropdown-divider">`)
signOutIdx := strings.Index(body, `<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`)
if !(idpIdx < helpIdx && helpIdx < divIdx && divIdx < signOutIdx) {
t.Error("the menu must read: header, Identity and Access, Get help, divider, Sign out")
}
}
// 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 menu"`,
`<script defer src="/static/shell.js"></script>`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
if strings.Contains(body, "navbar-collapse") {
t.Error("the old collapse list must not render")
}
rail := railRegion(t, body)
if strings.Contains(rail, "d-none d-lg-flex") {
t.Error("the rail must not be hidden below lg")
}
// The bar: the brand first, then the account menu at lg and up,
// then the toggler below lg, each pushed right by ms-auto so
// whichever renders sits opposite the brand (chrome-conventions
// "The top bar holds the brand and one control"; D2).
bar := topBarRegion(t, body)
brandIdx := strings.Index(bar, `<a class="navbar-brand" href="/">`)
accountIdx := strings.Index(bar, `<ul class="navbar-nav ms-auto d-none d-lg-flex">`)
togglerIdx := strings.Index(bar, `<button class="navbar-toggler d-lg-none ms-auto"`)
if brandIdx < 0 || accountIdx < 0 || togglerIdx < 0 {
t.Fatalf("the bar must hold the brand, the lg-and-up account menu, and the below-lg toggler, got:\n%s", bar)
}
if !(brandIdx < accountIdx && accountIdx < togglerIdx) {
t.Error("the brand must be the bar's first element, with the account menu and the toggler after it")
}
// 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 (the rail's account
// entries carry no section labels).
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 follow the rail's task entries")
}
// chrome-conventions "One place for the switch": below lg the
// drawer carries both the rail entries and the account items,
// and the switch stays a rail entry above the account block.
if strings.Index(rail, switchEntry) > strings.Index(rail, `class="app-rail-account`) {
t.Error("the surface switch must render above the account block, never inside it")
}
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")
}
})
}
// TestShellRailAccountBlock pins chrome-conventions "Session controls live
// in the account menu on both surfaces" and its "Only one account menu
// renders" scenario at narrow width: below lg the rail is the shell's one
// menu, so the account items render inside it, after the task entries and
// the surface switch, in the dropdown's order and under the same rules,
// and every entry is hidden at lg and up (mobile-shell-menu D3/D4).
func TestShellRailAccountBlock(t *testing.T) {
for _, page := range []string{"index.html", "products.html", "billing.html"} {
t.Run(page, func(t *testing.T) {
rail := railRegion(t, renderMemberShell(t, page, memberShell("dashboard", true)))
for _, want := range []string{
`<div class="app-rail-account d-lg-none">`,
`<span class="app-rail-account-name" id="app-rail-account-label">Alice Admin</span>`,
`<span class="app-rail-account-email">alice@example.test</span>`,
`<ul class="nav flex-column" aria-labelledby="app-rail-account-label">`,
`<li class="nav-item"><a class="nav-link" href="https://idp.example.test/account" target="_blank" hx-boost="false" title="Manage your account and sign-in. Opens in a new tab.">Identity and <span class="text-nowrap">Access<svg class="external-link-icon"`,
`<li class="nav-item"><button type="button" class="nav-link" hx-post="/logout" hx-swap="none">Sign out</button></li>`,
} {
if got := strings.Count(rail, want); got != 1 {
t.Errorf("expected exactly one %q in the drawer, got %d in:\n%s", want, got, rail)
}
}
// Order: the account group comes last, after the task entries
// and the switch, headed by the person and ending in Sign out.
headerIdx := strings.Index(rail, `class="app-rail-account d-lg-none"`)
idpIdx := strings.Index(rail, `class="nav-link" href="https://idp.example.test/account"`)
outIdx := strings.Index(rail, `>Sign out</button>`)
if !(strings.Index(rail, `>Billing</a>`) < headerIdx && headerIdx < idpIdx && idpIdx < outIdx) {
t.Errorf("the drawer must read: task entries, switch, the person, Identity and Access, Sign out, got:\n%s", rail)
}
// design D8: two groups, told apart by distance and by one
// hairline each. The account group is a second list, not more
// entries in the rail's, and nothing draws a line inside it.
if got := strings.Count(rail, `<ul class="nav flex-column`); got != 2 {
t.Errorf("expected the drawer to hold two lists, the rail's and the account group's, got %d", got)
}
if strings.Contains(rail, "app-rail-signout") {
t.Error("the account group must carry no divider of its own (design D8)")
}
if got := strings.Count(rail, "d-lg-none"); got != 1 {
t.Errorf("expected one d-lg-none, on the account group's wrapper, got %d", got)
}
// Get help renders only when support-url is configured, the
// dropdown's rule unchanged.
if strings.Contains(rail, ">Get help<") {
t.Error("Get help must not render in the drawer when support-url is unset")
}
})
}
t.Run("get help joins when configured", func(t *testing.T) {
const helpURL = "https://help.example.test/console"
viper.Set("support-url", helpURL)
t.Cleanup(func() { viper.Set("support-url", "") })
rail := railRegion(t, renderMemberShell(t, "index.html", memberShell("dashboard", true)))
getHelp := `<li class="nav-item"><a class="nav-link" href="` + helpURL + `" target="_blank" hx-boost="false" title="Get help. Opens in a new tab.">Get help</a></li>`
if got := strings.Count(rail, getHelp); got != 1 {
t.Fatalf("expected exactly one Get help entry in the drawer, got %d in:\n%s", got, rail)
}
idpIdx := strings.Index(rail, `class="nav-link" href="https://idp.example.test/account"`)
helpIdx := strings.Index(rail, getHelp)
outIdx := strings.Index(rail, `>Sign out</button>`)
if !(idpIdx < helpIdx && helpIdx < outIdx) {
t.Error("the drawer's account block must read: header, Identity and Access, Get help, divider, Sign out")
}
})
t.Run("no operator role: the account block still renders", func(t *testing.T) {
rail := railRegion(t, renderMemberShell(t, "index.html", memberShell("dashboard", false)))
if !strings.Contains(rail, `>Sign out</button>`) {
t.Error("a member without the operator role must still reach Sign out from the drawer")
}
})
}
// TestMemberPagesHaveOneMainLandmark pins page-anatomy "Every page has one
// main landmark" (ACC-40): each of the three member root pages wraps its
// content in exactly one <main>, matching the operator shell's placement.
func TestMemberPagesHaveOneMainLandmark(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", false))
if n := strings.Count(body, "<main"); n != 1 {
t.Errorf("%s must render exactly one <main>, got %d", page, n)
}
if !strings.Contains(body, "</main>") {
t.Errorf("%s must close its <main>", page)
}
})
}
}
// TestShellCarriesConfirmModal pins page-anatomy "Destructive actions
// confirm through the shared modal on every surface": every member page
// renders the shared modal partial and loads its script.
func TestShellCarriesConfirmModal(t *testing.T) {
for _, page := range []string{"index.html", "products.html", "billing.html"} {
body := renderMemberShell(t, page, memberShell("dashboard", false))
if strings.Count(body, `id="confirmActionModal"`) != 1 || !strings.Contains(body, `data-form="shell.confirm"`) {
t.Errorf("%s must render the shared confirm modal once", page)
}
if !strings.Contains(body, `<script defer src="/static/confirm-action-modal.js"></script>`) {
t.Errorf("%s must load confirm-action-modal.js", page)
}
}
}