- 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
350 lines
13 KiB
Go
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)")
|
|
}
|
|
}
|