- 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
184 lines
6.4 KiB
Go
184 lines
6.4 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestIsRecurringPrice pins the one-time vs recurring distinction the Extras
|
|
// bucket's purchase-affordance gate relies on (resolvePurchasableRecurring):
|
|
// one-time-purchase delivery is the schema's reserved, unimplemented arm
|
|
// (ux-ia-naming design.md D6) — nothing creates a purchase-linked
|
|
// pool_provision from a one-time Stripe charge — so a one-time price must
|
|
// never be treated as purchasable even when it otherwise passes
|
|
// resolvePurchasable. A one-time price stores recurring_interval NULL
|
|
// (00001_init.sql: a bare nullable VARCHAR, no "one_time" literal).
|
|
func TestIsRecurringPrice(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
price billing.Price
|
|
want bool
|
|
}{
|
|
{"recurring monthly price", billing.Price{RecurringInterval: sql.NullString{String: "month", Valid: true}}, true},
|
|
{"recurring yearly price", billing.Price{RecurringInterval: sql.NullString{String: "year", Valid: true}}, true},
|
|
{"one-time price (NULL interval)", billing.Price{RecurringInterval: sql.NullString{}}, false},
|
|
{"valid-but-empty interval treated as one-time", billing.Price{RecurringInterval: sql.NullString{String: "", Valid: true}}, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := isRecurringPrice(c.price); got != c.want {
|
|
t.Errorf("isRecurringPrice(%+v) = %v, want %v", c.price, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildAddonsDataExtrasBucket covers the member-product-discovery delta's
|
|
// Extras bucket against a real DB: published, public, non-tier products with
|
|
// display_category values of "addon", some other label, and blank all
|
|
// render; a product that is also a tier on a plan ladder is never duplicated
|
|
// into Extras regardless of carrying the "addon" label; and a carried label
|
|
// surfaces as DisplayCategory without affecting membership.
|
|
func TestBuildAddonsDataExtrasBucket(t *testing.T) {
|
|
database := newRollbackTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
bq := billing.New(tx)
|
|
sfx := uuid.New().String()[:8]
|
|
h := &MemberProductsHandler{BillingQ: bq, Logger: slog.Default()}
|
|
|
|
mkProduct := func(name, category string) billing.Product {
|
|
t.Helper()
|
|
p, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: name,
|
|
DisplayCategory: sql.NullString{String: category, Valid: category != ""},
|
|
IsActive: true,
|
|
IsPublic: true,
|
|
LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create product %s: %v", name, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
labeled := mkProduct("Labeled Extra "+sfx, "addon")
|
|
otherLabel := mkProduct("Other-Label Extra "+sfx, "storage")
|
|
blank := mkProduct("Blank-Label Extra "+sfx, "")
|
|
|
|
// A tier that also carries display_category = 'addon': membership is
|
|
// structural (ladder-tier presence), so this must render in its plan
|
|
// section only and never duplicate into Extras.
|
|
tierWithAddonLabel := mkProduct("Tiered Extra "+sfx, "addon")
|
|
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
|
|
LadderKey: "extras-test-" + sfx, Name: "Extras Test Ladder", IsActive: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create ladder: %v", err)
|
|
}
|
|
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
|
PlanLadderID: ladder.PlanLadderID, ProductID: tierWithAddonLabel.ProductID,
|
|
}); err != nil {
|
|
t.Fatalf("create tier: %v", err)
|
|
}
|
|
|
|
data := h.buildAddonsData(ctx)
|
|
if data.Error != "" {
|
|
t.Fatalf("unexpected error: %s", data.Error)
|
|
}
|
|
|
|
byID := make(map[string]AddonViewModel, len(data.Addons))
|
|
for _, a := range data.Addons {
|
|
byID[a.ProductID] = a
|
|
}
|
|
|
|
for _, want := range []billing.Product{labeled, otherLabel, blank} {
|
|
if _, ok := byID[want.ProductID]; !ok {
|
|
t.Errorf("expected %s to render in Extras", want.Name)
|
|
}
|
|
}
|
|
if _, ok := byID[tierWithAddonLabel.ProductID]; ok {
|
|
t.Error("tier product must not be duplicated into Extras regardless of its display_category label")
|
|
}
|
|
|
|
if got := byID[labeled.ProductID].DisplayCategory; got != "addon" {
|
|
t.Errorf("labeled product DisplayCategory = %q, want %q", got, "addon")
|
|
}
|
|
if got := byID[otherLabel.ProductID].DisplayCategory; got != "storage" {
|
|
t.Errorf("other-label product DisplayCategory = %q, want %q", got, "storage")
|
|
}
|
|
if got := byID[blank.ProductID].DisplayCategory; got != "" {
|
|
t.Errorf("blank-label product DisplayCategory = %q, want empty", got)
|
|
}
|
|
|
|
// None of these carry a price at all: Purchasable must be false, but
|
|
// they still render (purchasability gates the control, not visibility).
|
|
for _, id := range []string{labeled.ProductID, otherLabel.ProductID, blank.ProductID} {
|
|
if byID[id].Purchasable {
|
|
t.Errorf("product %s has no price and must not be Purchasable", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildAddonsDataOneTimePriceNotPurchasable covers the "purchasability
|
|
// gates the control, not the visibility" scenario for a real one-time price:
|
|
// a published, public, non-tier product with an active price whose
|
|
// recurring_interval is NULL still renders in Extras, but never as
|
|
// purchasable.
|
|
func TestBuildAddonsDataOneTimePriceNotPurchasable(t *testing.T) {
|
|
database := newRollbackTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
bq := billing.New(tx)
|
|
sfx := uuid.New().String()[:8]
|
|
|
|
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
|
Name: "One-Time Extra " + sfx, IsActive: true, IsPublic: true, LifecycleStatus: "published",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create product: %v", err)
|
|
}
|
|
if _, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
|
|
ProductID: product.ProductID, Currency: "usd", UnitAmount: 500,
|
|
// RecurringInterval intentionally left unset: a one-time price.
|
|
}); err != nil {
|
|
t.Fatalf("create price: %v", err)
|
|
}
|
|
|
|
h := &MemberProductsHandler{BillingQ: bq, Logger: slog.Default()}
|
|
data := h.buildAddonsData(ctx)
|
|
if data.Error != "" {
|
|
t.Fatalf("unexpected error: %s", data.Error)
|
|
}
|
|
|
|
var found *AddonViewModel
|
|
for i := range data.Addons {
|
|
if data.Addons[i].ProductID == product.ProductID {
|
|
found = &data.Addons[i]
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("one-time-priced product must still render in Extras")
|
|
}
|
|
if found.Purchasable {
|
|
t.Error("one-time-priced product must not be Purchasable, even with an active price")
|
|
}
|
|
}
|