Files
member-console/internal/server/member_extras_test.go
T
cgalo5758 9114271cc6 Right-align every Actions column and hide its header
Spread the Rules table's treatment to every table that carries verbs
(record-table-actions, archived 2026-09-19): the table is align-middle,
the Actions header and cells are text-end, every button in an Actions
cell carries ms-1, and the header's word is for assistive technology
only (a visually-hidden span; Primer: row actions do not require a
visible column header). The two integrations tables and the member
domains list, which headed their controls with an empty cell, take the
hidden word too. A new anatomy-lint rule, actions-column, refuses a
table that drifts from any of it.

Decisions on the way: a row tint marks the row in play (the current
workspace, a staged change), never a record's status; the member catalog
lists only what can be bought, so a Listed product without an active,
synced, recurring price is left out instead of shown with "Not available
for purchase"; two Actions cells that carried text in a verb's place are
empty (the Placements column and the pending panel already say why); and
four record tables gain their width floor.

Specs: page-anatomy "Tables share one density" modified, ui-quality-gate
gains "Lint refuses an Actions column without its treatment",
member-product-discovery's Extras bucket and truthful-copy requirements
modified. docs/design-system.md §6 states the treatment and its reasons.
2026-09-19 22:31:45 -05:00

171 lines
6.2 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"database/sql"
"log/slog"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"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 member-product-discovery's Extras
// bucket against a real DB after record-table-actions (maintainer,
// 2026-09-19: a catalog offers what can be bought): published, public,
// non-tier products with no price are not listed whatever their
// display_category ("addon", some other label, blank), and a product that
// is also a tier on a plan ladder is never listed here regardless of
// carrying the "addon" label. A listed row needs a synced Stripe price
// mapping that the handler reads through its own *sql.DB, which this
// rollback fixture cannot supply, so the rendered row (its Add control and
// its label badge) is pinned by member_addons_test.go instead.
func TestBuildAddonsDataExtrasBucket(t *testing.T) {
database := newRollbackTestDB(t)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, 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{
Name: "Extras Test Ladder " + sfx, 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
}
// None of these carry a price, so none can be bought and none is
// listed, whatever its label (member-product-discovery "A product that
// cannot be bought is not listed").
for _, unpriced := range []billing.Product{labeled, otherLabel, blank} {
if _, ok := byID[unpriced.ProductID]; ok {
t.Errorf("%s has no price and must not be listed in Extras", unpriced.Name)
}
}
if _, ok := byID[tierWithAddonLabel.ProductID]; ok {
t.Error("tier product must not be duplicated into Extras regardless of its display_category label")
}
}
// TestBuildAddonsDataOneTimePriceNotListed covers member-product-discovery
// "A product that cannot be bought is not listed" for a real one-time
// price: a published, public, non-tier product with an active price whose
// recurring_interval is NULL cannot deliver (one-time delivery is the
// schema's unimplemented arm), so the catalog leaves it out rather than
// listing it without a purchase control (maintainer, 2026-09-19).
func TestBuildAddonsDataOneTimePriceNotListed(t *testing.T) {
database := newRollbackTestDB(t)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, 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)
}
for _, a := range data.Addons {
if a.ProductID == product.ProductID {
t.Error("one-time-priced product must not be listed: its price cannot deliver, so it cannot be bought")
}
}
}