Files
member-console/internal/server/operator_tier_removal_test.go
T
cgalo5758 16a15560c8 Add reconciled tier removal flow
Preview affected orgs by position source and require keep or migrate for
default-sourced positions. Commit deletion, renumbering, and holder
reconciliation atomically while preserving other-source delivery.
2026-07-12 21:09:43 -05:00

340 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server_test
// Handler tests for tier removal with live holders (tier-removal-preview-commit
// tasks 5.15.3): the preview is read-only and classifies holders by position
// source, the commit gates on the default-sourced disposition (nothing written,
// tier intact), other-source holders align-shrink (position ends, delivery
// continues), migrate ends the default-sourced position and floor-restores onto
// the promoted rank 0, keep documents the chosen floor-union semantics, and the
// zero-holder modal delete path is unchanged. Reuses the otc*/tr* fixtures
// (same package).
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// reqLadderProduct invokes a handler scoped to {ladderID}/{productID} with the
// harness's authenticated-operator context. Returns status, body, HX-Trigger.
func (h *otcHarness) reqLadderProduct(method string, handler http.HandlerFunc, ladderID, productID string, form url.Values) (int, string, string) {
h.t.Helper()
req := httptest.NewRequestWithContext(h.ctx, method, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("ladderID", ladderID)
req.SetPathValue("productID", productID)
rec := httptest.NewRecorder()
handler(rec, req)
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
}
// trConferManual puts a manual-grant (other-source) position of productID on
// the org's pool via the conferral primitive.
func trConferManual(t *testing.T, database *sql.DB, f otcFixture, orgID, productID string) {
t.Helper()
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
res, err := entitlements.ConferGrantTx(ctx, tx, entitlements.ConferGrantInput{
ProductID: productID,
OrgID: orgID,
GrantedByPersonID: f.operatorID,
GrantReason: "manual",
Description: "tier-removal test manual confer",
Quantity: 1,
ActorType: "operator",
ActorID: uuid.NullUUID{UUID: uuid.MustParse(f.operatorID), Valid: true},
TransitionReason: "tier-removal test manual confer",
})
if err != nil || res.Outcome != "created" {
t.Fatalf("manual confer: res=%+v err=%v", res, err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit manual confer: %v", err)
}
}
func trTierExists(t *testing.T, database *sql.DB, ladderID, productID string) bool {
t.Helper()
return otcScalar(t, database, `SELECT count(*) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1 AND product_id = $2`, ladderID, productID) == 1
}
// The removal preview classifies holders by position source, offers the
// disposition only for default-sourced holders, and writes nothing.
func TestTierRemoval_PreviewIsReadOnlyAndClassifies(t *testing.T) {
database := testDB(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "trm-c-"+uuid.New().String()[:8])
daveOrg, davePool := otcOrg(t, database, f, "TRM Dave", true)
otcConferDefaultA(t, database, f, davePool) // default-sourced prodA at rank 0
bobOrg, _ := otcOrg(t, database, f, "TRM Bob", true)
trConferManual(t, database, f, bobOrg, prodC) // other-source holder of prodC
_ = daveOrg
before := otcCounts(t, database, f)
code, body, _ := h.reqLadderProduct(http.MethodPost, h.handler.PreviewPlanLadderTierRemoval, f.ladderA, f.prodA, nil)
if code != http.StatusOK {
t.Fatalf("preview status=%d", code)
}
for _, want := range []string{
"nothing removed yet",
"TRM Dave", // default-sourced holders are named
`name="default_disposition"`,
"becomes the rank-0 tier", // removing rank 0 promotes the successor
"Commit removal",
"Discard",
} {
if !strings.Contains(body, want) {
t.Errorf("preview missing %q", want)
}
}
if !trTierExists(t, database, f.ladderA, f.prodA) {
t.Errorf("preview deleted the tier")
}
if after := otcCounts(t, database, f); before["grants"] != after["grants"] || before["provisions"] != after["provisions"] ||
before["junctions"] != after["junctions"] || before["transitions"] != after["transitions"] {
t.Errorf("preview wrote rows: before=%v after=%v", before, after)
}
t.Run("other-source-only tier needs no disposition", func(t *testing.T) {
code, body, _ := h.reqLadderProduct(http.MethodPost, h.handler.PreviewPlanLadderTierRemoval, f.ladderA, prodC, nil)
if code != http.StatusOK {
t.Fatalf("preview status=%d", code)
}
if !strings.Contains(body, "TRM Bob") || !strings.Contains(body, "manual grant") {
t.Errorf("other-source holder not named with its source")
}
if !strings.Contains(body, "delivery continues") {
t.Errorf("preview missing the delivery-continues copy")
}
if strings.Contains(body, `name="default_disposition"`) {
t.Errorf("other-source-only preview rendered disposition radios")
}
})
}
// A commit with default-sourced holders and no disposition is rejected —
// nothing written, tier intact.
func TestTierRemoval_CommitRequiresDisposition(t *testing.T) {
database := testDB(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
_ = trAddTier(t, database, f.ladderA, "trm-c-"+uuid.New().String()[:8])
_, davePool := otcOrg(t, database, f, "TRM Dave", true)
otcConferDefaultA(t, database, f, davePool)
before := otcCounts(t, database, f)
code, body, _ := h.reqLadderProduct(http.MethodPost, h.handler.CommitPlanLadderTierRemoval, f.ladderA, f.prodA, nil)
if code != http.StatusOK {
t.Fatalf("commit status=%d", code)
}
if !strings.Contains(body, "Choose whether to keep or migrate") {
t.Errorf("gate error not rendered; body=%.400s", body)
}
if !trTierExists(t, database, f.ladderA, f.prodA) {
t.Errorf("rejected commit deleted the tier")
}
if after := otcCounts(t, database, f); before["grants"] != after["grants"] || before["provisions"] != after["provisions"] ||
before["junctions"] != after["junctions"] || before["transitions"] != after["transitions"] {
t.Errorf("rejected commit wrote rows: before=%v after=%v", before, after)
}
}
// Committing the removal of a tier held by another source ends the position on
// this ladder while delivery continues — no commercial rows created or ended.
func TestTierRemoval_OtherSourceAlignShrink(t *testing.T) {
database := testDB(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "trm-c-"+uuid.New().String()[:8])
bobOrg, bobPool := otcOrg(t, database, f, "TRM Bob", true)
trConferManual(t, database, f, bobOrg, prodC)
grantsBefore := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, bobOrg)
code, body, trigger := h.reqLadderProduct(http.MethodPost, h.handler.CommitPlanLadderTierRemoval, f.ladderA, prodC, nil)
if code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", code, body)
}
if !strings.Contains(trigger, "delivery continues") {
t.Errorf("commit toast missing delivery-continues copy; trigger=%q", trigger)
}
if trTierExists(t, database, f.ladderA, prodC) {
t.Fatalf("tier row still present after commit")
}
// Position on this ladder ended; the provision keeps delivering.
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provision_ladders
WHERE pool_id = $1 AND plan_ladder_id = $2 AND status IN ('active','suspended')`, bobPool, f.ladderA); n != 0 {
t.Errorf("live junctions on removed ladder = %d, want 0", n)
}
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provisions
WHERE pool_id = $1 AND product_id = $2 AND status = 'active'`, bobPool, prodC); n != 1 {
t.Errorf("delivering provisions = %d, want 1 (delivery continues)", n)
}
if n := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1`, bobOrg); n != grantsBefore {
t.Errorf("grants changed: %d → %d (no commercial event expected)", grantsBefore, n)
}
}
// Migrate ends the default-sourced position and floor-restores in the same
// commit — resolved after renumbering, so removing the default ladder's rank 0
// migrates the holder onto the promoted tier.
func TestTierRemoval_MigrateOntoPromotedTier(t *testing.T) {
database := testDB(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "trm-c-"+uuid.New().String()[:8])
daveOrg, davePool := otcOrg(t, database, f, "TRM Dave", true)
otcConferDefaultA(t, database, f, davePool)
code, body, _ := h.reqLadderProduct(http.MethodPost, h.handler.CommitPlanLadderTierRemoval, f.ladderA, f.prodA,
url.Values{"default_disposition": {"migrate"}})
if code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", code, body)
}
if trTierExists(t, database, f.ladderA, f.prodA) {
t.Fatalf("tier row still present after commit")
}
if n := otcScalar(t, database, `SELECT rank FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1 AND product_id = $2`, f.ladderA, prodC); n != 0 {
t.Errorf("promoted tier rank = %d, want 0", n)
}
// Old default position ended; the restoration conferred the PROMOTED tier.
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provisions
WHERE pool_id = $1 AND product_id = $2 AND status = 'active'`, davePool, f.prodA); n != 0 {
t.Errorf("removed-product provisions = %d, want 0 (migrated off)", n)
}
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
WHERE l.pool_id = $1 AND p.product_id = $2 AND l.status = 'active'`, davePool, prodC); n != 1 {
t.Errorf("promoted-tier attachments = %d, want 1 (migrated on)", n)
}
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provision_transitions
WHERE pool_id = $1 AND transition_type = 'end'`, davePool); n < 1 {
t.Errorf("no end transition recorded")
}
// The floor is satisfied afterwards: a further restoration is a no-op.
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
res, err := entitlements.ReapplyDefaultsIfVacant(ctx, tx, davePool, entitlements.Actor{ActorType: "system", Reason: "trm test"})
if err != nil {
t.Fatalf("reapply: %v", err)
}
if res.Outcome == "created" {
t.Errorf("post-migrate restoration conferred again — pool should not be vacant")
}
_ = daveOrg
}
// Keep align-shrinks the default-sourced position: delivery continues
// off-ladder, and — the documented union semantics the preview warns about —
// a later floor restoration confers the current default alongside it.
func TestTierRemoval_KeepThenFloorUnion(t *testing.T) {
database := testDB(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "trm-c-"+uuid.New().String()[:8])
_, davePool := otcOrg(t, database, f, "TRM Dave", true)
otcConferDefaultA(t, database, f, davePool)
code, body, _ := h.reqLadderProduct(http.MethodPost, h.handler.CommitPlanLadderTierRemoval, f.ladderA, f.prodA,
url.Values{"default_disposition": {"keep"}})
if code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", code, body)
}
// Position ended, delivery continues, nothing else conferred yet.
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provision_ladders
WHERE pool_id = $1 AND status IN ('active','suspended')`, davePool); n != 0 {
t.Errorf("live junctions = %d, want 0 after keep", n)
}
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provisions
WHERE pool_id = $1 AND product_id = $2 AND status = 'active'`, davePool, f.prodA); n != 1 {
t.Errorf("kept provisions = %d, want 1 (delivery continues)", n)
}
// The next restoration event floors the pool onto the current default
// (prodC, promoted to rank 0) alongside the kept delivery — the union the
// preview's keep copy warns the operator about.
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
res, err := entitlements.ReapplyDefaultsIfVacant(ctx, tx, davePool, entitlements.Actor{ActorType: "system", Reason: "trm test"})
if err != nil || res.Outcome != "created" {
t.Fatalf("floor restoration after keep: res=%+v err=%v", res, err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit restoration: %v", err)
}
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provision_ladders l
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
WHERE l.pool_id = $1 AND p.product_id = $2 AND l.status = 'active'`, davePool, prodC); n != 1 {
t.Errorf("floored default attachments = %d, want 1", n)
}
if n := otcScalar(t, database, `
SELECT count(*) FROM core.pool_provisions
WHERE pool_id = $1 AND status = 'active'`, davePool); n != 2 {
t.Errorf("active provisions = %d, want 2 (kept product + floored default union)", n)
}
}
// The zero-holder delete path is unchanged, and a stale DELETE against a
// holder-bearing tier redirects to the preview flow without writing.
func TestTierRemoval_ZeroHolderDeleteUnchanged(t *testing.T) {
database := testDB(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "trm-c-"+uuid.New().String()[:8])
_, davePool := otcOrg(t, database, f, "TRM Dave", true)
otcConferDefaultA(t, database, f, davePool) // holder on prodA only
// Holder-less tier: plain delete works.
code, _, trigger := h.reqLadderProduct(http.MethodDelete, h.handler.DeletePlanLadderTier, f.ladderA, prodC, nil)
if code != http.StatusOK {
t.Fatalf("delete status=%d", code)
}
if !strings.Contains(trigger, "Tier removed") {
t.Errorf("holder-less delete toast = %q", trigger)
}
if trTierExists(t, database, f.ladderA, prodC) {
t.Errorf("holder-less tier not deleted")
}
// Holder-bearing tier: DELETE is a stale-page defense, not a block bypass.
code, body, _ := h.reqLadderProduct(http.MethodDelete, h.handler.DeletePlanLadderTier, f.ladderA, f.prodA, nil)
if code != http.StatusOK {
t.Fatalf("delete status=%d", code)
}
if !strings.Contains(body, "live holder") {
t.Errorf("stale DELETE missing redirect-to-preview copy; body=%.300s", body)
}
if !trTierExists(t, database, f.ladderA, f.prodA) {
t.Errorf("stale DELETE removed a holder-bearing tier")
}
}