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.
171 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|