Files
member-console/internal/integrations/fedwiki/web/sites_render_test.go
T
cgalo5758 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00

302 lines
12 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"log/slog"
"net/http/httptest"
"os"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
)
// Scenario: core's index.html renders only generic card shells
// (dashboard-display-genericity), so FedWiki's create modal must travel
// inside this partial, and must carry hx-preserve, because the create
// flow leaves the modal open when refreshSites re-renders the card (a
// plain swap would orphan Bootstrap's backdrop). Guard that the modal
// furniture is in the fragment on every render, including the no-sites
// empty state. Delete has no modal of its own (finding FA-41): it fires
// through the shared confirmAction modal, like Archive.
func TestSitesPartialCarriesModals(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
for name, data := range map[string]SitesData{
"with sites": {
HasEntitlement: true,
CanCreate: true,
Quota: 2,
CurrentCount: 1,
Sites: []SiteViewModel{{
ID: "site-1", Domain: "wiki.example.test", URL: "https://wiki.example.test",
CreatedAt: "Jan 2, 2026", Status: "active",
}},
},
"no access empty state": {},
} {
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", data)
if rec.Code != 200 {
t.Fatalf("%s: render = %d, body: %s", name, rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `id="createSiteModal"`) {
t.Errorf("%s: partial missing id=\"createSiteModal\"", name)
}
if strings.Contains(body, `id="deleteSiteModal"`) {
t.Errorf("%s: partial still carries the retired deleteSiteModal (finding FA-41)", name)
}
if strings.Count(body, "hx-preserve") < 1 {
t.Errorf("%s: the create modal must carry hx-preserve to survive refreshSites swaps", name)
}
}
}
// Scenario (dissolve-member-domains): domains are managed where sites live.
// The sites partial embeds the core claims section — a .domains-surface
// container that self-loads /partials/domains/claims — for every member with
// site access, and nothing in the partial links to the retired standalone
// /domains page.
func TestSitesPartialEmbedsDomainsSection(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
withAccess := SitesData{HasEntitlement: true, CanCreate: true, Quota: 2}
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", withAccess)
if rec.Code != 200 {
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`class="domains-surface"`,
">Domains</h6>",
// ux-ia-naming D7: the section carries its own fragment anchor (the
// /domains redirect target) and a workspace conveyance line at the
// heading, so a member landing here from the redirect sees both
// where they are and what "workspace" means.
`id="domains"`,
} {
if !strings.Contains(body, want) {
t.Errorf("sites partial missing domains section piece %q", want)
}
}
if strings.Contains(body, `href="/domains"`) {
t.Error("sites partial must not link the retired /domains page")
}
// The create form is rendered separately; it must not link /domains either.
allowed := []string{"example.test"}
values := forms.NewValues()
values.Set("customDomainsEnabled", "true")
view := forms.Render(siteCreateForm, forms.Binding{
Mode: forms.ModeUnbound,
Values: values,
Options: siteCreateOptions(allowed),
})
frec := httptest.NewRecorder()
h.Templates.Render(frec, "fedwiki_create_form.html", CreateFormData{
AllowedDomains: allowed, Form: view,
})
if frec.Code != 200 {
t.Fatalf("create form render = %d", frec.Code)
}
if strings.Contains(frec.Body.String(), `href="/domains"`) {
t.Error("create form must not link the retired /domains page")
}
}
// Scenario (design D6, spec fedwiki-sites "UI displays quota usage"): at
// the site limit the Create control renders through the shared
// disabledControl part, with its reason in the DOM and referenced by
// aria-describedby rather than a bare disabled+title button, and the usage
// line still reads the at-or-under-limit sentence. Over the limit (a
// downgrade before its force-reduce sweep runs) the usage line switches to
// its own over-limit sentence with the exact count and allowance.
func TestSitesCardCreateControlAtAndOverLimit(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
render := func(data SitesData) string {
t.Helper()
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", data)
if rec.Code != 200 {
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
atLimit := render(SitesData{HasEntitlement: true, CanCreate: false, CurrentCount: 1, Quota: 1})
if !strings.Contains(atLimit, `aria-describedby="fedwiki-create-site-reason"`) {
t.Errorf("at the limit: Create control missing aria-describedby through the part: %s", atLimit)
}
if !strings.Contains(atLimit, "Site limit reached") {
t.Errorf("at the limit: Create control missing its reason: %s", atLimit)
}
if strings.Contains(atLimit, "disabled title=") {
t.Errorf("at the limit: Create control must not hand-roll disabled+title: %s", atLimit)
}
if !strings.Contains(atLimit, "1 of 1 active sites used") {
t.Errorf("at the limit: usage line does not read the at-or-under-limit sentence: %s", atLimit)
}
overLimit := render(SitesData{HasEntitlement: true, CanCreate: false, CurrentCount: 12, Quota: 1})
if !strings.Contains(overLimit, "12 active sites, 1 allowed.") {
t.Errorf("over the limit: usage line does not read the over-limit sentence: %s", overLimit)
}
if strings.Contains(overLimit, "of 1 active sites used") {
t.Errorf("over the limit: usage line must not also read the at-or-under-limit sentence: %s", overLimit)
}
unavailable := render(SitesData{
HasEntitlement: false,
CanCreate: false,
Sites: []SiteViewModel{{ID: "site-1", Domain: "wiki.example.test", Status: "active"}},
})
if !strings.Contains(unavailable, "Site creation is unavailable right now") {
t.Errorf("no entitlement: Create control keeps its existing reason: %s", unavailable)
}
}
// Scenario (design D6): the swap-cooldown Keep active and Restore buttons
// were the other two hand-rolled disabled+title buttons the
// disabled-outside-part lint rule would otherwise refuse; both now render
// through the shared part with their cooldown reason in the DOM.
func TestSitesCardCooldownButtonsUseThePart(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", SitesData{
HasEntitlement: true, CanCreate: true, Quota: 1, CurrentCount: 1,
CooldownMsg: "Wait 2 hours before swapping another site.",
Sites: []SiteViewModel{{
ID: "site-1", Domain: "wiki.example.test", Status: "readonly", IsReadonly: true,
}},
ArchivedSites: []SiteViewModel{{
ID: "site-2", Domain: "archived.example.test", IsForceReduced: true,
}},
})
if rec.Code != 200 {
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "disabled title=") {
t.Errorf("cooldown buttons must not hand-roll disabled+title: %s", body)
}
for _, want := range []string{
`aria-describedby="fedwiki-site-keep-active-reason-site-1"`,
`aria-describedby="fedwiki-site-restore-reason-site-2"`,
} {
if !strings.Contains(body, want) {
t.Errorf("cooldown button missing %q: %s", want, body)
}
}
if strings.Count(body, "Wait 2 hours before swapping another site.") < 2 {
t.Errorf("both cooldown buttons should carry the cooldown reason: %s", body)
}
}
// Scenario (ux-first-run task 3.6): the sites list's blocked empty state
// names which blocker actually applies instead of one generic message, and
// its copy must differ from both the other blocker's copy and the
// genuinely-empty state's copy (no sites yet, nothing blocking creation).
func TestSitesEmptyStateNamesTheActiveBlocker(t *testing.T) {
h, err := NewFedWikiPartialsHandler(FedWikiPartialsConfig{
Logger: slog.Default(),
TemplatesFS: os.DirFS("../templates"),
})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
render := func(data SitesData) string {
t.Helper()
rec := httptest.NewRecorder()
h.Templates.Render(rec, "fedwiki_sites.html", data)
if rec.Code != 200 {
t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
entitlementBlocked := render(SitesData{})
zeroDomainsBlocked := render(SitesData{NoDomainsConfigured: true})
genuinelyEmpty := render(SitesData{HasEntitlement: true, CanCreate: true, Quota: 2})
noPublishedPlan := render(SitesData{NoPublishedPlan: true})
// Zero domains configured takes priority even when the member DOES hold
// a delivering entitlement: no member can create a site regardless of
// plan, and that is the deployment's fault, not the member's.
entitledButZeroDomains := render(SitesData{HasEntitlement: true, NoDomainsConfigured: true})
if !strings.Contains(entitlementBlocked, "currently include FedWiki site creation") {
t.Errorf("entitlement-blocked state does not name the plan/entitlement cause: %s", entitlementBlocked)
}
if strings.Contains(entitlementBlocked, "configured any site domains") {
t.Errorf("entitlement-blocked state must not render the zero-domains copy: %s", entitlementBlocked)
}
for name, body := range map[string]string{"zero domains alone": zeroDomainsBlocked, "zero domains despite entitlement": entitledButZeroDomains} {
if !strings.Contains(body, "configured any site domains") {
t.Errorf("%s: does not name the deployment configuration cause: %s", name, body)
}
if strings.Contains(body, "currently include FedWiki site creation") {
t.Errorf("%s: must not blame the member's plan when the real blocker is deployment configuration: %s", name, body)
}
}
if !strings.Contains(genuinelyEmpty, "active FedWiki sites yet") {
t.Errorf("genuinely-empty state missing its own copy: %s", genuinelyEmpty)
}
// The three states must all read distinctly from one another.
if entitlementBlocked == zeroDomainsBlocked {
t.Error("entitlement-blocked and zero-domains-blocked states render identical copy")
}
if entitlementBlocked == genuinelyEmpty {
t.Error("entitlement-blocked and genuinely-empty states render identical copy")
}
if zeroDomainsBlocked == genuinelyEmpty {
t.Error("zero-domains-blocked and genuinely-empty states render identical copy")
}
// The third entitlement-branch case (ACC-7, fedwiki-sites spec "The
// sites card distinguishes an empty catalog from an excluded
// entitlement"): no published product confers site creation at all, so
// the deployment is the blocker, never the member's plan.
if !strings.Contains(noPublishedPlan, "Website plans") || !strings.Contains(noPublishedPlan, "published yet") {
t.Errorf("no-published-plan state does not name the deployment cause: %s", noPublishedPlan)
}
if strings.Contains(noPublishedPlan, "currently include FedWiki site creation") {
t.Errorf("no-published-plan state must not blame the member's plan: %s", noPublishedPlan)
}
if noPublishedPlan == entitlementBlocked {
t.Error("no-published-plan and entitlement-blocked (member's plan excludes it) states render identical copy")
}
}