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.
340 lines
14 KiB
Go
340 lines
14 KiB
Go
package server_test
|
||
|
||
// Handler tests for tier removal with live holders (tier-removal-preview-commit
|
||
// tasks 5.1–5.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")
|
||
}
|
||
}
|