// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// The rule-less warning (task 10.5): all five surfaces render the identical
// sentence, read from core.product_shape's active_rule_count, and every
// commit stays enabled.
import (
"bytes"
"context"
"database/sql"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// theRuleLessSentence is the one wording, spelled out here rather than built
// from the helper, so a drift in the helper fails this test.
const theRuleLessSentence = `Community Plan provides nothing: its entitlement set Community Baseline has no active rule.`
// renderPartial executes one operator partial (or one shared define) against
// the real template set.
func renderPartial(t *testing.T, name string, data any) string {
t.Helper()
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub partials: %v", err)
}
tmpl := template.New("operator").Funcs(template.FuncMap{
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
"routeURL": web.RouteURL,
"fieldErr": func(string, string, string, string, web.FieldErrors, string) string { return "" },
"stripeEntityURL": func(string, string) string { return "" },
"helpIcon": helpIcon,
})
tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html")))
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatalf("ExecuteTemplate %s: %v", name, err)
}
return buf.String()
}
// TestRuleLessWarningIsOneSentenceOnFiveSurfaces renders the sentence
// through each of the five surfaces' own markup and asserts every one of
// them carries it byte for byte, with no disabled control anywhere.
func TestRuleLessWarningIsOneSentenceOnFiveSurfaces(t *testing.T) {
setID := uuid.New().String()
sentence := string(ruleLessWarningSentence("Community Plan", setID, "Community Baseline"))
if want := strings.Replace(theRuleLessSentence, "SETID", setID, 1); sentence != want {
t.Fatalf("sentence =\n%s\nwant\n%s", sentence, want)
}
warning := template.HTML(sentence)
products := []ProductOption{{ProductID: "p1", Name: "Community Plan"}}
issuance := []IssuanceProductOption{{ProductID: "p1", Name: "Community Plan"}}
surfaces := []struct {
name string
render func() string
}{
{"org type default change", func() string {
return renderPartial(t, "operator_org_type_preview.html",
&OrgTypeChangePreview{CandidateLadderID: "l1", RuleLessWarning: warning})
}},
{"tier add", func() string {
return renderPartial(t, "form",
planLadderTierAddFormView("l1", forms.NewValues(), nil, products, warning))
}},
{"tier removal", func() string {
return renderPartial(t, "operator_plan_ladder_tier_removal_message.html",
&PendingTierRemoval{ProductName: "Old Plan", PromotedName: "Community Plan", RuleLessWarning: warning})
}},
{"tier reorder", func() string {
return renderPartial(t, "operator_plan_ladder_tier_reorder_message.html",
&PendingTierReorder{RankZeroChanged: true, OldRankZeroName: "Old Plan",
NewRankZeroName: "Community Plan", RuleLessWarning: warning})
}},
{"issue grant", func() string {
return renderPartial(t, "form",
issueGrantFormView("org-1", issuance, forms.ModeRecord, forms.NewValues(), nil, warning))
}},
}
for _, s := range surfaces {
t.Run(s.name, func(t *testing.T) {
body := s.render()
if !strings.Contains(body, sentence) {
t.Errorf("%s does not render the sentence, got:\n%s", s.name, body)
}
// A8, M1: no disabled control is added on any of them. The
// shared disabled-control idiom is the wrapper the console
// uses everywhere a commit is blocked; a select's own
// placeholder option is not a disabled control.
if strings.Contains(body, "disabled-control") {
t.Errorf("%s renders a disabled control:\n%s", s.name, body)
}
if i := strings.Index(body, `")]
if strings.Contains(commit, "disabled") {
t.Errorf("%s disables its commit: %s", s.name, commit)
}
}
})
}
}
// The two form surfaces render no region at all when the selected product's
// set carries a rule, which is what an empty slot means.
func TestRuleLessWarningAbsentWhenTheSetCarriesARule(t *testing.T) {
products := []ProductOption{{ProductID: "p1", Name: "Community Plan"}}
body := renderPartial(t, "form", planLadderTierAddFormView("l1", forms.NewValues(), nil, products, ""))
if strings.Contains(body, "provides nothing") {
t.Errorf("tier add renders the sentence with no warning bound:\n%s", body)
}
issuance := []IssuanceProductOption{{ProductID: "p1", Name: "Community Plan"}}
body = renderPartial(t, "form", issueGrantFormView("org-1", issuance, forms.ModeRecord, forms.NewValues(), nil, ""))
if strings.Contains(body, "provides nothing") {
t.Errorf("issue grant renders the sentence with no warning bound:\n%s", body)
}
}
// ruleLessFixture is one scratch catalog for the DB-backed cases: a product
// whose set carries no active rule, and one whose set carries a limit rule.
// The rules are seeded through core.commit_rule_change, the only write path
// to core.entitlement_set_rules, which is why the transaction comes from
// BeginRuleChange.
type ruleLessFixture struct {
tx *sql.Tx
h *OperatorPartialsHandler
emptySetID string
emptyProductID string
ruledSetID string
ruledProductID string
ruledRuleID string
personID string
sfx string
billingQ *billing.Queries
entitlementsQ *entitlements.Queries
organizationQ *organization.Queries
newSetForProduct func(name string) string
}
func newRuleLessFixture(t *testing.T, database *sql.DB, ctx context.Context) *ruleLessFixture {
t.Helper()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatalf("begin rule change: %v", err)
}
t.Cleanup(func() { tx.Rollback() })
bq, eq, oq, iq := billing.New(tx), entitlements.New(tx), organization.New(tx), identity.New(tx)
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
BillingQ: bq,
EntitlementsQ: eq,
OrgQ: oq,
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
sfx := uuid.New().String()[:8]
f := &ruleLessFixture{tx: tx, h: h, sfx: sfx, billingQ: bq, entitlementsQ: eq, organizationQ: oq}
f.newSetForProduct = func(name string) string {
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: name + " " + sfx, IsActive: true,
})
if err != nil {
t.Fatalf("create set %s: %v", name, err)
}
return set.SetID
}
f.emptySetID = f.newSetForProduct("Community Baseline")
f.emptyProductID = f.newProduct(t, ctx, "Community Plan", f.emptySetID)
f.ruledSetID = f.newSetForProduct("Ruled Baseline")
f.ruledProductID = f.newProduct(t, ctx, "Community Plus", f.ruledSetID)
f.ruledRuleID = f.addRule(t, ctx, f.ruledSetID)
user, err := iq.CreateUser(ctx, "rlw-"+uuid.NewString())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Rule Less Operator",
PrimaryEmail: fmt.Sprintf("rlw-%s@example.com", sfx),
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
f.personID = person.PersonID
return f
}
func (f *ruleLessFixture) newProduct(t *testing.T, ctx context.Context, name, setID string) string {
t.Helper()
prod, err := f.billingQ.CreateProduct(ctx, billing.CreateProductParams{
Name: name, IsActive: true, IsPublic: true, LifecycleStatus: "published",
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(setID), Valid: true},
})
if err != nil {
t.Fatalf("create product %s: %v", name, err)
}
return prod.ProductID
}
// addRule seeds one active limit rule through core.commit_rule_change.
func (f *ruleLessFixture) addRule(t *testing.T, ctx context.Context, setID string) string {
t.Helper()
row, err := entitlements.CommitRuleChangeTx(ctx, f.entitlementsQ, entitlements.CommitRuleChangeInput{
SetID: setID,
ChangeKind: entitlements.ChangeKindRuleAdded,
ActorType: entitlements.ActorTypeSystem,
Rule: entitlements.RuleFields{
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 5, Valid: true},
ResourcePerUnit: sql.NullBool{Bool: false, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
},
})
if err != nil {
t.Fatalf("add rule: %v", err)
}
return row.RuleID
}
// TestRuleLessWarningReadsActiveRuleCount is the source test: the sentence
// comes from core.product_shape's active_rule_count and from nothing else,
// including on the org-type default-change preview.
func TestRuleLessWarningReadsActiveRuleCount(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
f := newRuleLessFixture(t, database, ctx)
got := string(f.h.ruleLessWarning(ctx, f.emptyProductID))
if !strings.Contains(got, "provides nothing: its entitlement set") ||
!strings.Contains(got, `/operator/entitlement-sets/`+f.emptySetID) {
t.Errorf("rule-less product warning = %q", got)
}
if got := f.h.ruleLessWarning(ctx, f.ruledProductID); got != "" {
t.Errorf("a set with an active rule must render no sentence, got %q", got)
}
// Deactivating the only rule flips active_rule_count to 0, and the
// sentence follows it without any other read changing.
if _, err := entitlements.CommitRuleChangeTx(ctx, f.entitlementsQ, entitlements.CommitRuleChangeInput{
SetID: f.ruledSetID,
ChangeKind: entitlements.ChangeKindRuleDeactivated,
RuleID: f.ruledRuleID,
ActorType: entitlements.ActorTypeSystem,
}); err != nil {
t.Fatalf("deactivate rule: %v", err)
}
if got := f.h.ruleLessWarning(ctx, f.ruledProductID); got == "" {
t.Error("a set whose rules are all inactive must render the sentence")
}
// The org-type default-change preview reads it through the same helper.
preview, err := f.h.buildOrgTypeChangePreview(ctx, "personal", uuid.New().String(), f.emptyProductID)
if err != nil {
t.Fatalf("buildOrgTypeChangePreview: %v", err)
}
if preview.RuleLessWarning == "" {
t.Error("the default-change preview must carry the sentence for a rule-less rank-0 candidate")
}
body := renderPartial(t, "operator_org_type_preview.html", preview)
if !strings.Contains(body, "provides nothing: its entitlement set") {
t.Errorf("the default-change preview does not render the sentence:\n%s", body)
}
}
// TestRuleLessWarningComputedOnTierSurfaces drives the two ladder previews
// against the real catalog rather than binding a sentence into their view
// models: the removal preview's promoted rank 0 and the reorder preview's
// new rank 0 each read active_rule_count for themselves.
func TestRuleLessWarningComputedOnTierSurfaces(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
f := newRuleLessFixture(t, database, ctx)
for _, c := range []struct {
name string
candidateID string
wantWarning bool
}{
{"rule-less candidate", f.emptyProductID, true},
{"ruled candidate", f.ruledProductID, false},
} {
t.Run("tier removal, "+c.name, func(t *testing.T) {
ladderID, removedID := f.newLadder(t, ctx, "Removal "+c.name, c.candidateID)
f.holdTier(t, ctx, removedID, "Removal Holder "+c.name)
pending, errMsg := f.h.buildTierRemovalPreview(ctx, ladderID, removedID)
if errMsg != "" || pending == nil {
t.Fatalf("buildTierRemovalPreview: pending=%v err=%q", pending, errMsg)
}
assertWarning(t, string(pending.RuleLessWarning), c.wantWarning)
})
t.Run("tier reorder, "+c.name, func(t *testing.T) {
ladderID, otherID := f.newLadder(t, ctx, "Reorder "+c.name, c.candidateID)
pending, errMsg := f.h.buildPendingTierReorder(ctx, ladderID, []string{c.candidateID, otherID})
if errMsg != "" || pending == nil {
t.Fatalf("buildPendingTierReorder: pending=%v err=%q", pending, errMsg)
}
if !pending.RankZeroChanged {
t.Fatal("the submitted order must move rank 0")
}
assertWarning(t, string(pending.RuleLessWarning), c.wantWarning)
})
}
}
func assertWarning(t *testing.T, got string, want bool) {
t.Helper()
if want && !strings.Contains(got, "provides nothing: its entitlement set") {
t.Errorf("the surface must compute the sentence from active_rule_count, got %q", got)
}
if !want && got != "" {
t.Errorf("a ruled candidate must carry no sentence, got %q", got)
}
}
// newLadder builds a two-tier ladder whose rank 0 is a fresh product and
// whose rank 1 is candidateID, and returns the ladder with its rank-0
// product: the one the removal preview removes and the reorder preview
// displaces.
func (f *ruleLessFixture) newLadder(t *testing.T, ctx context.Context, name, candidateID string) (string, string) {
t.Helper()
rankZeroID := f.newProduct(t, ctx, name+" Top "+uuid.New().String()[:8], f.newSetForProduct(name+" Top Baseline"))
ladder, err := f.billingQ.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
Name: name + " " + uuid.New().String()[:8], IsActive: true,
})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
for _, productID := range []string{rankZeroID, candidateID} {
if _, err := f.billingQ.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID, ProductID: productID,
}); err != nil {
t.Fatalf("create tier: %v", err)
}
}
return ladder.PlanLadderID, rankZeroID
}
// holdTier puts one live holder on the product, which core.confer attaches to
// the ladder the product is a tier of: what the removal preview needs before
// it classifies anything.
func (f *ruleLessFixture) holdTier(t *testing.T, ctx context.Context, productID, orgName string) {
t.Helper()
org, err := f.organizationQ.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: orgName, OrgType: "personal", OwnerPersonID: f.personID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
pool, err := f.entitlementsQ.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID, Name: "Default", PoolType: "default", IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
grant, err := f.entitlementsQ.CreateGrant(ctx, entitlements.CreateGrantParams{
ProductID: productID,
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(org.OrgID), Valid: true},
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(f.personID), Valid: true},
GrantReason: "manual",
Quantity: 1,
ValidFrom: time.Now(),
})
if err != nil {
t.Fatalf("create grant: %v", err)
}
if _, outcome, err := f.entitlementsQ.Confer(ctx, entitlements.ConferParams{
PoolID: pool.PoolID,
ProductID: productID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: 1,
}); err != nil || outcome != "created" {
t.Fatalf("confer: outcome=%q err=%v", outcome, err)
}
}
// TestGetProductRuleCheckAnswersTheLiveRegion drives the route the tier-add
// panel and the Issue grant panel fire on change: the sentence for a
// rule-less product, nothing for a ruled one, and nothing for a product_id
// that names no product or names none at all.
func TestGetProductRuleCheckAnswersTheLiveRegion(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
f := newRuleLessFixture(t, database, ctx)
call := func(productID string) string {
req := httptest.NewRequestWithContext(ctx, "GET",
"/partials/operator/products/rule-check?product_id="+productID, nil)
rec := httptest.NewRecorder()
f.h.GetProductRuleCheck(rec, req)
if rec.Code != 200 {
t.Fatalf("rule-check = %d, body: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
if body := call(f.emptyProductID); !strings.Contains(body, "provides nothing: its entitlement set") ||
!strings.Contains(body, "/operator/entitlement-sets/"+f.emptySetID) {
t.Errorf("a rule-less product must answer with the sentence, got:\n%s", body)
}
for _, c := range []struct{ name, productID string }{
{"ruled product", f.ruledProductID},
{"unknown product", uuid.New().String()},
{"no product", ""},
} {
if body := call(c.productID); strings.TrimSpace(body) != "" {
t.Errorf("%s must answer with an empty region, got:\n%s", c.name, body)
}
}
}