Files
member-console/internal/server/member_catalog_test.go
T
cgalo5758 9b96e9c9e9 Rework operator IA and unify UI vocabulary
- Restructure operator sidebar into a flat task list with indented
  children; fold plan topology into plan ladders
- Expand member catalog non-plan section to all published non-tier
  products; require recurring Stripe-mapped prices for purchase
- Add operator domains placements and terminal-claims ledger; redirect
  /domains to the FedWiki Sites Domains anchor
- Apply canonical vocabulary and chrome/form conventions; migrate seeded
  FedWiki Sites display name
2026-08-23 17:12:42 -05:00

302 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
)
// TestGetPlansLadderAware exercises the ladder-grouped catalog end to end against
// the database: plans group by ladder in rank order, ladder sections honor the
// configurable sort_order, the current rung is marked per (pool, ladder), and a
// product that is a tier in two ladders renders under each with independent
// state. Covers tasks 7.17.5.
func TestGetPlansLadderAware(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sfx := uuid.New().String()[:8]
// Shared entitlement set so the rank-0 default product can back a grant
// (ReapplyDefaultsForPool requires the default tier to have one).
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "catalog-set-" + sfx,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
// A numeric rule on a seeded resource key: tier cards must render its
// display name ("Wiki Sites", 00002_seed_resource_keys.sql), never the
// raw key (member-product-discovery delta, hardening-polish).
if _, err := eq.CreateEntitlementSetRule(ctx, entitlements.CreateEntitlementSetRuleParams{
SetID: es.SetID,
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 16, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
}); err != nil {
t.Fatalf("create entitlement rule: %v", err)
}
// Three public plan products.
mkProduct := func(name string) billing.Product {
p, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + "-" + sfx,
IsActive: true,
IsPublic: true,
EntitlementSetID: esID,
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create product %s: %v", name, err)
}
return p
}
public := mkProduct("Public")
standard := mkProduct("Standard")
premium := mkProduct("Premium")
mkLadder := func(key, name string, sortOrder int32) billing.PlanLadder {
l, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: key + "-" + sfx,
Name: name,
IsActive: true,
})
if err != nil {
t.Fatalf("create ladder %s: %v", key, err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE core.plan_ladders SET sort_order = $1 WHERE plan_ladder_id = $2`,
sortOrder, l.PlanLadderID); err != nil {
t.Fatalf("set sort_order on %s: %v", key, err)
}
return l
}
// Support sorts before Hosting (5 < 10) despite the alphabetical order.
hosting := mkLadder("hosting", "Hosting Services", 10)
support := mkLadder("support", "Support Services", 5)
// CreatePlanLadderTier appends at MAX+1; fixtures that need an explicit
// rank place the tier with UpdateTierRank afterwards.
mkTier := func(l billing.PlanLadder, p billing.Product, rank int32) {
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: l.PlanLadderID,
ProductID: p.ProductID,
}); err != nil {
t.Fatalf("create tier: %v", err)
}
if _, err := bq.UpdateTierRank(ctx, billing.UpdateTierRankParams{
PlanLadderID: l.PlanLadderID, ProductID: p.ProductID, Rank: rank,
}); err != nil {
t.Fatalf("set tier rank: %v", err)
}
}
// hosting: Public(0) -> Standard(1) -> Premium(2)
mkTier(hosting, public, 0)
mkTier(hosting, standard, 1)
mkTier(hosting, premium, 2)
// support: Public(0) -> Standard(1). Public and Standard are tiers in BOTH
// ladders (the M:N case).
mkTier(support, public, 0)
mkTier(support, standard, 1)
// Org type defaults to the hosting ladder, so ReapplyDefaults attaches the
// pool to hosting's rank-0 rung (Public).
orgType := "ca-" + uuid.New().String()[:4]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id)
VALUES ($1, $2, true, $3)`,
orgType, "Catalog Type", hosting.PlanLadderID); err != nil {
t.Fatalf("create org type: %v", err)
}
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Catalog Member",
PrimaryEmail: "cat-" + sfx + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Catalog Org",
Slug: "cat-org-" + sfx,
OrgType: orgType,
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
pool, err := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID,
Name: "default",
Slug: "default",
PoolType: "default",
IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
if _, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID,
entitlements.Actor{ActorType: "system", Reason: "test"}); err != nil {
t.Fatalf("reapply defaults: %v", err)
}
// Authenticated session for this org.
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", org.OrgID)
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq,
BillingQ: bq,
AuthConfig: &auth.Config{SessionManager: sm},
Logger: discardLogger(),
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(sctx)
rec := httptest.NewRecorder()
h.GetPlans(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// Both ladder sections render.
hostingIdx := strings.Index(body, "Hosting Services")
supportIdx := strings.Index(body, "Support Services")
if hostingIdx < 0 || supportIdx < 0 {
t.Fatalf("expected both ladder section headers; hosting=%d support=%d", hostingIdx, supportIdx)
}
// 7.5: ladder sections ordered by sort_order (support=5 before hosting=10).
if supportIdx > hostingIdx {
t.Errorf("expected Support Services (sort_order 5) before Hosting Services (sort_order 10)")
}
// 7.2 + 7.3: the current rung is marked per (pool, ladder). Public is a
// tier in BOTH ladders, and Doc 41's confer derives the product's full
// shape — ReapplyDefaultsForPool enrolls the pool on hosting AND support
// in one conferral (one provision, one junction row per ladder). "Current"
// is a set — one rung per enrolled ladder — so a badge appears in each
// section, computed from that ladder's own attachment.
if got := strings.Count(body, "Current plan"); got != 2 {
t.Errorf("expected 2 'Current plan' badges (Public holds a rung on both ladders), got %d", got)
}
// 7.3: the support section (rendered first) lists Public and Standard — the
// products it shares with hosting — and marks Public current there too.
supportSection := body[:hostingIdx]
if !strings.Contains(supportSection, "Public-"+sfx) || !strings.Contains(supportSection, "Standard-"+sfx) {
t.Errorf("support section should list the shared Public and Standard tiers")
}
if got := strings.Count(supportSection, "Current plan"); got != 1 {
t.Errorf("support section 'Current plan' badges = %d, want 1 (Public's rung on support)", got)
}
// 7.4: nothing is purchasable (no Stripe mapping, Database nil), so no move
// routes to checkout; builtPath moves render the purchase-unavailable reason.
if got := strings.Count(body, `hx-post="/billing/checkout"`); got != 0 {
t.Errorf("expected 0 enabled checkout controls without a Stripe mapping, got %d", got)
}
if !strings.Contains(body, "Not available for purchase yet") {
t.Errorf("expected the purchase-unavailable reason on the disabled move controls")
}
// Feature lines resolve resource-key display names; the raw key must not
// leak onto the card.
if !strings.Contains(body, "16 FedWiki Sites") {
t.Errorf("expected entitlement feature to render the display name '16 FedWiki Sites'")
}
if strings.Contains(body, "fedwiki_sites") {
t.Errorf("raw resource key 'fedwiki_sites' leaked into the catalog body")
}
}
// TestMemberEntitlementsPerAxisRendering verifies the entitlement partial lists
// the current tier for every enrolled axis (a set), and shows the off-plan state
// for a pool with no attachment. Covers task 7.6 at the presentation layer; the
// handler's per-attachment emission is exercised live by TestGetPlansLadderAware
// via the same GetActiveAttachmentsByPool path.
func TestMemberEntitlementsPerAxisRendering(t *testing.T) {
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
if err != nil {
t.Fatalf("new handler: %v", err)
}
data := server.EntitlementsData{
HasEntitlements: true,
PoolTiers: []server.PoolTierViewModel{
{PoolName: "Hosting", LadderKey: "hosting", TierName: "Standard", HasTier: true},
{PoolName: "Support", LadderKey: "support", TierName: "Basic", HasTier: true},
},
}
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()
// Both enrolled axes are listed (not just the first attachment), by
// their display name — not the raw ladder key (ui-vocabulary D4.3 bans
// member-facing slug/resource-key badges).
for _, want := range []string{"Hosting", "Standard", "Support", "Basic"} {
if !strings.Contains(body, want) {
t.Errorf("expected entitlement view to mention %q (per-axis listing)", want)
}
}
if strings.Contains(body, "hosting") || strings.Contains(body, "support") {
t.Errorf("raw ladder key leaked into the entitlements body")
}
if got := strings.Count(body, "You are on"); got != 2 {
t.Errorf("expected one 'You are on' line per enrolled axis (2), got %d", got)
}
// A pool with no attachment shows the off-plan state.
off := httptest.NewRecorder()
h.Templates.Render(off, "member_entitlements.html", server.EntitlementsData{
HasEntitlements: true,
PoolTiers: []server.PoolTierViewModel{{PoolName: "default"}},
})
if !strings.Contains(off.Body.String(), "No active plan") {
t.Errorf("expected 'No active plan' for a pool with no attachment")
}
}