Files
member-console/internal/server/operator_entitlement_set_rules_test.go
T

1402 lines
54 KiB
Go

// 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"`,
`<col class="app-col-limit">`,
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Limit</th></tr>`,
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Boolean</th></tr>`,
`<th scope="col">Reduction policy</th>`,
`<td>Force reduce</td>`,
`<th scope="col" class="text-end"><span class="visually-hidden">Actions</span></th>`,
`<td class="text-end">`,
`>Edit</button>`,
`>Remove</button>`,
">Add rule</button>",
} {
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"`, "&mdash;", "<caption",
} {
if strings.Contains(body, reject) {
t.Errorf("a table at rest must not carry %q, got:\n%s", reject, body)
}
}
// One table: the Limit group comes first, and the boolean rule's name
// spans the value columns so its row carries no empty cell.
if strings.Count(body, "<table") != 1 {
t.Errorf("the rules render as one table, got %d", strings.Count(body, "<table"))
}
if strings.Index(body, ">Limit</th></tr>") > strings.Index(body, ">Boolean</th></tr>") {
t.Error("the Limit group renders before the Boolean group")
}
booleanRow := rowMarkup(t, body, fx.booleanID)
if strings.Count(booleanRow, "<td") != 1 || !strings.Contains(booleanRow, `<th scope="row" colspan="4">`) {
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], "<tr")
end := strings.Index(body[i:], "</tr>")
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</button>",
">Cancel</button>",
`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], "<button")
end := strings.Index(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</label>`,
"Choose a resource key",
`value="discourse_posting"`,
">Stage change</button>",
">Cancel</button>",
`<tr class="app-rules-group"><th scope="rowgroup" colspan="5" class="small fw-semibold text-body-secondary">Limit</th></tr>`,
} {
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, "<table") {
t.Errorf("the add form renders above the table, got:\n%s", body)
}
if strings.Contains(tableMarkup(t, body), `name="rows.new.`) {
t.Errorf("the add form's controls must not sit in the table, got:\n%s", body)
}
if !strings.Contains(keySelect(t, body), "autofocus") {
t.Errorf("Add rule focuses the resource-key select, got:\n%s", body)
}
// The add form renders on a set with no rule too, with no table at all.
empty := newRulesFixture(t, database, 5, "clamp")
rec = ruleAction(t, h, empty.setID, url.Values{"act": {"add"}})
if rec.Code != 200 {
t.Fatalf("add on an empty set = %d, body: %s", rec.Code, rec.Body.String())
}
if b := rec.Body.String(); !strings.Contains(b, `name="rows.new.resource_key"`) || strings.Contains(b, "<table") {
t.Errorf("an empty set's add form renders alone, got:\n%s", b)
}
fx = empty
// Choosing a numeric key reveals the numeric fields beside the select,
// each under its own label; the select's change posts the whole form, so
// the act is empty and the state is the page's.
numeric := ruleAction(t, h, fx.setID, url.Values{
"open": {"new"},
"rows.new.resource_key": {"fedwiki_sites"},
})
if numeric.Code != 200 {
t.Fatalf("key change = %d, body: %s", numeric.Code, numeric.Body.String())
}
body = numeric.Body.String()
for _, want := range []string{
`id="entitlement-set-rule-add"`,
`name="rows.new.resource_value"`, `name="rows.new.resource_per_unit"`, `name="rows.new.tier_reduction_policy"`,
`>Limit</label>`, `>Per unit</label>`, `>Reduction policy</label>`, `value="clamp" selected`,
} {
if !strings.Contains(body, want) {
t.Errorf("a numeric key reveals %q, got:\n%s", want, body)
}
}
if strings.Contains(body, "<table") {
t.Errorf("a chosen key does not put the add form in a table, got:\n%s", body)
}
if !strings.Contains(limitControl(t, body, "new"), "autofocus") {
t.Errorf("a numeric key moves focus to the Limit, got:\n%s", body)
}
boolean := ruleAction(t, h, fx.setID, url.Values{
"open": {"new"},
"rows.new.resource_key": {"discourse_posting"},
})
if boolean.Code != 200 {
t.Fatalf("boolean key change = %d, body: %s", boolean.Code, boolean.Body.String())
}
body = boolean.Body.String()
for _, reject := range []string{`name="rows.new.resource_value"`, `name="rows.new.resource_per_unit"`, `name="rows.new.tier_reduction_policy"`, "<table"} {
if strings.Contains(body, reject) {
t.Errorf("a boolean key must not reveal %q, got:\n%s", reject, body)
}
}
if !strings.Contains(body, `id="entitlement-set-rule-add"`) || !strings.Contains(keySelect(t, body), "autofocus") {
t.Errorf("a boolean key keeps the add form open with focus on the select, got:\n%s", body)
}
// A key that already carries an active rule on the set is not offered,
// since choosing it could only be refused (finding #21).
ruled := newRulesFixture(t, database, 5, "clamp", "numeric")
rec = ruleAction(t, h, ruled.setID, url.Values{"act": {"add"}})
if rec.Code != 200 {
t.Fatalf("add on a ruled set = %d, body: %s", rec.Code, rec.Body.String())
}
if body := keySelect(t, rec.Body.String()); strings.Contains(body, `value="fedwiki_sites"`) {
t.Errorf("a key with an active rule on this set must not be offered, got:\n%s", body)
}
}
// tableMarkup carves out the Rules table.
func tableMarkup(t *testing.T, body string) string {
t.Helper()
i := strings.Index(body, "<table")
if i < 0 {
t.Fatalf("no table in:\n%s", body)
}
end := strings.Index(body[i:], "</table>")
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:], "</select>")
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{
`<tr class="table-light">`,
`<span class="text-decoration-line-through text-muted">5</span> 8`,
`<span class="text-decoration-line-through text-muted">Clamp</span> Force reduce`,
">Undo</button>",
"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</button>",
">Discard all</button>",
// 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 <span class=\"text-muted\">(optional)</span></label>",
} {
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</button>"} {
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, "<select") || strings.Contains(tray, ">Reduction policy</label>") {
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{
`<tr class="table-light">`,
`<span class="text-decoration-line-through text-muted">FedWiki Sites`,
">Undo</button>",
"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, "<td>Clamp</td>") {
t.Errorf("a staged removal states the rule as it stands, got:\n%s", row)
}
if strings.Contains(trayMarkup(t, body), "<select") {
t.Errorf("the tray carries no control of its own, got:\n%s", body)
}
// A boolean removal's line states the provision.
rec = ruleAction(t, h, fx.setID, url.Values{"act": {"remove"}, "key": {fx.booleanID}})
if rec.Code != 200 {
t.Fatalf("boolean remove = %d, body: %s", rec.Code, rec.Body.String())
}
body = rec.Body.String()
if !strings.Contains(body, "Forum Posting: no longer provided by this set.") {
t.Errorf("the boolean removal's line is missing, got:\n%s", body)
}
// The delta's policy rides as a hidden input, so the batch survives the
// request, but no control offers one.
if strings.Contains(trayMarkup(t, body), "<select") {
t.Errorf("a boolean delta's line carries no control, got:\n%s", body)
}
if !strings.Contains(body, `type="hidden" name="staged.0.tier_reduction_policy"`) {
t.Errorf("the delta still carries its stored policy, got:\n%s", body)
}
}
// trayMarkup carves out the tray's box.
func trayMarkup(t *testing.T, body string) string {
t.Helper()
i := strings.Index(body, "app-form-box-wide")
if i < 0 {
t.Fatalf("no tray in:\n%s", body)
}
end := strings.Index(body[i:], "app-form-commit")
if end < 0 {
t.Fatalf("unterminated tray")
}
return body[i : i+end]
}
// Undo drops one delta and leaves every other and every open editor alone;
// Discard all drops the batch and closes every editor.
func TestEntitlementSetRulesUndoAndDiscard(t *testing.T) {
database := testDB(t)
fx := newRulesFixture(t, database, 5, "clamp", "numeric", "boolean")
h := newRulesHandler(t, entitlements.New(database), database)
state := url.Values{
"staged.0.verb": {"edit"},
"staged.0.key": {fx.numericID},
"staged.0.resource_key": {"fedwiki_sites"},
"staged.0.resource_key_kind": {"numeric"},
"staged.0.resource_value": {"8"},
"staged.0.tier_reduction_policy": {"clamp"},
"staged.1.verb": {"remove"},
"staged.1.key": {fx.booleanID},
"staged.1.resource_key": {"discourse_posting"},
"staged.1.resource_key_kind": {"boolean"},
"staged.1.tier_reduction_policy": {"clamp"},
// One editor open beside the batch: Undo must leave it alone.
"open": {"new"},
"rows.new.resource_key": {""},
"note": {"Trimming"},
}
undo := url.Values{"act": {"undo"}, "key": {fx.numericID}}
for k, v := range state {
undo[k] = v
}
rec := ruleAction(t, h, fx.setID, undo)
if rec.Code != 200 {
t.Fatalf("undo = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "FedWiki Sites: limit 5 becomes limit 8.") {
t.Errorf("Undo drops that delta, got:\n%s", body)
}
for _, want := range []string{
"Forum Posting: no longer provided by this set.",
`name="rows.new.resource_key"`,
`value="Trimming"`,
">Edit</button>",
} {
if !strings.Contains(body, want) {
t.Errorf("Undo leaves the rest alone, missing %q, got:\n%s", want, body)
}
}
if !strings.Contains(body, `tabindex="-1" autofocus`) {
t.Error("Undo focuses the tray's heading while the batch has a line")
}
discard := url.Values{"act": {"discard"}}
for k, v := range state {
discard[k] = v
}
rec = ruleAction(t, h, fx.setID, discard)
if rec.Code != 200 {
t.Fatalf("discard = %d, body: %s", rec.Code, rec.Body.String())
}
body = rec.Body.String()
for _, reject := range []string{
"Nothing has been changed.", ">Apply changes</button>",
`name="rows.new.resource_key"`, `value="Trimming"`,
} {
if strings.Contains(body, reject) {
t.Errorf("Discard all clears the page, still carries %q", reject)
}
}
}
// A refused staging answers 422 with the refusal on that row, the other
// editor still open with its typed values, and the staged delta intact.
func TestEntitlementSetRulesRefusedStagingKeepsEverything(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": {"new"},
"open": {"new", fx.numericID},
"rows.new.resource_key": {"fedwiki_sites"},
"rows.new.resource_key_kind": {"numeric"},
"rows.new.resource_value": {"2"},
"staged.0.verb": {"remove"},
"staged.0.key": {fx.booleanID},
"staged.0.resource_key": {"discourse_posting"},
"staged.0.resource_key_kind": {"boolean"},
"staged.0.tier_reduction_policy": {"clamp"},
"rows." + fx.numericID + ".resource_key_kind": {"numeric"},
"rows." + fx.numericID + ".resource_value": {"7"},
})
if rec.Code != 422 {
t.Fatalf("staging a key that already has a rule = %d, want 422, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"This resource key already has an active rule. Edit it instead.",
`name="rows.new.resource_key"`,
`name="rows.` + fx.numericID + `.resource_value"`,
`value="7"`,
"Forum Posting: no longer provided by this set.",
} {
if !strings.Contains(body, want) {
t.Errorf("the refusal is missing %q, got:\n%s", want, body)
}
}
// The refusal takes the focus from whatever the act would have named.
if !strings.Contains(keySelect(t, body), "autofocus") {
t.Errorf("a refusal focuses the first invalid control, got:\n%s", body)
}
if strings.Contains(body, `tabindex="-1" autofocus`) {
t.Error("a refusal wins the focus from the tray's heading")
}
}
// A new row for a key that already carries an active rule is refused on the
// row; so is a staging whose limit, per-unit flag and policy all equal the
// stored rule's, under the row's Limit; a row whose only change is its
// policy stages with the policy sentence on its line.
func TestEntitlementSetRulesDuplicateAndUnchangedRefused(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": {"new"},
"open": {"new"},
"rows.new.resource_key": {"fedwiki_sites"},
"rows.new.resource_key_kind": {"numeric"},
"rows.new.resource_value": {"3"},
})
if rec.Code != 422 {
t.Fatalf("duplicate key = %d, want 422, body: %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); !strings.Contains(body, "This resource key already has an active rule. Edit it instead.") {
t.Errorf("the duplicate-key refusal is missing, got:\n%s", body)
}
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": {"5"},
"rows." + fx.numericID + ".tier_reduction_policy": {"clamp"},
})
if rec.Code != 422 {
t.Fatalf("an unchanged staging = %d, want 422, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Nothing changed.") {
t.Errorf("an unchanged staging is refused under the row's Limit, got:\n%s", body)
}
if strings.Contains(body, "Nothing has been changed.") {
t.Error("an unchanged staging stages nothing")
}
if !strings.Contains(limitControl(t, body, fx.numericID), "autofocus") {
t.Errorf("the refusal focuses the row's Limit, got:\n%s", body)
}
// A policy-only edit is a change: the row stages with its own sentence.
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": {"5"},
"rows." + fx.numericID + ".tier_reduction_policy": {"force_reduce"},
})
if rec.Code != 200 {
t.Fatalf("policy change = %d, body: %s", rec.Code, rec.Body.String())
}
body = rec.Body.String()
if !strings.Contains(body, "FedWiki Sites: reduction policy clamp becomes force reduce.") {
t.Errorf("a moved policy gives the line its sentence, got:\n%s", body)
}
if !strings.Contains(body, `<span class="text-decoration-line-through text-muted">Clamp</span> Force reduce`) {
t.Errorf("the staged row strikes the old policy beside the new, got:\n%s", body)
}
if strings.Contains(body, "unchanged.") {
t.Error("a delta that moves the policy is not unchanged")
}
}
// A delta naming a rule that is no longer active is refused on its own line,
// with the rest of the batch intact.
func TestEntitlementSetRulesStaleDeltaOnItsLine(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{
"staged.0.verb": {"edit"},
"staged.0.key": {uuid.New().String()},
"staged.0.resource_key": {"fedwiki_sites"},
"staged.0.resource_key_kind": {"numeric"},
"staged.0.resource_value": {"8"},
"staged.0.tier_reduction_policy": {"clamp"},
"staged.1.verb": {"remove"},
"staged.1.key": {fx.booleanID},
"staged.1.resource_key": {"discourse_posting"},
"staged.1.resource_key_kind": {"boolean"},
"staged.1.tier_reduction_policy": {"clamp"},
})
if rec.Code != 422 {
t.Fatalf("a stale delta = %d, want 422, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "That rule is no longer active.") {
t.Errorf("the stale delta's line is missing its refusal, got:\n%s", body)
}
if !strings.Contains(body, "Forum Posting: no longer provided by this set.") {
t.Errorf("the rest of the batch must stay staged, got:\n%s", body)
}
if !strings.Contains(body, ">Apply changes</button>") {
t.Error("the commit stays in the tray; the operator undoes the stale delta")
}
}
// Each stored policy decides the options its row's select offers when the
// row is edited: the two acting values always, and the stored value too when
// it is one of the dormant two, selected, so nothing is rewritten unless the
// operator picks. At rest the column states the stored value's label.
func TestEntitlementSetRulesPolicyOptionsPerStoredValue(t *testing.T) {
database := testDB(t)
h := newRulesHandler(t, entitlements.New(database), database)
cases := []struct {
stored string
label string
want []string
reject []string
}{
{stored: "clamp", label: "Clamp", want: []string{`value="clamp" selected`, `value="force_reduce"`}, reject: []string{`value="defer"`, `value="block"`}},
{stored: "force_reduce", label: "Force reduce", want: []string{`value="force_reduce" selected`, `value="clamp"`}, reject: []string{`value="defer"`, `value="block"`}},
{stored: "defer", label: "Defer", want: []string{`value="defer" selected`, `value="clamp"`, `value="force_reduce"`}, reject: []string{`value="block"`}},
{stored: "block", label: "Block", want: []string{`value="block" selected`, `value="clamp"`, `value="force_reduce"`}, reject: []string{`value="defer"`}},
}
for _, tc := range cases {
t.Run(tc.stored, func(t *testing.T) {
fx := newRulesFixture(t, database, 5, tc.stored, "numeric", "boolean")
rest := ruleAction(t, h, fx.setID, url.Values{})
if row := rowMarkup(t, rest.Body.String(), fx.numericID); !strings.Contains(row, "<td>"+tc.label+"</td>") {
t.Errorf("stored %s: the column at rest reads %q, got:\n%s", tc.stored, tc.label, row)
}
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 := policySelect(t, rec.Body.String(), fx.numericID)
for _, want := range tc.want {
if !strings.Contains(body, want) {
t.Errorf("stored %s: the select is missing %q, got:\n%s", tc.stored, want, body)
}
}
for _, reject := range tc.reject {
if strings.Contains(body, reject) {
t.Errorf("stored %s: the select must not offer %q, got:\n%s", tc.stored, reject, body)
}
}
})
}
// A new rule's select opens on Clamp, the value the console writes, and
// a value no row offered falls back to it when the row is staged.
fx := newRulesFixture(t, database, 5, "clamp", "boolean")
rec := ruleAction(t, h, fx.setID, url.Values{
"open": {"new"},
"rows.new.resource_key": {"fedwiki_sites"},
})
if rec.Code != 200 {
t.Fatalf("key change = %d, body: %s", rec.Code, rec.Body.String())
}
body := policySelect(t, rec.Body.String(), "new")
if !strings.Contains(body, `value="clamp" selected`) {
t.Errorf("a new rule's select opens on Clamp, got:\n%s", body)
}
for _, reject := range []string{`value="defer"`, `value="block"`} {
if strings.Contains(body, reject) {
t.Errorf("a new rule is not offered %q", reject)
}
}
rec = ruleAction(t, h, fx.setID, url.Values{
"act": {"stage"},
"key": {"new"},
"open": {"new"},
"rows.new.resource_key": {"fedwiki_sites"},
"rows.new.resource_key_kind": {"numeric"},
"rows.new.resource_value": {"3"},
"rows.new.tier_reduction_policy": {"defer"},
})
if rec.Code != 200 {
t.Fatalf("stage a new rule = %d, body: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `type="hidden" name="staged.0.tier_reduction_policy" value="clamp"`) {
t.Errorf("a value the row never offered falls back to Clamp, got:\n%s", rec.Body.String())
}
}
// policySelect carves out one row's reduction-policy select.
func policySelect(t *testing.T, body, instance string) string {
t.Helper()
i := strings.Index(body, `name="rows.`+instance+`.tier_reduction_policy"`)
if i < 0 {
t.Fatalf("no policy select for %s in:\n%s", instance, body)
}
end := strings.Index(body[i:], "</select>")
if end < 0 {
t.Fatalf("unterminated policy select")
}
return body[i : i+end]
}
// The tray's own lines: the population once with its singulars, the
// above-cap statement, and the rule type that reaches no pool. Rendered
// straight, because a population above the cap is not a fixture.
func TestEntitlementSetRulesTrayLines(t *testing.T) {
h := newRulesHandler(t, nil, nil)
cases := []struct {
name string
view server.EntitlementSetRuleTrayView
want []string
reject []string
}{
{
name: "no pool",
view: server.EntitlementSetRuleTrayView{},
want: []string{"Nothing has been changed.", "Applying to no pools:"},
},
{
name: "one pool in one organization",
view: server.EntitlementSetRuleTrayView{PoolCount: 1, OrgCount: 1, Pools: "1", Orgs: "1"},
want: []string{"Applying to 1 pool in 1 organization:"},
},
{
name: "many",
view: server.EntitlementSetRuleTrayView{PoolCount: 13, OrgCount: 12, Pools: "13", Orgs: "12"},
want: []string{"Applying to 13 pools in 12 organizations:"},
},
{
name: "above the cap",
view: server.EntitlementSetRuleTrayView{PoolCount: 400, OrgCount: 300, Pools: "400", Orgs: "300", AboveCap: true},
want: []string{"Recomputation continues after the change is applied.", "Applying to 400 pools in 300 organizations:"},
},
{
name: "reaches no pool",
view: server.EntitlementSetRuleTrayView{ReachesNoPool: true},
want: []string{"This rule type reaches no pool."},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.Templates.Render(rec, "operator_entitlement_set_rules_tray.html", tc.view)
if rec.Code != 200 {
t.Fatalf("render tray = %d, body: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range tc.want {
if !strings.Contains(body, want) {
t.Errorf("the tray is missing %q, got:\n%s", want, body)
}
}
for _, reject := range append(tc.reject, "Kept in this set's History.", "badge") {
if strings.Contains(body, reject) {
t.Errorf("the tray must not carry %q, got:\n%s", reject, body)
}
}
})
}
}
// One delta's line reads the ledger's narrative, and no more of it.
func TestEntitlementSetRuleLineNarrative(t *testing.T) {
h := newRulesHandler(t, nil, nil)
cases := []struct {
name string
view server.EntitlementSetRuleLineView
want string
}{
{"add numeric", server.EntitlementSetRuleLineView{Verb: "add", Label: "Seats", LimitAfter: "5"}, "Seats: no rule becomes limit 5."},
{"add per unit", server.EntitlementSetRuleLineView{Verb: "add", Label: "Seats", LimitAfter: "5", PerUnit: true}, "Seats: no rule becomes limit 5 for each unit."},
{"add boolean", server.EntitlementSetRuleLineView{Verb: "add", Label: "Posting", Boolean: true}, "Posting: provided by this set."},
{"edit limit", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", LimitBefore: "5", LimitAfter: "8", LimitChanged: true}, "Seats: limit 5 becomes limit 8."},
{"edit per unit", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", PerUnitChanged: true, PerUnit: true}, "Seats: per unit no becomes yes."},
{"edit policy", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", PolicyBefore: "clamp", PolicyAfter: "force reduce", PolicyChanged: true}, "Seats: reduction policy clamp becomes force reduce."},
{"edit unchanged", server.EntitlementSetRuleLineView{Verb: "edit", Label: "Seats", Unchanged: true}, "Seats: unchanged."},
{"remove numeric", server.EntitlementSetRuleLineView{Verb: "remove", Label: "Seats", LimitBefore: "5"}, "Seats: limit 5 is withdrawn from this set."},
{"remove boolean", server.EntitlementSetRuleLineView{Verb: "remove", Label: "Posting", Boolean: true}, "Posting: no longer provided by this set."},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.Templates.Render(rec, "entitlementSetRuleLine", tc.view)
if rec.Code != 200 {
t.Fatalf("render line = %d, body: %s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
if !strings.Contains(body, tc.want) {
t.Errorf("the line reads %q, want %q", body, tc.want)
}
if strings.Contains(body, "Kept in this set's History.") {
t.Error("the tray does not repeat what History keeps")
}
})
}
}
// rule_type is derived server-side from the key's kind: a boolean key yields a
// boolean rule with the NULLs chk_entitlement_set_rules_type requires — even
// when the client submits numeric fields — and a numeric key yields a limit
// rule. Also asserts the migration backfill kinds (core 00008 + discourse
// 00002 + fedwiki default).
func TestCreateEntitlementSetRuleKindDerived(t *testing.T) {
database := testDB(t)
ctx := context.Background()
// The handler writes rules through its own transaction, so the fixtures
// are committed rather than held open in one this test rolls back.
eq := entitlements.New(database)
h := newRulesHandler(t, eq, database)
// Migration assertions: discourse stream corrects its key to boolean;
// platform keys ride the 'numeric' default.
if rk, err := eq.GetResourceKey(ctx, "discourse_posting"); err != nil {
t.Fatalf("get discourse_posting: %v", err)
} else if rk.Kind != "boolean" {
t.Fatalf("discourse_posting kind = %q, want boolean", rk.Kind)
}
if rk, err := eq.GetResourceKey(ctx, "fedwiki_sites"); err != nil {
t.Fatalf("get fedwiki_sites: %v", err)
} else if rk.Kind != "numeric" {
t.Fatalf("fedwiki_sites kind = %q, want numeric", rk.Kind)
}
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Rule Authoring Test Set " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create set: %v", err)
}
postRule := func(form url.Values) *httptest.ResponseRecorder {
return commitRuleChange(t, h, set.SetID, form)
}
// Boolean key with stale numeric fields: the extras are ignored, the rule
// is boolean, and the insert passes the CHECK (explicit NULL stacking).
rec := postRule(url.Values{
"resource_key": {"discourse_posting"},
"resource_value": {"7"},
"stacking_policy": {"additive"},
"resource_per_unit": {"true"},
})
if rec.Code != 200 {
t.Fatalf("boolean create = %d, body: %s", rec.Code, rec.Body.String())
}
rec = postRule(url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"additive"},
})
if rec.Code != 200 {
t.Fatalf("limit create = %d, body: %s", rec.Code, rec.Body.String())
}
rules, err := eq.GetActiveRulesBySetID(ctx, set.SetID)
if err != nil {
t.Fatalf("list rules: %v", err)
}
if len(rules) != 2 {
t.Fatalf("rules = %d, want 2", len(rules))
}
// Ordered by resource_key ASC: discourse_posting, fedwiki_sites.
boolRule, limitRule := rules[0], rules[1]
if boolRule.RuleType != "boolean" {
t.Errorf("discourse_posting rule_type = %q, want boolean", boolRule.RuleType)
}
if boolRule.ResourceValue.Valid || boolRule.StackingPolicy.Valid || boolRule.ResourcePerUnit.Valid {
t.Errorf("boolean rule must have NULL value/stacking/per_unit, got %+v", boolRule)
}
if limitRule.RuleType != "limit" {
t.Errorf("fedwiki_sites rule_type = %q, want limit", limitRule.RuleType)
}
if !limitRule.ResourceValue.Valid || limitRule.ResourceValue.Int64 != 5 {
t.Errorf("limit rule value = %+v, want 5", limitRule.ResourceValue)
}
if limitRule.StackingPolicy.String != "additive" {
t.Errorf("limit rule stacking = %q, want additive", limitRule.StackingPolicy.String)
}
// Numeric key without its required fields still 422s.
set2, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Rule Authoring Test Set 2 " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create set2: %v", err)
}
rec = commitRuleChange(t, h, set2.SetID, url.Values{"resource_key": {"fedwiki_sites"}})
if rec.Code != 422 {
t.Errorf("limit create without fields = %d, want 422", rec.Code)
}
// Unknown key 422s with a field error rather than 500ing.
rec = commitRuleChange(t, h, set2.SetID, url.Values{"resource_key": {"nope_missing"}})
if rec.Code != 422 {
t.Errorf("unknown key create = %d, want 422", rec.Code)
}
}
// A ticked Per unit box stores resource_per_unit = true (design D1): the
// defect was forms.Values.SetBool filling only the raw half of a bound
// value set while the rule handler read the typed half, which only Parse
// filled, so every rule was stored with resource_per_unit = false regardless
// of what the operator ticked.
func TestCreateEntitlementSetRuleResourcePerUnitStored(t *testing.T) {
database := testDB(t)
ctx := context.Background()
eq := entitlements.New(database)
h := newRulesHandler(t, eq, database)
postRule := func(setID string, form url.Values) *httptest.ResponseRecorder {
return commitRuleChange(t, h, setID, form)
}
tickedSet, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Per Unit Ticked Test Set " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create ticked set: %v", err)
}
rec := postRule(tickedSet.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"additive"},
"resource_per_unit": {"true"},
})
if rec.Code != 200 {
t.Fatalf("ticked create = %d, body: %s", rec.Code, rec.Body.String())
}
tickedRules, err := eq.GetActiveRulesBySetID(ctx, tickedSet.SetID)
if err != nil {
t.Fatalf("list ticked rules: %v", err)
}
if len(tickedRules) != 1 {
t.Fatalf("ticked rules = %d, want 1", len(tickedRules))
}
if !tickedRules[0].ResourcePerUnit.Valid || !tickedRules[0].ResourcePerUnit.Bool {
t.Errorf("ticked Per unit stored ResourcePerUnit = %+v, want {Bool: true, Valid: true}", tickedRules[0].ResourcePerUnit)
}
untickedSet, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Per Unit Unticked Test Set " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create unticked set: %v", err)
}
rec = postRule(untickedSet.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"additive"},
})
if rec.Code != 200 {
t.Fatalf("unticked create = %d, body: %s", rec.Code, rec.Body.String())
}
untickedRules, err := eq.GetActiveRulesBySetID(ctx, untickedSet.SetID)
if err != nil {
t.Fatalf("list unticked rules: %v", err)
}
if len(untickedRules) != 1 {
t.Fatalf("unticked rules = %d, want 1", len(untickedRules))
}
if !untickedRules[0].ResourcePerUnit.Valid || untickedRules[0].ResourcePerUnit.Bool {
t.Errorf("unticked Per unit stored ResourcePerUnit = %+v, want {Bool: false, Valid: true}", untickedRules[0].ResourcePerUnit)
}
}
// The per-unit checkbox carries its submitted state back on a refusal, so a
// restaging writes the rule the operator intended and not its opposite
// (finding FA-23).
func TestEntitlementSetRulesPerUnitCarriesBackOnRefusal(t *testing.T) {
database := testDB(t)
fx := newRulesFixture(t, database, 5, "clamp", "boolean")
h := newRulesHandler(t, entitlements.New(database), database)
rec := ruleAction(t, h, fx.setID, url.Values{
"act": {"stage"},
"key": {"new"},
"open": {"new"},
"rows.new.resource_key": {"fedwiki_sites"},
"rows.new.resource_key_kind": {"numeric"},
"rows.new.resource_value": {""},
})
if rec.Code != 422 {
t.Fatalf("staging with no limit = %d, want 422", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Enter a limit.") {
t.Errorf("a numeric row staged with no limit is refused on its Limit, got:\n%s", body)
}
i := strings.Index(body, `name="rows.new.resource_per_unit"`)
if i < 0 {
t.Fatalf("the Per unit checkbox is missing from the refused row:\n%s", body)
}
if strings.Contains(body[i:i+strings.Index(body[i:], ">")], "checked") {
t.Errorf("an unticked box renders unticked, got:\n%s", body[i:i+120])
}
}
// Rule authoring is additive-only as of 2026-08-22 (maintainer decision,
// design D9): the form no longer submits a stacking_policy field, so a
// normal create defaults to "additive"; a non-additive value smuggled past
// the removed control (stale tab or crafted POST) is rejected with a
// validation error and writes nothing.
func TestCreateEntitlementSetRuleAdditiveOnly(t *testing.T) {
database := testDB(t)
ctx := context.Background()
eq := entitlements.New(database)
h := newRulesHandler(t, eq, database)
postRule := func(setID string, form url.Values) *httptest.ResponseRecorder {
return commitRuleChange(t, h, setID, form)
}
set1, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Additive Default Test Set " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create set1: %v", err)
}
rec := postRule(set1.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
})
if rec.Code != 200 {
t.Fatalf("create without stacking_policy = %d, body: %s", rec.Code, rec.Body.String())
}
rules, err := eq.GetActiveRulesBySetID(ctx, set1.SetID)
if err != nil {
t.Fatalf("list rules for set1: %v", err)
}
if len(rules) != 1 {
t.Fatalf("set1 rules = %d, want 1", len(rules))
}
if !rules[0].StackingPolicy.Valid || rules[0].StackingPolicy.String != "additive" {
t.Errorf("stacking policy = %+v, want additive", rules[0].StackingPolicy)
}
set2, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "Smuggled Stacking Test Set " + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create set2: %v", err)
}
rec = postRule(set2.SetID, url.Values{
"resource_key": {"fedwiki_sites"},
"resource_value": {"5"},
"stacking_policy": {"maximum"},
})
if rec.Code != 422 {
t.Fatalf("smuggled maximum create = %d, want 422, body: %s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); !strings.Contains(body, "Stacking policy must be additive.") {
t.Errorf("expected the additive-only refusal, got:\n%s", body)
}
rules2, err := eq.GetActiveRulesBySetID(ctx, set2.SetID)
if err != nil {
t.Fatalf("list rules for set2: %v", err)
}
if len(rules2) != 0 {
t.Fatalf("set2 rules = %d, want 0 (a refused smuggled stacking policy must write nothing)", len(rules2))
}
}
// TestCreateEntitlementSet_CreateThenLand exercises CreateEntitlementSet end
// to end against a real DB: design D20's create-then-land convention. An
// htmx submission (HX-Request: true) answers 200 with HX-Redirect to the new
// set's own page carrying ?flash=created and an empty body; a non-htmx
// submission (a native form fallback, no HX-Request header) answers 303
// straight to the same URL. The set is created active regardless (design
// D21: the create form has no Active control).
func TestCreateEntitlementSet_CreateThenLand(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginRuleChange(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
eq := entitlements.New(tx)
h := newRulesHandler(t, eq, nil)
post := func(name string, htmx bool) *httptest.ResponseRecorder {
form := url.Values{"name": {name}}
req := httptest.NewRequest("POST", "/partials/operator/entitlement-sets", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if htmx {
req.Header.Set("HX-Request", "true")
}
rec := httptest.NewRecorder()
h.CreateEntitlementSet(rec, req)
return rec
}
t.Run("htmx submit gets HX-Redirect with an empty body", func(t *testing.T) {
rec := post("Create Then Land Set Htmx "+uuid.New().String()[:8], true)
if rec.Code != 200 {
t.Fatalf("code = %d, body: %s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("HX-Redirect")
if !strings.HasPrefix(loc, "/operator/entitlement-sets/") || !strings.HasSuffix(loc, "?flash=created") {
t.Errorf("HX-Redirect = %q, want /operator/entitlement-sets/{id}?flash=created", loc)
}
if rec.Body.Len() != 0 {
t.Errorf("body = %q, want empty", rec.Body.String())
}
})
t.Run("non-htmx submit gets a 303 to the same URL", func(t *testing.T) {
rec := post("Create Then Land Set Native "+uuid.New().String()[:8], false)
if rec.Code != 303 {
t.Fatalf("code = %d, want 303, body: %s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/operator/entitlement-sets/") || !strings.HasSuffix(loc, "?flash=created") {
t.Errorf("Location = %q, want /operator/entitlement-sets/{id}?flash=created", loc)
}
})
t.Run("the created set is active", func(t *testing.T) {
rec := post("Create Then Land Set Active Check "+uuid.New().String()[:8], true)
loc := rec.Header().Get("HX-Redirect")
id := strings.TrimSuffix(strings.TrimPrefix(loc, "/operator/entitlement-sets/"), "?flash=created")
set, err := eq.GetEntitlementSetByID(ctx, id)
if err != nil {
t.Fatalf("get entitlement set: %v", err)
}
if !set.IsActive {
t.Error("a newly created set must be active")
}
})
}