Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
425 lines
18 KiB
Go
425 lines
18 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||
|
||
package server_test
|
||
|
||
// Handler tests for the two-step tier reorder (tier-reorder-preview-commit
|
||
// tasks 5.1–5.3): the drop-preview is read-only, cosmetic commits write ranks
|
||
// only, a rank-0 change on a default ladder gates on the bucket-2 disposition,
|
||
// grandfather/migrate enact like the org-type default-change commit, re-runs
|
||
// are idempotent, and a reorder commit racing an org-type default-change
|
||
// commit serializes on the pool lock. Reuses the otc* fixture and harness
|
||
// (same package, operator_org_type_change_test.go).
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||
)
|
||
|
||
// postLadder invokes a ladder-scoped handler with the harness's
|
||
// authenticated-operator context. Returns status, body, and the HX-Trigger
|
||
// header — mutation success copy travels as a toast trigger (ux-conventions
|
||
// §3), not in the body.
|
||
func (h *otcHarness) postLadder(handler http.HandlerFunc, ladderID string, form url.Values) (int, string, string) {
|
||
h.t.Helper()
|
||
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.SetPathValue("ladderID", ladderID)
|
||
rec := httptest.NewRecorder()
|
||
handler(rec, req)
|
||
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
|
||
}
|
||
|
||
// trAddTier appends a fresh published product as the ladder's bottom tier
|
||
// (rank self-assigned), returning the product ID.
|
||
func trAddTier(t *testing.T, database *sql.DB, ladderID, name string) string {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
||
if err != nil {
|
||
t.Fatalf("begin: %v", err)
|
||
}
|
||
defer tx.Rollback()
|
||
eq := entitlements.New(tx)
|
||
bq := billing.New(tx)
|
||
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: name + "-set", IsActive: true})
|
||
if err != nil {
|
||
t.Fatalf("set %s: %v", name, err)
|
||
}
|
||
prod, err := bq.CreateProduct(ctx, billing.CreateProductParams{
|
||
Name: name + "-prod", IsActive: true, IsPublic: false,
|
||
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(set.SetID), Valid: true},
|
||
LifecycleStatus: "published",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("product %s: %v", name, err)
|
||
}
|
||
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
|
||
PlanLadderID: ladderID, ProductID: prod.ProductID,
|
||
}); err != nil {
|
||
t.Fatalf("tier %s: %v", name, err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
t.Fatalf("commit tier: %v", err)
|
||
}
|
||
return prod.ProductID
|
||
}
|
||
|
||
func trRank(t *testing.T, database *sql.DB, ladderID, productID string) int {
|
||
t.Helper()
|
||
return otcScalar(t, database, `SELECT rank FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1 AND product_id = $2`, ladderID, productID)
|
||
}
|
||
|
||
// The drop-preview classifies the population against the pending rank-0
|
||
// product, renders the pending panel with disposition controls only when an
|
||
// outgoing-default bucket is non-empty, and writes nothing.
|
||
func TestTierReorder_PreviewIsReadOnlyAndClassifies(t *testing.T) {
|
||
database := testDB(t)
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
sfx := uuid.New().String()[:8]
|
||
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+sfx)
|
||
_, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool) // type default = ladder A; holder at prodA
|
||
|
||
// design D8 (round 2): the tiers table renders ranks ascending (rank 0
|
||
// first), so the sortable tbody's DOM order — what a real drop submits
|
||
// via hx-include="this" — already arrives canonical (rank-0-first); the
|
||
// handler no longer reverses it. These tests submit the DOM order
|
||
// directly, so the list order here is the intended new rank order
|
||
// (prodC as the new rank 0 goes first).
|
||
before := otcCounts(t, database, f)
|
||
code, body, _ := h.postLadder(h.handler.PreviewPlanLadderTiersReorder, f.ladderA,
|
||
url.Values{"product": {prodC, f.prodA}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("preview status=%d body=%s", code, body)
|
||
}
|
||
for _, want := range []string{
|
||
"Preview: nothing saved yet",
|
||
"Rank 0 changes from",
|
||
"TR Holder", // outgoing-default orgs are named
|
||
`name="bucket2_disposition"`,
|
||
"Apply change",
|
||
"Discard",
|
||
} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("preview missing %q", want)
|
||
}
|
||
}
|
||
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
|
||
t.Errorf("prodA rank = %d after preview, want 0 (read-only)", r)
|
||
}
|
||
if r := trRank(t, database, f.ladderA, prodC); r != 1 {
|
||
t.Errorf("prodC rank = %d after preview, want 1 (read-only)", r)
|
||
}
|
||
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("rank-0-unchanged order is display-order-only", func(t *testing.T) {
|
||
code, body, _ := h.postLadder(h.handler.PreviewPlanLadderTiersReorder, f.ladderA,
|
||
url.Values{"product": {f.prodA, prodC}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("preview status=%d", code)
|
||
}
|
||
if !strings.Contains(body, "Display order only") {
|
||
t.Errorf("cosmetic preview missing display-order-only copy")
|
||
}
|
||
if strings.Contains(body, `name="bucket2_disposition"`) {
|
||
t.Errorf("cosmetic preview rendered disposition radios")
|
||
}
|
||
})
|
||
|
||
t.Run("non-default ladder rank-0 change is display-order-only", func(t *testing.T) {
|
||
prodD := trAddTier(t, database, f.ladderB, "tr-d-"+sfx)
|
||
code, body, _ := h.postLadder(h.handler.PreviewPlanLadderTiersReorder, f.ladderB,
|
||
url.Values{"product": {prodD, f.prodB}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("preview status=%d", code)
|
||
}
|
||
if !strings.Contains(body, "not any org type's default") {
|
||
t.Errorf("non-default preview missing not-a-default copy")
|
||
}
|
||
if strings.Contains(body, `name="bucket2_disposition"`) {
|
||
t.Errorf("non-default preview rendered disposition radios")
|
||
}
|
||
})
|
||
}
|
||
|
||
// A commit whose pending rank 0 displaces default-sourced incumbents is
|
||
// rejected without a disposition — nothing written, not even ranks.
|
||
func TestTierReorder_CommitRequiresDisposition(t *testing.T) {
|
||
database := testDB(t)
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+uuid.New().String()[:8])
|
||
_, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool)
|
||
|
||
before := otcCounts(t, database, f)
|
||
code, body, _ := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}})
|
||
// design D9: a refusal answers 422, the same render in submission mode.
|
||
if code != http.StatusUnprocessableEntity {
|
||
t.Fatalf("commit status=%d, want 422", code)
|
||
}
|
||
if !strings.Contains(body, "Choose whether to grandfather or migrate") {
|
||
t.Errorf("gate error not rendered; body=%.400s", body)
|
||
}
|
||
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
|
||
t.Errorf("prodA rank = %d after rejected commit, want 0 (ranks untouched)", r)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// A commit that keeps rank 0 writes ranks only — no dispositions required or
|
||
// enacted, even with default-sourced incumbents present.
|
||
func TestTierReorder_CosmeticCommitWritesRanksOnly(t *testing.T) {
|
||
database := testDB(t)
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
sfx := uuid.New().String()[:8]
|
||
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+sfx)
|
||
prodD := trAddTier(t, database, f.ladderA, "tr-d-"+sfx)
|
||
_, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool)
|
||
|
||
before := otcCounts(t, database, f)
|
||
code, body, trigger := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||
url.Values{"order": {strings.Join([]string{f.prodA, prodD, prodC}, ",")}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("commit status=%d body=%.300s", code, body)
|
||
}
|
||
if !strings.Contains(trigger, "Tier order updated.") {
|
||
t.Errorf("cosmetic commit missing plain success toast; trigger=%q", trigger)
|
||
}
|
||
if r := trRank(t, database, f.ladderA, prodD); r != 1 {
|
||
t.Errorf("prodD rank = %d, want 1", r)
|
||
}
|
||
if r := trRank(t, database, f.ladderA, prodC); r != 2 {
|
||
t.Errorf("prodC rank = %d, want 2", r)
|
||
}
|
||
if after := otcCounts(t, database, f); before["grants"] != after["grants"] || before["provisions"] != after["provisions"] ||
|
||
before["junctions"] != after["junctions"] || before["transitions"] != after["transitions"] {
|
||
t.Errorf("cosmetic commit wrote conferral rows: before=%v after=%v", before, after)
|
||
}
|
||
}
|
||
|
||
// Grandfather enactment through the reorder commit: ranks renumber, the
|
||
// incumbent is reissued as an operator-attributed legacy grant with a transfer
|
||
// supersession (incumbent grant row untouched), plan-less pools initiate onto
|
||
// the new rank 0, org_types is never written, and re-running the same order is
|
||
// a no-op.
|
||
func TestTierReorder_GrandfatherCommitAndRerun(t *testing.T) {
|
||
database := testDB(t)
|
||
ctx := context.Background()
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+uuid.New().String()[:8])
|
||
_, planlessPool := otcOrg(t, database, f, "TR Planless", true)
|
||
holderOrg, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool)
|
||
|
||
var incumbentGrant string
|
||
if err := database.QueryRowContext(ctx,
|
||
`SELECT grant_id FROM core.grants WHERE granted_to_org_id = $1 AND grant_reason = 'default'`, holderOrg,
|
||
).Scan(&incumbentGrant); err != nil {
|
||
t.Fatalf("resolve incumbent: %v", err)
|
||
}
|
||
|
||
code, body, trigger := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}, "needs_disposition": {"true"}, "bucket2_disposition": {"grandfather"}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("commit status=%d body=%.300s", code, body)
|
||
}
|
||
if !strings.Contains(trigger, "is now the default plan for new") {
|
||
t.Errorf("commit toast missing the default-change headline; trigger=%q", trigger)
|
||
}
|
||
|
||
if r := trRank(t, database, f.ladderA, prodC); r != 0 {
|
||
t.Fatalf("prodC rank = %d, want 0", r)
|
||
}
|
||
// The reorder changes the effective default without touching org_types.
|
||
if got := otcDefaultLadder(t, database, f.orgType); got != f.ladderA {
|
||
t.Errorf("org_types default ladder changed to %q; a reorder must never write it", got)
|
||
}
|
||
// Legacy grant: operator-attributed, lineaged to the incumbent; transfer
|
||
// recorded; incumbent grant row untouched; holder still delivers prodA.
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.grants
|
||
WHERE granted_to_org_id = $1 AND grant_reason = 'legacy'
|
||
AND granted_by_person_id = $2 AND extends_grant_id = $3`,
|
||
holderOrg, f.operatorID, incumbentGrant); n != 1 {
|
||
t.Errorf("legacy grants = %d, want 1", n)
|
||
}
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.pool_provision_transitions
|
||
WHERE pool_id = $1 AND transition_type = 'transfer'`, holderPool); n != 1 {
|
||
t.Errorf("transfer transitions = %d, want 1", n)
|
||
}
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.grants
|
||
WHERE grant_id = $1 AND status = 'active' AND revoked_at IS NULL`, incumbentGrant); n != 1 {
|
||
t.Errorf("incumbent default grant was mutated")
|
||
}
|
||
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'`, holderPool, f.prodA); n != 1 {
|
||
t.Errorf("holder prodA attachments = %d, want 1 (grandfathered in place)", n)
|
||
}
|
||
// The plan-less pool was initiated onto the new rank 0.
|
||
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'`, planlessPool, prodC); n != 1 {
|
||
t.Errorf("planless pool prodC attachments = %d, want 1 (initiated onto new rank 0)", n)
|
||
}
|
||
|
||
// Idempotent re-run: the same order finds rank 0 unchanged — no enactment,
|
||
// zero new conferral rows.
|
||
before := otcCounts(t, database, f)
|
||
code, _, _ = h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}, "needs_disposition": {"true"}, "bucket2_disposition": {"grandfather"}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("re-run status=%d", code)
|
||
}
|
||
if after := otcCounts(t, database, f); before["grants"] != after["grants"] || before["provisions"] != after["provisions"] ||
|
||
before["junctions"] != after["junctions"] || before["transitions"] != after["transitions"] {
|
||
t.Errorf("re-run wrote rows: before=%v after=%v", before, after)
|
||
}
|
||
}
|
||
|
||
// Migrate enactment through the reorder commit: the incumbent default-source
|
||
// position ends and the floor-guarded restoration confers the new rank 0.
|
||
func TestTierReorder_MigrateCommit(t *testing.T) {
|
||
database := testDB(t)
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+uuid.New().String()[:8])
|
||
holderOrg, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool)
|
||
|
||
code, body, _ := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}, "needs_disposition": {"true"}, "bucket2_disposition": {"migrate"}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("commit status=%d body=%.300s", code, body)
|
||
}
|
||
|
||
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'`, holderPool, f.prodA); n != 0 {
|
||
t.Errorf("prodA attachments = %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'`, holderPool, prodC); n != 1 {
|
||
t.Errorf("prodC attachments = %d, want 1 (migrated onto new rank 0)", n)
|
||
}
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.pool_provision_transitions
|
||
WHERE pool_id = $1 AND transition_type = 'end'`, holderPool); n < 1 {
|
||
t.Errorf("no end transition recorded")
|
||
}
|
||
// Outgoing decree kept as ledger history + the fresh restoration decree.
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.grants
|
||
WHERE granted_to_org_id = $1 AND grant_reason = 'default' AND status = 'active' AND revoked_at IS NULL`,
|
||
holderOrg); n != 2 {
|
||
t.Errorf("default decrees = %d, want 2", n)
|
||
}
|
||
}
|
||
|
||
// Adding a provision-bearing product as a tier of another ladder runs the
|
||
// induction backfill with a fully-attributed operator actor. Regression: the
|
||
// handler passed ActorType "operator" with no ActorID, violating
|
||
// chk_pool_provision_transitions_operator_actor and rolling back every such
|
||
// add (found 2026-07-12 by the tier-reorder walkthrough).
|
||
func TestAddTierInductionAlignsLiveProvisions(t *testing.T) {
|
||
database := testDB(t)
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
_, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool) // prodA live on ladder A
|
||
|
||
code, body, _ := h.postLadder(h.handler.CreatePlanLadderTier, f.ladderB,
|
||
url.Values{"product_id": {f.prodA}})
|
||
if code != http.StatusOK {
|
||
t.Fatalf("add-tier status=%d", code)
|
||
}
|
||
if strings.Contains(body, "Failed to add tier") {
|
||
t.Fatalf("add-tier failed: %.300s", body)
|
||
}
|
||
// prodA is now a tier of ladder B, and the live provision gained the rung.
|
||
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 l.plan_ladder_id = $2 AND p.product_id = $3
|
||
AND l.status IN ('active','suspended')`,
|
||
holderPool, f.ladderB, f.prodA); n != 1 {
|
||
t.Errorf("induction junction on ladder B = %d, want 1", n)
|
||
}
|
||
}
|
||
|
||
// A tier-reorder commit racing an org-type default-change commit on the same
|
||
// pool serializes on the per-pool FOR UPDATE lock: whichever enacts first
|
||
// grandfathers the incumbent, the other re-derives the bucket in-tx and finds
|
||
// a legacy-held (other-source) pool — never a second grandfather.
|
||
func TestTierReorder_CommitRacesDefaultChangeCommit(t *testing.T) {
|
||
database := testDB(t)
|
||
f := newOtcFixture(t, database)
|
||
h := newOtcHarness(t, database, f.operatorID)
|
||
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+uuid.New().String()[:8])
|
||
_, holderPool := otcOrg(t, database, f, "TR Holder", true)
|
||
otcConferDefaultA(t, database, f, holderPool)
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(2)
|
||
go func() {
|
||
defer wg.Done()
|
||
h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}, "needs_disposition": {"true"}, "bucket2_disposition": {"grandfather"}})
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
h.post(h.handler.CommitOrgTypeDefaultChange, f.orgType,
|
||
url.Values{"default_plan_ladder_id": {f.ladderB}, "bucket2_disposition": {"grandfather"}})
|
||
}()
|
||
wg.Wait()
|
||
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.grants g
|
||
JOIN core.organizations o ON o.org_id = g.granted_to_org_id
|
||
WHERE o.org_type = $1 AND g.grant_reason = 'legacy'`, f.orgType); n != 1 {
|
||
t.Errorf("legacy grants = %d, want exactly 1 (one winner)", n)
|
||
}
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.pool_provision_transitions
|
||
WHERE pool_id = $1 AND transition_type = 'transfer'`, holderPool); n != 1 {
|
||
t.Errorf("transfer transitions = %d, want exactly 1", n)
|
||
}
|
||
if n := otcScalar(t, database, `
|
||
SELECT count(*) FROM core.pool_provision_ladders
|
||
WHERE pool_id = $1 AND status IN ('active','suspended')`, holderPool); n != 1 {
|
||
t.Errorf("live attachments = %d, want exactly 1", n)
|
||
}
|
||
}
|