// 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) } } }