Files
member-console/internal/server/member_upgrade_render_test.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

350 lines
13 KiB
Go

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{
{
Key: "hosting", 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.
if !strings.Contains(body, `hx-post="/partials/member/plans/cancel"`) {
t.Error("cancel move: expected an hx-post cancel control")
}
// Non-purchasable target: a disabled control with the reason shown.
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")
}
// Current rung: a Current plan badge and no control.
if !strings.Contains(body, "Current plan") {
t.Error("current rung: expected a 'Current plan' badge")
}
}
// 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,
}).ParseFS(sub, "*.html")
if err != nil {
t.Fatalf("parse root templates: %v", err)
}
return server.NewSafeTemplates(tmpl, discardLogger())
}
// dashboardData mirrors the anonymous struct the root handler renders index.html with.
type dashboardData struct {
Name string
Username string
Email string
KeycloakAccountURL string
CSRFToken string
IsOperator bool
HasMultipleWorkspaces bool
CheckoutStatus string
DashboardCards []server.DashboardCard
PendingDomainClaims []server.PendingDomainClaim
}
// 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", LadderKey: "hosting", 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 found") {
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{
{
Key: "hosting", 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{
{
Key: "hosting", 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",
},
},
},
{
Key: "support", 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)")
}
}