Answer a tier add whose product lost its entitlement set mid-add under the Product field
Third section of #153's change. The tier add reads the product list at submit and parses against it, so a product whose set was cleared before the submission is refused as a choice the list did not offer. A set cleared after that read and before the insert meets section 2's trigger, and refuseTierAdd now maps chk_plan_ladder_tiers_product_has_set to "Only products with an entitlement set can be tiers." under the Product field, as it maps the duplicate tier and the rank collision. Before, the refusal fell to the form-level class message. docs/plan-management.md, "Attaching Tiers", states both answers. The spec delta's scenario places the window at the add's run, not at the panel's opening, and holds while another product remains to add: when the refused product was the ladder's last candidate, the re-rendered panel shows its empty state, which states the same prerequisite. Test (internal/server/operator_plan_ladder_tier_add_test.go): TestTierAddRefusesAProductWhoseSetWasClearedSinceThePicker, with a billing querier that is stale on the add's own list read only. Review: one major, the texts that placed the window at the panel's opening (doc, comments, spec scenario, task items); fixed in round 1, round 2 clean but for nits, applied. Breaks: 1 of 1 caught (the map entry removed). Full suite on reset databases: 4712 pass, 14 skip. make screens: no change beyond section 1's.
This commit is contained in:
@@ -70,7 +70,7 @@ The panel also flags the **invisible-limbo** case: a published product that is n
|
||||
|
||||
The add decides inside the one transaction that inserts the tier:
|
||||
|
||||
- **The product.** It reads the product there and refuses one that is not published; a product no row names answers "Product not found.".
|
||||
- **The product.** It reads the product there and refuses one that is not published; a product no row names answers "Product not found.". The database refuses, at the insert, a product whose entitlement set was cleared after the add checked it against the list, answering "Only products with an entitlement set can be tiers." under the Product field; a product whose set was cleared before the submission is refused as a choice the list did not offer, "Choose a product from the list."
|
||||
- **The holder limit.** It counts the organizations holding a live provision of the product and refuses above 200, the limit set from the timed cost of reconciling one holder, so a tier change's exclusive hold on the materialization lock stays near half a second. The add reconciles a holder faster than the removal, so at the limit it holds about 0.15 s; the removal sets the shared limit. The refusal names the count and the limit. When more than half of those organizations hold the product through their org type's default on a ladder, it adds that changing that default with Migrate moves them in the background. A default-sourced provision that a Keep removal left off every ladder counts toward the limit and not toward that sentence, because changing the default would not move it.
|
||||
- **A held rung.** After the tier insert and before any alignment, it reads the holders' pools it has locked. A pool that already holds a live position on the ladder refuses the add, naming the organization and the product it holds; so does a pool that holds the product in more than one live provision, such as an operator grant and a subscription, because an organization holds one position per ladder. The first such pool in ascending pool order is the one named.
|
||||
- **The record.** It writes one act (cause `tier_added`) naming the ladder and the product, and the alignments record the ladder's name in their reason. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; every other failure answers "Failed to add tier. Reference: {request id}.", and the log line carries the same request id and the step that failed.
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
||||
@@ -211,6 +213,74 @@ func TestTierAddRefusesAProductTheListLeftOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// staleSetBillingQ is the billing querier whose first product list puts the
|
||||
// named product's entitlement set back, as the add's list read saw it before
|
||||
// the set was cleared; later lists (the refusal's re-render) show the product
|
||||
// as the clear left it.
|
||||
type staleSetBillingQ struct {
|
||||
billing.Querier
|
||||
productID string
|
||||
setID string
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (s staleSetBillingQ) ListActiveProducts(ctx context.Context) ([]billing.Product, error) {
|
||||
products, err := s.Querier.ListActiveProducts(ctx)
|
||||
*s.calls++
|
||||
if *s.calls == 1 {
|
||||
for i := range products {
|
||||
if products[i].ProductID == s.productID {
|
||||
products[i].EntitlementSetID = uuid.NullUUID{UUID: uuid.MustParse(s.setID), Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
return products, err
|
||||
}
|
||||
|
||||
// formLevelText is the named form's form-level error slot, empty when absent.
|
||||
func formLevelText(t *testing.T, body, form string) string {
|
||||
t.Helper()
|
||||
slot := htmltest.Parse(t, body).All("#form-" + strings.ReplaceAll(form, ".", `\.`) + "-error")
|
||||
if len(slot) == 0 {
|
||||
return ""
|
||||
}
|
||||
return slot[0].Text()
|
||||
}
|
||||
|
||||
// A product whose set is cleared after the add read the product list and
|
||||
// before the insert passes that list check; the database refuses the tier
|
||||
// insert, and the refusal is the Product field's error. Nothing is written.
|
||||
// The world keeps another addable product (Extra), so the re-render shows the
|
||||
// form.
|
||||
func TestTierAddRefusesAProductWhoseSetWasClearedSinceThePicker(t *testing.T) {
|
||||
w, operator, main, _, _ := addTierWorld(t)
|
||||
bare := w.Product("Bare")
|
||||
w.WithoutSet(bare)
|
||||
h := newOtcHarnessWith(t, w.DB, operator.ID, otcOptions{
|
||||
billingQ: staleSetBillingQ{Querier: billing.New(w.DB), productID: bare.ID, setID: bare.SetID, calls: new(int)},
|
||||
})
|
||||
|
||||
status, body := h.addTierInRequest("ref-153", main.ID, bare.ID)
|
||||
|
||||
if status != http.StatusUnprocessableEntity {
|
||||
t.Errorf("the tier add answered %d, want 422", status)
|
||||
}
|
||||
const want = "Only products with an entitlement set can be tiers."
|
||||
errNode := htmltest.Parse(t, body).All(`#form-operator\.plan-ladder\.tier\.add-product_id-error`)
|
||||
if len(errNode) == 0 {
|
||||
t.Errorf("the Product field carries no error; the form-level slot reads %q", formLevelText(t, body, "operator.plan-ladder.tier.add"))
|
||||
} else if got := errNode[0].Text(); got != want {
|
||||
t.Errorf("the Product field's error = %q, want %q", got, want)
|
||||
}
|
||||
q := dbtest.On(t, w.DB)
|
||||
if n := q.Int(`SELECT count(*) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1 AND product_id = $2`, main.ID, bare.ID); n != 0 {
|
||||
t.Errorf("the refused add left %d tier rows of the product, want 0", n)
|
||||
}
|
||||
if n := q.Int(`SELECT count(*) FROM core.plan_act_details WHERE cause = 'tier_added' AND product_id = $1`, bare.ID); n != 0 {
|
||||
t.Errorf("the refused add left %d tier_added acts naming the product, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// wantInLine fails the test unless one line of logs holds every part: the
|
||||
// request's own access log carries the request id too, so the parts must meet
|
||||
// in the failure's line.
|
||||
|
||||
@@ -445,15 +445,16 @@ func (h *OperatorPartialsHandler) CreatePlanLadderTier(w http.ResponseWriter, r
|
||||
|
||||
// refuseTierAdd answers a tier add that did not commit. A refusal of the
|
||||
// add's own guards, or a database refusal the operator can act on (the
|
||||
// product already a tier, a rank collision with a concurrent add), goes under
|
||||
// the product field and the result is "". Any other failure is logged with
|
||||
// the step that failed, through the request's logger so the line carries the
|
||||
// request id, and the result is the sentence for the page's alert: a lock
|
||||
// wait or a deadlock reads as the contention refusal, the rest as the failure
|
||||
// sentence with that request id, so an operator who cannot read the logs can
|
||||
// hand it to the person who can. That includes a CF003 from the alignment,
|
||||
// which the add's collision read should have prevented: its driver text is in
|
||||
// the log line, not on the page.
|
||||
// product already a tier, a rank collision with a concurrent add, a product
|
||||
// whose entitlement set was cleared after the add read the product list),
|
||||
// goes under the product field and the result is "". Any other failure is
|
||||
// logged with the step that failed, through the request's logger so the line
|
||||
// carries the request id, and the result is the sentence for the page's
|
||||
// alert: a lock wait or a deadlock reads as the contention refusal, the rest
|
||||
// as the failure sentence with that request id, so an operator who cannot
|
||||
// read the logs can hand it to the person who can. That includes a CF003 from
|
||||
// the alignment, which the add's collision read should have prevented: its
|
||||
// driver text is in the log line, not on the page.
|
||||
func (h *OperatorPartialsHandler) refuseTierAdd(r *http.Request, ladderID, productID string, errs *forms.Errors, err error) string {
|
||||
var published *entitlements.ProductNotPublishedError
|
||||
var limit *entitlements.HolderLimitError
|
||||
@@ -481,6 +482,7 @@ func (h *OperatorPartialsHandler) refuseTierAdd(r *http.Request, ladderID, produ
|
||||
if fe, ok := web.FieldErrorsFromDB(failed.Err, web.ConstraintMessages{
|
||||
"plan_ladder_tiers_pkey": {Field: "product_id", Message: "This product is already a tier in this ladder."},
|
||||
"plan_ladder_tiers_plan_ladder_id_rank_key": {Field: "product_id", Message: entitlements.MessageInProgress},
|
||||
"chk_plan_ladder_tiers_product_has_set": {Field: "product_id", Message: "Only products with an entitlement set can be tiers."},
|
||||
}); ok {
|
||||
for field, msg := range fe {
|
||||
errs.Field(field, msg)
|
||||
|
||||
@@ -343,10 +343,12 @@ existing member between plans.
|
||||
are available to add. A tier needs a published product with an entitlement set
|
||||
that is not on this ladder yet."
|
||||
|
||||
#### Scenario: A product whose set was cleared after the panel opened is refused
|
||||
#### Scenario: A product whose set is cleared while the add runs is refused
|
||||
|
||||
- **WHEN** an operator submits a tier add of a product whose entitlement set was
|
||||
cleared after the panel listed it
|
||||
- **WHEN** an operator submits a tier add of a product that had an entitlement
|
||||
set when the add read the product list, and the set is cleared before the tier
|
||||
is inserted
|
||||
- **AND** another product remains that the ladder can add
|
||||
- **THEN** the answer SHALL be 422 with "Only products with an entitlement set can
|
||||
be tiers." under the product field
|
||||
- **AND** no tier row and no act record SHALL be written
|
||||
|
||||
@@ -90,11 +90,11 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
|
||||
|
||||
## 3. The tier add refuses a product with no set
|
||||
|
||||
- [ ] 3.1 `internal/server/operator_plan_ladders.go` (design D2, Copy R1): `refuseTierAdd`'s map at `StepCreateTier` (`:481-484`) gains `"chk_plan_ladder_tiers_product_has_set": {Field: "product_id", Message: "Only products with an entitlement set can be tiers."}`, and its doc comment names the refusal among the database refusals the operator can act on.
|
||||
- [ ] 3.2 Test. T4 `TestTierAddRefusesAProductWhoseSetWasClearedSinceThePicker` (`internal/server/operator_plan_ladder_tier_add_test.go`): `addTierWorld(t)` (`:132-144`); a product `Bare` made by `w.Product` and cleared with `w.WithoutSet`, which the trigger lets through because Bare is no tier; a harness `newOtcHarnessWith(t, w.DB, operator.ID, otcOptions{billingQ: staleSetBillingQ{…}})`, whose `ListActiveProducts` returns the real list with Bare's set put back, as the picker read it before the clear; `h.addTierInRequest("ref-153", main.ID, bare.ID)`. The answer is 422 (`the tier add answered %d, want 422`), the Product field's error (`#form-operator\.plan-ladder\.tier\.add-product_id-error`, read through `htmltest`) is R1 (`the Product field carries no error; the form-level slot reads %q` when it is absent, `the Product field's error = %q, want %q` when it differs), the ladder holds no tier of Bare (`the refused add left %d tier rows of the product, want 0`), and no `tier_added` act names it (`SELECT count(*) FROM core.plan_act_details WHERE cause = 'tier_added' AND product_id = $1`; `the refused add left %d tier_added acts naming the product, want 0`).
|
||||
- [ ] 3.3 `docs/plan-management.md`, "Attaching Tiers": the bullet **The product.** adds that the database refuses, at the insert, a product whose entitlement set was cleared after the list was shown, answering "Only products with an entitlement set can be tiers." under the Product field.
|
||||
- [ ] 3.4 `make screens` (it resets the app database; item 1.0 has run): no changed screen is expected beyond item 1.9's, because the demo ladder's Add tier panel offers no product, so no capture reaches the refusal; a changed screen is a stop to report, never a baseline this change accepts.
|
||||
- [ ] 3.5 Breaks (test records):
|
||||
- [x] 3.1 `internal/server/operator_plan_ladders.go` (design D2, Copy R1): `refuseTierAdd`'s map at `StepCreateTier` (`:481-484`) gains `"chk_plan_ladder_tiers_product_has_set": {Field: "product_id", Message: "Only products with an entitlement set can be tiers."}`, and its doc comment names the refusal among the database refusals the operator can act on.
|
||||
- [x] 3.2 Test. T4 `TestTierAddRefusesAProductWhoseSetWasClearedSinceThePicker` (`internal/server/operator_plan_ladder_tier_add_test.go`): `addTierWorld(t)` (`:132-144`); a product `Bare` made by `w.Product` and cleared with `w.WithoutSet`, which the trigger lets through because Bare is no tier; a harness `newOtcHarnessWith(t, w.DB, operator.ID, otcOptions{billingQ: staleSetBillingQ{…}})`, whose first `ListActiveProducts` call returns the real list with Bare's set put back, as the add's list read saw it before the clear, and whose later calls (the refusal's re-render) return the real list; `h.addTierInRequest("ref-153", main.ID, bare.ID)`. The answer is 422 (`the tier add answered %d, want 422`), the Product field's error (`#form-operator\.plan-ladder\.tier\.add-product_id-error`, read through `htmltest`) is R1 (`the Product field carries no error; the form-level slot reads %q` when it is absent, `the Product field's error = %q, want %q` when it differs), the ladder holds no tier of Bare (`the refused add left %d tier rows of the product, want 0`), and no `tier_added` act names it (`SELECT count(*) FROM core.plan_act_details WHERE cause = 'tier_added' AND product_id = $1`; `the refused add left %d tier_added acts naming the product, want 0`).
|
||||
- [x] 3.3 `docs/plan-management.md`, "Attaching Tiers": the bullet **The product.** adds that the database refuses, at the insert, a product whose entitlement set was cleared after the add checked it against the list, answering "Only products with an entitlement set can be tiers." under the Product field, and that a product whose set was cleared before the submission is refused as a choice the list did not offer, "Choose a product from the list."
|
||||
- [x] 3.4 `make screens` (it resets the app database; item 1.0 has run): no changed screen is expected beyond item 1.9's, because the demo ladder's Add tier panel offers no product, so no capture reaches the refusal; a changed screen is a stop to report, never a baseline this change accepts.
|
||||
- [x] 3.5 Breaks (test records):
|
||||
- the map entry removed (`"chk_plan_ladder_tiers_product_has_set": {Field: "product_id", Message: "Only products with an entitlement set can be tiers."},` deleted, so `web.FieldErrorsFromDB` answers its class message in the form-level slot, `internal/web/dberrors.go:51-52`): caught by T4, `want_new` `the Product field carries no error`;
|
||||
|
||||
## 4. The product edit does not offer "None" for a tier product
|
||||
@@ -155,7 +155,7 @@ Sections 1 to 4 are one commit each, and their items, item 1.0 aside, are that c
|
||||
>
|
||||
> The database now refuses the two writes that produced that state: making a product with no entitlement set a tier of any ladder, and clearing the entitlement set of a product that is a tier. Every default ladder starts on a tier, so no change to a ladder, a product or a default can leave one starting on a product with no set. This holds on ladders that no organization type uses as well, because a tier with no set can never be delivered: conferring it is refused for grants and for the default, and a subscription to it is skipped with a warning in the server log.
|
||||
>
|
||||
> On a ladder's page, Add tier no longer lists products with no entitlement set, and a product whose set was cleared after the list was shown is refused with "Only products with an entitlement set can be tiers." On a product's page, a product that is a tier no longer offers "None" for its entitlement set, and the field reads "This product is a tier of Hosting, and a tier needs an entitlement set."
|
||||
> On a ladder's page, Add tier no longer lists products with no entitlement set, refuses one submitted from a page shown before its set was cleared, and refuses with "Only products with an entitlement set can be tiers." a product whose set is cleared while the add runs. On a product's page, a product that is a tier no longer offers "None" for its entitlement set, and the field reads "This product is a tier of Hosting, and a tier needs an entitlement set."
|
||||
>
|
||||
> The same cause broke more than the revoke reported above. When the broken default was the personal type's, a new person's first sign-in failed with the "Sign-in failed" page, because sign-up confers that default and the whole sign-up is undone when that fails. A subscription's end was undone too, so a cancelled subscription's plan kept delivering, and every replay of the cancellation failed the same way. A grant's expiry, a tier removal that moves holders onto the default, and each organization's move after a default change (the comment above) or a reorder failed on it as well. None of these can now fail on this cause.
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user