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.
417 lines
19 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|