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">Alice Admin`) { t.Error("the account menu's trigger must be a button labelled with the person's display name") } if !strings.Contains(body, ``) { 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 AccessSign out` 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, ``) 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">alice`) { 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">Account`) { 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{ `