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.
479 lines
19 KiB
Go
479 lines
19 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"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/server"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
"github.com/alexedwards/scs/v2"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// TestMemberPlansUpgradeControlRendering verifies the ladder-grouped plans
|
|
// partial renders: the ladder section header; an active checkout control for an
|
|
// enabled (built) move carrying the price; a disabled-with-reason control for an
|
|
// unbuilt move; and no control for the current rung.
|
|
func TestMemberPlansUpgradeControlRendering(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
data := server.PlansData{
|
|
Ladders: []server.LadderViewModel{
|
|
{
|
|
Name: "Hosting", Enrolled: true,
|
|
Tiers: []server.TierViewModel{
|
|
{Name: "Public", Relation: "current"},
|
|
// free/default → first paid: Checkout.
|
|
{Name: "Standard", Relation: "upgrade", PriceID: "price_std", Purchasable: true, MoveKind: "checkout", MoveEnabled: true, MoveLabel: "Upgrade"},
|
|
// paid → another paid rung: in-place switch.
|
|
{Name: "Premium", Relation: "upgrade", PriceID: "price_prem", MoveLadderID: "ladder_1", Purchasable: true, MoveKind: "switch", MoveEnabled: true, MoveLabel: "Upgrade"},
|
|
// paid → free/default rung: cancel.
|
|
{Name: "Free", Relation: "downgrade", MoveLadderID: "ladder_1", MoveKind: "cancel", MoveEnabled: true, MoveLabel: "Downgrade"},
|
|
// target with no purchasable price: disabled-with-reason.
|
|
{Name: "Locked", Relation: "upgrade", MoveLadderID: "ladder_1", MoveKind: "switch", MoveEnabled: false, MoveLabel: "Upgrade", DisabledReason: "Not available for purchase yet"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_plans.html", data)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
|
|
// Ladder section header.
|
|
if !strings.Contains(body, "Hosting") {
|
|
t.Error("expected the ladder section header 'Hosting'")
|
|
}
|
|
|
|
// All move controls post via HTMX (not native forms) so the request inherits
|
|
// the page's X-CSRF-Token header and a same-origin Origin — a native form POST
|
|
// is sent with Origin: null under the app's no-referrer policy and rejected.
|
|
|
|
// free/default → paid: a checkout control carrying the price (exactly one).
|
|
if !strings.Contains(body, `hx-post="/billing/checkout"`) || !strings.Contains(body, `"price_id": "price_std"`) {
|
|
t.Error("checkout move: expected an hx-post checkout control carrying price_std")
|
|
}
|
|
if got := strings.Count(body, `hx-post="/billing/checkout"`); got != 1 {
|
|
t.Errorf("expected exactly 1 checkout control, got %d", got)
|
|
}
|
|
|
|
// paid → paid: a control that fetches the proration preview (the actual switch
|
|
// POST lives on the preview banner's Confirm button) carrying ladder_id + price.
|
|
if !strings.Contains(body, `hx-get="/partials/member/plans/switch/preview"`) ||
|
|
!strings.Contains(body, `"ladder_id": "ladder_1"`) || !strings.Contains(body, `"price_id": "price_prem"`) {
|
|
t.Error("switch move: expected an hx-get preview control carrying ladder_id + price_prem")
|
|
}
|
|
|
|
// paid → free: a cancel control carrying ladder_id, confirming
|
|
// through the shared modal (design-system.md §3; hx-confirm is banned).
|
|
if !strings.Contains(body, `data-action-url="/partials/member/plans/cancel"`) ||
|
|
!strings.Contains(body, `data-action-fields='{"ladder_id": "ladder_1"}'`) {
|
|
t.Error("cancel move: expected a modal-confirmed cancel control carrying ladder_id")
|
|
}
|
|
|
|
// Non-purchasable target: the shared disabledControl part's member-surface
|
|
// (visible-text) variant — the reason renders as ordinary text via
|
|
// aria-describedby, not a title-attribute tooltip (design D10, ACC-8).
|
|
if !strings.Contains(body, "Not available for purchase yet") || !strings.Contains(body, "disabled") {
|
|
t.Error("non-purchasable move: expected a disabled control with the reason")
|
|
}
|
|
if !strings.Contains(body, `aria-describedby="move-disabled-ladder_1-"`) {
|
|
t.Errorf("non-purchasable move: expected the disabledControl part's aria-describedby wiring, got:\n%s", body)
|
|
}
|
|
if strings.Contains(body, `title="Not available for purchase yet"`) {
|
|
t.Error("member surface must use the visible-text variant, not a title-attribute tooltip")
|
|
}
|
|
|
|
// Current rung: a Current plan badge and no control.
|
|
if !strings.Contains(body, "Current plan") {
|
|
t.Error("current rung: expected a 'Current plan' badge")
|
|
}
|
|
}
|
|
|
|
// TestMemberPlansPriceOnEveryTier verifies every plan card states its cost
|
|
// (member-product-discovery: "Every plan card states its cost", ACC-7): a
|
|
// non-current tier with an active synced price shows its price and
|
|
// interval; a priceless rank-0 tier reads "Included"; a tier whose price
|
|
// exists but isn't synced shows neither (the purchasability gate already
|
|
// disables its move control with a visible reason).
|
|
func TestMemberPlansPriceOnEveryTier(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
data := server.PlansData{
|
|
Ladders: []server.LadderViewModel{
|
|
{
|
|
Name: "Hosting", Enrolled: true,
|
|
Tiers: []server.TierViewModel{
|
|
{Name: "Public", Relation: "current"},
|
|
// Priced and synced: shows its own price and interval.
|
|
{Name: "Standard", Relation: "upgrade", PriceID: "price_std", Purchasable: true, PriceText: "$10.00/month", MoveKind: "checkout", MoveEnabled: true, MoveLabel: "Upgrade"},
|
|
// No price at all and the org holds nothing on this ladder:
|
|
// the free rung reads "Included" and carries no move control
|
|
// (member-product-discovery: "A free rung offers no purchase").
|
|
{Name: "Free", Relation: "available", Rank: 0, NoPriceTier: true},
|
|
// Priced but not yet synced: no price line, disabled control keeps its reason.
|
|
{Name: "Locked", Relation: "upgrade", MoveKind: "switch", MoveEnabled: false, MoveLabel: "Upgrade", DisabledReason: "Not available for purchase yet"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_plans.html", data)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
|
|
if !strings.Contains(body, "$10.00/month") {
|
|
t.Error("expected a non-current priced tier to show its price and interval")
|
|
}
|
|
if !strings.Contains(body, "Included") {
|
|
t.Error("expected the priceless rank-0 tier to read 'Included'")
|
|
}
|
|
// "Locked" carries neither PriceText nor NoPriceTier: no price line for
|
|
// it, only the disabledControl part's visible reason (design D10, ACC-8).
|
|
// "Free" carries no MoveKind at all, so it contributes no occurrence.
|
|
if got := strings.Count(body, "Not available for purchase yet"); got != 1 {
|
|
t.Errorf("expected the disabled reason to render once, for the unsynced priced tier only, got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestMemberPlansFreeRungOffersNoPurchase covers the member-product-discovery
|
|
// ADDED requirement "A free rung offers no purchase" (design D5): a rank-0
|
|
// tier with no price is conferred, never bought, so it carries a move
|
|
// control only when the org already holds a paid rung to move down from.
|
|
func TestMemberPlansFreeRungOffersNoPurchase(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
// Scenario: not enrolled sees no control on the free rung.
|
|
notEnrolled := server.PlansData{
|
|
Ladders: []server.LadderViewModel{
|
|
{
|
|
Name: "Hosting",
|
|
Tiers: []server.TierViewModel{
|
|
{Name: "Free", Relation: "available", Rank: 0, NoPriceTier: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_plans.html", notEnrolled)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Included") {
|
|
t.Error("expected the free rung to read 'Included'")
|
|
}
|
|
if strings.Contains(body, "<button") {
|
|
t.Errorf("expected no button on a free rung the org holds nothing on, got:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "Not available for purchase yet") {
|
|
t.Error("expected no 'Not available' line on a free rung the org holds nothing on")
|
|
}
|
|
|
|
// Scenario: a paid holder sees the cancel control on the free rung.
|
|
paidHolder := server.PlansData{
|
|
Ladders: []server.LadderViewModel{
|
|
{
|
|
Name: "Hosting", Enrolled: true,
|
|
Tiers: []server.TierViewModel{
|
|
{Name: "Standard", Relation: "current"},
|
|
{Name: "Free", Relation: "downgrade", Rank: 0, NoPriceTier: true, MoveLadderID: "ladder_1", MoveKind: "cancel", MoveEnabled: true, MoveLabel: "Downgrade"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_plans.html", paidHolder)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body = rec.Body.String()
|
|
if !strings.Contains(body, `data-action-url="/partials/member/plans/cancel"`) {
|
|
t.Error("expected the cancel control on the free rung for an org on a paid rung")
|
|
}
|
|
if !strings.Contains(body, ">Downgrade<") {
|
|
t.Errorf("expected the cancel control labeled 'Downgrade', got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestGetPlansUnauthenticatedReturns401 verifies the upgrade surface keeps its
|
|
// authentication guard: an unauthenticated request to the plans partial is
|
|
// rejected before any rendering or data access.
|
|
func TestGetPlansUnauthenticatedReturns401(t *testing.T) {
|
|
sm := scs.New()
|
|
ctx, err := sm.Load(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("load session: %v", err)
|
|
}
|
|
h := &server.MemberProductsHandler{AuthConfig: &auth.Config{SessionManager: sm}}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
h.GetPlans(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated request, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// rootTemplates parses the root template set the same way the server does.
|
|
func rootTemplates(t *testing.T) *server.SafeTemplates {
|
|
t.Helper()
|
|
sub, err := fs.Sub(embeds.Templates, "templates")
|
|
if err != nil {
|
|
t.Fatalf("fs.Sub: %v", err)
|
|
}
|
|
tmpl, err := template.New("root").Funcs(template.FuncMap{
|
|
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
|
|
"routeURL": web.RouteURL,
|
|
"deploymentName": config.DeploymentName,
|
|
// operator.html rides the *.html glob here as it does in the real
|
|
// root set, and its <title> calls pageTitle.
|
|
"pageTitle": server.PageTitle,
|
|
}).ParseFS(sub, "*.html")
|
|
if err != nil {
|
|
t.Fatalf("parse root templates: %v", err)
|
|
}
|
|
tmpl, err = web.ParseUIPartials(tmpl)
|
|
if err != nil {
|
|
t.Fatalf("parse shell partials: %v", err)
|
|
}
|
|
// The member surface's root crumb (design D18); see server.go's own
|
|
// override for why this must run after ParseUIPartials.
|
|
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": server.MemberSurfaceRoot})
|
|
return server.NewSafeTemplates(tmpl, discardLogger())
|
|
}
|
|
|
|
// dashboardData aliases the named page data the root handler renders
|
|
// index.html with, so these tests exercise the same methods (Header,
|
|
// section headers) the real page uses.
|
|
type dashboardData = server.IndexPageData
|
|
|
|
// TestDashboardCheckoutBanner verifies the post-checkout return banner reflects
|
|
// the checkout status and never asserts the tier is already active on success.
|
|
func TestDashboardCheckoutBanner(t *testing.T) {
|
|
st := rootTemplates(t)
|
|
|
|
render := func(status string) string {
|
|
rec := httptest.NewRecorder()
|
|
st.Render(rec, "index.html", dashboardData{CheckoutStatus: status})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %q: expected 200, got %d: %s", status, rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
|
|
success := render("success")
|
|
if !strings.Contains(success, "finalizing your upgrade") {
|
|
t.Error("success: expected a finalizing-upgrade banner")
|
|
}
|
|
if strings.Contains(success, "Payment received") {
|
|
t.Error("success: must not assert payment settled")
|
|
}
|
|
if strings.Contains(success, "Checkout canceled") {
|
|
t.Error("success: must not render the cancel banner")
|
|
}
|
|
|
|
cancel := render("cancel")
|
|
if !strings.Contains(cancel, "Checkout canceled") {
|
|
t.Error("cancel: expected a 'Checkout canceled' banner")
|
|
}
|
|
if strings.Contains(cancel, "finalizing your upgrade") {
|
|
t.Error("cancel: must not render the success banner")
|
|
}
|
|
|
|
none := render("")
|
|
if strings.Contains(none, "finalizing your upgrade") || strings.Contains(none, "Checkout canceled") {
|
|
t.Error("no status: expected no checkout banner")
|
|
}
|
|
|
|
// The banner must not promise an in-place dashboard update the page never
|
|
// delivers — it should point the member at /products instead (finding #8).
|
|
if !strings.Contains(success, `href="/products"`) {
|
|
t.Error("success: expected a link to /products instead of an in-place update promise")
|
|
}
|
|
if strings.Contains(success, "update here") {
|
|
t.Error("success: must not promise the plan will update here")
|
|
}
|
|
}
|
|
|
|
// TestMemberEntitlementsEnrolledWithoutNumericEntitlements verifies a member
|
|
// enrolled on a tier whose entitlement set carries only boolean/metadata rules
|
|
// (no numeric entitlements) still sees the tier badge and Sources list, rather
|
|
// than the "no active entitlements" empty state (finding #33).
|
|
func TestMemberEntitlementsEnrolledWithoutNumericEntitlements(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
data := server.EntitlementsData{
|
|
Enrolled: true,
|
|
HasEntitlements: false, // no numeric entitlements on this tier
|
|
PoolTiers: []server.PoolTierViewModel{
|
|
{PoolName: "default", TierName: "Community", HasTier: true},
|
|
},
|
|
Sources: []server.EntitlementSourceViewModel{
|
|
{Reason: "subscription", ProductName: "Community", ValidFrom: "Jan 1, 2026"},
|
|
},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_entitlements.html", data)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
|
|
if strings.Contains(body, "No active entitlements found") {
|
|
t.Error("enrolled member on a boolean/metadata-only tier must not see the empty state")
|
|
}
|
|
if !strings.Contains(body, "You are on") || !strings.Contains(body, "Community") {
|
|
t.Error("expected the tier badge to render for an enrolled member")
|
|
}
|
|
if !strings.Contains(body, "Sources") {
|
|
t.Error("expected the Sources list to render for an enrolled member")
|
|
}
|
|
}
|
|
|
|
// TestMemberEntitlementsGenuinelyUnenrolledShowsEmptyState guards the other
|
|
// side of finding #33: a member with no ladder attachment and no numeric
|
|
// entitlements still sees the empty state, not a blank tier/Sources section.
|
|
func TestMemberEntitlementsGenuinelyUnenrolledShowsEmptyState(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_entitlements.html", server.EntitlementsData{})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "No active entitlements.") {
|
|
t.Error("genuinely unenrolled member should see the empty state")
|
|
}
|
|
}
|
|
|
|
// TestMemberPlansSuccessBannerAndCancelsOnBadge verifies renderPlansAfterMove's
|
|
// success message and buildPlansData's pending-cancel badge both render, so a
|
|
// completed cancel-at-period-end is distinguishable from a no-op (finding #22).
|
|
func TestMemberPlansSuccessBannerAndCancelsOnBadge(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
data := server.PlansData{
|
|
Success: "Your cancellation is scheduled — see the plan below for the effective date.",
|
|
Ladders: []server.LadderViewModel{
|
|
{
|
|
Name: "Hosting", Enrolled: true, CancelsOn: "Aug 1, 2026",
|
|
Tiers: []server.TierViewModel{
|
|
{Name: "Standard", Relation: "current"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_plans.html", data)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
|
|
if !strings.Contains(body, "Your cancellation is scheduled") {
|
|
t.Error("expected the success banner to render")
|
|
}
|
|
if !strings.Contains(body, "Cancels on Aug 1, 2026") {
|
|
t.Error("expected the pending-cancel badge to render")
|
|
}
|
|
}
|
|
|
|
// TestMemberPlansCheckoutErrorTargetScopedPerLadder verifies the checkout
|
|
// control's hx-target and its error container share a ladder+product-scoped
|
|
// id (finding #29) — plain enough to avoid clobbering the button label on a
|
|
// 4xx, and unique even when a shared tier renders under two ladders
|
|
// (finding #7.5 in buildPlansData).
|
|
func TestMemberPlansCheckoutErrorTargetScopedPerLadder(t *testing.T) {
|
|
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
|
|
if err != nil {
|
|
t.Fatalf("new handler: %v", err)
|
|
}
|
|
|
|
data := server.PlansData{
|
|
Ladders: []server.LadderViewModel{
|
|
{
|
|
Name: "Hosting",
|
|
Tiers: []server.TierViewModel{
|
|
{Name: "Public", Relation: "current"},
|
|
{
|
|
Name: "Standard", Relation: "upgrade", ProductID: "prod_std", PriceID: "price_std",
|
|
MoveLadderID: "ladder_hosting", Purchasable: true, MoveKind: "checkout",
|
|
MoveEnabled: true, MoveLabel: "Subscribe",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Support",
|
|
Tiers: []server.TierViewModel{
|
|
{
|
|
Name: "Standard", Relation: "available", ProductID: "prod_std", PriceID: "price_std",
|
|
MoveLadderID: "ladder_support", Purchasable: true, MoveKind: "checkout",
|
|
MoveEnabled: true, MoveLabel: "Subscribe",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
h.Templates.Render(rec, "member_plans.html", data)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
|
|
if !strings.Contains(body, `hx-target="#checkout-error-ladder_hosting-prod_std"`) {
|
|
t.Error("expected the hosting checkout control's hx-target to be scoped by ladder and product")
|
|
}
|
|
if !strings.Contains(body, `id="checkout-error-ladder_hosting-prod_std"`) {
|
|
t.Error("expected a matching error container for the hosting checkout control")
|
|
}
|
|
if !strings.Contains(body, `hx-target="#checkout-error-ladder_support-prod_std"`) {
|
|
t.Error("expected the support checkout control's hx-target to be scoped by ladder and product (same shared product, different ladder)")
|
|
}
|
|
}
|