// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server import ( "strings" "testing" ) // TestMemberAddonsTruthfulCopy pins the member-product-discovery Extras // bucket after record-table-actions (maintainer, 2026-09-19): every listed // row can be bought, because buildAddonsData drops a product without an // active, synced, recurring price instead of listing it with "Not available // for purchase" where its Add would be. A listed product renders the normal // purchase affordance (an enabled Add control wired to checkout) with no // unavailability copy and no "Coming soon" roadmap claim, and a carried // display_category label renders as a small grouping badge alongside it. func TestMemberAddonsTruthfulCopy(t *testing.T) { t.Run("a listed product renders the purchase affordance and a category badge, no unavailability copy", func(t *testing.T) { data := AddonsData{Addons: []AddonViewModel{ {ProductID: "p2", Name: "Extra Storage", Description: "More room.", DisplayCategory: "addon", PriceID: "price-1"}, }} out := renderMemberDomains(t, "member_addons.html", data) if strings.Contains(out, "Not available for purchase") || strings.Contains(out, "Coming soon") { t.Error("a listed product must not render unavailability copy") } if strings.Contains(out, "disabled") { t.Error("purchasable product's control must not render disabled") } if !strings.Contains(out, `hx-post="/billing/checkout"`) || !strings.Contains(out, `"price_id": "price-1"`) { t.Error("purchasable product must submit to checkout with its price id") } if !strings.Contains(out, `addon`) { t.Error("a carried display_category must render as a small grouping badge (statusBadge, light)") } }) t.Run("blank display_category renders no badge", func(t *testing.T) { data := AddonsData{Addons: []AddonViewModel{ {ProductID: "p3", Name: "Extra Storage", Description: "More room.", PriceID: "price-1"}, }} out := renderMemberDomains(t, "member_addons.html", data) if strings.Contains(out, `class="badge`) { t.Error("a blank display_category must not render a grouping badge") } }) } // TestMemberAddonsHeadingAndDefinition pins the non-plan section's // conditional heading (design.md D6, maintainer decision 2026-08-23): // "More products" beneath plan sections (with the definition line), plain // "Products" when no plans exist and this section is the whole catalog — // a plan-less deployment must not read its catalog as extras. "Add-ons" // stays retired: a solo-purchasable product is not an addition to anything. func TestMemberAddonsHeadingAndDefinition(t *testing.T) { row := []AddonViewModel{ {ProductID: "p1", Name: "Extra Storage", Description: "More room.", PriceID: "price-1"}, } withPlans := renderMemberDomains(t, "member_addons.html", AddonsData{Addons: row, HasPlans: true}) if !strings.Contains(withPlans, ">More products<") { t.Error(`with plans above, expected the section heading "More products"`) } if !strings.Contains(withPlans, "Products to add alongside your plan, or to use on their own.") { t.Error("with plans above, expected the definition line") } alone := renderMemberDomains(t, "member_addons.html", AddonsData{Addons: row, HasPlans: false}) if !strings.Contains(alone, ">Products<") { t.Error(`standing alone, expected the plain heading "Products"`) } if strings.Contains(alone, "alongside your plan") { t.Error("standing alone, the plan-referencing definition line must not render") } for _, out := range []string{withPlans, alone} { if strings.Contains(out, ">Add-ons<") || strings.Contains(out, ">Extras<") { t.Error(`"Add-ons" and "Extras" must both be retired as the bucket heading`) } } } // TestMemberAddonsNoQualifyingProductsRendersNothing pins the "No qualifying // products" scenario: an empty Addons slice renders no section at all — no // heading, no table, no empty-state chrome. func TestMemberAddonsNoQualifyingProductsRendersNothing(t *testing.T) { out := renderMemberDomains(t, "member_addons.html", AddonsData{}) if strings.TrimSpace(strings.ReplaceAll(out, "\n", "")) == "" { return // an all-comment render collapses to whitespace; that's fine } for _, unwanted := range []string{">More products<", ">Products<", "