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.
302 lines
12 KiB
Go
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")
|
|
}
|
|
}
|