// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package server_test // The Rules table as the form (spec entitlement-set-management "The Rules // table is the rule form", "A staged change lives in the page until it is // applied", "One tray under the table carries the batch and its commit", // "The reduction policy is a field of the rule", "Rules are grouped by kind", // "Focus follows the act", "The Rules table is a record table on fixed // columns and stacks on narrow screens"). Every // test here drives the row-action route, which writes nothing; the commit is // operator_entitlement_set_rule_change_test.go's. import ( "context" "database/sql" "log/slog" "net/http/httptest" "net/url" "strings" "testing" "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" "git.coopcloud.tech/wiki-cafe/member-console/internal/server" "github.com/google/uuid" ) // entitlementSetRulesFormName mirrors the unexported constant of the same // name in package server (operator_entitlement_set_forms.go): the rules // declaration's registry name, needed here because this file lives in the // external server_test package. const entitlementSetRulesFormName = "operator.entitlement-set.rules" // newRulesHandler builds the operator partials handler for the rule surfaces. // database is the handler's own write path: a rule change opens its own // transaction through BeginRuleChange, because core.commit_rule_change asserts // the exclusive materialization rendezvous. Tests that only read pass nil. func newRulesHandler(t *testing.T, eq entitlements.Querier, database *sql.DB) *server.OperatorPartialsHandler { t.Helper() h, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{ EntitlementsQ: eq, Database: database, Logger: slog.Default(), }) if err != nil { t.Fatalf("construct operator partials handler: %v", err) } return h } // ruleAction drives one row action of the rules form: the act, the row it was // pressed on, and the whole page state the form would have posted with it. func ruleAction(t *testing.T, h *server.OperatorPartialsHandler, setID string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+setID+"/rules", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetPathValue("setID", setID) rec := httptest.NewRecorder() h.PostEntitlementSetRules(rec, req) return rec } // applyRules drives the commit: the staged batch as the form carries it. func applyRules(t *testing.T, h *server.OperatorPartialsHandler, setID string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets/"+setID+"/rules/apply", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetPathValue("setID", setID) rec := httptest.NewRecorder() h.ApplyEntitlementSetRules(rec, req) return rec } // commitRuleChange applies one delta through the rules form's commit, from // the fields the retired one-rule form used to carry. The rule-authoring // tests below and the History tests drive the write path through it, so one // helper states how a single change reaches the engine now. func commitRuleChange(t *testing.T, h *server.OperatorPartialsHandler, setID string, form url.Values) *httptest.ResponseRecorder { t.Helper() verb := form.Get("verb") if verb == "" { verb = "add" } key := form.Get("rule_id") if verb == "add" { key = "new:" + form.Get("resource_key") } // The hidden kind marker the rendered page would carry: the handler // re-derives the real one from the database, so a numeric marker on a // boolean key is exactly the stale submission the derivation covers. kind := "boolean" if form.Get("resource_value") != "" { kind = "numeric" } staged := url.Values{ "staged.0.verb": {verb}, "staged.0.key": {key}, "staged.0.resource_key": {form.Get("resource_key")}, "staged.0.resource_key_kind": {kind}, } for name, staged_name := range map[string]string{ "resource_value": "staged.0.resource_value", "resource_per_unit": "staged.0.resource_per_unit", "tier_reduction_policy": "staged.0.tier_reduction_policy", } { if v := form.Get(name); v != "" { staged.Set(staged_name, v) } } if v := form.Get("note"); v != "" { staged.Set("note", v) } if v := form.Get("stacking_policy"); v != "" { staged.Set("stacking_policy", v) } return applyRules(t, h, setID, staged) } // rulesFixture is one set carrying the rules a test names, with no pool: the // row actions write nothing, so the population is beside the point and the // fixture stays cheap. A key with no rule on the set is what a new row may // choose, so a test that adds a rule asks for a set without that key's rule. // // The two keys are the catalog's own: fedwiki_sites is numeric and // discourse_posting is boolean, and a test database seeds no others. type rulesFixture struct { setID string numericID string booleanID string } // newRulesFixture creates the set and the rules named: "numeric" for the // numeric key's limit rule, "boolean" for the boolean key's. func newRulesFixture(t *testing.T, database *sql.DB, limit int64, policy string, rules ...string) rulesFixture { t.Helper() ctx := context.Background() tx, err := entitlements.BeginRuleChange(ctx, database) if err != nil { t.Fatalf("begin rule change: %v", err) } defer tx.Rollback() q := entitlements.New(tx) set, err := q.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{ Name: "Rules Form Set " + uuid.New().String()[:8], IsActive: true, }) if err != nil { t.Fatalf("create set: %v", err) } fx := rulesFixture{setID: set.SetID} want := map[string]bool{} for _, r := range rules { want[r] = true } if want["numeric"] { numeric, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{ SetID: set.SetID, ChangeKind: entitlements.ChangeKindRuleAdded, ActorType: entitlements.ActorTypeSystem, Rule: entitlements.RuleFields{ RuleType: "limit", ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true}, ResourceValue: sql.NullInt64{Int64: limit, Valid: true}, ResourcePerUnit: sql.NullBool{Bool: false, Valid: true}, StackingPolicy: sql.NullString{String: "additive", Valid: true}, TierReductionPolicy: policy, }, }) if err != nil { t.Fatalf("add numeric rule: %v", err) } fx.numericID = numeric.RuleID } if want["boolean"] { boolean, err := entitlements.CommitRuleChangeTx(ctx, q, entitlements.CommitRuleChangeInput{ SetID: set.SetID, ChangeKind: entitlements.ChangeKindRuleAdded, ActorType: entitlements.ActorTypeSystem, Rule: entitlements.RuleFields{ RuleType: "boolean", ResourceKey: sql.NullString{String: "discourse_posting", Valid: true}, TierReductionPolicy: "clamp", }, }) if err != nil { t.Fatalf("add boolean rule: %v", err) } fx.booleanID = boolean.RuleID } if err := tx.Commit(); err != nil { t.Fatalf("commit fixture: %v", err) } return fx } // The tables at rest: one group per kind, each with only its own columns // (design D10); the record-table treatment and a right-aligned Actions column // on both; Edit and Remove on a limit rule, Remove alone on an on/off one; // the reduction policy stated in the Rules table's own column. func TestEntitlementSetRulesTableAtRest(t *testing.T) { database := testDB(t) fx := newRulesFixture(t, database, 5, "force_reduce", "numeric", "boolean") h := newRulesHandler(t, entitlements.New(database), database) rec := ruleAction(t, h, fx.setID, url.Values{}) if rec.Code != 200 { t.Fatalf("rest render = %d, body: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ `id="entitlement-set-rules"`, `data-form="` + entitlementSetRulesFormName + `"`, `class="table table-hover table-sm table-record align-middle app-rules-table"`, ``, `Limit`, `Boolean`, `Reduction policy`, `Force reduce`, `Actions`, ``, `>Edit`, `>Remove`, ">Add rule", } { if !strings.Contains(body, want) { t.Errorf("the rules section is missing %q, got:\n%s", want, body) } } for _, reject := range []string{ "Apply changes", "Discard all", "Nothing has been changed.", `id="addRulePanel"`, `id="rule-change-panel"`, "Kept in this set's History.", `name="tier_reduction_policy"`, "—", "Limit") > strings.Index(body, ">Boolean") { t.Error("the Limit group renders before the Boolean group") } booleanRow := rowMarkup(t, body, fx.booleanID) if strings.Count(booleanRow, "`) { t.Errorf("a boolean row spans the value columns and carries its Actions cell alone, got:\n%s", booleanRow) } // The on/off row carries Remove and no Edit: it has no field to edit. if strings.Contains(booleanRow, `"edit"`) { t.Errorf("a boolean row must carry no Edit, got:\n%s", booleanRow) } if !strings.Contains(booleanRow, `"remove"`) { t.Errorf("a boolean row must carry Remove, got:\n%s", booleanRow) } } // rowMarkup carves out the markup around one rule id, which is how a row's // own actions are read apart from its neighbours'. func rowMarkup(t *testing.T, body, ruleID string) string { t.Helper() // The row's own action buttons name the rule in their hx-vals; the // hidden batch inputs above the table name it too, so look for the // button's form first. i := strings.Index(body, `"key":"`+ruleID+`"`) if i < 0 { i = strings.Index(body, ruleID) } if i < 0 { t.Fatalf("no row for %s in:\n%s", ruleID, body) } start := strings.LastIndex(body[:i], "") if start < 0 || end < 0 { t.Fatalf("no row markup around %s", ruleID) } return body[start : i+end] } // Edit turns the row's Limit, Per unit and Reduction policy cells into the // declaration's controls, in their own columns, with Stage change and Cancel // as the row's actions and the Limit carrying focus. func TestEntitlementSetRulesEditOpensTheRow(t *testing.T) { database := testDB(t) fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean") h := newRulesHandler(t, entitlements.New(database), database) rec := ruleAction(t, h, fx.setID, url.Values{"act": {"edit"}, "key": {fx.numericID}}) if rec.Code != 200 { t.Fatalf("edit = %d, body: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ `name="rows.` + fx.numericID + `.resource_value"`, `name="rows.` + fx.numericID + `.resource_per_unit"`, `name="rows.` + fx.numericID + `.tier_reduction_policy"`, `value="5"`, `value="clamp" selected`, ">Stage change", ">Cancel", `class="d-block d-lg-table-cell`, `d-lg-none`, `id="form-` + entitlementSetRulesFormName + `-resource_value-` + fx.numericID + `"`, } { if !strings.Contains(body, want) { t.Errorf("the editing row is missing %q, got:\n%s", want, body) } } if n := strings.Count(body, "autofocus"); n != 1 { t.Errorf("exactly one control takes focus, got %d in:\n%s", n, body) } if !strings.Contains(limitControl(t, body, fx.numericID), "autofocus") { t.Errorf("Edit focuses the row's Limit, got:\n%s", body) } // Cancel closes that editor and nothing else. rec = ruleAction(t, h, fx.setID, url.Values{ "act": {"cancel"}, "key": {fx.numericID}, "open": {fx.numericID}, "rows." + fx.numericID + ".resource_key_kind": {"numeric"}, "rows." + fx.numericID + ".resource_value": {"9"}, }) if rec.Code != 200 { t.Fatalf("cancel = %d, body: %s", rec.Code, rec.Body.String()) } body = rec.Body.String() if strings.Contains(body, `name="rows.`+fx.numericID+`.resource_value"`) { t.Errorf("Cancel closes the editor, got:\n%s", body) } // Focus returns to the control that opened the editor. if !strings.Contains(rowMarkup(t, body, fx.numericID), `"act":"edit"`) { t.Fatalf("the cancelled row renders its Edit again, got:\n%s", body) } if !strings.Contains(editButton(t, body, fx.numericID), "autofocus") { t.Errorf("Cancel focuses the row's Edit, got:\n%s", body) } } // editButton carves out one row's Edit control. func editButton(t *testing.T, body, ruleID string) string { t.Helper() row := rowMarkup(t, body, ruleID) i := strings.Index(row, `"act":"edit"`) if i < 0 { t.Fatalf("no Edit in the row for %s:\n%s", ruleID, row) } start := strings.LastIndex(row[:i], "") return row[start : i+end] } // limitControl carves out one row's Limit input. func limitControl(t *testing.T, body, instance string) string { t.Helper() name := `name="rows.` + instance + `.resource_value"` i := strings.Index(body, name) if i < 0 { t.Fatalf("no Limit control for %s in:\n%s", instance, body) } end := strings.Index(body[i:], ">") return body[i : i+end] } // Add rule opens the add form above the table and outside it: the key // select alone until a key is chosen, then that kind's fields beside it in // one dense row (design D8). The form never sits in the table; a staged // addition does. func TestEntitlementSetRulesAddRuleRow(t *testing.T) { database := testDB(t) fx := newRulesFixture(t, database, 5, "clamp", "numeric") h := newRulesHandler(t, entitlements.New(database), database) rec := ruleAction(t, h, fx.setID, url.Values{"act": {"add"}}) if rec.Code != 200 { t.Fatalf("add = %d, body: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ `id="entitlement-set-rule-add"`, `name="rows.new.resource_key"`, `>Resource`, "Choose a resource key", `value="discourse_posting"`, ">Stage change", ">Cancel", `Limit`, } { if !strings.Contains(body, want) { t.Errorf("the add form is missing %q, got:\n%s", want, body) } } for _, reject := range []string{`name="rows.new.resource_value"`, `name="rows.new.resource_per_unit"`, `name="rows.new.tier_reduction_policy"`, `id="addRulePanel"`} { if strings.Contains(body, reject) { t.Errorf("an add form with no key chosen must not carry %q", reject) } } if strings.Index(body, `id="entitlement-set-rule-add"`) > strings.Index(body, "Limit`, `>Per unit`, `>Reduction policy`, `value="clamp" selected`, } { if !strings.Contains(body, want) { t.Errorf("a numeric key reveals %q, got:\n%s", want, body) } } if strings.Contains(body, "") if end < 0 { t.Fatalf("unterminated table") } return body[i : i+end] } // keySelect carves out the new row's resource-key select. func keySelect(t *testing.T, body string) string { t.Helper() i := strings.Index(body, `name="rows.new.resource_key"`) if i < 0 { t.Fatalf("no resource-key select in:\n%s", body) } end := strings.Index(body[i:], "") if end < 0 { t.Fatalf("unterminated resource-key select") } return body[i : i+end] } // Stage change tints the row, strikes the old values beside the new, leaves // Undo alone in its Actions cell, lists the delta in the tray with the // population, and puts focus on the tray's heading. The tray carries no // control of its own: the policy rides as a hidden field of the delta. func TestEntitlementSetRulesStageChange(t *testing.T) { database := testDB(t) fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean") h := newRulesHandler(t, entitlements.New(database), database) rec := ruleAction(t, h, fx.setID, url.Values{ "act": {"stage"}, "key": {fx.numericID}, "open": {fx.numericID}, "rows." + fx.numericID + ".resource_key_kind": {"numeric"}, "rows." + fx.numericID + ".resource_value": {"8"}, "rows." + fx.numericID + ".tier_reduction_policy": {"force_reduce"}, }) if rec.Code != 200 { t.Fatalf("stage = %d, body: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ ``, `5 8`, `Clamp Force reduce`, ">Undo", "Nothing has been changed.", "Applying to no pools:", "FedWiki Sites: limit 5 becomes limit 8. FedWiki Sites: reduction policy clamp becomes force reduce.", `name="staged.0.verb" value="edit"`, `name="staged.0.key" value="` + fx.numericID + `"`, `name="staged.0.resource_value" value="8"`, `type="hidden" name="staged.0.tier_reduction_policy" value="force_reduce"`, ">Apply changes", ">Discard all", // The commit swaps what #operator-body holds, never the container // itself: an outerHTML swap would leave the next request no target. `hx-target="#operator-body" hx-swap="innerHTML"`, ">Note (optional)", } { if !strings.Contains(body, want) { t.Errorf("the staged render is missing %q, got:\n%s", want, body) } } for _, reject := range []string{"Kept in this set's History.", "badge", ">Edit"} { if strings.Contains(body, reject) { t.Errorf("the staged render must not carry %q, got:\n%s", reject, body) } } if tray := trayMarkup(t, body); strings.Contains(tray, "Reduction policy") { t.Errorf("the tray carries no control of its own, got:\n%s", tray) } if !strings.Contains(body, `tabindex="-1" autofocus`) { t.Errorf("Stage change focuses the tray's heading, got:\n%s", body) } if n := strings.Count(body, "autofocus"); n != 1 { t.Errorf("exactly one region takes focus, got %d", n) } // Nothing was written: the rule still reads 5. rules, err := entitlements.New(database).GetActiveRulesBySetID(context.Background(), fx.setID) if err != nil { t.Fatalf("list rules: %v", err) } for _, rule := range rules { if rule.RuleID == fx.numericID && rule.ResourceValue.Int64 != 5 { t.Errorf("staging wrote the rule: value = %d, want 5", rule.ResourceValue.Int64) } } } // Remove stages a withdrawal in one press, with no modal: the row is tinted, // its resource name struck, and its line reads the ledger's own sentence. func TestEntitlementSetRulesRemoveStagesInOnePress(t *testing.T) { database := testDB(t) fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean") h := newRulesHandler(t, entitlements.New(database), database) rec := ruleAction(t, h, fx.setID, url.Values{"act": {"remove"}, "key": {fx.numericID}}) if rec.Code != 200 { t.Fatalf("remove = %d, body: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ ``, `FedWiki Sites`, ">Undo", "FedWiki Sites: limit 5 is withdrawn from this set.", `name="staged.0.verb" value="remove"`, } { if !strings.Contains(body, want) { t.Errorf("the staged removal is missing %q, got:\n%s", want, body) } } for _, reject := range []string{"Kept in this set's History.", "data-bs-toggle=\"modal\""} { if strings.Contains(body, reject) { t.Errorf("a staged removal must not carry %q", reject) } } // The struck row keeps its values as they stand, the policy included. if row := rowMarkup(t, body, fx.numericID); !strings.Contains(row, "Clamp") { t.Errorf("a staged removal states the rule as it stands, got:\n%s", row) } if strings.Contains(trayMarkup(t, body), "