// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "html/template" "io/fs" "net/http/httptest" "strings" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/embeds" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" "log/slog" ) func renderIndex(t *testing.T, data IndexPageData) string { t.Helper() sub, err := fs.Sub(embeds.Templates, "templates") if err != nil { t.Fatalf("sub templates FS: %v", err) } // index.html references no other templates, so it parses standalone; the // pending-domain notice uses routeURL, registered for real. tmpl, err := template.New("root").Funcs(template.FuncMap{ "routeURL": web.RouteURL, "deploymentName": config.DeploymentName, }).ParseFS(sub, "index.html") if err != nil { t.Fatalf("parse index.html: %v", err) } tmpl = template.Must(web.ParseUIPartials(tmpl)) rec := httptest.NewRecorder() NewSafeTemplates(tmpl, slog.Default()).Render(rec, "index.html", data) if rec.Code != 200 { t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String()) } return rec.Body.String() } // The raw template bytes must be integration-free too — a rendered-output // check could miss a reference hidden behind an untaken template branch. func TestIndexTemplateNamesNoIntegration(t *testing.T) { raw, err := fs.ReadFile(embeds.Templates, "templates/index.html") if err != nil { t.Fatalf("read embedded index.html: %v", err) } for _, banned := range []string{"fedwiki", "discourse", "stripe"} { if strings.Contains(strings.ToLower(string(raw)), banned) { t.Errorf("core index.html template references integration %q", banned) } } } // The dashboard is registry-driven: core's index.html must not name any // integration, and with no declared cards it must render no card section // chrome and no integration script tags. func TestIndexNoDashboardCards(t *testing.T) { body := renderIndex(t, IndexPageData{}) if strings.Contains(strings.ToLower(body), "fedwiki") { t.Errorf("index.html without cards still references fedwiki") } if strings.Contains(strings.ToLower(body), "discourse") { t.Errorf("index.html without cards references discourse") } if strings.Contains(body, "hx-trigger=") { t.Errorf("card shell rendered despite zero declared cards") } } // Declared cards render as generic shells in declaration (registry) order, // with the declared partial path, trigger spec, and page-level deferred // script tags. func TestIndexRendersDeclaredCards(t *testing.T) { body := renderIndex(t, IndexPageData{ DashboardCards: []DashboardCardView{ {DashboardCard: DashboardCard{ Title: "Stub Sites", PartialPath: "/partials/stub/sites", RefreshEvent: "refreshStub", Scripts: []string{"/static/stub/stub-form.js"}, }, Body: "

stub body

"}, {DashboardCard: DashboardCard{ Title: "Other Service", PartialPath: "/partials/other/status", }, Body: "

other body

"}, }, }) first := strings.Index(body, "Stub Sites") second := strings.Index(body, "Other Service") if first == -1 || second == -1 { t.Fatalf("declared card titles missing (first=%d second=%d)", first, second) } if first > second { t.Errorf("cards rendered out of declaration order") } // Bodies are composed on the server (page-anatomy "A page arrives // complete"): both render inline; only the card with a refresh event // keeps its partial route as an HTMX swap source, and nothing fetches // after load. if !strings.Contains(body, "

stub body

") || !strings.Contains(body, "

other body

") { t.Errorf("composed card bodies missing") } if !strings.Contains(body, `

stub body

`) { t.Errorf("refresh-event card must keep its partial route as the swap source") } if strings.Contains(body, `hx-get="/partials/other/status"`) { t.Errorf("a card without a refresh event must not carry an hx-get") } // The confirm modal's submit indicator is a spinner too, but it is an // action's loading state, hidden until a request; the first-paint // spinner variant is what must be gone. if strings.Contains(body, `hx-trigger="load`) || strings.Contains(body, "spinner-border text-primary") { t.Errorf("nothing on the dashboard may fetch after load or show a spinner") } if !strings.Contains(body, ``) { t.Errorf("declared card script not rendered as page-level deferred tag") } if strings.Contains(strings.ToLower(body), "fedwiki") { t.Errorf("generic shell leaked an integration name") } } // A card declaration's Description renders as the card's lead, in the same // style pageHeader uses for its Lead (design D7); a card with no // description renders no lead paragraph. func TestIndexRendersCardDescription(t *testing.T) { body := renderIndex(t, IndexPageData{ DashboardCards: []DashboardCardView{ {DashboardCard: DashboardCard{ Title: "FedWiki sites", Description: "Your hosted wiki sites.", PartialPath: "/partials/stub/sites", }, Body: "

stub body

"}, {DashboardCard: DashboardCard{ Title: "Other Service", PartialPath: "/partials/other/status", }, Body: "

other body

"}, }, }) if !strings.Contains(body, `

Your hosted wiki sites.

`) { t.Errorf("card description did not render as the card's lead") } // The second card declares no description: no stray empty lead // paragraph for it. if strings.Count(body, `class="text-body-secondary mb-2"`) != 1 { t.Errorf("a card with no description must render no lead paragraph") } } // The member dashboard wraps its content in exactly one
(page-anatomy // "Every page has one main landmark", ACC-40). func TestIndexHasOneMainLandmark(t *testing.T) { body := renderIndex(t, IndexPageData{}) if n := strings.Count(body, ", got %d", n) } if !strings.Contains(body, "
") { t.Errorf("index.html must close its
") } } // TestIndexEmptyState covers member-dashboard's ADDED requirement "The // dashboard has one empty state" (design D25): with no declared card and // no org entitlement, one emptyState renders the D25 sentence in place of // the cards section, and no card chrome renders beside it. func TestIndexEmptyState(t *testing.T) { body := renderIndex(t, IndexPageData{}) if !strings.Contains(body, "Nothing to show yet.") { t.Errorf("empty dashboard missing the D25 headline, got:\n%s", body) } if !strings.Contains(body, "Your services and entitlements appear here once your organization holds a plan or a grant.") { t.Errorf("empty dashboard missing the D25 note, got:\n%s", body) } // The Account section always renders its own card; what must NOT // render is dashboard-card chrome (an integration card's swap-source // wrapper), the same marker TestIndexNoDashboardCards checks. if strings.Contains(body, "hx-trigger=") { t.Errorf("empty dashboard must render no dashboard-card chrome beside the empty state") } } // TestIndexNoEmptyStateWithEntitlement covers the other half of the same // requirement: an org that holds an entitlement is not "nothing to show", // even with zero declared cards (no configured integration on this // deployment) — the empty state must not render. func TestIndexNoEmptyStateWithEntitlement(t *testing.T) { body := renderIndex(t, IndexPageData{HasEntitlement: true}) if strings.Contains(body, "Nothing to show yet.") { t.Errorf("dashboard with an org entitlement must not render the empty state, got:\n%s", body) } } // TestIndexNoEmptyStateWithCards covers the same requirement from the // other input: a declared card alone (regardless of HasEntitlement) is // enough to skip the empty state. func TestIndexNoEmptyStateWithCards(t *testing.T) { body := renderIndex(t, IndexPageData{ DashboardCards: []DashboardCardView{ {DashboardCard: DashboardCard{Title: "Other Service", PartialPath: "/partials/other/status"}, Body: "

other body

"}, }, }) if strings.Contains(body, "Nothing to show yet.") { t.Errorf("dashboard with a declared card must not render the empty state, got:\n%s", body) } }