// 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], "",
">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, "
")
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, "