Replace the entity slugs on organizations, workspaces, resource pools, and plan ladders with nullable `key` columns and add keys to products, prices, and entitlement sets. Rename `providers.slug` to `provider` and add partial unique indexes for system and org role names. Assign invoice numbers per billing account from a gapless transactional counter; Stripe's number moves to the invoice mapping as an external reference. Seeds, fixtures, and the operator lookup address rows by key, and the returning-login resync no longer blanks a display name when the IdP sends no `name` claim.
365 lines
12 KiB
Go
365 lines
12 KiB
Go
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")
|
|
}
|
|
}
|