package server_test import ( "context" "database/sql" "net/http" "net/http/httptest" "strings" "testing" "time" "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" internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store" "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" ) // TestDraftProductHiddenFromMemberCatalog covers member-product-discovery's // "Draft product hidden from member catalog" scenario: a draft product that is // otherwise fully purchasable (active, public, priced, and Stripe-mapped) must // not appear in the plans catalog or the add-ons listing, because // ListPublicProducts/ListPublicPlanProducts now filter lifecycle_status = // 'published' (purchase-path-blockers task 1.1). func TestDraftProductHiddenFromMemberCatalog(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) sq := internalstripe.New(tx) sfx := uuid.New().String()[:8] es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{ Name: "draft-gate-set-" + sfx, }) if err != nil { t.Fatalf("create entitlement set: %v", err) } esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true} // A draft add-on: active, public, priced, and Stripe-mapped -- every axis // green except lifecycle_status. addon, err := bq.CreateProduct(ctx, billing.CreateProductParams{ Name: "Hidden Addon " + sfx, DisplayCategory: sql.NullString{String: "addon", Valid: true}, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: "draft", }) if err != nil { t.Fatalf("create addon product: %v", err) } addonPrice, err := bq.CreatePrice(ctx, billing.CreatePriceParams{ ProductID: addon.ProductID, Currency: "usd", UnitAmount: 500, }) if err != nil { t.Fatalf("create addon price: %v", err) } if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{ PriceID: addonPrice.PriceID, StripePriceID: sql.NullString{String: "price_stripe_addon_" + sfx, Valid: true}, SyncStatus: "synced", }); err != nil { t.Fatalf("addon price mapping: %v", err) } // A draft plan product tiered onto a ladder nobody is enrolled on: active, // public, priced, and Stripe-mapped. plan, err := bq.CreateProduct(ctx, billing.CreateProductParams{ Name: "Hidden Plan " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: "draft", }) if err != nil { t.Fatalf("create plan product: %v", err) } planPrice, err := bq.CreatePrice(ctx, billing.CreatePriceParams{ ProductID: plan.ProductID, Currency: "usd", UnitAmount: 1000, RecurringInterval: sql.NullString{String: "month", Valid: true}, }) if err != nil { t.Fatalf("create plan price: %v", err) } if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{ PriceID: planPrice.PriceID, StripePriceID: sql.NullString{String: "price_stripe_plan_" + sfx, Valid: true}, SyncStatus: "synced", }); err != nil { t.Fatalf("plan price mapping: %v", err) } ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{ Name: "Draft Gate Ladder " + sfx, IsActive: true, }) if err != nil { t.Fatalf("create ladder: %v", err) } if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{ PlanLadderID: ladder.PlanLadderID, ProductID: plan.ProductID, }); err != nil { t.Fatalf("create tier: %v", err) } // A member org with no ladder attachment at all -- neither product is its // current rung, so both must simply be invisible. 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: "Draft Gate Member", PrimaryEmail: "draft-gate-" + sfx + "@example.com", PrimaryEmailVerified: true, }) if err != nil { t.Fatalf("create person: %v", err) } org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{ Name: "Draft Gate Org", OrgType: "personal", OwnerPersonID: person.PersonID, }) if err != nil { t.Fatalf("create org: %v", err) } 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) } plansReq := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(sctx) plansRec := httptest.NewRecorder() h.GetPlans(plansRec, plansReq) if plansRec.Code != 200 { t.Fatalf("plans: expected 200, got %d: %s", plansRec.Code, plansRec.Body.String()) } if strings.Contains(plansRec.Body.String(), "Hidden Plan "+sfx) { t.Errorf("draft plan product must not appear in the plans listing") } addonsReq := httptest.NewRequest(http.MethodGet, "/partials/member/addons", nil).WithContext(sctx) addonsRec := httptest.NewRecorder() h.GetAddons(addonsRec, addonsReq) if addonsRec.Code != 200 { t.Fatalf("addons: expected 200, got %d: %s", addonsRec.Code, addonsRec.Body.String()) } if strings.Contains(addonsRec.Body.String(), "Hidden Addon "+sfx) { t.Errorf("draft add-on product must not appear in the add-ons listing") } } // TestCurrentRungSurvivesRetirement covers member-product-discovery's // "Enrolled rung on an unpublished product still renders" and "Retired // product hidden from prospective buyers" scenarios together: an org enrolled // on a tier before it was retired keeps seeing that tier as its current plan // (name, description, current marker, no move control), while an org that was // never enrolled on it does not see the tier at all. func TestCurrentRungSurvivesRetirement(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] es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{ Name: "retire-set-" + sfx, }) if err != nil { t.Fatalf("create entitlement set: %v", err) } esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true} // Created (and enrolled) while published; retired afterward, mirroring an // operator retiring a product a member is already subscribed to. tier, err := bq.CreateProduct(ctx, billing.CreateProductParams{ Name: "Legacy Tier " + sfx, Description: sql.NullString{String: "The tier this org is grandfathered on.", Valid: true}, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: "published", }) if err != nil { t.Fatalf("create tier product: %v", err) } ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{ Name: "Retire Ladder " + sfx, IsActive: true, }) if err != nil { t.Fatalf("create ladder: %v", err) } if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{ PlanLadderID: ladder.PlanLadderID, ProductID: tier.ProductID, }); err != nil { t.Fatalf("create tier row: %v", err) } // Enrolled org: person + org + pool, then a grant conferred onto the pool // while the product is still published (core.confer rejects draft/retired // products outright -- this is intake, done before the retirement below). mkPersonOrgPool := func(label string) (person identity.Person, org organization.Organization, pool entitlements.ResourcePool) { 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: label, PrimaryEmail: label + "-" + sfx + "@example.com", PrimaryEmailVerified: true, }) if err != nil { t.Fatalf("create person: %v", err) } org, err = oq.CreateOrganization(ctx, organization.CreateOrganizationParams{ Name: label + " Org", OrgType: "personal", OwnerPersonID: person.PersonID, }) if err != nil { t.Fatalf("create org: %v", err) } pool, err = eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{ OrgID: org.OrgID, Name: "default", PoolType: "default", IsAutoManaged: true, }) if err != nil { t.Fatalf("create pool: %v", err) } return person, org, pool } enrolledPerson, enrolledOrg, enrolledPool := mkPersonOrgPool("Enrolled") _, nonEnrolledOrg, _ := mkPersonOrgPool("NonEnrolled") grant, err := eq.CreateGrant(ctx, entitlements.CreateGrantParams{ ProductID: tier.ProductID, GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(enrolledOrg.OrgID), Valid: true}, GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(enrolledPerson.PersonID), Valid: true}, GrantReason: "promotional", Quantity: 1, ValidFrom: time.Now(), }) if err != nil { t.Fatalf("create grant: %v", err) } _, outcome, err := eq.Confer(ctx, entitlements.ConferParams{ PoolID: enrolledPool.PoolID, ProductID: tier.ProductID, GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true}, Quantity: 1, }) if err != nil { t.Fatalf("confer: %v", err) } if outcome != "created" { t.Fatalf("confer outcome = %q, want created", outcome) } // Retire the product now that the org is enrolled on it -- the scenario // under test. if _, err := tx.ExecContext(ctx, `UPDATE core.products SET lifecycle_status = 'retired' WHERE product_id = $1`, tier.ProductID, ); err != nil { t.Fatalf("retire product: %v", err) } h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{ EntitlementsQ: eq, BillingQ: bq, Logger: discardLogger(), }) if err != nil { t.Fatalf("new handler: %v", err) } // scs binds session data to a context key scoped to the SessionManager // instance that created it, so each org's request needs its own manager // wired onto the (shared) handler before that request runs. sessionFor := func(orgID string) context.Context { 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", orgID) h.AuthConfig = &auth.Config{SessionManager: sm} return sctx } // Enrolled org still sees its current rung. enrolledCtx := sessionFor(enrolledOrg.OrgID) req := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(enrolledCtx) rec := httptest.NewRecorder() h.GetPlans(rec, req) if rec.Code != 200 { t.Fatalf("enrolled: expected 200, got %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Legacy Tier "+sfx) { t.Errorf("enrolled org: expected the retired current-rung product's name to render") } if !strings.Contains(body, "The tier this org is grandfathered on.") { t.Errorf("enrolled org: expected the retired current-rung product's description to render") } if !strings.Contains(body, "Current plan") { t.Errorf("enrolled org: expected a 'Current plan' badge on the retired rung") } if strings.Contains(body, `hx-post="/billing/checkout"`) || strings.Contains(body, `hx-get="/partials/member/plans/switch/preview"`) { t.Errorf("enrolled org: the retired current rung must not offer a move control") } // Never-enrolled org does not see the tier at all. otherCtx := sessionFor(nonEnrolledOrg.OrgID) req2 := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(otherCtx) rec2 := httptest.NewRecorder() h.GetPlans(rec2, req2) if rec2.Code != 200 { t.Fatalf("non-enrolled: expected 200, got %d: %s", rec2.Code, rec2.Body.String()) } if strings.Contains(rec2.Body.String(), "Legacy Tier "+sfx) { t.Errorf("non-enrolled org must not see the retired tier") } }