Stop offering "None" for the entitlement set of a product that is a tier

Fourth section of #153's change. The product edit reads the ladders the
product is a tier of. For a tier product the Entitlement set select no
longer offers "None"; it opens with the disabled placeholder "Choose an
entitlement set", and the field's one line reads "This product is a tier
of <ladders>, and a tier needs an entitlement set." in place of the
off-ladder sentence, which moves from the field's declared hint to the
same line slot (the create page keeps it). A submitted "None" is refused
with that sentence; so is a clear refused by section 2's trigger when
the product became a tier after the page was read, after the ladders are
read again. A failed read of the ladders is logged and saves nothing,
instead of reading as "on no ladder".

The edit's write now runs in its own READ COMMITTED transaction
(db.InTx with db.ReadCommitted, item 4.6), so a tier add that commits
first is seen by the clear whatever the server's default isolation; at
REPEATABLE READ the edit's snapshot predated the tier and the clear
committed. UpdateProduct is split into helpers, each answer and log line
kept.

Tests: TestTheEntitlementSetFieldFollowsTheProductsTiers,
TestTheCreatePageCarriesTheOffLadderLine, TestTierNeedsSetSentence,
TestProductEditOfATierProductOffersNoNone,
TestProductEditRefusesNoneForAProductThatBecameATier,
TestProductEditWithUnreadLaddersSavesNothing,
TestProductEditClearingASetIsRefusedWhenATierAddCommitsFirstAtAnyDefaultIsolation
(on a connection whose default is REPEATABLE READ). The forms golden
operator.product.html moves the hint to the line.
TestUpdateProductEntitlementSetSentinel builds its product on committed
rows, since the edit's own transaction cannot see an uncommitted
fixture; its assertions are unchanged. productTierOf takes no context:
it logs through the handler's logger and unparam refuses the unused
parameter.

Breaks: 20 of 20 caught. The parse's error left in place; None offered
whatever the tiers (T6, T6c, T6d); the placeholder left out (T6d, T6);
the ladders out of name order; the line kept beside the error; the
off-ladder line blanked beside an error; the create page's line lost;
the failed read treated as no tier; the save going ahead when the read
failed; the parse's field error left when the ladders are unread; L3 not
logged; the second-read refusal not logged; the database's refusal
unmapped; the refusal named over a failed second read; the join reduced
to the first name; the write at the server's default isolation; the
empty-value condition dropped.

Review: round 1, no blocker or major; four minor and three nits,
applied.

#153's sequence (item 5.4), app rebuilt from this tree, from the demo
snapshot, the whole walkthrough package three times without a reset: ok
each run. The plan-ladders walkthrough's ladder now has tiers with sets,
the org-types walkthrough committed a default change to it on runs 2
and 3 and put the default back, and the grant revoke passed on all three
runs; the app log has no "has no entitlement set". Item 5.4 is reworded
to what ran: the three-walkthrough filter it named finds no revoke
subject on the demo snapshot, which another walkthrough's grant
supplies.

Full suite on reset databases: 4731 pass, 14 skip. make screens: no
change beyond section 1's; the captured product is not a tier.
This commit is contained in:
2026-10-26 19:02:13 -05:00
parent bd4041a1f7
commit fc68a6b78e
13 changed files with 851 additions and 121 deletions
+3 -2
View File
@@ -613,8 +613,9 @@ type Field struct {
Only Side
// Slot renders an empty container after the control, with the id
// "<wrapper id>-<Slot>", for a region a scoped htmx request swaps into
// (the product name's duplicate warning). The render supplies its
// first content through Binding.Slots.
// (the product name's duplicate warning), or for a line the handler
// decides per render (the product's Entitlement set). The render
// supplies its first content through Binding.Slots.
Slot string
// ShowIf makes this field's rendering and parsing conditional on
// another field's bound value (the entitlement-set rule form's Limit
@@ -84,7 +84,7 @@ func TestProductDetailComposite(t *testing.T) {
}
// The Details section is the product declaration bound to the record
// (form-library), the same value the page handler builds.
edit.Form = productEditForm(edit.Product.ProductID, edit.EntitlementSets,
edit.Form = productEditForm(edit.Product.ProductID, edit.EntitlementSets, edit.tier,
productEditValues(edit.Product), nil,
productEditSlots(NewSafeTemplates(tmpl, slog.New(slog.NewTextHandler(io.Discard, nil))), edit))
+108 -20
View File
@@ -5,7 +5,9 @@ package server
import (
"html/template"
"maps"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
@@ -79,7 +81,11 @@ var productForm = forms.Register(forms.FormSpec{
// side's own first option, so they are supplied per request
// and the same list is given to ParseWith.
RuntimeOptions: true,
Hint: "The set is what the product delivers; a product with none delivers no capabilities.",
// The field's one line is decided per record, so it travels
// through the slot (productSetLine) and not through a declared
// hint: a tier product's line states the prerequisite, a
// product on no ladder's line describes "None".
Slot: "line",
},
{
Name: "visibility",
@@ -183,16 +189,91 @@ func productExcludeField() forms.Field {
// edited (finding FA-10).
const productExcludeName = "exclude"
// productSetOffLadderLine is the Entitlement set field's line for a product
// on no ladder: it describes the "None" option that product is offered; the create page carries it as well, as it did as the declared hint.
const productSetOffLadderLine = "The set is what the product delivers; a product with none delivers no capabilities."
// productTier is what the product edit knows about a product's tiers: the
// names of the ladders it is a tier of, in the query's order (ladder name),
// and whether they could not be read. A failed read is never "no tier"
// (#207). Its methods read no row.
type productTier struct {
ladders []string
unread bool
}
// productTierFrom builds the answer from one ListLaddersByProduct read.
func productTierFrom(rows []billing.ListLaddersByProductRow, err error) productTier {
if err != nil {
return productTier{unread: true}
}
names := make([]string, len(rows))
for i, row := range rows {
names[i] = row.LadderName
}
return productTier{ladders: names}
}
// isTier reports whether the product is a tier of at least one ladder.
func (t productTier) isTier() bool { return len(t.ladders) > 0 }
// offersNone reports whether "None" is offered: only when the product is
// known to be no ladder's tier. A tier product never gets it, and neither
// does a product whose ladders could not be read.
func (t productTier) offersNone() bool { return !t.unread && !t.isTier() }
// tierNeedsSetSentence is the Entitlement set field's line for a tier
// product, and the field's error when "None" is refused for one.
func tierNeedsSetSentence(ladders []string) string {
return "This product is a tier of " + joinWithAnd(ladders) + ", and a tier needs an entitlement set."
}
// productSetLine is the Entitlement set field's one line on the edit side:
// nothing when the ladders are unread (the off-ladder sentence describes an
// option that is not offered, and the tier sentence needs the names);
// nothing when a tier product's field carries an error, so the error is the
// field's one line; the tier sentence for a tier product; the off-ladder
// sentence otherwise.
func productSetLine(tier productTier, errs *forms.Errors) string {
if tier.unread {
return ""
}
if tier.isTier() {
if errs.Has("entitlement_set_id") {
return ""
}
return tierNeedsSetSentence(tier.ladders)
}
return productSetOffLadderLine
}
// productSetSlot is a line in the markup a declared hint renders with, so
// the line keeps the hint's type; an empty line gives no entry.
func productSetSlot(line string) map[string]template.HTML {
if line == "" {
return nil
}
return map[string]template.HTML{
"entitlement_set_id": template.HTML(`<div class="form-text">` + template.HTMLEscapeString(line) + `</div>`),
}
}
// productSetOptions builds the entitlement-set select's options for one
// side. The create side opens on "New set named after this product"; the
// edit side opens on "None", whose consequence the field's hint states.
// The active sets follow, plus, on the edit side, the product's own set
// when it has been deactivated since (so Save round-trips the existing
// assignment instead of silently rewriting it).
func productSetOptions(side forms.Side, sets []EntitlementSetOption) []forms.Option {
// side. The create side opens on "New set named after this product". The
// edit side opens on "None" only when the product is known to be on no
// ladder; a tier product, or one whose ladders could not be read, opens on
// the disabled "Choose an entitlement set", because a tier needs a set. The
// active sets follow, plus, on the edit side, the product's own set when it
// has been deactivated since (so Save round-trips the existing assignment
// instead of silently rewriting it).
func productSetOptions(side forms.Side, sets []EntitlementSetOption, tier productTier) []forms.Option {
var out []forms.Option
if side == forms.EditOnly {
out = append(out, forms.NoneOption())
if tier.offersNone() {
out = append(out, forms.NoneOption())
} else {
out = append(out, forms.ChooseOption("an entitlement set"))
}
} else {
out = append(out, forms.Option{Value: productSetOptionNew, Label: "New set named after this product"})
}
@@ -208,8 +289,9 @@ func productSetOptions(side forms.Side, sets []EntitlementSetOption) []forms.Opt
// productFormOptions is the option map both the render and the parse read,
// so what the control offered and what the handler accepts are one list.
func productFormOptions(side forms.Side, sets []EntitlementSetOption) map[string][]forms.Option {
return map[string][]forms.Option{"entitlement_set_id": productSetOptions(side, sets)}
// The create side does not read tier.
func productFormOptions(side forms.Side, sets []EntitlementSetOption, tier productTier) map[string][]forms.Option {
return map[string][]forms.Option{"entitlement_set_id": productSetOptions(side, sets, tier)}
}
// productCreateForm renders the create page's form: unbound on a first
@@ -224,13 +306,14 @@ func productCreateForm(sets []EntitlementSetOption, values forms.Values, errs *f
Side: forms.CreateOnly,
Values: values,
Errors: errs,
Options: productFormOptions(forms.CreateOnly, sets),
Options: productFormOptions(forms.CreateOnly, sets, productTier{}),
Slots: productSetSlot(productSetOffLadderLine),
})
}
// productEditForm renders the record page's form, bound to the product or
// to a refused submission of it.
func productEditForm(productID string, sets []EntitlementSetOption, values forms.Values, errs *forms.Errors, slots map[string]template.HTML) forms.FormView {
func productEditForm(productID string, sets []EntitlementSetOption, tier productTier, values forms.Values, errs *forms.Errors, slots map[string]template.HTML) forms.FormView {
mode := forms.ModeRecord
if errs.Any() {
mode = forms.ModeSubmission
@@ -239,24 +322,29 @@ func productEditForm(productID string, sets []EntitlementSetOption, values forms
if err != nil {
action = productForm.Path
}
slots = maps.Clone(slots)
if slots == nil {
slots = map[string]template.HTML{}
}
maps.Copy(slots, productSetSlot(productSetLine(tier, errs)))
return forms.Render(productForm, forms.Binding{
Mode: mode,
Side: forms.EditOnly,
Action: action,
Values: values,
Errors: errs,
Options: productFormOptions(forms.EditOnly, sets),
Options: productFormOptions(forms.EditOnly, sets, tier),
Slots: slots,
})
}
// productEditSlots builds the edit form's one live region: the
// duplicate-name warning under the name field, rendered at load so the
// no-JavaScript path and the "someone else created it since" case are both
// covered. It is the only slot the product form declares; the
// cross-reference to the assigned set's rules that once sat under the
// entitlement-set select is gone (maintainer, 2026-09-03: "It seems
// superfluous. Remove.").
// productEditSlots builds the name slot: the duplicate-name warning under
// the name field, rendered at load so the no-JavaScript path and the
// "someone else created it since" case are both covered. The form's other
// slot, the Entitlement set field's line, is added per render by
// productEditForm (productSetLine). The cross-reference to the assigned
// set's rules that once sat under the entitlement-set select is gone
// (maintainer, 2026-09-03: "It seems superfluous. Remove.").
func productEditSlots(t *SafeTemplates, data OperatorProductEditData) map[string]template.HTML {
if len(data.NameWarning.Matches) == 0 {
return nil
@@ -25,7 +25,7 @@ func renderProductEdit(t *testing.T, data OperatorProductEditData) string {
// would build, so every render test still exercises the real form.
if data.Form.Name == "" {
safe := NewSafeTemplates(tmpl, slog.New(slog.NewTextHandler(io.Discard, nil)))
data.Form = productEditForm(data.Product.ProductID, data.EntitlementSets,
data.Form = productEditForm(data.Product.ProductID, data.EntitlementSets, data.tier,
productEditValues(data.Product), nil, productEditSlots(safe, data))
}
return Render(t, tmpl, "operator_product_edit.html", data)
@@ -0,0 +1,434 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// The product edit of a tier product (openspec change
// tiers-need-an-entitlement-set, design D3): the Entitlement set select does
// not offer "None", the field's one line names the ladders, and a submission
// that would clear the set is refused with that line as the field's error.
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
const (
productSetField = `#form-operator\.product-entitlement_set_id`
choosePlaceholder = "Choose an entitlement set"
)
// setFieldOption is one option of the Entitlement set select.
type setFieldOption struct {
label, value string
disabled, selected bool
}
// setFieldOptions are the select's options in order.
func setFieldOptions(t *testing.T, body string) []setFieldOption {
t.Helper()
var out []setFieldOption
for _, opt := range htmltest.Parse(t, body).All(productSetField + "-control option") {
value, _ := opt.Attr("value")
_, disabled := opt.Attr("disabled")
_, selected := opt.Attr("selected")
out = append(out, setFieldOption{label: opt.Text(), value: value, disabled: disabled, selected: selected})
}
return out
}
func selectedLabel(opts []setFieldOption) string {
for _, o := range opts {
if o.selected {
return o.label
}
}
return ""
}
func selectedValue(opts []setFieldOption) string {
for _, o := range opts {
if o.selected {
return o.value
}
}
return ""
}
func firstLabel(opts []setFieldOption) string {
if len(opts) == 0 {
return ""
}
return opts[0].label
}
func offersNone(opts []setFieldOption) bool {
for _, o := range opts {
if o.label == "None" {
return true
}
}
return false
}
// setFieldLine is the text of the field's slot, empty when there is no line.
func setFieldLine(t *testing.T, body string) string {
t.Helper()
return htmltest.Parse(t, body).One(t, productSetField+"-line").Text()
}
// setFieldError is the field's own error, and whether it carries one.
func setFieldError(t *testing.T, body string) (string, bool) {
t.Helper()
nodes := htmltest.Parse(t, body).All(productSetField + "-error")
if len(nodes) == 0 {
return "", false
}
return nodes[0].Text(), true
}
// setFieldLines counts the lines under the Entitlement set control.
func setFieldLines(t *testing.T, body string) int {
t.Helper()
return htmltest.Parse(t, body).Count(productSetField + " .form-text, " + productSetField + " .invalid-feedback")
}
func tierNeedsSet(ladders string) string {
return "This product is a tier of " + ladders + ", and a tier needs an entitlement set."
}
// storedSet is the product's entitlement set as the database holds it.
func storedSet(t *testing.T, w *world.World, p world.Product) string {
t.Helper()
return dbtest.On(t, w.DB).String(`SELECT coalesce(entitlement_set_id::text, '') FROM core.products WHERE product_id = $1`, p.ID)
}
func storedName(t *testing.T, w *world.World, p world.Product) string {
t.Helper()
return dbtest.On(t, w.DB).String(`SELECT name FROM core.products WHERE product_id = $1`, p.ID)
}
// getProductPage reads the product's record page.
func (h *otcHarness) getProductPage(productID string) (int, string) {
h.t.Helper()
rec := h.postPath(h.handler.GetProductDetailPage, map[string]string{"productID": productID}, url.Values{})
return rec.Code, rec.Body.String()
}
// putProduct submits the product's edit form with the given name and set.
func (h *otcHarness) putProduct(p world.Product, name, setID string) (int, string) {
h.t.Helper()
form := url.Values{"name": {name}, "entitlement_set_id": {setID}}
rec := h.postPath(h.handler.UpdateProduct, map[string]string{"productID": p.ID}, form)
return rec.Code, rec.Body.String()
}
// tierProductWorld is a product that is a tier of two ladders, created in the
// order given (names Forum and Hosting).
func tierProductWorld(t *testing.T, order []string) (w *world.World, h func(billing.Querier) *otcHarness, tiered world.Product, ladders map[string]world.Ladder) {
t.Helper()
w = world.New(t, dbtest.Open(t))
operator := w.Person("Operator")
tiered = w.Product("Tiered")
ladders = map[string]world.Ladder{}
for _, name := range order {
ladders[name] = w.Ladder(name, tiered)
}
h = func(q billing.Querier) *otcHarness {
return newOtcHarnessWith(t, w.DB, operator.ID, otcOptions{billingQ: q})
}
return w, h, tiered, ladders
}
func checkTierRecordPage(t *testing.T, h *otcHarness, p world.Product, wantLine string) {
t.Helper()
status, body := h.getProductPage(p.ID)
if status != http.StatusOK {
t.Errorf("the record page answered %d, want 200", status)
}
opts := setFieldOptions(t, body)
if offersNone(opts) {
t.Errorf(`the record page of a tier product offers "None"`)
}
if len(opts) == 0 || opts[0].label != choosePlaceholder || !opts[0].disabled {
first := setFieldOption{}
if len(opts) > 0 {
first = opts[0]
}
t.Errorf(`the first option = %q (disabled %v), want "Choose an entitlement set", disabled`, first.label, first.disabled)
}
if got := selectedValue(opts); got != p.SetID {
t.Errorf("the selected option = %q, want the product's set %q", got, p.SetID)
}
if got := setFieldLine(t, body); got != wantLine {
t.Errorf("the field's line = %q, want %q", got, wantLine)
}
}
func checkTierRefusal(t *testing.T, h *otcHarness, w *world.World, p world.Product, wantErr string) {
t.Helper()
status, body := h.putProduct(p, p.Name, "")
if status != http.StatusUnprocessableEntity {
t.Errorf(`the PUT of "None" answered %d, want 422`, status)
}
if got, ok := setFieldError(t, body); !ok {
t.Errorf("the Entitlement set field carries no error; the form-level slot reads %q", formLevelText(t, body, "operator.product"))
} else if got != wantErr {
t.Errorf("the Entitlement set field's error = %q, want %q", got, wantErr)
}
if n := setFieldLines(t, body); n != 1 {
t.Errorf("lines under the Entitlement set field after the refusal = %d, want 1", n)
}
if got := selectedLabel(setFieldOptions(t, body)); got != choosePlaceholder {
t.Errorf(`the selected option after the refusal = %q, want "Choose an entitlement set"`, got)
}
if got := storedSet(t, w, p); got != p.SetID {
t.Errorf("the stored set after the refused PUT = %v, want %s", got, p.SetID)
}
}
// checkTierUnlistedSet is a submission of a set the list does not offer: the
// parse's own refusal stays, not the sentence that names the ladders.
func checkTierUnlistedSet(t *testing.T, h *otcHarness, w *world.World, p world.Product) {
t.Helper()
status, body := h.putProduct(p, p.Name, "00000000-0000-0000-0000-000000000001")
if status != http.StatusUnprocessableEntity {
t.Errorf("the PUT of a set the list does not offer answered %d, want 422", status)
}
const want = "Choose an entitlement set from the list."
if got, _ := setFieldError(t, body); got != want {
t.Errorf("the Entitlement set field's error = %q, want %q", got, want)
}
if got := storedSet(t, w, p); got != p.SetID {
t.Errorf("the stored set after the refused PUT = %v, want %s", got, p.SetID)
}
}
func checkTierProductRow(t *testing.T, order []string) {
t.Helper()
w, harness, tiered, _ := tierProductWorld(t, order)
h := harness(nil)
line := tierNeedsSet("Forum " + w.Sfx + " and Hosting " + w.Sfx)
checkTierRecordPage(t, h, tiered, line)
checkTierRefusal(t, h, w, tiered, line)
checkTierUnlistedSet(t, h, w, tiered)
}
// A tier product's record page offers no "None": its first option is the
// disabled placeholder, its own set is selected, and the field's one line
// names its ladders in name order, whichever ladder was created first. A
// submission of "None" is refused with that line as the field's error and
// nothing is written.
func TestProductEditOfATierProductOffersNoNone(t *testing.T) {
t.Run("ladders created in name order", func(t *testing.T) { checkTierProductRow(t, []string{"Forum", "Hosting"}) })
t.Run("ladders created in reverse", func(t *testing.T) { checkTierProductRow(t, []string{"Hosting", "Forum"}) })
}
// errScriptedRead is the read error the scripted querier answers with.
var errScriptedRead = errors.New("scripted ladder read failure")
// scriptedLaddersQ is the billing querier whose ListLaddersByProduct answers
// its calls in turn from script ("none": no row; "fail": a read error), and
// reads the database once the script ends.
type scriptedLaddersQ struct {
billing.Querier
script []string
calls *int
}
func (s scriptedLaddersQ) ListLaddersByProduct(ctx context.Context, productID string) ([]billing.ListLaddersByProductRow, error) {
i := *s.calls
*s.calls++
if i < len(s.script) {
if s.script[i] == "fail" {
return nil, errScriptedRead
}
return nil, nil
}
return s.Querier.ListLaddersByProduct(ctx, productID)
}
// unreadLaddersQ is the billing querier whose ladder read always fails.
type unreadLaddersQ struct{ billing.Querier }
func (unreadLaddersQ) ListLaddersByProduct(context.Context, string) ([]billing.ListLaddersByProductRow, error) {
return nil, errScriptedRead
}
const productUpdateFailedSentence = "Failed to update the product. Details are in the server logs."
func checkBecameATierRefused(t *testing.T) {
t.Helper()
w, harness, tiered, ladders := tierProductWorld(t, []string{"Forum"})
h := harness(scriptedLaddersQ{Querier: billing.New(w.DB), script: []string{"none"}, calls: new(int)})
checkTierRefusal(t, h, w, tiered, tierNeedsSet(ladders["Forum"].Name))
}
// checkBecameATierUnanswered is the row whose second read fails or finds no
// ladder: the form-level failure sentence, and the refusal in the log.
func checkBecameATierUnanswered(t *testing.T, second string) {
t.Helper()
w, harness, tiered, _ := tierProductWorld(t, []string{"Forum"})
h := harness(scriptedLaddersQ{Querier: billing.New(w.DB), script: []string{"none", second}, calls: new(int)})
status, body := h.putProduct(tiered, tiered.Name, "")
if status != http.StatusUnprocessableEntity {
t.Errorf(`the PUT of "None" answered %d, want 422`, status)
}
if got := formLevelText(t, body, "operator.product"); got != productUpdateFailedSentence {
t.Errorf("the form-level slot = %q, want %q", got, productUpdateFailedSentence)
}
if second == "none" {
wantInLine(t, h.logs.String(), "failed to update product", "product_id="+tiered.ID, "is a plan tier and keeps its entitlement set")
}
if got := storedSet(t, w, tiered); got != tiered.SetID {
t.Errorf("the stored set after the refused PUT = %v, want %s", got, tiered.SetID)
}
}
// A page rendered before the product became a tier offers "None". The parse
// accepts it, the trigger refuses the write, and the handler reads the
// ladders again: R2 over the names it finds, or the failure sentence when
// that read fails or finds none.
func TestProductEditRefusesNoneForAProductThatBecameATier(t *testing.T) {
t.Run("none", checkBecameATierRefused)
t.Run("none, fail", func(t *testing.T) { checkBecameATierUnanswered(t, "fail") })
t.Run("none, none", func(t *testing.T) { checkBecameATierUnanswered(t, "none") })
}
// A product edit whose ladders cannot be read saves nothing: the page offers
// no "None" and no line, and every submission answers the failure sentence
// alone.
func TestProductEditWithUnreadLaddersSavesNothing(t *testing.T) {
w, harness, tiered, _ := tierProductWorld(t, []string{"Forum"})
h := harness(unreadLaddersQ{Querier: billing.New(w.DB)})
status, body := h.getProductPage(tiered.ID)
if status != http.StatusOK {
t.Errorf("the record page answered %d, want 200", status)
}
opts := setFieldOptions(t, body)
if offersNone(opts) {
t.Errorf(`the page offers "None" while the ladders are unread`)
}
if first := firstLabel(opts); first != choosePlaceholder {
t.Errorf(`the first option = %q, want "Choose an entitlement set"`, first)
}
if got := setFieldLine(t, body); got != "" {
t.Errorf("the field carries a line while the ladders are unread: %q", got)
}
renamed := tiered.Name + " renamed"
status, body = h.putProduct(tiered, renamed, tiered.SetID)
if status != http.StatusUnprocessableEntity {
t.Errorf("the PUT answered %d while the ladders are unread, want 422", status)
}
if got := formLevelText(t, body, "operator.product"); got != productUpdateFailedSentence {
t.Errorf("the form-level slot = %q, want %q", got, productUpdateFailedSentence)
}
if got := storedName(t, w, tiered); got != tiered.Name {
t.Errorf("the name changed although the ladders could not be read")
}
status, body = h.putProduct(tiered, tiered.Name, "")
if status != http.StatusUnprocessableEntity {
t.Errorf("the PUT answered %d while the ladders are unread, want 422", status)
}
if got := formLevelText(t, body, "operator.product"); got != productUpdateFailedSentence {
t.Errorf("the form-level slot = %q, want %q", got, productUpdateFailedSentence)
}
if got, ok := setFieldError(t, body); ok {
t.Errorf("the Entitlement set field carries %q while the ladders are unread", got)
}
wantInLine(t, h.logs.String(), "product edit: tier read failed", "product_id="+tiered.ID)
}
// openRepeatableRead opens the test database with the server-side default
// isolation REPEATABLE READ, as a connection option, and fails the test
// unless the connection reports it.
func openRepeatableRead(t *testing.T) *sql.DB {
t.Helper()
dsn := dbtest.DSN(t)
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
database := dbtest.Connect(t, dsn+sep+"default_transaction_isolation=repeatable%20read")
if got := dbtest.On(t, database).String(`SHOW default_transaction_isolation`); got != "repeatable read" {
t.Fatalf("default_transaction_isolation = %q, want %q", got, "repeatable read")
}
return database
}
// The product edit's write opens at READ COMMITTED whatever the server's
// default isolation is (task 4.6): the tier trigger's guarantee that a tier
// add and a set clear cannot both commit holds only there. A side transaction
// inserts the tier and holds it; the edit clears the set through
// UpdateProduct on a database whose default is REPEATABLE READ and waits on
// the product row; once the side transaction commits, the edit is refused
// with the tier sentence. At the server's default the edit would run on a
// snapshot that predates the tier and commit beside it.
func TestProductEditClearingASetIsRefusedWhenATierAddCommitsFirstAtAnyDefaultIsolation(t *testing.T) {
w := world.New(t, dbtest.Open(t))
operator := w.Person("Operator")
product := w.Product("Raced")
ladder := w.Ladder("Forum")
h := newOtcHarnessWith(t, openRepeatableRead(t), operator.ID, otcOptions{})
side, err := w.DB.BeginTx(t.Context(), nil)
if err != nil {
t.Fatalf("begin the side transaction: %v", err)
}
defer side.Rollback()
if _, err := billing.New(side).CreatePlanLadderTier(t.Context(), billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.ID, ProductID: product.ID,
}); err != nil {
t.Fatalf("the side transaction's tier insert: %v", err)
}
sidePID := int(dbtest.On(t, side).Int(`SELECT pg_backend_pid()`))
type answer struct {
status int
body string
}
answered := make(chan answer, 1)
go func() {
status, body := h.putProduct(product, product.Name, "")
answered <- answer{status, body}
}()
dbtest.AwaitLockWait(t, w.DB, dbtest.Backend{BlockedBy: sidePID, Abort: func() error {
select {
case got := <-answered:
return fmt.Errorf("the edit answered %d before the tier add committed", got.status)
default:
return nil
}
}})
if err := side.Commit(); err != nil {
t.Fatalf("commit the side transaction: %v", err)
}
got := <-answered
if got.status != http.StatusUnprocessableEntity {
t.Errorf("the edit that cleared the set answered %d, want 422", got.status)
}
want := tierNeedsSet(ladder.Name)
if msg, ok := setFieldError(t, got.body); !ok {
t.Errorf("the Entitlement set field carries no error; the form-level slot reads %q", formLevelText(t, got.body, "operator.product"))
} else if msg != want {
t.Errorf("the Entitlement set field's error = %q, want %q", msg, want)
}
if set := storedSet(t, w, product); set != product.SetID {
t.Errorf("the stored set after the refused edit = %v, want %s", set, product.SetID)
}
}
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"slices"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// The product edit's Entitlement set field follows the product's tiers
// (openspec change tiers-need-an-entitlement-set, design D3): "None" only for
// a product known to be on no ladder, the placeholder otherwise, and one line
// under the field.
const (
setFieldID = `#form-operator\.product-entitlement_set_id`
choosePlaceholder = "Choose an entitlement set"
)
// setFieldRead is what a rendered Entitlement set field shows.
type setFieldRead struct {
options []string // option labels, in order
selected string // the selected option's label, "" when none is
line string // the slot's text
err string // the field's error text
}
func readSetField(t *testing.T, body string) setFieldRead {
t.Helper()
page := htmltest.Parse(t, body)
var got setFieldRead
for _, opt := range page.All(setFieldID + "-control option") {
got.options = append(got.options, opt.Text())
if _, ok := opt.Attr("selected"); ok {
got.selected = opt.Text()
}
}
got.line = page.One(t, setFieldID+"-line").Text()
if errNode := page.All(setFieldID + "-error"); len(errNode) > 0 {
got.err = errNode[0].Text()
}
return got
}
// setFieldRow is one state of the field: the product's tiers, its stored set,
// and whether the form re-renders a refused submission.
type setFieldRow struct {
name string
tier productTier
setID string
refused bool
wantOptions []string
wantSelected string
wantLine string
wantErr string
}
func renderSetFieldRow(t *testing.T, row setFieldRow) string {
t.Helper()
data := OperatorProductEditData{
Product: OperatorProductViewModel{ProductID: "p1", Name: "Custom Domain", EntitlementSetID: row.setID},
EntitlementSets: []EntitlementSetOption{{SetID: "set-a", Name: "Set A"}, {SetID: "set-b", Name: "Set B"}},
tier: row.tier,
}
if row.refused {
errs := forms.NewErrors()
errs.Field("entitlement_set_id", "Choose an entitlement set.")
values := productEditValues(data.Product)
values.Set("entitlement_set_id", "")
data.Form = productEditForm(data.Product.ProductID, data.EntitlementSets, row.tier, values, errs, nil)
}
return renderProductEdit(t, data)
}
func checkSetFieldRow(t *testing.T, row setFieldRow) {
t.Helper()
got := readSetField(t, renderSetFieldRow(t, row))
if !slices.Equal(got.options, row.wantOptions) {
t.Errorf("%s: the options = %v, want %v", row.name, got.options, row.wantOptions)
}
if got.selected != row.wantSelected {
t.Errorf("%s: the selected option = %q, want %q", row.name, got.selected, row.wantSelected)
}
if got.line != row.wantLine {
t.Errorf("%s: the field's line = %q, want %q", row.name, got.line, row.wantLine)
}
if got.err != row.wantErr {
t.Errorf("%s: the field's error = %q, want %q", row.name, got.err, row.wantErr)
}
}
func TestTheEntitlementSetFieldFollowsTheProductsTiers(t *testing.T) {
const offLadder = "The set is what the product delivers; a product with none delivers no capabilities."
const tierLine = "This product is a tier of Forum and Hosting, and a tier needs an entitlement set."
withNone := []string{"None", "Set A", "Set B"}
withPlaceholder := []string{choosePlaceholder, "Set A", "Set B"}
tier := productTier{ladders: []string{"Forum", "Hosting"}}
unread := productTier{unread: true}
rows := []setFieldRow{
{name: "on no ladder, with a set", setID: "set-b", wantOptions: withNone, wantSelected: "Set B", wantLine: offLadder},
{name: "on no ladder, with no set", wantOptions: withNone, wantSelected: "None", wantLine: offLadder},
{name: "on no ladder, with a refused set", refused: true, wantOptions: withNone, wantSelected: "None", wantLine: offLadder, wantErr: "Choose an entitlement set."},
{name: "a tier, with a set", tier: tier, setID: "set-b", wantOptions: withPlaceholder, wantSelected: "Set B", wantLine: tierLine},
{name: "a tier, with no set", tier: tier, wantOptions: withPlaceholder, wantSelected: choosePlaceholder, wantLine: tierLine},
{name: "unread, with a set", tier: unread, setID: "set-b", wantOptions: withPlaceholder, wantSelected: "Set B"},
{name: "unread, with no set", tier: unread, wantOptions: withPlaceholder, wantSelected: choosePlaceholder},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkSetFieldRow(t, row) })
}
}
func TestTheCreatePageCarriesTheOffLadderLine(t *testing.T) {
body := Render(t, OperatorSet(t), "operator_product_new.html", ProductNewData{
Form: productCreateForm([]EntitlementSetOption{{SetID: "s1", Name: "Standard"}}, forms.NewValues(), nil),
})
got := htmltest.Parse(t, body).One(t, setFieldID+"-line").Text()
if got != productSetOffLadderLine {
t.Errorf("the create page's line = %q, want %q", got, productSetOffLadderLine)
}
}
func TestTierNeedsSetSentence(t *testing.T) {
rows := []struct {
name string
ladders []string
want string
}{
{"one ladder", []string{"Forum"}, "This product is a tier of Forum, and a tier needs an entitlement set."},
{"two ladders", []string{"Forum", "Hosting"}, "This product is a tier of Forum and Hosting, and a tier needs an entitlement set."},
{"three ladders", []string{"Docs", "Forum", "Hosting"}, "This product is a tier of Docs, Forum and Hosting, and a tier needs an entitlement set."},
}
for _, row := range rows {
if got := tierNeedsSetSentence(row.ladders); got != row.want {
t.Errorf("tierNeedsSetSentence(%s) = %q, want %q", row.name, got, row.want)
}
}
}
+104 -23
View File
@@ -20,6 +20,7 @@ import (
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/sqlc-dev/pqtype"
)
@@ -204,6 +205,10 @@ type OperatorProductEditData struct {
Form forms.FormView
Success string
Error string
// tier is what the "Plan ladders" read says about the product's tiers,
// for the Entitlement set field's options and line (productTier).
tier productTier
}
// ProductDetailData is the body data for the addressable per-product composite
@@ -224,7 +229,7 @@ type ProductDetailData struct {
// edit-only fields cannot be set from here.
func (h *OperatorPartialsHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
sets := h.loadEntitlementSetOptions(r)
values, errs := productForm.ParseSide(r, forms.CreateOnly, productFormOptions(forms.CreateOnly, sets))
values, errs := productForm.ParseSide(r, forms.CreateOnly, productFormOptions(forms.CreateOnly, sets, productTier{}))
if errs.Any() {
h.renderProductNewRefusal(w, r, values, errs)
return
@@ -296,10 +301,9 @@ func (h *OperatorPartialsHandler) createProductWithSet(ctx context.Context, choi
return h.BillingQ.CreateProduct(ctx, params)
}
// Two rows, so one transaction. This is the one write on this surface
// that opens its own: the queriers the handler holds are per-schema
// interfaces with no shared transaction handle, and the set and the
// product must land together or not at all.
// Two rows, so one transaction: the queriers the handler holds are
// per-schema interfaces with no shared transaction handle, and the set
// and the product must land together or not at all.
var product billing.Product
err := db.InTx(ctx, h.Database, db.Plain, func(tx *sql.Tx) error {
set, err := entitlements.New(tx).CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
@@ -333,13 +337,19 @@ func (h *OperatorPartialsHandler) createProductWithSet(ctx context.Context, choi
// reading the body through the same declaration the create page posts
// through, on its edit side (form-library). The edit side's
// entitlement-set select opens on "None", whose empty value is a
// legitimate submission that unsets the column, so requiredness differs
// between the two sides through the option list rather than through two
// hand-written rules that could drift (finding FA-1).
// legitimate submission that unsets the column, only for a product known
// to be on no ladder; a tier product, or one whose ladders could not be
// read, opens on the disabled placeholder, so requiredness differs between
// the two sides, and between products, through the option list rather than
// through hand-written rules that could drift (finding FA-1). The handler
// reads the product's ladders before the parse because the option list
// depends on them.
func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
sets := h.loadEntitlementSetOptionsFor(r, productID)
values, errs := productForm.ParseSide(r, forms.EditOnly, productFormOptions(forms.EditOnly, sets))
tier := h.loadProductTier(r.Context(), productID)
values, errs := productForm.ParseSide(r, forms.EditOnly, productFormOptions(forms.EditOnly, sets, tier))
errs = refuseProductEditForTier(tier, values, errs)
if errs.Any() {
h.renderProductEditRefusal(w, r, productID, values, errs)
return
@@ -377,7 +387,7 @@ func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.R
}
}
_, err := h.BillingQ.UpdateProduct(r.Context(), billing.UpdateProductParams{
err := h.saveProduct(r.Context(), billing.UpdateProductParams{
ProductID: productID,
Name: name,
Description: sql.NullString{String: description, Valid: description != ""},
@@ -388,17 +398,7 @@ func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.R
Metadata: metadata,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"chk_products_lifecycle_status_domain": {Field: "", Message: "Lifecycle status must be draft, published, or retired."},
}); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderProductEditRefusal(w, r, productID, values, errs)
return
}
h.Logger.Error("failed to update product", slog.Any("error", err))
errs.Form("Failed to update the product. Details are in the server logs.")
h.refuseProductWrite(r.Context(), productID, err, errs)
h.renderProductEditRefusal(w, r, productID, values, errs)
return
}
@@ -408,6 +408,86 @@ func (h *OperatorPartialsHandler) UpdateProduct(w http.ResponseWriter, r *http.R
h.renderProductDetailBody(w, r, productID, "Product updated successfully.", "")
}
// productUpdateFailed is the form-level refusal of a product edit that
// could not be saved or could not be checked.
const productUpdateFailed = "Failed to update the product. Details are in the server logs."
// refuseProductEditForTier applies the product's tiers to the parse's
// result. When the ladders could not be read nothing is written: the only
// refusal is the form-level failure sentence, in a fresh error set because
// the library has no call that removes one field's error, and the parse's
// "Choose an entitlement set." would read as though a set were required
// while the product may be on no ladder. When a tier product's submission
// left the set empty (a page rendered before the product became a tier
// offered "None"), the parse's refusal of the empty value is replaced by the
// sentence that names the ladders.
func refuseProductEditForTier(tier productTier, values forms.Values, errs *forms.Errors) *forms.Errors {
if tier.unread {
failed := forms.NewErrors()
failed.Form(productUpdateFailed)
return failed
}
if tier.isTier() && errs.Has("entitlement_set_id") && values.Raw("entitlement_set_id") == "" {
errs.Field("entitlement_set_id", tierNeedsSetSentence(tier.ladders))
}
return errs
}
// saveProduct writes the product edit in a READ COMMITTED transaction. The
// tier trigger's guarantee that a tier add and a set clear on one product
// cannot both commit holds only at READ COMMITTED (migration 00035; the
// second writer must see the first one's commit), and a lone statement
// would run at the server's default isolation (docs/go-conventions.md,
// "Transactions and pool locks"). The querier is bound to the transaction.
func (h *OperatorPartialsHandler) saveProduct(ctx context.Context, params billing.UpdateProductParams) error {
return db.InTx(ctx, h.Database, db.ReadCommitted, func(tx *sql.Tx) error {
_, err := billing.New(tx).UpdateProduct(ctx, params)
return err
})
}
// refuseProductWrite records a refused product write in errs. A write the
// tier trigger refused (chk_products_tier_keeps_set) reads the ladders
// again and names them under the Entitlement set field; when that read
// fails or finds no ladder (the tier was removed again), or the write failed
// in a way `web.FieldErrorsFromDB` does not map, the answer is the form-level
// failure sentence and the error is logged, so the page's "Details are in the server logs" has a line behind
// it. A constraint it maps answers its own message and is not logged. The
// second read runs only for that constraint.
func (h *OperatorPartialsHandler) refuseProductWrite(ctx context.Context, productID string, err error, errs *forms.Errors) {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.ConstraintName == "chk_products_tier_keeps_set" {
if tier := h.loadProductTier(ctx, productID); tier.isTier() {
errs.Field("entitlement_set_id", tierNeedsSetSentence(tier.ladders))
return
}
} else if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"chk_products_lifecycle_status_domain": {Field: "", Message: "Lifecycle status must be draft, published, or retired."},
}); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
return
}
h.Logger.Error("failed to update product", slog.Any("error", err), slog.String("product_id", productID))
errs.Form(productUpdateFailed)
}
// productTierOf is the one place the product edit's read of the product's
// tiers is logged: the page loader and loadProductTier both call it.
func (h *OperatorPartialsHandler) productTierOf(productID string, rows []billing.ListLaddersByProductRow, err error) productTier {
if err != nil {
h.Logger.Error("product edit: tier read failed", slog.String("product_id", productID), slog.Any("error", err))
}
return productTierFrom(rows, err)
}
// loadProductTier reads the ladders the product is a tier of, once.
func (h *OperatorPartialsHandler) loadProductTier(ctx context.Context, productID string) productTier {
rows, err := h.BillingQ.ListLaddersByProduct(ctx, productID)
return h.productTierOf(productID, rows, err)
}
func (h *OperatorPartialsHandler) renderProductsPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
fireSuccessToast(w, success)
data := h.loadProductsPageData(r, errMsg)
@@ -428,7 +508,7 @@ func (h *OperatorPartialsHandler) renderProductEditRefusal(w http.ResponseWriter
h.renderProductsPage(w, r, "", "Product not found")
return
}
edit.Form = productEditForm(productID, edit.EntitlementSets, values, errs, productEditSlots(h.Templates, edit))
edit.Form = productEditForm(productID, edit.EntitlementSets, edit.tier, values, errs, productEditSlots(h.Templates, edit))
h.Templates.Render(w, "operator_product_detail.html", ProductDetailData{
Edit: edit,
Prices: h.loadProductPricesData(r, productID),
@@ -805,6 +885,7 @@ func (h *OperatorPartialsHandler) loadProductEditDataResult(r *http.Request, pro
// Load ladder memberships for this product
ladders, err := h.BillingQ.ListLaddersByProduct(r.Context(), productID)
data.tier = h.productTierOf(productID, ladders, err)
if err == nil {
data.LadderMemberships = make([]ProductLadderMembership, len(ladders))
for i, l := range ladders {
@@ -852,7 +933,7 @@ func (h *OperatorPartialsHandler) loadProductEditDataResult(r *http.Request, pro
// The edit form is the product declaration bound to this record: the
// same field list the create page renders unbound, with the three
// edit-only fields and their reasons (design D5, D8).
data.Form = productEditForm(productID, data.EntitlementSets, productEditValues(data.Product), nil, productEditSlots(h.Templates, data))
data.Form = productEditForm(productID, data.EntitlementSets, data.tier, productEditValues(data.Product), nil, productEditSlots(h.Templates, data))
return data, true, nil
}
@@ -15,6 +15,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
"github.com/google/uuid"
)
@@ -28,44 +29,23 @@ func TestUpdateProductEntitlementSetSentinel(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
// Active: the edit form offers only active sets, and a value outside
// the offered options is refused rather than silently accepted
// (form-library, "A value outside the options is refused").
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "sentinel-test-set-" + uuid.New().String()[:8],
IsActive: true,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Sentinel Test Product",
IsActive: true,
IsPublic: false,
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true},
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create product: %v", err)
}
// Committed rows, not an open transaction's: the edit writes in its own
// READ COMMITTED transaction (task 4.6), which cannot see a product an
// uncommitted transaction holds. The set is active: the edit form offers
// only active sets, and a value outside the offered options is refused
// rather than silently accepted (form-library, "A value outside the
// options is refused").
w := world.New(t, database)
seeded := w.Product("Sentinel Test Product")
bq := billing.New(database)
eq := entitlements.New(database)
h, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
BillingQ: bq,
EntitlementsQ: eq,
// The success path re-renders the composite detail page, which reads
// core.outbox for Stripe sync-failure state (catalogsync.FailedSync) via
// h.Database directly rather than the tx — the pool, not the tx, so
// it must be non-nil even though this test never touches that table.
// The edit writes through h.Database (its own READ COMMITTED
// transaction), and the success path re-renders the detail page, which
// reads core.outbox through it (catalogsync.FailedSync).
Database: database,
Logger: slog.Default(),
})
@@ -75,12 +55,12 @@ func TestUpdateProductEntitlementSetSentinel(t *testing.T) {
put := func(entitlementSetID string) *httptest.ResponseRecorder {
form := url.Values{
"name": {"Sentinel Test Product"},
"name": {seeded.Name},
"entitlement_set_id": {entitlementSetID},
}
req := httptest.NewRequest("PUT", "/partials/operator/products/"+product.ProductID, strings.NewReader(form.Encode()))
req := httptest.NewRequest("PUT", "/partials/operator/products/"+seeded.ID, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("productID", product.ProductID)
req.SetPathValue("productID", seeded.ID)
rec := httptest.NewRecorder()
h.UpdateProduct(rec, req)
return rec
@@ -91,7 +71,7 @@ func TestUpdateProductEntitlementSetSentinel(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("clear entitlement set = %d, body: %s", rec.Code, rec.Body.String())
}
got, err := bq.GetProductByID(ctx, product.ProductID)
got, err := bq.GetProductByID(ctx, seeded.ID)
if err != nil {
t.Fatalf("get product: %v", err)
}
@@ -106,7 +86,7 @@ func TestUpdateProductEntitlementSetSentinel(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("re-save with sentinel = %d, body: %s", rec.Code, rec.Body.String())
}
got, err = bq.GetProductByID(ctx, product.ProductID)
got, err = bq.GetProductByID(ctx, seeded.ID)
if err != nil {
t.Fatalf("get product: %v", err)
}
@@ -115,16 +95,16 @@ func TestUpdateProductEntitlementSetSentinel(t *testing.T) {
}
// Choosing a real set still works.
rec = put(es.SetID)
rec = put(seeded.SetID)
if rec.Code != 200 {
t.Fatalf("assign entitlement set = %d, body: %s", rec.Code, rec.Body.String())
}
got, err = bq.GetProductByID(ctx, product.ProductID)
got, err = bq.GetProductByID(ctx, seeded.ID)
if err != nil {
t.Fatalf("get product: %v", err)
}
if !got.EntitlementSetID.Valid || got.EntitlementSetID.UUID.String() != es.SetID {
t.Fatalf("entitlement_set_id = %+v, want %s", got.EntitlementSetID, es.SetID)
if !got.EntitlementSetID.Valid || got.EntitlementSetID.UUID.String() != seeded.SetID {
t.Fatalf("entitlement_set_id = %+v, want %s", got.EntitlementSetID, seeded.SetID)
}
}
+5 -3
View File
@@ -18,7 +18,9 @@ import (
// The sentinel's wording is the forms library's optional-select
// placeholder now (form-conventions, "A select that needs a choice opens
// with a placeholder option"); the create side's first option is the one
// that creates the set with the product (design D14).
// that creates the set with the product (design D14). "None" is offered to
// a product on no ladder; a tier product opens on the placeholder instead
// (TestTheEntitlementSetFieldFollowsTheProductsTiers).
func TestProductEditEntitlementSetSentinel(t *testing.T) {
sets := []EntitlementSetOption{
{SetID: "set-a", Name: "Set A"},
@@ -61,14 +63,14 @@ func TestProductEditEntitlementSetSentinel(t *testing.T) {
}
})
t.Run("sentinel is present as an option regardless of selection state", func(t *testing.T) {
t.Run("sentinel is present as an option regardless of selection state, for a product on no ladder", func(t *testing.T) {
data := OperatorProductEditData{
Product: OperatorProductViewModel{ProductID: "p1", Name: "Custom Domain", EntitlementSetID: "set-a"},
EntitlementSets: sets,
}
out := renderProductEdit(t, data)
if !strings.Contains(out, `<option value=""`) || !strings.Contains(out, ">None</option>") {
t.Error("the unset sentinel option must always be present on the edit side")
t.Error("the unset sentinel option must always be present on the edit side for a product on no ladder")
}
})
}
+12 -12
View File
@@ -40,16 +40,16 @@
<label class="form-label" for="form-operator.product-entitlement_set_id-control">Entitlement set</label>
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-hint">
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-line">
<option value="" disabled selected>Choose one</option>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</select>
<div id="form-operator.product-entitlement_set_id-line"></div>
<div id="form-operator.product-entitlement_set_id-hint" class="form-text">The set is what the product delivers; a product with none delivers no capabilities.</div>
</div><div id="form-operator.product-visibility" class="mb-3 app-form-field">
@@ -159,16 +159,16 @@
<label class="form-label" for="form-operator.product-entitlement_set_id-control">Entitlement set</label>
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-hint">
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-line">
<option value="" disabled selected>Choose one</option>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</select>
<div id="form-operator.product-entitlement_set_id-line"></div>
<div id="form-operator.product-entitlement_set_id-hint" class="form-text">The set is what the product delivers; a product with none delivers no capabilities.</div>
</div><div id="form-operator.product-visibility" class="mb-3 app-form-field">
@@ -275,16 +275,16 @@
<label class="form-label" for="form-operator.product-entitlement_set_id-control">Entitlement set</label>
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-hint">
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-line">
<option value="" disabled selected>Choose one</option>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</select>
<div id="form-operator.product-entitlement_set_id-line"></div>
<div id="form-operator.product-entitlement_set_id-hint" class="form-text">The set is what the product delivers; a product with none delivers no capabilities.</div>
</div><div id="form-operator.product-visibility" class="mb-3 app-form-field">
@@ -357,16 +357,16 @@
<label class="form-label" for="form-operator.product-entitlement_set_id-control">Entitlement set</label>
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-hint">
<select class="form-select" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-line">
<option value="" disabled selected>Choose one</option>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</select>
<div id="form-operator.product-entitlement_set_id-line"></div>
<div id="form-operator.product-entitlement_set_id-hint" class="form-text">The set is what the product delivers; a product with none delivers no capabilities.</div>
</div><div id="form-operator.product-visibility" class="mb-3 app-form-field">
@@ -476,16 +476,16 @@
<label class="form-label" for="form-operator.product-i1-entitlement_set_id-control">Entitlement set</label>
<select class="form-select" id="form-operator.product-i1-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-i1-entitlement_set_id-hint">
<select class="form-select" id="form-operator.product-i1-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-i1-entitlement_set_id-line">
<option value="" disabled>Choose one</option>
<option value="a" selected>Alpha</option>
<option value="b">Beta</option>
</select>
<div id="form-operator.product-i1-entitlement_set_id-line"></div>
<div id="form-operator.product-i1-entitlement_set_id-hint" class="form-text">The set is what the product delivers; a product with none delivers no capabilities.</div>
</div><div id="form-operator.product-i1-visibility" class="mb-3 app-form-field">
@@ -595,16 +595,16 @@
<label class="form-label" for="form-operator.product-entitlement_set_id-control">Entitlement set</label>
<select class="form-select is-invalid" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-hint form-operator.product-entitlement_set_id-error" aria-invalid="true">
<select class="form-select is-invalid" id="form-operator.product-entitlement_set_id-control" name="entitlement_set_id" required aria-describedby="form-operator.product-entitlement_set_id-error form-operator.product-entitlement_set_id-line" aria-invalid="true">
<option value="" disabled selected>Choose one</option>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</select>
<div id="form-operator.product-entitlement_set_id-line"></div>
<div id="form-operator.product-entitlement_set_id-hint" class="form-text">The set is what the product delivers; a product with none delivers no capabilities.</div>
<div id="form-operator.product-entitlement_set_id-error" class="invalid-feedback d-block">Choose an entitlement set.</div>
</div><div id="form-operator.product-visibility" class="mb-3 app-form-field">
@@ -218,7 +218,7 @@ Pick: none in this change. Under D1 a missing set cannot cause one. The draft an
The new lock is the product row, `FOR SHARE`, taken by the tier trigger inside the tier insert. The tier add's order becomes: the exclusive rendezvous, the reads that decide the add, the pool rows of the product's holders in ascending order, the ladder's name, the act rows, then the tier insert, whose trigger takes the product row, then the collision read, the alignments and the materializations (`docs/database-locks.md`, "Tier add").
The product edit takes the same row in `FOR NO KEY UPDATE` through its `UPDATE`, in one statement with no transaction of its own and no other lock. Its trigger reads the tier rows without locking them. So the product edit never waits for a lock the tier add holds, and no cycle forms. Nothing else locks product rows: `UpdateProduct` is the only writer of the table outside the test kit, and the foreign keys that reference it take `FOR KEY SHARE`, which conflicts with neither mode. Two tier adds of one product share the row in `FOR SHARE`, and the exclusive rendezvous already serializes them. The demo seed adds its tiers outside the rendezvous (`docs/database-locks.md`, rule 1) and takes the row the same way.
The product edit takes the same row in `FOR NO KEY UPDATE` through its `UPDATE`, in one statement, in its own READ COMMITTED transaction (item 4.6), with no other lock. Its trigger reads the tier rows without locking them. So the product edit never waits for a lock the tier add holds, and no cycle forms. Nothing else locks product rows: `UpdateProduct` is the only writer of the table outside the test kit, and the foreign keys that reference it take `FOR KEY SHARE`, which conflicts with neither mode. Two tier adds of one product share the row in `FOR SHARE`, and the exclusive rendezvous already serializes them. The demo seed adds its tiers outside the rendezvous (`docs/database-locks.md`, rule 1) and takes the row the same way.
A wait for the product row is bounded by the rendezvous's 5 s `lock_timeout`. A tier add that waits past it fails with SQLSTATE 55P03, which `refuseTierAdd` already reads as the contention refusal (`db.IsLockContention`, `operator_plan_ladders.go:497-499`).
@@ -255,7 +255,7 @@ Reused unchanged: "Failed to update the product. Details are in the server logs.
|---|---|---|
| L1 | `core.refuse_tier_product_without_set`, the raise; reaches logs only | `tier: product <id> has no entitlement set`, constraint `chk_plan_ladder_tiers_product_has_set` |
| L2 | `core.refuse_tier_set_clear`, the raise; reaches logs only | `product <id> is a plan tier and keeps its entitlement set`, constraint `chk_products_tier_keeps_set` |
| L3 | The product edit's read of the product's tiers (the product row and its ladders), on the page and in `UpdateProduct` (Error); logged at one site, `productTierOf`, which the page loader and `loadProductTier` both call | `product edit: tier read failed`, attributes `product_id`, `error` |
| L3 | The product edit's read of the product's tiers (the product row and its ladders), on the page and in `UpdateProduct` (Error); logged at one site, `productTierOf` (it takes no `ctx`, as `h.Logger.Error` takes none), which the page loader and `loadProductTier` both call | `product edit: tier read failed`, attributes `product_id`, `error` |
| L4 | `UpdateProduct`, a write refused with `chk_products_tier_keeps_set` whose second ladder read fails or finds no ladder (Error) | `failed to update product`, attributes `product_id`, `error` |
## Tests
@@ -278,7 +278,7 @@ Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deli
| T8 `TestResumption_ARefusedConferralIsRolledBackAlone` (`internal/entitlements/grant_resumption_test.go`) | Plus is drafted in place of having its set cleared at `:304`, which the trigger now refuses because Plus is a tier | The draft removed (the `UPDATE` deleted): both grants resume |
| T9 `TestTierProductsKeepASetMigrationUpDownUp` (`internal/db`) | Down restores the schema Up started from, and a second Up rebuilds the first (`requireMigrationUpDownUp`, `internal/db/outgoing_default_rule_test.go:159-187`) | A Down without one of its `DROP TRIGGER` statements |
`TestUpdateProductEntitlementSetSentinel` (`internal/server/operator_products_handler_test.go:27-129`), whose product is on no ladder, passes unedited; it is the proof that F12 is unchanged. `TestRuntimeRolePrivileges` (`internal/db/runtime_role_privileges_test.go:235`) passes unedited: its function list names definer functions only (`:218-223`), and the two trigger functions run with the caller's rights. `TestFormsHTML` (`internal/server/forms_golden_test.go`) regenerates `internal/server/testdata/forms/operator.product.html` once, with `-update` and its diff read: the Entitlement set field's `Hint` becomes a `line` slot; it renders each declaration through bindings of its own, never through `productSetOptions` or `productSetLine`, so the diff shows that declaration change (in each binding the `-hint` div becomes an empty `-line` div, and `aria-describedby` names `-line` in place of `-hint`, after `-error` in the refused binding) and no other. `TestProductEditEntitlementSetSentinel` (`internal/server/operator_products_test.go:22`) passes with a wording edit, because its subtest "sentinel is present as an option regardless of selection state" and its error "the unset sentinel option must always be present on the edit side" are no longer true for a tier product: they say "for a product on no ladder".
`TestUpdateProductEntitlementSetSentinel` (`internal/server/operator_products_handler_test.go:27-129`), whose product is on no ladder, keeps its assertions and is the proof that F12 is unchanged; it runs on committed rows and renames with the seeded product's own name, because the edit now writes through the pool in its own transaction. `TestRuntimeRolePrivileges` (`internal/db/runtime_role_privileges_test.go:235`) passes unedited: its function list names definer functions only (`:218-223`), and the two trigger functions run with the caller's rights. `TestFormsHTML` (`internal/server/forms_golden_test.go`) regenerates `internal/server/testdata/forms/operator.product.html` once, with `-update` and its diff read: the Entitlement set field's `Hint` becomes a `line` slot; it renders each declaration through bindings of its own, never through `productSetOptions` or `productSetLine`, so the diff shows that declaration change (in each binding the `-hint` div becomes an empty `-line` div, and `aria-describedby` names `-line` in place of `-hint`, after `-error` in the refused binding) and no other. `TestProductEditEntitlementSetSentinel` (`internal/server/operator_products_test.go:22`) passes with a wording edit, because its subtest "sentinel is present as an option regardless of selection state" and its error "the unset sentinel option must always be present on the edit side" are no longer true for a tier product: they say "for a product on no ladder".
Breaks no test sees, with the reason:
- The two `GRANT EXECUTE` lines: `PUBLIC` keeps `EXECUTE` on a function, and a trigger function's `EXECUTE` is checked when the trigger is created, not when it fires, so removing a grant changes nothing a role can do. The grants follow `00026` so a later revoke from `PUBLIC` leaves the three core roles able to call them.
@@ -376,9 +376,9 @@ is draft or retired or its set id names no set (#99).
update that leaves a NULL set NULL or gives it a value SHALL be allowed.
- The tier check SHALL take the product row `FOR SHARE`, so that a tier insert
and a clear of the same product's set cannot both commit: whichever writes
second SHALL see the first one's commit and be refused. Both writers run at
READ COMMITTED, the server's default isolation; the guarantee holds at that
isolation only.
second SHALL see the first one's commit and be refused. The tier add runs at the
server's default, READ COMMITTED, and the product edit in an explicit READ
COMMITTED transaction; the guarantee holds at that isolation only.
- The rule is about the set being absent. A set with no active rule, or an
inactive set, SHALL NOT be refused here; a set with no active rule stays the
warning the ladder previews and the org-type default-change preview state.
@@ -99,7 +99,7 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
## 4. The product edit does not offer "None" for a tier product
- [ ] 4.1 `internal/server/operator_product_forms.go` (design D3; Copy R2, R3):
- [x] 4.1 `internal/server/operator_product_forms.go` (design D3; Copy R2, R3):
- `productTier` (`ladders []string`, the names of the ladders the product is a tier of in the query's order; `unread bool`), `productTierFrom(rows []billing.ListLaddersByProductRow, err error) productTier`, and its methods `isTier() bool` (`len(ladders) > 0`) and `offersNone() bool` (`!unread && !isTier()`). Each reads no row.
- `internal/forms/forms.go:614-617`: the `Slot` doc comment, which names only the htmx use, also says "or a line the handler decides per render (the product's Entitlement set)"; a comment, no behaviour.
- `tierNeedsSetSentence(ladders []string) string`: `"This product is a tier of " + joinWithAnd(ladders) + ", and a tier needs an entitlement set."`.
@@ -107,14 +107,14 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
- The Entitlement set field in `productForm` drops its declared `Hint` and declares `Slot: "line"`, the pattern the product name (`productNameField`), the tier add (`operator_plan_ladder_forms.go:115`) and the grant form (`operator_enrollment_forms.go:220`) use for a line the record decides. The off-ladder sentence "The set is what the product delivers; a product with none delivers no capabilities." moves into a constant `productSetOffLadderLine` beside `productSetLine`.
- `productSetLine(tier productTier, errs *forms.Errors) string`: `""` when `tier.unread`; `tierNeedsSetSentence(tier.ladders)` when `tier.isTier()` and the field has no error, and `""` when it has one (`errs.Has`), so the error is the field's one line; `productSetOffLadderLine` otherwise. A helper turns a non-empty line into the slot's HTML in the markup a declared hint renders with (`ui_form_field.html:86`): ``template.HTML(`<div class="form-text">` + template.HTMLEscapeString(line) + `</div>`)``, as `compose.go:67` builds its own; an empty line gives no entry.
- `productEditForm(productID string, sets []EntitlementSetOption, tier productTier, values forms.Values, errs *forms.Errors, slots map[string]template.HTML)` passes `productFormOptions(forms.EditOnly, sets, tier)` and a copy of `slots` with the `entitlement_set_id` entry from `productSetLine(tier, errs)`; `productCreateForm` passes `Slots` with the `entitlement_set_id` entry `productSetOffLadderLine`, so the create page reads as before. `productEditSlots`' doc comment, which says the name slot is the only one the form declares, is corrected.
- [ ] 4.2 `internal/server/operator_products.go` (design D3):
- [x] 4.2 `internal/server/operator_products.go` (design D3):
- `OperatorProductEditData` gains the unexported `tier productTier`. `loadProductEditDataResult` (`:751-858`) sets it from the "Plan ladders" read it already makes (`:806-817`) through `productTierOf`; the "Plan ladders" section is unchanged. It passes `data.tier` to `productEditForm`, and the page makes no statement it did not make before.
- L3 has one site: `func (h *OperatorPartialsHandler) productTierOf(ctx context.Context, productID string, rows []billing.ListLaddersByProductRow, err error) productTier` logs L3 (`product edit: tier read failed`, attributes `product_id`, `error`, at Error on `h.Logger`) when `err` is set and returns `productTierFrom(rows, err)`. The page loader and `loadProductTier` both call it, so the page, the PUT and the refusal render (which loads the page data again) each go through the one `Logger.Error` call.
- L3 has one site: `func (h *OperatorPartialsHandler) productTierOf(productID string, rows []billing.ListLaddersByProductRow, err error) productTier` logs L3 (`product edit: tier read failed`, attributes `product_id`, `error`, at Error on `h.Logger`) when `err` is set and returns `productTierFrom(rows, err)`. It takes no `ctx`: `h.Logger.Error` takes none, and an unused parameter fails `unparam` in `make lint`. The page loader and `loadProductTier` both call it, so the page, the PUT and the refusal render (which loads the page data again) each go through the one `Logger.Error` call.
- `loadProductTier(ctx context.Context, productID string) productTier` reads `ListLaddersByProduct` once and returns `h.productTierOf(…)` of that read.
- `UpdateProduct` (`:339-409`): `tier := h.loadProductTier(r.Context(), productID)` before the parse, and the parse reads `productFormOptions(forms.EditOnly, sets, tier)`. When `tier.unread`, the handler renders a fresh `forms.NewErrors()` whose only message is the form-level "Failed to update the product. Details are in the server logs." (the library has no call that removes one field's error), writing nothing. When the parse refused `entitlement_set_id`, the submitted value is empty (`values.Raw`) and `tier.isTier()`, the field's error is replaced with `tierNeedsSetSentence(tier.ladders)`. A write refused with `chk_products_tier_keeps_set` (the error's `*pgconn.PgError`, through `errors.As`) reads the ladders again with `loadProductTier`: when they name a ladder, the map entry is R2 on `entitlement_set_id` over those names; when the read fails or finds none, it logs `failed to update product` (L4: `slog.Any("error", err)` and `product_id`) and the entry is the form-level failure sentence. That read runs only for that constraint, so a write refused on another constraint makes no extra read. The handler's doc comment states the rule in place of "opens on 'None', whose empty value is a legitimate submission". The function stays under the complexity limit by splitting, not by a lint exception.
- `renderProductEditRefusal` (`:424-436`) passes `edit.tier` to `productEditForm`.
- The two test callers of `productEditForm` pass the record's `tier`: `renderProductEdit` (`internal/server/operator_product_readiness_render_test.go:20-32`) passes `data.tier`, and `internal/server/operator_catalog_detail_render_test.go:87` passes `edit.tier`; their zero value is a product read and found on no ladder, so both render what they render today.
- [ ] 4.3 Tests. Every check in T6, T6c and T6d after the status code uses `t.Errorf`, the status checks included, so a row reports every mismatch and each later message below prints. The field's line is the text of `#form-operator\.product-entitlement_set_id-line`, which renders on every page and is empty when there is no line. "No line" means that text is empty. "Lines under the Entitlement set field" counts the `.form-text` and `.invalid-feedback` elements inside `#form-operator\.product-entitlement_set_id` (the slot's line carries `form-text`, item 4.1):
- [x] 4.3 Tests. Every check in T6, T6c and T6d after the status code uses `t.Errorf`, the status checks included, so a row reports every mismatch and each later message below prints. The field's line is the text of `#form-operator\.product-entitlement_set_id-line`, which renders on every page and is empty when there is no line. "No line" means that text is empty. "Lines under the Entitlement set field" counts the `.form-text` and `.invalid-feedback` elements inside `#form-operator\.product-entitlement_set_id` (the slot's line carries `form-text`, item 4.1):
- T6 `TestProductEditOfATierProductOffersNoNone` (a new `internal/server/operator_product_tier_set_db_test.go`, package `server_test`, over `world.New` and `newOtcHarness`), rows `ladders created in name order` (Forum, then Hosting) and `ladders created in reverse` (Hosting, then Forum), through a named function, the product `Tiered` a tier of both. The record page (`GetProductDetailPage`) offers no "None" (`the record page of a tier product offers "None"`), its first option is R3 and disabled (`the first option = %q (disabled %v), want "Choose an entitlement set", disabled`), the product's set is the selected option (`the selected option = %q, want the product's set %q`), and the line under the field (the text of `#form-operator\.product-entitlement_set_id-line`) is R2 over the two names in name order (`the field's line = %q, want %q`). A PUT of the product's name with an empty `entitlement_set_id` answers 422 (`the PUT of "None" answered %d, want 422`), with R2 as the field's error (`the Entitlement set field's error = %q, want %q`), one line under the control (`lines under the Entitlement set field after the refusal = %d, want 1`), R3 as the selected option (`the selected option after the refusal = %q, want "Choose an entitlement set"`), and the stored set unchanged (`the stored set after the refused PUT = %v, want %s`).
- T6b `TestProductEditRefusesNoneForAProductThatBecameATier` (same file), through `scriptedLaddersQ`, a billing querier whose `ListLaddersByProduct` answers its calls in turn from a list (`none`: no row; `fail`: a read error of the test's own), and the real read after the list ends; rows `none`, `none, fail` and `none, none` through a named function: with `none`, the PUT of "None" passes the parse, the trigger refuses the write, the second read finds the ladder, and the answer is 422 with R2 as the field's error (`the Entitlement set field carries no error; the form-level slot reads %q` when it is absent, `the Entitlement set field's error = %q, want %q` when it differs); with `none, fail`, the answer is 422 with the form-level failure sentence (`the form-level slot = %q, want %q`); with `none, none` (the second read answers no row), the answer is 422 with the form-level failure sentence and `wantInLine(t, h.logs.String(), "failed to update product", "product_id="+p.ID, "is a plan tier and keeps its entitlement set")` holds; in all three rows the stored set is unchanged (`the stored set after the refused PUT = %v, want %s`).
- T6c `TestProductEditWithUnreadLaddersSavesNothing` (same file), through a billing querier whose `ListLaddersByProduct` always fails: the record page offers no "None" (`the page offers "None" while the ladders are unread`), opens with R3 (`the first option = %q, want "Choose an entitlement set"`), and carries no line under the field (`the field carries a line while the ladders are unread: %q`); a PUT that renames the product and keeps its set answers 422 (`the PUT answered %d while the ladders are unread, want 422`) with the form-level failure sentence (`the form-level slot = %q, want %q`), the stored name is unchanged (`the name changed although the ladders could not be read`); a PUT of an empty `entitlement_set_id` answers the same sentence with no error under the Entitlement set field (`the Entitlement set field carries %q while the ladders are unread`); and `wantInLine(t, h.logs.String(), "product edit: tier read failed", "product_id="+p.ID)` holds.
@@ -122,9 +122,9 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
- `TestTheCreatePageCarriesTheOffLadderLine` (same file): `productCreateForm(sets, forms.NewValues(), nil)` rendered, expecting the line `productSetOffLadderLine`; a failure reads `the create page's line = %q, want %q`.
- `TestTierNeedsSetSentence` (same file), rows `one ladder`, `two ladders`, `three ladders`, each compared as the whole sentence (`This product is a tier of Forum, and a tier needs an entitlement set.`, `This product is a tier of Forum and Hosting, and a tier needs an entitlement set.`, `This product is a tier of Docs, Forum and Hosting, and a tier needs an entitlement set.`). A failure reads `tierNeedsSetSentence(%s) = %q, want %q`.
- `TestFormsHTML` (`internal/server/forms_golden_test.go`) is run with `-update` once: `internal/server/testdata/forms/operator.product.html` changes because the Entitlement set field's `Hint` becomes a `line` slot (in each binding the `-hint` div becomes an empty `-line` div, and `aria-describedby` names `-line` in place of `-hint`, after `-error` in the refused binding), and the diff is read; any other change is a stop. `TestProductEditEntitlementSetSentinel` (`internal/server/operator_products_test.go:22`) passes with a wording edit: the comment, the subtest name "sentinel is present as an option regardless of selection state" and the error "the unset sentinel option must always be present on the edit side" say "for a product on no ladder", because a tier product no longer offers it.
- Pass unedited: `TestUpdateProductEntitlementSetSentinel` (`internal/server/operator_products_handler_test.go:27-129`; its product is on no ladder, which proves F12), and the product page's row of `TestOperatorPagesReadAFixedNumberOfTimes` (`internal/server/operator_read_count_db_test.go:122-129`; the page reads the tiers once).
- [ ] 4.4 `make screens` (it resets the app database; item 1.0 has run): `operator-product-detail` captures the first product the products list links to (`test/e2e/screens/manifest.go:71-72`). If that product is a tier in the demo state, its Entitlement set field's line reads R2 and the commit message names the screen; any other changed screen is a stop to report, and `operator-product-new` does not change.
- [ ] 4.5 Breaks (test records):
- `TestUpdateProductEntitlementSetSentinel` (`internal/server/operator_products_handler_test.go:27-129`; its product is on no ladder, which proves F12) keeps its assertions but runs on committed rows and renames with the seeded product's own name, because `saveProduct` (item 4.6) writes through `h.Database` and a product inside the fixture transaction is invisible to that connection. The product page's row of `TestOperatorPagesReadAFixedNumberOfTimes` (`internal/server/operator_read_count_db_test.go:122-129`; the page reads the tiers once) passes unedited.
- [x] 4.4 `make screens` (it resets the app database; item 1.0 has run): `operator-product-detail` captures the first product the products list links to (`test/e2e/screens/manifest.go:71-72`). If that product is a tier in the demo state, its Entitlement set field's line reads R2 and the commit message names the screen; any other changed screen is a stop to report, and `operator-product-new` does not change.
- [x] 4.5 Breaks (test records):
- the parse's error left in place (`errs.Field("entitlement_set_id", tierNeedsSetSentence(tier.ladders))` deleted from `UpdateProduct`'s branch after the parse): caught by T6, `want_new` `the Entitlement set field's error = "Choose an entitlement set."`;
- "None" offered whatever the tiers (`if tier.offersNone() {` replaced by `if true {` in `productSetOptions`), one edit and three records: caught by T6, `want_new` `the record page of a tier product offers "None"`; by T6c, `want_new` `the page offers "None" while the ladders are unread`; and by T6d, `want_new` `a tier, with a set: the options =`;
- the placeholder left out (`out = append(out, forms.ChooseOption("an entitlement set"))` deleted): caught by T6d, `want_new` `a tier, with no set: the selected option = ""`, and by T6, `want_new` `the selected option after the refusal = ""`;
@@ -139,15 +139,17 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
- the second-read refusal not logged (the `failed to update product` log call of the `chk_products_tier_keeps_set` branch deleted): caught by the `none, none` row of T6b, `want_new` `no log line holds all of`;
- the database's refusal unmapped (the `chk_products_tier_keeps_set` branch of `UpdateProduct` deleted, so `web.FieldErrorsFromDB` answers its class message in the form-level slot): caught by T6b, `want_new` `the Entitlement set field carries no error`;
- the refusal named over a failed second read (the branch that answers the failure sentence when `loadProductTier` finds no ladder deleted, so R2 renders over no names): caught by T6b, `want_new` `the form-level slot = ""`;
- the join reduced to the first name (`joinWithAnd(ladders)` replaced by `ladders[0]` in `tierNeedsSetSentence`): caught by `TestTierNeedsSetSentence`, `want_new` `tierNeedsSetSentence(two ladders) =`.
- [ ] 4.6 `internal/server/operator_products.go` (design D2): the product edit's write in `UpdateProduct` (`h.BillingQ.UpdateProduct`, one autocommit statement at the server's default isolation) runs inside `db.InTx(ctx, h.Database, db.ReadCommitted, ...)` with the querier bound to the transaction, and the handler's doc comment says why (`docs/go-conventions.md`, "Transactions and pool locks"): the tier trigger's guarantee that a tier add and a set clear cannot both commit holds only at READ COMMITTED. A test in `internal/server` over a database opened with `default_transaction_isolation=repeatable read` in its connection options: it runs the first order of T3 (a side transaction inserts the tier and holds it) with the product edit clearing the set through `UpdateProduct`, commits the side transaction, and expects the edit refused with R2; it fails if the write runs at the server's default. If the test kit cannot open a database with that option plainly, the item records the reason and leaves the test out.
- the join reduced to the first name (`joinWithAnd(ladders)` replaced by `ladders[0]` in `tierNeedsSetSentence`): caught by `TestTierNeedsSetSentence`, `want_new` `tierNeedsSetSentence(two ladders) =`;
- the empty-value condition dropped (` && values.Raw("entitlement_set_id") == ""` deleted from the `refuseProductEditForTier` condition): caught by T6, `want_new` `the Entitlement set field's error = "This product is a tier of`;
- the write at the server's default isolation (`db.ReadCommitted` replaced by `db.Plain` in `saveProduct`, item 4.6): caught by `TestProductEditClearingASetIsRefusedWhenATierAddCommitsFirstAtAnyDefaultIsolation`, `want_new` `the edit that cleared the set answered 200, want 422`.
- [x] 4.6 `internal/server/operator_products.go` (design D2): the product edit's write in `UpdateProduct` (`h.BillingQ.UpdateProduct`, one autocommit statement at the server's default isolation) runs inside `db.InTx(ctx, h.Database, db.ReadCommitted, ...)` with the querier bound to the transaction, and the handler's doc comment says why (`docs/go-conventions.md`, "Transactions and pool locks"): the tier trigger's guarantee that a tier add and a set clear cannot both commit holds only at READ COMMITTED. A test in `internal/server` over a database opened with `default_transaction_isolation=repeatable read` in its connection options: it runs the first order of T3 (a side transaction inserts the tier and holds it) with the product edit clearing the set through `UpdateProduct`, commits the side transaction, and expects the edit refused with R2; it fails if the write runs at the server's default. If the test kit cannot open a database with that option plainly, the item records the reason and leaves the test out.
## 5. Landing
- [ ] 5.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate tiers-need-an-entitlement-set --strict` passes; the lint's notebook-citations check is what holds the change's files free of a path into the local notebook.
- [ ] 5.2 The race run of item 2.3 is in its commit's message, and every section's break list reads CAUGHT or names its unseen breaks with the reason.
- [ ] 5.3 The same counts on a production deployment (item 1.0's three statements) are the maintainer's: he runs them as `console_reader` (`docs/database-management.md`, "The `console_reader` role"), which reads every column they name, and fixes each row by hand: the product given an entitlement set on its page, after which Retry moves the organizations a default change or a reorder left behind; or the default changed; or the bad tier removed. This change's tasks do not touch any production deployment.
- [ ] 5.4 With the app rebuilt from section 4's tree, after section 4's `make screens` and before its commit, reproduce #153's sequence: run `go test ./test/e2e/operator-walkthroughs/ -count=1 -run 'PlanLadders|OrgTypes|GrantRevoke'` twice without a reset between the runs. Section 4's commit message records both runs and the second run's revoke result.
- [x] 5.4 With the app rebuilt from section 4's tree, after section 4's `make screens` and before its commit, reproduce #153's sequence: run `go test ./test/e2e/operator-walkthroughs/ -count=1` three times from the demo snapshot without a reset between the runs. The whole package runs, not only the plan-ladders, org-types and grant-revoke walkthroughs, because on the demo snapshot the grant-revoke walkthrough's subject is a grant another walkthrough issues; and three runs, because the walkthroughs run in file order, so the org-types walkthrough first finds the plan-ladders walkthrough's ladder on the second run and a revoke first follows that on the third. Section 4's commit message records the runs and the third run's revoke result.
- [ ] 5.5 After the push, post these comments under `CONTRIBUTING.md`'s rules for forge text (item 1: each reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count). Comments need no go. #61 gets none: it is about gaps in a ladder's rank order, a different rule from the one this change adds.
- On #153, then close it, with the change's commits filled in: