- 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
302 lines
11 KiB
Go
302 lines
11 KiB
Go
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.1–7.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")
|
||
}
|
||
}
|