Files
member-console/internal/server/member_upgrade_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

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)")
}
}