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.
319 lines
14 KiB
Go
319 lines
14 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// sidebarRegion slices the rendered operator.html document down to the
|
|
// sidebar <aside>, so link-text assertions can't accidentally match the
|
|
// top bar's account menu or the main content.
|
|
func sidebarRegion(t *testing.T, out string) string {
|
|
t.Helper()
|
|
const open = `<aside id="app-rail" class="app-sidebar operator-ia-sidebar`
|
|
start := strings.Index(out, open)
|
|
if start < 0 {
|
|
t.Fatalf("rendered output has no operator sidebar")
|
|
}
|
|
rest := out[start:]
|
|
end := strings.Index(rest, "</aside>")
|
|
if end < 0 {
|
|
t.Fatalf("rendered output sidebar has no closing </aside>")
|
|
}
|
|
return rest[:end]
|
|
}
|
|
|
|
// sidebarSections slices the sidebar region down to its rail entries: the
|
|
// eight sections and the surface switch, stopping before the account block
|
|
// the drawer carries below lg (mobile-shell-menu D3). The rail's flatness
|
|
// and its entry count are claims about the sections, not about the account
|
|
// menu's second home.
|
|
func sidebarSections(t *testing.T, sidebar string) string {
|
|
t.Helper()
|
|
end := strings.Index(sidebar, `class="app-rail-account`)
|
|
if end < 0 {
|
|
t.Fatalf("sidebar has no account block in:\n%s", sidebar)
|
|
}
|
|
return sidebar[:end]
|
|
}
|
|
|
|
// sidebarLinkActive reports whether the sidebar <a> whose text is exactly
|
|
// linkText carries the "active" class. Fails the test if no such link is
|
|
// found in the sidebar region.
|
|
func sidebarLinkActive(t *testing.T, sidebar, linkText string) bool {
|
|
t.Helper()
|
|
re := regexp.MustCompile(`<a class="nav-link([^"]*)"[^>]*>` + regexp.QuoteMeta(linkText) + `</a>`)
|
|
m := re.FindStringSubmatch(sidebar)
|
|
if m == nil {
|
|
t.Fatalf("sidebar link %q not found in:\n%s", linkText, sidebar)
|
|
}
|
|
return strings.Contains(m[1], "active")
|
|
}
|
|
|
|
// TestOperatorSidebarFlatTopLevelOrder covers operator-panel-navigation
|
|
// ("No rendered sidebar groups", "The sidebar is a flat list of section
|
|
// entries"): exactly the eight top-level entries Overview, People,
|
|
// Organizations, Grants, Billing, Products, Domains, Integrations in that
|
|
// order, with no children, no indentation, and no hairline group-separator
|
|
// markup (maintainer 2026-08-23: second-level surfaces are reached
|
|
// in-page, not from the sidebar; People joined via ux-operator-scale).
|
|
func TestOperatorSidebarFlatTopLevelOrder(t *testing.T) {
|
|
out := sidebarSections(t, sidebarRegion(t, renderOperator(t, OperatorPageData{})))
|
|
|
|
topLevel := []string{">Overview<", ">People<", ">Organizations<", ">Grants<", ">Billing<", ">Products<", ">Domains<", ">Integrations<"}
|
|
positions := make([]int, len(topLevel))
|
|
for i, want := range topLevel {
|
|
idx := strings.Index(out, want)
|
|
if idx < 0 {
|
|
t.Fatalf("sidebar missing top-level entry %q in:\n%s", want, out)
|
|
}
|
|
positions[i] = idx
|
|
}
|
|
for i := 1; i < len(positions); i++ {
|
|
if positions[i] <= positions[i-1] {
|
|
t.Errorf("sidebar top-level entries out of order: %q (at %d) did not follow %q (at %d)",
|
|
topLevel[i], positions[i], topLevel[i-1], positions[i-1])
|
|
}
|
|
}
|
|
|
|
// The eight sections plus the surface switch, the rail's one separated
|
|
// non-section entry (unify-shell), and nothing else.
|
|
if got := strings.Count(out, "nav-link"); got != len(topLevel)+1 {
|
|
t.Errorf("sidebar must hold exactly the %d section entries plus the surface switch, found %d nav-link occurrences in:\n%s", len(topLevel), got, out)
|
|
}
|
|
switchEntry := `<li class="nav-item app-rail-switch"><a class="nav-link" href="/">Member panel</a></li>`
|
|
if strings.Count(out, switchEntry) != 1 || strings.Index(out, switchEntry) < positions[len(positions)-1] {
|
|
t.Errorf("the surface switch must render exactly once, after Integrations, in:\n%s", out)
|
|
}
|
|
for _, banned := range []string{
|
|
">Org Types<",
|
|
">Accounts<", ">Subscriptions<", ">Invoices<", ">Payments<",
|
|
">Entitlement Sets<", ">Plan Ladders<", ">Plan Topology<",
|
|
"ps-4",
|
|
"operator-ia-group-start",
|
|
"mt-2 pt-2 border-top",
|
|
"/logout",
|
|
} {
|
|
if strings.Contains(out, banned) {
|
|
t.Errorf("sidebar must not contain %q (children and group markup are retired), got:\n%s", banned, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOperatorSidebarSecondLevelMarksSection covers operator-panel-navigation
|
|
// ("A second-level page marks its section's sidebar entry active"): a page
|
|
// reached from within a section (Org Types, the billing views, Entitlement
|
|
// Sets, Plan Ladders) lights up that section's single sidebar entry, and no
|
|
// other.
|
|
func TestOperatorSidebarSecondLevelMarksSection(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
activeCapability string
|
|
sectionText string
|
|
}{
|
|
{"person-detail", "persons", "People"},
|
|
{"org-types", "org-types", "Organizations"},
|
|
{"entitlement-sets", "entitlement-sets", "Products"},
|
|
{"plan-ladders", "plan-ladders", "Products"},
|
|
{"billing-accounts", "billing-accounts", "Billing"},
|
|
{"billing-invoices", "billing-invoices", "Billing"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out := sidebarRegion(t, renderOperator(t, OperatorPageData{
|
|
ActiveCapability: tc.activeCapability,
|
|
}))
|
|
if !sidebarLinkActive(t, out, tc.sectionText) {
|
|
t.Errorf("section entry %q must be marked active for capability %q, sidebar:\n%s", tc.sectionText, tc.activeCapability, out)
|
|
}
|
|
// A sibling section must not light up for another section's page.
|
|
if tc.sectionText != "Organizations" && sidebarLinkActive(t, out, "Organizations") {
|
|
t.Errorf("unrelated section Organizations must not be marked active for %q, sidebar:\n%s", tc.activeCapability, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOperatorSidebarOverviewOnlyActiveOnLanding guards against the
|
|
// section active-marking logic accidentally lighting up every top-level
|
|
// entry (an `or` chain with a missing `eq` degenerating to always-true).
|
|
func TestOperatorSidebarOverviewOnlyActiveOnLanding(t *testing.T) {
|
|
out := sidebarRegion(t, renderOperator(t, OperatorPageData{}))
|
|
for _, text := range []string{"People", "Organizations", "Grants", "Billing", "Products", "Domains", "Integrations"} {
|
|
if sidebarLinkActive(t, out, text) {
|
|
t.Errorf("%q must not be active on the landing surface, sidebar:\n%s", text, out)
|
|
}
|
|
}
|
|
if !sidebarLinkActive(t, out, "Overview") {
|
|
t.Errorf("Overview must be active on the landing surface, sidebar:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestOperatorShellTopBarAndDrawer covers chrome-conventions "One shell for
|
|
// both surfaces" and "The surface switch is a visible, mirrored rail entry"
|
|
// on the operator surface (unify-shell): the top bar renders the brand and
|
|
// the account menu, and no section links or surface label (design D18: the
|
|
// trail's root crumb states the surface instead); the rail is the
|
|
// responsive offcanvas element whose toggler lives in the top bar; the
|
|
// "Member panel" switch is never marked active, whatever the page.
|
|
func TestOperatorShellTopBarAndDrawer(t *testing.T) {
|
|
for _, active := range []string{"", "organizations", "billing-invoices"} {
|
|
out := renderOperator(t, OperatorPageData{
|
|
Name: "Alice Admin",
|
|
Username: "alice",
|
|
Email: "alice@example.test",
|
|
KeycloakAccountURL: "https://idp.example.test/account",
|
|
ActiveCapability: active,
|
|
})
|
|
topBar := out[:strings.Index(out, `<aside id="app-rail"`)]
|
|
|
|
for _, want := range []string{
|
|
`data-bs-toggle="dropdown" aria-expanded="false"><span class="app-account-label">Alice Admin</span></button>`,
|
|
`<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`,
|
|
`Identity and <span class="text-nowrap">Access<svg class="external-link-icon"`,
|
|
`data-bs-toggle="offcanvas" data-bs-target="#app-rail" aria-controls="app-rail" aria-label="Open menu"`,
|
|
`<a class="navbar-brand" href="/">`,
|
|
} {
|
|
if !strings.Contains(topBar, want) {
|
|
t.Errorf("top bar missing %q (active=%q)", want, active)
|
|
}
|
|
}
|
|
// 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(topBar, ">Get help<") {
|
|
t.Errorf("Get help must not render when support-url is unset (active=%q)", active)
|
|
}
|
|
// The bar's order (chrome-conventions "The top bar holds the brand
|
|
// and one control", mobile-shell-menu D2): the brand first, then
|
|
// the account menu at lg and up, then the toggler below lg.
|
|
brandIdx := strings.Index(topBar, `<a class="navbar-brand" href="/">`)
|
|
accountIdx := strings.Index(topBar, `<ul class="navbar-nav ms-auto d-none d-lg-flex">`)
|
|
togglerIdx := strings.Index(topBar, `<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 (active=%q), got:\n%s", active, topBar)
|
|
}
|
|
if !(brandIdx < accountIdx && accountIdx < togglerIdx) {
|
|
t.Errorf("the brand must be the bar's first element, with the account menu and the toggler after it (active=%q)", active)
|
|
}
|
|
|
|
// design D16/D18: the surface label left the top bar for the
|
|
// trail's root crumb; the bar holds the brand and one control.
|
|
for _, banned := range []string{">Dashboard</a>", ">Products</a>", ">Operator</a>", ">Billing</a>", "Logout", "text-danger", "navbar-collapse", "app-surface-label"} {
|
|
if strings.Contains(topBar, banned) {
|
|
t.Errorf("top bar must not contain %q (active=%q)", banned, active)
|
|
}
|
|
}
|
|
|
|
sidebar := sidebarRegion(t, out)
|
|
if sidebarLinkActive(t, sidebar, "Member panel") {
|
|
t.Errorf("the surface switch must never be active (active=%q)", active)
|
|
}
|
|
for _, want := range []string{`<div class="offcanvas-header">`, `<div class="offcanvas-body p-3">`, `data-bs-dismiss="offcanvas" data-bs-target="#app-rail" aria-label="Close menu"`} {
|
|
if !strings.Contains(sidebar, want) {
|
|
t.Errorf("rail missing drawer markup %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(sidebar, "d-none d-lg-flex") {
|
|
t.Error("the rail must not be hidden below lg any more")
|
|
}
|
|
if !strings.Contains(out, `<script defer src="/static/shell.js"></script>`) {
|
|
t.Error("operator.html must load shell.js")
|
|
}
|
|
if strings.Count(out, `id="confirmActionModal"`) != 1 {
|
|
t.Error("operator.html must render the shared confirm modal partial once")
|
|
}
|
|
if !strings.Contains(out, `<h1 class="h2 mb-0">Operator overview</h1>`) && active == "" {
|
|
t.Error("the landing surface must title itself through pageHeader")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOperatorShellRailAccountBlock mirrors TestShellRailAccountBlock
|
|
// (member_nav_test.go) on the operator surface: below lg the drawer is the
|
|
// shell's one menu, so it carries the account items after the eight
|
|
// sections and the surface switch, from the same shared partial
|
|
// (partials/shell_rail_account.html), hidden at lg and up. Both surfaces
|
|
// assert the same markup because one template produces it
|
|
// (mobile-shell-menu D1/D4); a drift between them fails here or in the
|
|
// member test, whichever surface moved.
|
|
func TestOperatorShellRailAccountBlock(t *testing.T) {
|
|
sidebar := sidebarRegion(t, renderOperator(t, OperatorPageData{
|
|
Name: "Alice Admin",
|
|
Username: "alice",
|
|
Email: "alice@example.test",
|
|
KeycloakAccountURL: "https://idp.example.test/account",
|
|
}))
|
|
|
|
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(sidebar, want); got != 1 {
|
|
t.Errorf("expected exactly one %q in the drawer, got %d in:\n%s", want, got, sidebar)
|
|
}
|
|
}
|
|
|
|
switchIdx := strings.Index(sidebar, `class="nav-item app-rail-switch"`)
|
|
headerIdx := strings.Index(sidebar, `class="app-rail-account`)
|
|
outIdx := strings.Index(sidebar, `>Sign out</button>`)
|
|
if !(switchIdx < headerIdx && headerIdx < outIdx) {
|
|
t.Errorf("the account group must follow the surface switch and end with Sign out, got:\n%s", sidebar)
|
|
}
|
|
if strings.Contains(sidebar, ">Get help<") {
|
|
t.Error("Get help must not render in the drawer when support-url is unset")
|
|
}
|
|
// design D8: two lists, one hairline each, none inside the group.
|
|
if got := strings.Count(sidebar, `<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(sidebar, "app-rail-signout") {
|
|
t.Error("the account group must carry no divider of its own (design D8)")
|
|
}
|
|
if got := strings.Count(sidebar, "d-lg-none"); got != 1 {
|
|
t.Errorf("expected one d-lg-none, on the account group's wrapper, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestOperatorShellAccountMenuGetHelp mirrors
|
|
// TestShellAccountMenuGetHelp (member_nav_test.go) on the operator
|
|
// surface: the account menu renders "Get help" between Identity and
|
|
// Access and the divider only when support-url resolves to a value
|
|
// (chrome-conventions "Get help appears only with a destination", ACC-35).
|
|
func TestOperatorShellAccountMenuGetHelp(t *testing.T) {
|
|
const helpURL = "https://help.example.test/console"
|
|
viper.Set("support-url", helpURL)
|
|
t.Cleanup(func() { viper.Set("support-url", "") })
|
|
|
|
out := renderOperator(t, OperatorPageData{
|
|
Name: "Alice Admin",
|
|
Username: "alice",
|
|
Email: "alice@example.test",
|
|
KeycloakAccountURL: "https://idp.example.test/account",
|
|
})
|
|
topBar := out[:strings.Index(out, `<aside id="app-rail"`)]
|
|
|
|
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(topBar, getHelp); got != 1 {
|
|
t.Fatalf("expected exactly one Get help item, got %d in:\n%s", got, topBar)
|
|
}
|
|
|
|
idpIdx := strings.Index(topBar, `href="https://idp.example.test/account"`)
|
|
helpIdx := strings.Index(topBar, getHelp)
|
|
divIdx := strings.Index(topBar, `<hr class="dropdown-divider">`)
|
|
signOutIdx := strings.Index(topBar, `<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")
|
|
}
|
|
}
|