Decide an outgoing default and a pool's class by one rule in SQL
A default change or a tier reorder asks the operator to grandfather or migrate the organizations whose default plan it leaves behind. The preview read every live position of the type and classified each pool in Go, and the commit decided from that preview, so both grew with the type: on a personal type, one organization per member, carried into Go on every change of the disposition radios (#187). Migration 00026 states the rule once: - Each position row carries two copies: its organization's type, and whether a default grant sources it. Triggers fill them on insert and when the row moves to another pool or provision; existing rows are backfilled. - core.plan_position_is_outgoing says whether a position is outgoing for a target, and core.plan_pool_bucket maps what a pool's live positions show into one of five classes. Both are IMMUTABLE SQL that PostgreSQL inlines into the statement calling them. - core.outgoing_default_exists reads, for each type, the lowest and the highest product among its live default-sourced positions through a partial index: two index probes whatever the type's size. - Guard triggers refuse an update that changes an organization's type, a pool's organization, a provision's grant or pool, or a grant's reason, the facts the copies rest on. The preview reads each class's exact count and at most 50 names per class in one statement, plus the sources of the listed other-source pools in a second. ClassifyPoolAttachments is gone, and the Go class constants take the SQL function's five values. Every count shown reads the class's count, never a list's length; a shortened list ends with "And N more." under a "show 50 of N" disclosure. The reorder's preview gains the named other-source list the default change's has. The commit handlers decide that a disposition is required with the outgoing-default check and build the preview only for the 422 answer. When the check is true and the preview finds no outgoing organization, they log an Error with the types and the target and answer the form's generic failure. No golden output moves: the changed and new queries are reads, which the plan-act goldens leave out, and every fixture's lists are shorter than 50. The full suite passes.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
-- +goose Up
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- One rule for an outgoing default and for a pool's class. The default
|
||||
-- change's preview, its disposition gate and each move decide whether a
|
||||
-- default-sourced position is outgoing, and which class a pool falls in, from
|
||||
-- the two functions below, so the three cannot disagree. The gate answers in
|
||||
-- a time that does not grow with the org type, because it reads two entries
|
||||
-- of a partial index per type; the index needs the position to carry the
|
||||
-- type of its pool's organization and whether its grant is a default grant,
|
||||
-- so both are copied onto the position row by trigger.
|
||||
--
|
||||
-- The copies rest on four facts: a pool's organization, an organization's
|
||||
-- type, a provision's grant and a grant's reason. No runtime statement
|
||||
-- changes any of them, and the five guard triggers at the end refuse any
|
||||
-- update that does. A feature that must change one decides what the copies do
|
||||
-- and replaces the guard in its own migration.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Nullable until the backfill below has filled the existing rows.
|
||||
ALTER TABLE core.pool_provision_ladders
|
||||
ADD COLUMN org_type VARCHAR(20),
|
||||
ADD COLUMN default_sourced BOOLEAN;
|
||||
|
||||
-- Whether the grant is a default grant: the reason the org type's default
|
||||
-- plan is conferred under. False for a provision with no grant (a
|
||||
-- subscription's or a purchase's) and for a grant of any other reason.
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.provision_is_default_sourced(p_grant_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SET search_path = core, pg_temp
|
||||
AS $$
|
||||
SELECT COALESCE((SELECT g.grant_reason = 'default' FROM core.grants g WHERE g.grant_id = p_grant_id), false);
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- Fills both copies from the position's pool and provision. The update
|
||||
-- trigger fires only when the position moves to another pool or provision:
|
||||
-- core.sync_pool_provision_ladders assigns pool_id on every status,
|
||||
-- activation or end change of a provision, and the guard triggers below keep
|
||||
-- any such assignment from changing the copies.
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.copy_position_sources()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = core, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.org_type := (SELECT o.org_type
|
||||
FROM core.resource_pools rp
|
||||
JOIN core.organizations o ON o.org_id = rp.org_id
|
||||
WHERE rp.pool_id = NEW.pool_id);
|
||||
NEW.default_sourced := core.provision_is_default_sourced(
|
||||
(SELECT pp.grant_id FROM core.pool_provisions pp WHERE pp.provision_id = NEW.provision_id));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER trigger_pool_provision_ladders_copy_sources_insert
|
||||
BEFORE INSERT ON core.pool_provision_ladders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION core.copy_position_sources();
|
||||
|
||||
CREATE TRIGGER trigger_pool_provision_ladders_copy_sources_update
|
||||
BEFORE UPDATE OF pool_id, provision_id ON core.pool_provision_ladders
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.pool_id IS DISTINCT FROM NEW.pool_id OR OLD.provision_id IS DISTINCT FROM NEW.provision_id)
|
||||
EXECUTE FUNCTION core.copy_position_sources();
|
||||
|
||||
-- The rows written before this migration. updated_at keeps its value: the
|
||||
-- backfill describes the rows, it does not change them.
|
||||
ALTER TABLE core.pool_provision_ladders DISABLE TRIGGER trigger_pool_provision_ladders_updated_at;
|
||||
UPDATE core.pool_provision_ladders l
|
||||
SET org_type = o.org_type,
|
||||
default_sourced = core.provision_is_default_sourced(pp.grant_id)
|
||||
FROM core.resource_pools rp
|
||||
JOIN core.organizations o ON o.org_id = rp.org_id,
|
||||
core.pool_provisions pp
|
||||
WHERE rp.pool_id = l.pool_id
|
||||
AND pp.provision_id = l.provision_id;
|
||||
ALTER TABLE core.pool_provision_ladders ENABLE TRIGGER trigger_pool_provision_ladders_updated_at;
|
||||
|
||||
ALTER TABLE core.pool_provision_ladders
|
||||
ALTER COLUMN org_type SET NOT NULL,
|
||||
ALTER COLUMN default_sourced SET NOT NULL;
|
||||
|
||||
-- The gate's index: the live default-sourced positions of a type, ordered by
|
||||
-- product, so the lowest and the highest product of a type are two entries.
|
||||
CREATE INDEX idx_pool_provision_ladders_live_default
|
||||
ON core.pool_provision_ladders (org_type, product_id)
|
||||
WHERE default_sourced AND status IN ('active', 'suspended');
|
||||
|
||||
-- The only statement of "outgoing" in the code: a default-sourced position
|
||||
-- is outgoing when the target is None (NULL) or a different product.
|
||||
-- IMMUTABLE SQL with no SET clause, as core.transition_change_kind is, so
|
||||
-- PostgreSQL inlines it into the statement that calls it.
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.plan_position_is_outgoing(p_default_sourced BOOLEAN, p_product_id UUID, p_target UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT p_default_sourced AND (p_target IS NULL OR p_product_id <> p_target);
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- The class of one pool, from what its live positions show: whether the
|
||||
-- organization has an active default pool, whether any position is outgoing,
|
||||
-- whether any is default-sourced and whether any exists. Inlined as above.
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.plan_pool_bucket(p_has_pool BOOLEAN, p_any_outgoing BOOLEAN, p_any_default_sourced BOOLEAN, p_any_position BOOLEAN)
|
||||
RETURNS VARCHAR
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN NOT p_has_pool THEN 'would_fail'
|
||||
WHEN p_any_outgoing THEN 'outgoing_default'
|
||||
WHEN p_any_default_sourced THEN 'already_at_default'
|
||||
WHEN p_any_position THEN 'other_source'
|
||||
ELSE 'would_initiate'
|
||||
END::VARCHAR;
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- Whether any organization of the given types holds a live default-sourced
|
||||
-- position the rule marks outgoing. Per type it reads the lowest and the
|
||||
-- highest product through the partial index: when every product equals the
|
||||
-- target both equal it and neither is outgoing, and when any differs one
|
||||
-- extreme does; with a None target any position is outgoing.
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.outgoing_default_exists(p_org_types VARCHAR[], p_target UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(p_org_types) AS t(org_type)
|
||||
CROSS JOIN LATERAL (
|
||||
(SELECT l.product_id FROM core.pool_provision_ladders l
|
||||
WHERE l.org_type = t.org_type AND l.default_sourced AND l.status IN ('active', 'suspended')
|
||||
ORDER BY l.product_id LIMIT 1)
|
||||
UNION ALL
|
||||
(SELECT l.product_id FROM core.pool_provision_ladders l
|
||||
WHERE l.org_type = t.org_type AND l.default_sourced AND l.status IN ('active', 'suspended')
|
||||
ORDER BY l.product_id DESC LIMIT 1)
|
||||
) e
|
||||
WHERE core.plan_position_is_outgoing(true, e.product_id, p_target)
|
||||
);
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- Refuses an update that changes a fact the copies rest on. One function,
|
||||
-- attached once per watched column; the trigger's argument names the column.
|
||||
-- UPDATE OF fires on any statement that assigns the column, so each trigger's
|
||||
-- WHEN clause lets an assignment of the same value through.
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION core.refuse_position_source_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '% cannot change: plan positions copy it', TG_ARGV[0]
|
||||
USING ERRCODE = 'restrict_violation', TABLE = TG_TABLE_NAME, COLUMN = TG_ARGV[0];
|
||||
END;
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER trigger_organizations_refuse_org_type_change
|
||||
BEFORE UPDATE OF org_type ON core.organizations
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.org_type IS DISTINCT FROM NEW.org_type)
|
||||
EXECUTE FUNCTION core.refuse_position_source_change('org_type');
|
||||
|
||||
CREATE TRIGGER trigger_resource_pools_refuse_org_id_change
|
||||
BEFORE UPDATE OF org_id ON core.resource_pools
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.org_id IS DISTINCT FROM NEW.org_id)
|
||||
EXECUTE FUNCTION core.refuse_position_source_change('org_id');
|
||||
|
||||
CREATE TRIGGER trigger_pool_provisions_refuse_grant_id_change
|
||||
BEFORE UPDATE OF grant_id ON core.pool_provisions
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.grant_id IS DISTINCT FROM NEW.grant_id)
|
||||
EXECUTE FUNCTION core.refuse_position_source_change('grant_id');
|
||||
|
||||
CREATE TRIGGER trigger_pool_provisions_refuse_pool_id_change
|
||||
BEFORE UPDATE OF pool_id ON core.pool_provisions
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.pool_id IS DISTINCT FROM NEW.pool_id)
|
||||
EXECUTE FUNCTION core.refuse_position_source_change('pool_id');
|
||||
|
||||
CREATE TRIGGER trigger_grants_refuse_grant_reason_change
|
||||
BEFORE UPDATE OF grant_reason ON core.grants
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.grant_reason IS DISTINCT FROM NEW.grant_reason)
|
||||
EXECUTE FUNCTION core.refuse_position_source_change('grant_reason');
|
||||
|
||||
GRANT EXECUTE ON FUNCTION core.provision_is_default_sourced(UUID) TO core_reader, core_writer, core_owner;
|
||||
GRANT EXECUTE ON FUNCTION core.copy_position_sources() TO core_reader, core_writer, core_owner;
|
||||
GRANT EXECUTE ON FUNCTION core.plan_position_is_outgoing(BOOLEAN, UUID, UUID) TO core_reader, core_writer, core_owner;
|
||||
GRANT EXECUTE ON FUNCTION core.plan_pool_bucket(BOOLEAN, BOOLEAN, BOOLEAN, BOOLEAN) TO core_reader, core_writer, core_owner;
|
||||
GRANT EXECUTE ON FUNCTION core.outgoing_default_exists(VARCHAR[], UUID) TO core_reader, core_writer, core_owner;
|
||||
GRANT EXECUTE ON FUNCTION core.refuse_position_source_change() TO core_reader, core_writer, core_owner;
|
||||
|
||||
-- +goose Down
|
||||
DROP TRIGGER trigger_grants_refuse_grant_reason_change ON core.grants;
|
||||
DROP TRIGGER trigger_pool_provisions_refuse_pool_id_change ON core.pool_provisions;
|
||||
DROP TRIGGER trigger_pool_provisions_refuse_grant_id_change ON core.pool_provisions;
|
||||
DROP TRIGGER trigger_resource_pools_refuse_org_id_change ON core.resource_pools;
|
||||
DROP TRIGGER trigger_organizations_refuse_org_type_change ON core.organizations;
|
||||
DROP FUNCTION core.refuse_position_source_change();
|
||||
DROP FUNCTION core.outgoing_default_exists(VARCHAR[], UUID);
|
||||
DROP FUNCTION core.plan_pool_bucket(BOOLEAN, BOOLEAN, BOOLEAN, BOOLEAN);
|
||||
DROP FUNCTION core.plan_position_is_outgoing(BOOLEAN, UUID, UUID);
|
||||
DROP INDEX core.idx_pool_provision_ladders_live_default;
|
||||
DROP TRIGGER trigger_pool_provision_ladders_copy_sources_update ON core.pool_provision_ladders;
|
||||
DROP TRIGGER trigger_pool_provision_ladders_copy_sources_insert ON core.pool_provision_ladders;
|
||||
DROP FUNCTION core.copy_position_sources();
|
||||
DROP FUNCTION core.provision_is_default_sourced(UUID);
|
||||
ALTER TABLE core.pool_provision_ladders
|
||||
DROP COLUMN default_sourced,
|
||||
DROP COLUMN org_type;
|
||||
@@ -0,0 +1,339 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package db_test
|
||||
|
||||
// Migration 00026 (openspec change plan-act-obligations, tasks 3.1 and 3.5):
|
||||
// the copies a position carries of its pool's organization's type and of
|
||||
// whether its grant is a default grant, and the guards that keep the facts
|
||||
// those copies rest on from changing. The fixture writes its rows by SQL,
|
||||
// because the triggers and the backfill are the subject and a builder that
|
||||
// went through the console's functions would not exercise them.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
)
|
||||
|
||||
const (
|
||||
outgoingRuleVersion = 26
|
||||
outgoingRulePrevious = 25
|
||||
restrictViolation = "23001"
|
||||
)
|
||||
|
||||
// positionKind is one position the fixture writes for each of its two org
|
||||
// types: what sources it, the status it is in and which of the two products
|
||||
// it delivers.
|
||||
type positionKind struct {
|
||||
source string // "default", "legacy", "manual" (grant reasons) or "subscription"
|
||||
status string
|
||||
product int
|
||||
}
|
||||
|
||||
var positionKinds = []positionKind{
|
||||
{"default", "active", 0},
|
||||
{"legacy", "active", 0},
|
||||
{"manual", "active", 1},
|
||||
{"subscription", "active", 0},
|
||||
{"default", "suspended", 1},
|
||||
{"default", "ended", 0},
|
||||
}
|
||||
|
||||
// positionFixture names the rows of one seedPositions call.
|
||||
type positionFixture struct {
|
||||
types [2]string
|
||||
orgs []string // the organization of each position, in write order
|
||||
pools []string
|
||||
grants []string // "" for a subscription's position
|
||||
provisions []string
|
||||
}
|
||||
|
||||
// seedPositions writes, for each of two new org types, one organization with
|
||||
// a default pool for each of positionKinds, and the provision and position
|
||||
// each kind describes. It writes no column the migration adds, so it runs
|
||||
// before and after 00026.
|
||||
func seedPositions(t *testing.T, q *dbtest.Q) positionFixture {
|
||||
t.Helper()
|
||||
tag := uuid.NewString()[:8]
|
||||
var f positionFixture
|
||||
f.types = [2]string{"odr1-" + tag, "odr2-" + tag}
|
||||
person := q.ID(`WITH u AS (INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id)
|
||||
INSERT INTO core.persons (user_id, display_name, primary_email)
|
||||
SELECT user_id, 'Owner', $2 FROM u RETURNING person_id`, "odr-"+tag, "odr-"+tag+"@example.test")
|
||||
set := q.ID(`INSERT INTO core.entitlement_sets (name) VALUES ($1) RETURNING set_id`, "odr set "+tag)
|
||||
var products, ladders [2]string
|
||||
for i := range products {
|
||||
products[i] = q.ID(`INSERT INTO core.products (name, entitlement_set_id) VALUES ($1, $2) RETURNING product_id`,
|
||||
"odr product "+tag+string(rune('a'+i)), set)
|
||||
ladders[i] = q.ID(`INSERT INTO core.plan_ladders (name) VALUES ($1) RETURNING plan_ladder_id`,
|
||||
"odr ladder "+tag+string(rune('a'+i)))
|
||||
}
|
||||
for _, orgType := range f.types {
|
||||
q.Exec(`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $1)`, orgType)
|
||||
for _, k := range positionKinds {
|
||||
org := q.ID(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1, $2, $3) RETURNING org_id`,
|
||||
"odr org "+tag, orgType, person)
|
||||
pool := q.ID(`INSERT INTO core.resource_pools (org_id, name, pool_type) VALUES ($1, 'Default', 'default') RETURNING pool_id`, org)
|
||||
var provision, grant string
|
||||
if k.source == "subscription" {
|
||||
provision = q.ID(`INSERT INTO core.pool_provisions (pool_id, entitlement_set_id, product_id, subscription_id, status, ended_at)
|
||||
VALUES ($1, $2, $3, $4, $5::varchar, CASE WHEN $5::varchar = 'ended' THEN now() END) RETURNING provision_id`,
|
||||
pool, set, products[k.product], uuid.NewString(), k.status)
|
||||
} else {
|
||||
// A default grant has no author; every other grant has one.
|
||||
grant = q.ID(`INSERT INTO core.grants (product_id, grant_reason, granted_to_org_id, granted_by_person_id)
|
||||
VALUES ($1, $2::varchar, $3, CASE WHEN $2::varchar = 'default' THEN NULL ELSE $4::uuid END) RETURNING grant_id`,
|
||||
products[k.product], k.source, org, person)
|
||||
provision = q.ID(`INSERT INTO core.pool_provisions (pool_id, entitlement_set_id, product_id, grant_id, status, ended_at)
|
||||
VALUES ($1, $2, $3, $4, $5::varchar, CASE WHEN $5::varchar = 'ended' THEN now() END) RETURNING provision_id`,
|
||||
pool, set, products[k.product], grant, k.status)
|
||||
}
|
||||
q.Exec(`INSERT INTO core.pool_provision_ladders (provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, product_id)
|
||||
SELECT provision_id, $2, pool_id, status, activated_at, ended_at, product_id FROM core.pool_provisions WHERE provision_id = $1`,
|
||||
provision, ladders[k.product])
|
||||
f.orgs = append(f.orgs, org)
|
||||
f.pools = append(f.pools, pool)
|
||||
f.grants = append(f.grants, grant)
|
||||
f.provisions = append(f.provisions, provision)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// positionCopyMismatches counts the fixture's positions whose copies differ
|
||||
// from a recomputation from their pools, organizations, provisions and
|
||||
// grants.
|
||||
func positionCopyMismatches(q *dbtest.Q, f positionFixture) int64 {
|
||||
return q.Int(`SELECT count(*)
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.resource_pools rp ON rp.pool_id = l.pool_id
|
||||
JOIN core.organizations o ON o.org_id = rp.org_id
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
|
||||
WHERE o.org_type = ANY($1)
|
||||
AND (l.org_type IS DISTINCT FROM o.org_type
|
||||
OR l.default_sourced IS DISTINCT FROM COALESCE(g.grant_reason = 'default', false))`,
|
||||
f.types[:])
|
||||
}
|
||||
|
||||
// requirePositionCopies requires the fixture's positions to carry copies that
|
||||
// match their sources, and to cover both org types and both values of
|
||||
// default_sourced, so that a copy that is always the same value cannot pass.
|
||||
func requirePositionCopies(t *testing.T, q *dbtest.Q, f positionFixture, positions int) {
|
||||
t.Helper()
|
||||
if n := positionCopyMismatches(q, f); n != 0 {
|
||||
t.Errorf("%d positions carry a copy that differs from its source", n)
|
||||
}
|
||||
var rows, types, defaults int64
|
||||
q.Scan([]any{&rows, &types, &defaults}, `SELECT count(*), count(DISTINCT l.org_type), count(*) FILTER (WHERE l.default_sourced)
|
||||
FROM core.pool_provision_ladders l WHERE l.org_type = ANY($1)`, f.types[:])
|
||||
if int(rows) != positions || types != 2 || int(defaults) != positions/2 {
|
||||
t.Errorf("positions = %d across %d org types with %d default-sourced, want %d across 2 with %d", rows, types, defaults, positions, positions/2)
|
||||
}
|
||||
}
|
||||
|
||||
// updatedAts reads every fixture position's updated_at, by provision.
|
||||
func updatedAts(q *dbtest.Q, f positionFixture) map[string]time.Time {
|
||||
out := map[string]time.Time{}
|
||||
for _, provision := range f.provisions {
|
||||
var at time.Time
|
||||
q.Scan([]any{&at}, `SELECT updated_at FROM core.pool_provision_ladders WHERE provision_id = $1`, provision)
|
||||
out[provision] = at
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestOutgoingDefaultRuleMigrationUpDownUp(t *testing.T) {
|
||||
scratch := dbtest.Connect(t, dbtest.Scratch(t))
|
||||
ctx := t.Context()
|
||||
core := db.BaseSources()[0]
|
||||
|
||||
dbtest.UpTo(t, scratch, core, outgoingRulePrevious)
|
||||
before := schemaSnapshot(t, ctx, scratch)
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true)
|
||||
afterUp := schemaSnapshot(t, ctx, scratch)
|
||||
if slices.Equal(before, afterUp) {
|
||||
t.Fatalf("applying %d changed nothing in the snapshot", outgoingRuleVersion)
|
||||
}
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, false)
|
||||
if afterDown := schemaSnapshot(t, ctx, scratch); !slices.Equal(before, afterDown) {
|
||||
t.Errorf("the schema after Down differs from the schema before Up:\n%s", snapshotDiff(before, afterDown))
|
||||
}
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true)
|
||||
if again := schemaSnapshot(t, ctx, scratch); !slices.Equal(afterUp, again) {
|
||||
t.Errorf("the schema after a second Up differs from the first:\n%s", snapshotDiff(afterUp, again))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPositionCopiesMatchTheirSources writes default, legacy, operator-grant
|
||||
// and subscription positions on two org types before 00026, so the backfill
|
||||
// fills them; then more through the insert trigger, and then changes a
|
||||
// provision's status, which core.sync_pool_provision_ladders copies onto its
|
||||
// positions. After each step every position's two copies equal a
|
||||
// recomputation from the rows they rest on. The backfill leaves updated_at
|
||||
// alone, and a Down followed by an Up fills the copies again.
|
||||
func TestPositionCopiesMatchTheirSources(t *testing.T) {
|
||||
scratch := dbtest.Connect(t, dbtest.Scratch(t))
|
||||
core := db.BaseSources()[0]
|
||||
q := dbtest.On(t, scratch)
|
||||
|
||||
dbtest.UpTo(t, scratch, core, outgoingRulePrevious)
|
||||
old := seedPositions(t, q)
|
||||
oldUpdated := updatedAts(q, old)
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true)
|
||||
perFixture := 2 * len(positionKinds)
|
||||
requirePositionCopies(t, q, old, perFixture)
|
||||
for provision, at := range updatedAts(q, old) {
|
||||
if !at.Equal(oldUpdated[provision]) {
|
||||
t.Errorf("the backfill moved updated_at of the position of provision %s from %s to %s", provision, oldUpdated[provision], at)
|
||||
}
|
||||
}
|
||||
|
||||
fresh := seedPositions(t, q)
|
||||
requirePositionCopies(t, q, fresh, perFixture)
|
||||
|
||||
q.Exec(`UPDATE core.pool_provisions SET status = 'ended', ended_at = now() WHERE provision_id = ANY($1) AND status = 'suspended'`, fresh.provisions)
|
||||
q.Exec(`UPDATE core.pool_provisions SET status = 'suspended' WHERE provision_id = ANY($1) AND status = 'active'`, fresh.provisions)
|
||||
requirePositionCopies(t, q, fresh, perFixture)
|
||||
if n := q.Int(`SELECT count(*) FROM core.pool_provision_ladders WHERE provision_id = ANY($1) AND status = 'active'`, fresh.provisions); n != 0 {
|
||||
t.Errorf("%d positions are still active after their provisions were suspended or ended", n)
|
||||
}
|
||||
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, false)
|
||||
dbtest.ApplyVersion(t, scratch, core, outgoingRuleVersion, true)
|
||||
requirePositionCopies(t, q, old, perFixture)
|
||||
requirePositionCopies(t, q, fresh, perFixture)
|
||||
}
|
||||
|
||||
// sourceChange is an update that changes one fact the copies rest on, and
|
||||
// the same column assigned its own value.
|
||||
type sourceChange struct {
|
||||
column string
|
||||
change string // $1 is the row's id, $2 the value to change to
|
||||
same string // $1 is the row's id
|
||||
row func(f positionFixture) string
|
||||
to func(f positionFixture) string
|
||||
}
|
||||
|
||||
var sourceChanges = []sourceChange{
|
||||
{
|
||||
column: "org_type",
|
||||
change: `UPDATE core.organizations SET org_type = $2 WHERE org_id = $1`,
|
||||
same: `UPDATE core.organizations SET org_type = org_type WHERE org_id = $1`,
|
||||
row: func(f positionFixture) string { return f.orgs[0] },
|
||||
to: func(f positionFixture) string { return f.types[1] },
|
||||
},
|
||||
{
|
||||
column: "org_id",
|
||||
change: `UPDATE core.resource_pools SET org_id = $2 WHERE pool_id = $1`,
|
||||
same: `UPDATE core.resource_pools SET org_id = org_id WHERE pool_id = $1`,
|
||||
row: func(f positionFixture) string { return f.pools[0] },
|
||||
to: func(f positionFixture) string { return f.orgs[1] },
|
||||
},
|
||||
{
|
||||
column: "grant_id",
|
||||
change: `UPDATE core.pool_provisions SET grant_id = $2 WHERE provision_id = $1`,
|
||||
same: `UPDATE core.pool_provisions SET grant_id = grant_id WHERE provision_id = $1`,
|
||||
row: func(f positionFixture) string { return f.provisions[0] },
|
||||
to: func(f positionFixture) string { return f.grants[1] },
|
||||
},
|
||||
{
|
||||
column: "pool_id",
|
||||
change: `UPDATE core.pool_provisions SET pool_id = $2 WHERE provision_id = $1`,
|
||||
same: `UPDATE core.pool_provisions SET pool_id = pool_id WHERE provision_id = $1`,
|
||||
row: func(f positionFixture) string { return f.provisions[0] },
|
||||
to: func(f positionFixture) string { return f.pools[1] },
|
||||
},
|
||||
{
|
||||
column: "grant_reason",
|
||||
change: `UPDATE core.grants SET grant_reason = $2 WHERE grant_id = $1`,
|
||||
same: `UPDATE core.grants SET grant_reason = grant_reason WHERE grant_id = $1`,
|
||||
row: func(f positionFixture) string { return f.grants[0] },
|
||||
to: func(f positionFixture) string { return "legacy" },
|
||||
},
|
||||
}
|
||||
|
||||
// TestPositionSourcesCannotChange requires an update that changes any of the
|
||||
// five columns to be refused with restrict_violation naming that column, an
|
||||
// update that assigns the column its own value to pass, and an insert to be
|
||||
// unaffected (the fixture's own rows are inserts).
|
||||
func TestPositionSourcesCannotChange(t *testing.T) {
|
||||
scratch := dbtest.Connect(t, dbtest.Scratch(t))
|
||||
dbtest.UpTo(t, scratch, db.BaseSources()[0], outgoingRuleVersion)
|
||||
q := dbtest.Tx(t, scratch)
|
||||
f := seedPositions(t, q)
|
||||
for _, c := range sourceChanges {
|
||||
t.Run(c.column, func(t *testing.T) {
|
||||
q := dbtest.On(t, q.Tx())
|
||||
err := q.Attempt(c.change, c.row(f), c.to(f))
|
||||
dbtest.WantPgError(t, err, restrictViolation, "")
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
return
|
||||
}
|
||||
if pgErr.ColumnName != c.column || !strings.Contains(pgErr.Message, c.column) {
|
||||
t.Errorf("the refusal names column %q in %q, want %q", pgErr.ColumnName, pgErr.Message, c.column)
|
||||
}
|
||||
if err := q.Attempt(c.same, c.row(f)); err != nil {
|
||||
t.Errorf("assigning %s its own value was refused: %v", c.column, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPositionCopiesAreWrittenOnlyWhenTheirSourceMoves makes a position's
|
||||
// copies wrong on purpose and watches when a statement writes them again.
|
||||
// core.sync_pool_provision_ladders assigns pool_id on every status change of
|
||||
// a provision, so the update trigger's WHEN clause is what keeps each of them
|
||||
// from reading the pool, the organization and the grant: a status change and
|
||||
// an assignment of the same pool leave the wrong copies as they are, and a
|
||||
// move to another pool writes them from that pool.
|
||||
func TestPositionCopiesAreWrittenOnlyWhenTheirSourceMoves(t *testing.T) {
|
||||
scratch := dbtest.Connect(t, dbtest.Scratch(t))
|
||||
dbtest.UpTo(t, scratch, db.BaseSources()[0], outgoingRuleVersion)
|
||||
q := dbtest.Tx(t, scratch)
|
||||
f := seedPositions(t, q)
|
||||
|
||||
provision := f.provisions[0] // a default-sourced position in the first type
|
||||
copies := func() string {
|
||||
return q.String(`SELECT org_type || ' ' || default_sourced FROM core.pool_provision_ladders WHERE provision_id = $1`, provision)
|
||||
}
|
||||
want := f.types[0] + " true"
|
||||
if got := copies(); got != want {
|
||||
t.Fatalf("copies = %q, want %q", got, want)
|
||||
}
|
||||
q.Exec(`UPDATE core.pool_provision_ladders SET org_type = 'stale', default_sourced = false WHERE provision_id = $1`, provision)
|
||||
stale := copies()
|
||||
if stale != "stale false" {
|
||||
t.Fatalf("the copies could not be made wrong: %q", stale)
|
||||
}
|
||||
|
||||
q.Exec(`UPDATE core.pool_provisions SET status = 'suspended' WHERE provision_id = $1`, provision)
|
||||
if got := copies(); got != stale {
|
||||
t.Errorf("a status change wrote the copies again: %q", got)
|
||||
}
|
||||
q.Exec(`UPDATE core.pool_provision_ladders SET pool_id = pool_id WHERE provision_id = $1`, provision)
|
||||
if got := copies(); got != stale {
|
||||
t.Errorf("assigning the pool its own value wrote the copies again: %q", got)
|
||||
}
|
||||
|
||||
// The pool of the first organization of the second type.
|
||||
other := f.pools[len(positionKinds)]
|
||||
q.Exec(`UPDATE core.pool_provision_ladders SET pool_id = $2 WHERE provision_id = $1`, provision, other)
|
||||
if got, want := copies(), f.types[1]+" true"; got != want {
|
||||
t.Errorf("a move to another pool left the copies as %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -22,22 +22,23 @@
|
||||
<p class="small text-muted mb-2">No existing organizations are affected.</p>
|
||||
{{ else }}
|
||||
<ul class="small mb-2">
|
||||
{{ if gt (len .WouldInitiate) 0 }}
|
||||
{{ if gt .WouldInitiate 0 }}
|
||||
<li>
|
||||
{{ if .CandidateLadderID }}
|
||||
<strong>{{ len .WouldInitiate }}</strong> without a plan; initiated onto the new default.
|
||||
<strong>{{ .WouldInitiate }}</strong> without a plan; initiated onto the new default.
|
||||
{{ else }}
|
||||
<strong>{{ len .WouldInitiate }}</strong> without a plan; they stay without a plan.
|
||||
<strong>{{ .WouldInitiate }}</strong> without a plan; they stay without a plan.
|
||||
{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ if gt (len .OutgoingDefault) 0 }}
|
||||
{{ if gt .OutgoingDefaultCount 0 }}
|
||||
<li>
|
||||
{{ .OutgoingDefaultSummary }}; choose their disposition below.
|
||||
<details class="d-block">
|
||||
<summary class="text-muted">show all {{ len .OutgoingDefault }}</summary>
|
||||
<summary class="text-muted">{{ .OutgoingDefaultDisclosure }}</summary>
|
||||
<ul class="org-disclosure-list list-unstyled text-muted mb-0">
|
||||
{{ range .OutgoingDefault }}<li><a href="/operator/organizations/{{ .OrgID }}">{{ .Name }}</a></li>{{ end }}
|
||||
{{ if gt .OutgoingDefaultMore 0 }}<li>And {{ .OutgoingDefaultMore }} more.</li>{{ end }}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
@@ -45,20 +46,22 @@
|
||||
{{ if gt .AlreadyAtDefault 0 }}
|
||||
<li><strong>{{ .AlreadyAtDefault }}</strong> already at this default; untouched.</li>
|
||||
{{ end }}
|
||||
{{ if gt (len .OtherSource) 0 }}
|
||||
<li><strong>{{ len .OtherSource }}</strong> on a plan from another source; never touched.
|
||||
{{ if gt .OtherSourceCount 0 }}
|
||||
<li><strong>{{ .OtherSourceCount }}</strong> on a plan from another source; never touched.
|
||||
<details class="d-block">
|
||||
<summary class="text-muted">show all {{ len .OtherSource }}</summary>
|
||||
<summary class="text-muted">{{ .OtherSourceDisclosure }}</summary>
|
||||
<ul class="org-disclosure-list list-unstyled text-muted mb-0">
|
||||
{{ range .OtherSource }}<li><a href="/operator/organizations/{{ .OrgID }}">{{ .Name }}</a> ({{ .Source }})</li>{{ end }}
|
||||
{{ if gt .OtherSourceMore 0 }}<li>And {{ .OtherSourceMore }} more.</li>{{ end }}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ if gt (len .WouldFail) 0 }}
|
||||
<li class="text-danger"><strong>{{ len .WouldFail }}</strong> would fail:
|
||||
{{ if gt .WouldFailCount 0 }}
|
||||
<li class="text-danger"><strong>{{ .WouldFailCount }}</strong> would fail:
|
||||
<ul>
|
||||
{{ range .WouldFail }}<li><strong><a href="/operator/organizations/{{ .OrgID }}">{{ .OrgName }}</a></strong>: {{ .Reason }}</li>{{ end }}
|
||||
{{ if gt .WouldFailMore 0 }}<li>And {{ .WouldFailMore }} more.</li>{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
||||
@@ -31,16 +31,17 @@
|
||||
<p class="small text-muted mb-2">No existing organizations are affected.</p>
|
||||
{{ else }}
|
||||
<ul class="small mb-2">
|
||||
{{ if gt (len .WouldInitiate) 0 }}
|
||||
<li><strong>{{ len .WouldInitiate }}</strong> without a plan; initiated onto the new rank-0 tier.</li>
|
||||
{{ if gt .WouldInitiate 0 }}
|
||||
<li><strong>{{ .WouldInitiate }}</strong> without a plan; initiated onto the new rank-0 tier.</li>
|
||||
{{ end }}
|
||||
{{ if gt (len .OutgoingDefault) 0 }}
|
||||
{{ if gt .OutgoingDefaultCount 0 }}
|
||||
<li>
|
||||
<strong>{{ len .OutgoingDefault }}</strong> on the outgoing rank-0 via the type default; requires a disposition below.
|
||||
<strong>{{ .OutgoingDefaultCount }}</strong> on the outgoing rank-0 via the type default; requires a disposition below.
|
||||
<details class="d-block">
|
||||
<summary class="text-muted">show all {{ len .OutgoingDefault }}</summary>
|
||||
<summary class="text-muted">{{ .OutgoingDefaultDisclosure }}</summary>
|
||||
<ul class="org-disclosure-list list-unstyled text-muted mb-0">
|
||||
{{ range .OutgoingDefault }}<li><a href="/operator/organizations/{{ .OrgID }}">{{ .Name }}</a></li>{{ end }}
|
||||
{{ if gt .OutgoingDefaultMore 0 }}<li>And {{ .OutgoingDefaultMore }} more.</li>{{ end }}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
@@ -48,13 +49,22 @@
|
||||
{{ if gt .AlreadyAtDefault 0 }}
|
||||
<li><strong>{{ .AlreadyAtDefault }}</strong> already on the new rank-0 tier; untouched.</li>
|
||||
{{ end }}
|
||||
{{ if gt (len .OtherSource) 0 }}
|
||||
<li><strong>{{ len .OtherSource }}</strong> on a plan from another source; never touched.</li>
|
||||
{{ if gt .OtherSourceCount 0 }}
|
||||
<li><strong>{{ .OtherSourceCount }}</strong> on a plan from another source; never touched.
|
||||
<details class="d-block">
|
||||
<summary class="text-muted">{{ .OtherSourceDisclosure }}</summary>
|
||||
<ul class="org-disclosure-list list-unstyled text-muted mb-0">
|
||||
{{ range .OtherSource }}<li><a href="/operator/organizations/{{ .OrgID }}">{{ .Name }}</a> ({{ .Source }})</li>{{ end }}
|
||||
{{ if gt .OtherSourceMore 0 }}<li>And {{ .OtherSourceMore }} more.</li>{{ end }}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ if gt (len .WouldFail) 0 }}
|
||||
<li class="text-danger"><strong>{{ len .WouldFail }}</strong> would fail:
|
||||
{{ if gt .WouldFailCount 0 }}
|
||||
<li class="text-danger"><strong>{{ .WouldFailCount }}</strong> would fail:
|
||||
<ul>
|
||||
{{ range .WouldFail }}<li><strong><a href="/operator/organizations/{{ .OrgID }}">{{ .OrgName }}</a></strong>: {{ .Reason }}</li>{{ end }}
|
||||
{{ if gt .WouldFailMore 0 }}<li>And {{ .WouldFailMore }} more.</li>{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
||||
@@ -15,18 +15,21 @@ import (
|
||||
)
|
||||
|
||||
// DefaultBucket labels one pool's disposition class under a candidate
|
||||
// default. Classification is by provenance (which source holds the pool's
|
||||
// live plan positions), never by which ladder a position occupies — except
|
||||
// that a default-sourced position already delivering the candidate's rank-0
|
||||
// product is at the target, not outgoing, which is what makes a committed
|
||||
// change idempotent on re-run.
|
||||
// default. The values mirror the five results of core.plan_pool_bucket, the
|
||||
// one rule that decides a class: classification is by provenance (which
|
||||
// source holds the pool's live plan positions), never by which ladder a
|
||||
// position occupies, except that a default-sourced position already
|
||||
// delivering the candidate's rank-0 product is at the target, not outgoing,
|
||||
// which is what makes a committed change idempotent on re-run. No Go code
|
||||
// decides a class; the preview, the gate and the move read the database's.
|
||||
type DefaultBucket string
|
||||
|
||||
const (
|
||||
BucketWouldInitiate DefaultBucket = "would-initiate"
|
||||
BucketAlreadyAtDefault DefaultBucket = "already-at-default"
|
||||
BucketOutgoingDefault DefaultBucket = "outgoing-default"
|
||||
BucketOtherSource DefaultBucket = "other-source"
|
||||
BucketWouldFail DefaultBucket = "would_fail"
|
||||
BucketWouldInitiate DefaultBucket = "would_initiate"
|
||||
BucketAlreadyAtDefault DefaultBucket = "already_at_default"
|
||||
BucketOutgoingDefault DefaultBucket = "outgoing_default"
|
||||
BucketOtherSource DefaultBucket = "other_source"
|
||||
)
|
||||
|
||||
// DefaultIncumbent identifies one live default-sourced provision awaiting a
|
||||
@@ -37,49 +40,17 @@ type DefaultIncumbent struct {
|
||||
ProductID string
|
||||
}
|
||||
|
||||
// ClassifyPoolAttachments buckets a pool from its live (active|suspended)
|
||||
// plan-ladder attachment rows against the candidate default's rank-0 product
|
||||
// (candidateProductID is "" for a None candidate).
|
||||
//
|
||||
// No rows: the pool is plan-less (BucketWouldInitiate). Rows sourced by a
|
||||
// grant_reason='default' grant mark default-held positions: those NOT
|
||||
// delivering the candidate's rank-0 product are the outgoing default
|
||||
// (BucketOutgoingDefault) — the returned incumbents carry each such distinct
|
||||
// provision, and a pool that also holds other-source rows still lands here
|
||||
// (the default position is the one needing a disposition). Default-held
|
||||
// positions already delivering the candidate product are at the target
|
||||
// (BucketAlreadyAtDefault) — untouched, which is what makes a committed
|
||||
// change idempotent on re-run. Otherwise every live position belongs to
|
||||
// another source (BucketOtherSource).
|
||||
func ClassifyPoolAttachments(rows []GetLivePlanAttachmentsWithSourceByPoolRow, candidateProductID string) (DefaultBucket, []DefaultIncumbent) {
|
||||
if len(rows) == 0 {
|
||||
return BucketWouldInitiate, nil
|
||||
// PlanTarget is the target the outgoing rule reads: the candidate default's
|
||||
// rank-0 product, or None (an invalid NullUUID) for "".
|
||||
func PlanTarget(candidateProductID string) (uuid.NullUUID, error) {
|
||||
if candidateProductID == "" {
|
||||
return uuid.NullUUID{}, nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var incumbents []DefaultIncumbent
|
||||
atTarget := false
|
||||
for _, row := range rows {
|
||||
if !row.GrantReason.Valid || row.GrantReason.String != "default" || !row.GrantID.Valid || seen[row.ProvisionID] {
|
||||
continue
|
||||
}
|
||||
seen[row.ProvisionID] = true
|
||||
if candidateProductID != "" && row.ProductID == candidateProductID {
|
||||
atTarget = true
|
||||
continue
|
||||
}
|
||||
incumbents = append(incumbents, DefaultIncumbent{
|
||||
ProvisionID: row.ProvisionID,
|
||||
GrantID: row.GrantID.UUID.String(),
|
||||
ProductID: row.ProductID,
|
||||
})
|
||||
parsed, err := uuid.Parse(candidateProductID)
|
||||
if err != nil {
|
||||
return uuid.NullUUID{}, err
|
||||
}
|
||||
if len(incumbents) > 0 {
|
||||
return BucketOutgoingDefault, incumbents
|
||||
}
|
||||
if atTarget {
|
||||
return BucketAlreadyAtDefault, nil
|
||||
}
|
||||
return BucketOtherSource, nil
|
||||
return uuid.NullUUID{UUID: parsed, Valid: true}, nil
|
||||
}
|
||||
|
||||
// EndDefaultIncumbents ends each default-sourced incumbent position inside
|
||||
@@ -251,20 +222,33 @@ func enactDefault(ctx context.Context, b db.Beginner, orgID string, e Enactment,
|
||||
if _, err := q.LockPools(ctx, pool.PoolID); err != nil {
|
||||
return &EnactFailure{Step: StepLockPools, PoolID: pool.PoolID, Err: err}
|
||||
}
|
||||
rows, err := q.GetLivePlanAttachmentsWithSourceByPool(ctx, pool.PoolID)
|
||||
target, err := PlanTarget(e.CandidateProductID)
|
||||
if err != nil {
|
||||
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: err}
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, e.CandidateProductID)
|
||||
switch bucket {
|
||||
bucket, err := q.GetPlanPoolBucket(ctx, GetPlanPoolBucketParams{PoolID: pool.PoolID, Target: target})
|
||||
if err != nil {
|
||||
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: err}
|
||||
}
|
||||
switch DefaultBucket(bucket) {
|
||||
case BucketAlreadyAtDefault:
|
||||
result.AlreadyAt++ // already delivering the candidate's rank-0; rollback is a no-op
|
||||
case BucketWouldInitiate:
|
||||
return initiateDefault(ctx, tx, pool.PoolID, e.Actor, result)
|
||||
case BucketOutgoingDefault:
|
||||
positions, err := q.ListOutgoingPlanPositionsByPool(ctx, ListOutgoingPlanPositionsByPoolParams{PoolID: pool.PoolID, Target: target})
|
||||
if err != nil {
|
||||
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: err}
|
||||
}
|
||||
incumbents := make([]DefaultIncumbent, len(positions))
|
||||
for i, pos := range positions {
|
||||
incumbents[i] = DefaultIncumbent{ProvisionID: pos.ProvisionID, GrantID: pos.GrantID.UUID.String(), ProductID: pos.ProductID}
|
||||
}
|
||||
return enactOutgoingDefault(ctx, tx, q, orgID, pool.PoolID, incumbents, e, result)
|
||||
case BucketOtherSource:
|
||||
result.Untouched++ // never touched; rollback is a no-op
|
||||
default:
|
||||
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: fmt.Errorf("pool class %q", bucket)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements
|
||||
|
||||
// Unit tests for the classifier (org-type-default-change-flow task 2.2).
|
||||
// Pure function over query rows — no database.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type rowOpt func(*GetLivePlanAttachmentsWithSourceByPoolRow)
|
||||
|
||||
func attachmentRow(provisionID, ladderID, productID string, opts ...rowOpt) GetLivePlanAttachmentsWithSourceByPoolRow {
|
||||
row := GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
ProvisionLadderID: uuid.NewString(),
|
||||
PlanLadderID: ladderID,
|
||||
ProvisionID: provisionID,
|
||||
AttachmentStatus: "active",
|
||||
ProductID: productID,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&row)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func fromGrant(grantID, reason string) rowOpt {
|
||||
return func(r *GetLivePlanAttachmentsWithSourceByPoolRow) {
|
||||
r.GrantID = uuid.NullUUID{UUID: uuid.MustParse(grantID), Valid: true}
|
||||
r.GrantReason = sql.NullString{String: reason, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
func fromSubscription() rowOpt {
|
||||
return func(r *GetLivePlanAttachmentsWithSourceByPoolRow) {
|
||||
r.SubscriptionID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPoolAttachments_NoRowsIsWouldInitiate(t *testing.T) {
|
||||
bucket, incumbents := ClassifyPoolAttachments(nil, "candidate-prod")
|
||||
if bucket != BucketWouldInitiate || incumbents != nil {
|
||||
t.Fatalf("got (%s, %v), want (%s, nil)", bucket, incumbents, BucketWouldInitiate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPoolAttachments_SubscriptionOnlyIsOtherSource(t *testing.T) {
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "ladder-a", "prod-1", fromSubscription()),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketOtherSource || incumbents != nil {
|
||||
t.Fatalf("got (%s, %v), want (%s, nil)", bucket, incumbents, BucketOtherSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPoolAttachments_NonDefaultGrantIsOtherSource(t *testing.T) {
|
||||
// legacy and manual grants are other sources — only reason='default'
|
||||
// marks a default-held position.
|
||||
for _, reason := range []string{"legacy", "manual", "promotional"} {
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "ladder-a", "prod-1", fromGrant(uuid.NewString(), reason)),
|
||||
}
|
||||
if bucket, _ := ClassifyPoolAttachments(rows, "candidate-prod"); bucket != BucketOtherSource {
|
||||
t.Errorf("reason %q: got %s, want %s", reason, bucket, BucketOtherSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPoolAttachments_DefaultSourcedIsOutgoingDefault(t *testing.T) {
|
||||
grantID := uuid.NewString()
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "ladder-a", "prod-1", fromGrant(grantID, "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketOutgoingDefault {
|
||||
t.Fatalf("bucket = %s, want %s", bucket, BucketOutgoingDefault)
|
||||
}
|
||||
if len(incumbents) != 1 || incumbents[0].GrantID != grantID || incumbents[0].ProductID != "prod-1" || incumbents[0].ProvisionID != "p1" {
|
||||
t.Fatalf("incumbents = %+v, want the default grant/product/provision", incumbents)
|
||||
}
|
||||
}
|
||||
|
||||
// A default-sourced position already delivering the candidate's rank-0
|
||||
// product is at the target, not outgoing — this is what makes a committed
|
||||
// change idempotent on re-run (the freshly initiated/migrated defaults would
|
||||
// otherwise classify as outgoing again).
|
||||
func TestClassifyPoolAttachments_AtCandidateIsAlreadyAtDefault(t *testing.T) {
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "ladder-b", "candidate-prod", fromGrant(uuid.NewString(), "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketAlreadyAtDefault || incumbents != nil {
|
||||
t.Fatalf("got (%s, %+v), want (%s, nil)", bucket, incumbents, BucketAlreadyAtDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// With a None candidate there is no target, so every default-held position
|
||||
// is outgoing.
|
||||
func TestClassifyPoolAttachments_NoneCandidateMakesAllDefaultsOutgoing(t *testing.T) {
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "ladder-b", "prod-1", fromGrant(uuid.NewString(), "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "")
|
||||
if bucket != BucketOutgoingDefault || len(incumbents) != 1 {
|
||||
t.Fatalf("got (%s, %+v), want outgoing-default with one incumbent", bucket, incumbents)
|
||||
}
|
||||
}
|
||||
|
||||
// Membership is by provenance, not geometry: a default-sourced position
|
||||
// stranded on a ladder that is no longer (or never was) the configured
|
||||
// default still classifies as the outgoing default. The classifier compares
|
||||
// products, never ladder IDs.
|
||||
func TestClassifyPoolAttachments_ProvenanceNotLadder(t *testing.T) {
|
||||
grantID := uuid.NewString()
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "some-former-ladder", "prod-1", fromGrant(grantID, "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketOutgoingDefault || len(incumbents) != 1 {
|
||||
t.Fatalf("got (%s, %+v), want outgoing-default with one incumbent regardless of ladder", bucket, incumbents)
|
||||
}
|
||||
}
|
||||
|
||||
// A pool holding both a default-sourced position and another source's
|
||||
// position is still bucket 2 — the default position is the one needing a
|
||||
// disposition.
|
||||
func TestClassifyPoolAttachments_MixedDefaultAndOtherIsOutgoingDefault(t *testing.T) {
|
||||
grantID := uuid.NewString()
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p-sub", "ladder-b", "prod-2", fromSubscription()),
|
||||
attachmentRow("p-def", "ladder-a", "prod-1", fromGrant(grantID, "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketOutgoingDefault {
|
||||
t.Fatalf("bucket = %s, want %s", bucket, BucketOutgoingDefault)
|
||||
}
|
||||
if len(incumbents) != 1 || incumbents[0].ProvisionID != "p-def" {
|
||||
t.Fatalf("incumbents = %+v, want only the default-sourced provision", incumbents)
|
||||
}
|
||||
}
|
||||
|
||||
// Union debris from the pre-floor-guard era: a pool holding the outgoing
|
||||
// default AND a default-sourced position already at the candidate reports
|
||||
// only the outgoing one for disposition.
|
||||
func TestClassifyPoolAttachments_UnionDebrisReportsOnlyOutgoing(t *testing.T) {
|
||||
outgoing := uuid.NewString()
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p-old", "ladder-a", "prod-old", fromGrant(outgoing, "default")),
|
||||
attachmentRow("p-new", "ladder-b", "candidate-prod", fromGrant(uuid.NewString(), "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketOutgoingDefault || len(incumbents) != 1 || incumbents[0].GrantID != outgoing {
|
||||
t.Fatalf("got (%s, %+v), want outgoing-default with only the non-target incumbent", bucket, incumbents)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-rung positions share a provision: incumbents deduplicate by
|
||||
// provision, and distinct default-sourced provisions each get an entry.
|
||||
func TestClassifyPoolAttachments_DedupesRungsAndCollectsDistinctProvisions(t *testing.T) {
|
||||
g1, g2 := uuid.NewString(), uuid.NewString()
|
||||
rows := []GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
attachmentRow("p1", "ladder-a", "prod-1", fromGrant(g1, "default")),
|
||||
attachmentRow("p1", "ladder-a2", "prod-1", fromGrant(g1, "default")), // second rung, same provision
|
||||
attachmentRow("p2", "ladder-b", "prod-2", fromGrant(g2, "default")),
|
||||
}
|
||||
bucket, incumbents := ClassifyPoolAttachments(rows, "candidate-prod")
|
||||
if bucket != BucketOutgoingDefault {
|
||||
t.Fatalf("bucket = %s, want %s", bucket, BucketOutgoingDefault)
|
||||
}
|
||||
if len(incumbents) != 2 {
|
||||
t.Fatalf("incumbents = %+v, want exactly 2 (deduped by provision)", incumbents)
|
||||
}
|
||||
}
|
||||
|
||||
// Suspended attachments are live: a suspended default-sourced position still
|
||||
// requires a disposition.
|
||||
func TestClassifyPoolAttachments_SuspendedCountsAsLive(t *testing.T) {
|
||||
row := attachmentRow("p1", "ladder-a", "prod-1", fromGrant(uuid.NewString(), "default"))
|
||||
row.AttachmentStatus = "suspended"
|
||||
bucket, _ := ClassifyPoolAttachments([]GetLivePlanAttachmentsWithSourceByPoolRow{row}, "candidate-prod")
|
||||
if bucket != BucketOutgoingDefault {
|
||||
t.Fatalf("bucket = %s, want %s (suspended is live)", bucket, BucketOutgoingDefault)
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func lockTierHolders(ctx context.Context, qtx *Queries, ladderID, productID stri
|
||||
pw = &tierHolders{}
|
||||
work[row.PoolID] = pw
|
||||
}
|
||||
if row.GrantReason.Valid && row.GrantReason.String == "default" {
|
||||
if row.DefaultSourced {
|
||||
defaultHolders++
|
||||
pw.incumbents = append(pw.incumbents, DefaultIncumbent{
|
||||
ProvisionID: row.ProvisionID,
|
||||
|
||||
@@ -182,6 +182,8 @@ type PoolProvisionLadder struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ProductID string `json:"product_id"`
|
||||
ProvisionLadderID string `json:"provision_ladder_id"`
|
||||
OrgType string `json:"org_type"`
|
||||
DefaultSourced bool `json:"default_sourced"`
|
||||
}
|
||||
|
||||
type ResourceKey struct {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements_test
|
||||
|
||||
// The one rule for an outgoing default and for a pool's class (openspec
|
||||
// change plan-act-obligations, design D4, tasks 3.4 and 3.5): the preview's
|
||||
// read, the commit's gate and a move's read of its one pool all apply
|
||||
// core.plan_position_is_outgoing and core.plan_pool_bucket, and these tests
|
||||
// pin that they agree. Each case builds its organizations in a rolled-back
|
||||
// transaction under an org type of its own, so the reads see nothing else.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
)
|
||||
|
||||
// The five classes, as the Go constants name them.
|
||||
var planPoolBuckets = []entitlements.DefaultBucket{
|
||||
entitlements.BucketWouldFail,
|
||||
entitlements.BucketWouldInitiate,
|
||||
entitlements.BucketAlreadyAtDefault,
|
||||
entitlements.BucketOutgoingDefault,
|
||||
entitlements.BucketOtherSource,
|
||||
}
|
||||
|
||||
// TestPlanPoolBucketMirrorsItsConstants calls core.plan_pool_bucket with
|
||||
// every combination of its four arguments. It returns only the Go constants,
|
||||
// and each of them for some combination, so a class added on one side only
|
||||
// fails here.
|
||||
func TestPlanPoolBucketMirrorsItsConstants(t *testing.T) {
|
||||
q := dbtest.On(t, dbtest.Open(t))
|
||||
got := map[entitlements.DefaultBucket][]string{}
|
||||
for bits := range 16 {
|
||||
args := [4]bool{bits&8 != 0, bits&4 != 0, bits&2 != 0, bits&1 != 0}
|
||||
bucket := entitlements.DefaultBucket(q.String(`SELECT core.plan_pool_bucket($1, $2, $3, $4)`, args[0], args[1], args[2], args[3]))
|
||||
got[bucket] = append(got[bucket], fmt.Sprint(args))
|
||||
}
|
||||
for bucket, combinations := range got {
|
||||
if !slices.Contains(planPoolBuckets, bucket) {
|
||||
t.Errorf("core.plan_pool_bucket returned %q for %v, which no Go constant names", bucket, combinations)
|
||||
}
|
||||
}
|
||||
for _, bucket := range planPoolBuckets {
|
||||
if len(got[bucket]) == 0 {
|
||||
t.Errorf("no combination of core.plan_pool_bucket's arguments returns the Go constant %q", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ruleWorld builds organizations of one new org type and the positions they
|
||||
// hold, in the rolled-back transaction of a resumeWorld.
|
||||
type ruleWorld struct {
|
||||
*resumeWorld
|
||||
orgType string
|
||||
}
|
||||
|
||||
func newRuleWorld(t *testing.T) *ruleWorld {
|
||||
t.Helper()
|
||||
return &ruleWorld{resumeWorld: newResumeWorld(t)}
|
||||
}
|
||||
|
||||
// on points the world's failures at the subtest t until the returned
|
||||
// function restores the parent, so a subtest's fixture calls fail the
|
||||
// subtest that made them.
|
||||
func (r *ruleWorld) on(t *testing.T) func() {
|
||||
parent := r.t
|
||||
r.t = t
|
||||
return func() { r.t = parent }
|
||||
}
|
||||
|
||||
// useType creates a new org type and makes it the one newOrg builds into.
|
||||
func (r *ruleWorld) useType() string {
|
||||
r.t.Helper()
|
||||
r.orgType = "rule-" + uuid.NewString()[:8]
|
||||
r.exec(`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $1)`, r.orgType)
|
||||
return r.orgType
|
||||
}
|
||||
|
||||
func (r *ruleWorld) exec(query string, args ...any) {
|
||||
r.t.Helper()
|
||||
dbtest.On(r.t, r.tx).Exec(query, args...)
|
||||
}
|
||||
|
||||
// newOrg creates an organization of the current type, with a default pool
|
||||
// when withPool is set. The world's person owns it.
|
||||
func (r *ruleWorld) newOrg(name string, withPool bool) testOrg {
|
||||
r.t.Helper()
|
||||
org, err := organization.New(r.tx).CreateOrganization(r.ctx, organization.CreateOrganizationParams{
|
||||
Name: name, OrgType: r.orgType, OwnerPersonID: r.org.person.PersonID,
|
||||
})
|
||||
if err != nil {
|
||||
r.t.Fatalf("create organization %s: %v", name, err)
|
||||
}
|
||||
o := testOrg{person: r.org.person, org: org}
|
||||
if withPool {
|
||||
o.pool, err = r.q.CreateResourcePool(r.ctx, entitlements.CreateResourcePoolParams{
|
||||
OrgID: org.OrgID, Name: "Default", PoolType: "default", IsAutoManaged: true,
|
||||
})
|
||||
if err != nil {
|
||||
r.t.Fatalf("create pool of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// grantPosition confers product on o's pool from a grant of the reason and
|
||||
// returns the provision.
|
||||
func (r *ruleWorld) grantPosition(o testOrg, product, reason string) string {
|
||||
r.t.Helper()
|
||||
g := r.grant(product, grantOpts{reason: reason, org: &o})
|
||||
return r.conferIn(g, o, r.tick())
|
||||
}
|
||||
|
||||
// mustHold requires the pool to hold n live (active or suspended) positions,
|
||||
// so a case built to hold two does not pass because one superseded the other.
|
||||
func (r *ruleWorld) mustHold(o testOrg, n int) {
|
||||
r.t.Helper()
|
||||
if got := r.count(`SELECT count(*) FROM core.pool_provision_ladders WHERE pool_id = $1 AND status IN ('active', 'suspended')`, o.pool.PoolID); got != n {
|
||||
r.t.Fatalf("%s holds %d live positions, want %d", o.org.Name, got, n)
|
||||
}
|
||||
}
|
||||
|
||||
// subscriptionPosition confers product on o's pool from a subscription, as
|
||||
// reconcile does.
|
||||
func (r *ruleWorld) subscriptionPosition(o testOrg, product string) string {
|
||||
r.t.Helper()
|
||||
id, outcome, err := r.q.Confer(r.ctx, entitlements.ConferParams{
|
||||
PoolID: o.pool.PoolID, ProductID: product, Quantity: 1, ActivatedAt: r.tick(),
|
||||
SubscriptionID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ActorType: "webhook", Reason: sql.NullString{String: "test: stripe", Valid: true},
|
||||
})
|
||||
if err != nil || outcome != "created" {
|
||||
r.t.Fatalf("confer %s from a subscription: outcome %q, err %v", product, outcome, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// bucket reads the class of the pool under the target through the move's
|
||||
// read; the target "" is None.
|
||||
func (r *ruleWorld) bucket(o testOrg, target string) entitlements.DefaultBucket {
|
||||
r.t.Helper()
|
||||
t, err := entitlements.PlanTarget(target)
|
||||
if err != nil {
|
||||
r.t.Fatalf("target %q: %v", target, err)
|
||||
}
|
||||
b, err := r.q.GetPlanPoolBucket(r.ctx, entitlements.GetPlanPoolBucketParams{PoolID: o.pool.PoolID, Target: t})
|
||||
if err != nil {
|
||||
r.t.Fatalf("read the class of %s: %v", o.org.Name, err)
|
||||
}
|
||||
return entitlements.DefaultBucket(b)
|
||||
}
|
||||
|
||||
// outgoing reads the provisions the move acts on, through its read.
|
||||
func (r *ruleWorld) outgoing(o testOrg, target string) []string {
|
||||
r.t.Helper()
|
||||
t, err := entitlements.PlanTarget(target)
|
||||
if err != nil {
|
||||
r.t.Fatalf("target %q: %v", target, err)
|
||||
}
|
||||
rows, err := r.q.ListOutgoingPlanPositionsByPool(r.ctx, entitlements.ListOutgoingPlanPositionsByPoolParams{PoolID: o.pool.PoolID, Target: t})
|
||||
if err != nil {
|
||||
r.t.Fatalf("read the outgoing positions of %s: %v", o.org.Name, err)
|
||||
}
|
||||
var out []string
|
||||
for _, row := range rows {
|
||||
out = append(out, row.ProvisionID)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// preview reads the type through the preview's read: each class's exact
|
||||
// count and its listed organizations' names.
|
||||
func (r *ruleWorld) preview(orgType, target string, limit int32) (counts map[entitlements.DefaultBucket]int64, names map[entitlements.DefaultBucket][]string, rows []entitlements.ListPlanPoolBucketsByOrgTypeRow) {
|
||||
r.t.Helper()
|
||||
t, err := entitlements.PlanTarget(target)
|
||||
if err != nil {
|
||||
r.t.Fatalf("target %q: %v", target, err)
|
||||
}
|
||||
rows, err = r.q.ListPlanPoolBucketsByOrgType(r.ctx, entitlements.ListPlanPoolBucketsByOrgTypeParams{OrgType: orgType, Target: t, PerClassLimit: limit})
|
||||
if err != nil {
|
||||
r.t.Fatalf("read the preview of %s: %v", orgType, err)
|
||||
}
|
||||
counts = map[entitlements.DefaultBucket]int64{}
|
||||
names = map[entitlements.DefaultBucket][]string{}
|
||||
for _, row := range rows {
|
||||
b := entitlements.DefaultBucket(row.Bucket)
|
||||
counts[b] = row.BucketCount
|
||||
names[b] = append(names[b], row.Name)
|
||||
}
|
||||
return counts, names, rows
|
||||
}
|
||||
|
||||
// gate reads core.outgoing_default_exists for the types.
|
||||
func (r *ruleWorld) gate(target string, orgTypes ...string) bool {
|
||||
r.t.Helper()
|
||||
t, err := entitlements.PlanTarget(target)
|
||||
if err != nil {
|
||||
r.t.Fatalf("target %q: %v", target, err)
|
||||
}
|
||||
exists, err := r.q.GetOutgoingDefaultExists(r.ctx, entitlements.GetOutgoingDefaultExistsParams{OrgTypes: orgTypes, Target: t})
|
||||
if err != nil {
|
||||
r.t.Fatalf("read the gate: %v", err)
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// TestGetPlanPoolBucket gives a pool the class of the rule under each
|
||||
// arrangement of positions the default change meets, and
|
||||
// ListOutgoingPlanPositionsByPool the provisions the move acts on.
|
||||
func TestGetPlanPoolBucket(t *testing.T) {
|
||||
r := newRuleWorld(t)
|
||||
r.useType()
|
||||
target := r.product("Target")
|
||||
oldA, oldB := r.product("OldA"), r.product("OldB")
|
||||
shared := r.product("Shared")
|
||||
r.ladder("Target", target)
|
||||
r.ladder("OldA", oldA)
|
||||
r.ladder("OldB", oldB)
|
||||
r.ladder("SharedOne", shared)
|
||||
r.ladder("SharedTwo", shared)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
build func(o testOrg) (outgoing []string)
|
||||
want entitlements.DefaultBucket
|
||||
wantNone bool // the same pool is checked again with a None target
|
||||
}{
|
||||
{"plan-less", target, func(testOrg) []string { return nil }, entitlements.BucketWouldInitiate, false},
|
||||
{"default at the target", target, func(o testOrg) []string {
|
||||
r.grantPosition(o, target, "default")
|
||||
return nil
|
||||
}, entitlements.BucketAlreadyAtDefault, true},
|
||||
{"default outgoing", target, func(o testOrg) []string {
|
||||
return []string{r.grantPosition(o, oldA, "default")}
|
||||
}, entitlements.BucketOutgoingDefault, true},
|
||||
{"default outgoing, stranded on a ladder that is not the type's", target, func(o testOrg) []string {
|
||||
return []string{r.grantPosition(o, oldB, "default")}
|
||||
}, entitlements.BucketOutgoingDefault, true},
|
||||
{"subscription is another source", target, func(o testOrg) []string {
|
||||
r.subscriptionPosition(o, oldA)
|
||||
return nil
|
||||
}, entitlements.BucketOtherSource, false},
|
||||
{"legacy grant is another source", target, func(o testOrg) []string {
|
||||
r.grantPosition(o, oldA, "legacy")
|
||||
return nil
|
||||
}, entitlements.BucketOtherSource, false},
|
||||
{"operator grant is another source", target, func(o testOrg) []string {
|
||||
r.grantPosition(o, oldA, "manual")
|
||||
return nil
|
||||
}, entitlements.BucketOtherSource, false},
|
||||
{"a default and another source in one pool", target, func(o testOrg) []string {
|
||||
r.subscriptionPosition(o, oldB)
|
||||
p := r.grantPosition(o, oldA, "default")
|
||||
r.mustHold(o, 2)
|
||||
return []string{p}
|
||||
}, entitlements.BucketOutgoingDefault, true},
|
||||
{"a default at the target and one outgoing in one pool", target, func(o testOrg) []string {
|
||||
r.grantPosition(o, target, "default")
|
||||
p := r.grantPosition(o, oldA, "default")
|
||||
r.mustHold(o, 2)
|
||||
return []string{p}
|
||||
}, entitlements.BucketOutgoingDefault, true},
|
||||
{"a default at the target and another source in one pool", target, func(o testOrg) []string {
|
||||
r.grantPosition(o, target, "default")
|
||||
r.subscriptionPosition(o, oldA)
|
||||
r.mustHold(o, 2)
|
||||
return nil
|
||||
}, entitlements.BucketAlreadyAtDefault, true},
|
||||
{"a suspended default is live", target, func(o testOrg) []string {
|
||||
p := r.grantPosition(o, oldA, "default")
|
||||
r.exec(`UPDATE core.pool_provisions SET status = 'suspended' WHERE provision_id = $1`, p)
|
||||
return []string{p}
|
||||
}, entitlements.BucketOutgoingDefault, true},
|
||||
{"an ended default is not live", target, func(o testOrg) []string {
|
||||
r.endProvision(r.grantPosition(o, oldA, "default"))
|
||||
return nil
|
||||
}, entitlements.BucketWouldInitiate, false},
|
||||
{"two outgoing provisions, one on two ladders, listed once each", target, func(o testOrg) []string {
|
||||
p := []string{r.grantPosition(o, shared, "default"), r.grantPosition(o, oldA, "default")}
|
||||
r.mustHold(o, 3) // the shared product sits on two ladders
|
||||
return p
|
||||
}, entitlements.BucketOutgoingDefault, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer r.on(t)()
|
||||
o := r.newOrg(c.name, true)
|
||||
outgoing := c.build(o)
|
||||
slices.Sort(outgoing)
|
||||
if got := r.bucket(o, c.target); got != c.want {
|
||||
t.Errorf("class under the target = %q, want %q", got, c.want)
|
||||
}
|
||||
if got := r.outgoing(o, c.target); !slices.Equal(got, outgoing) {
|
||||
t.Errorf("outgoing provisions = %v, want %v", got, outgoing)
|
||||
}
|
||||
if !c.wantNone {
|
||||
return
|
||||
}
|
||||
// With a None target there is no product to be at, so every
|
||||
// default-sourced position is outgoing.
|
||||
if got := r.bucket(o, ""); got != entitlements.BucketOutgoingDefault {
|
||||
t.Errorf("class under a None target = %q, want %q", got, entitlements.BucketOutgoingDefault)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gateScenario is an arrangement of organizations in one org type and
|
||||
// whether the type holds an outgoing one under the target ("" is None).
|
||||
type gateScenario struct {
|
||||
name string
|
||||
target string
|
||||
build func()
|
||||
wantOutgoing bool
|
||||
}
|
||||
|
||||
// gateScenarios are the arrangements the rule meets.
|
||||
func gateScenarios(r *ruleWorld, target, other string) []gateScenario {
|
||||
// Products are ordered by id and ids rise with creation, so this one is
|
||||
// above target and other, and a gate that read one end only would miss
|
||||
// that the other end differs.
|
||||
late := r.product("Late")
|
||||
r.ladder("Late", late)
|
||||
return []gateScenario{
|
||||
{"a mixed pool under the newest target", late, func() {
|
||||
o := r.newOrg("Mixed late", true)
|
||||
r.grantPosition(o, late, "default")
|
||||
r.grantPosition(o, other, "default")
|
||||
r.mustHold(o, 2)
|
||||
}, true},
|
||||
{"a mixed pool", target, func() {
|
||||
o := r.newOrg("Mixed", true)
|
||||
r.grantPosition(o, target, "default")
|
||||
r.grantPosition(o, other, "default")
|
||||
r.mustHold(o, 2)
|
||||
}, true},
|
||||
{"a None target", "", func() {
|
||||
r.grantPosition(r.newOrg("Holder", true), target, "default")
|
||||
}, true},
|
||||
{"a type all at the target", target, func() {
|
||||
for _, name := range []string{"One", "Two"} {
|
||||
r.grantPosition(r.newOrg(name, true), target, "default")
|
||||
}
|
||||
}, false},
|
||||
{"a type holding another source only", target, func() {
|
||||
r.subscriptionPosition(r.newOrg("Subscriber", true), other)
|
||||
r.grantPosition(r.newOrg("Granted", true), other, "manual")
|
||||
}, false},
|
||||
{"a plan-less type", target, func() {
|
||||
r.newOrg("Planless", true)
|
||||
}, false},
|
||||
{"a pool-less organization", target, func() {
|
||||
r.newOrg("Poolless", false)
|
||||
}, false},
|
||||
{"an empty type", target, func() {}, false},
|
||||
{"an outgoing organization among others", target, func() {
|
||||
r.grantPosition(r.newOrg("A at target", true), target, "default")
|
||||
r.subscriptionPosition(r.newOrg("B other", true), other)
|
||||
r.newOrg("C plan-less", true)
|
||||
r.grantPosition(r.newOrg("D outgoing", true), other, "default")
|
||||
r.newOrg("E pool-less", false)
|
||||
}, true},
|
||||
}
|
||||
}
|
||||
|
||||
// requireAgreement requires the preview's read to find an outgoing
|
||||
// organization in the type exactly when the gate is true, and the move's
|
||||
// read to give every pool the class the preview's read gave it.
|
||||
func (r *ruleWorld) requireAgreement(orgType string, s gateScenario) {
|
||||
r.t.Helper()
|
||||
counts, _, rows := r.preview(orgType, s.target, 50)
|
||||
if found := counts[entitlements.BucketOutgoingDefault] > 0; found != s.wantOutgoing {
|
||||
r.t.Errorf("the preview finds an outgoing organization = %v, want %v", found, s.wantOutgoing)
|
||||
}
|
||||
if gate := r.gate(s.target, orgType); gate != s.wantOutgoing {
|
||||
r.t.Errorf("core.outgoing_default_exists = %v, want %v", gate, s.wantOutgoing)
|
||||
}
|
||||
target, err := entitlements.PlanTarget(s.target)
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.PoolID.Valid {
|
||||
if entitlements.DefaultBucket(row.Bucket) != entitlements.BucketWouldFail {
|
||||
r.t.Errorf("%s has no pool and the preview gives it %q", row.Name, row.Bucket)
|
||||
}
|
||||
continue
|
||||
}
|
||||
move, err := r.q.GetPlanPoolBucket(r.ctx, entitlements.GetPlanPoolBucketParams{PoolID: row.PoolID.UUID.String(), Target: target})
|
||||
if err != nil {
|
||||
r.t.Fatal(err)
|
||||
}
|
||||
if move != row.Bucket {
|
||||
r.t.Errorf("%s: the move reads class %q, the preview %q", row.Name, move, row.Bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispositionGateAgreesWithThePreview builds the arrangements the rule
|
||||
// meets, each in a type of its own, and requires the preview's read to find
|
||||
// an outgoing organization exactly when core.outgoing_default_exists is
|
||||
// true, and GetPlanPoolBucket to give every pool the class the preview's
|
||||
// read gave it.
|
||||
func TestDispositionGateAgreesWithThePreview(t *testing.T) {
|
||||
r := newRuleWorld(t)
|
||||
target, other := r.product("Target"), r.product("Other")
|
||||
r.ladder("Target", target)
|
||||
r.ladder("Other", other)
|
||||
|
||||
types := map[string]string{}
|
||||
for _, s := range gateScenarios(r, target, other) {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
defer r.on(t)()
|
||||
types[s.name] = r.useType()
|
||||
s.build()
|
||||
r.requireAgreement(types[s.name], s)
|
||||
})
|
||||
}
|
||||
// The gate reads every type it is given.
|
||||
emptyType, outgoingType := types["an empty type"], types["a mixed pool"]
|
||||
if emptyType == "" || outgoingType == "" {
|
||||
t.Fatal("the scenarios did not name an empty and an outgoing type")
|
||||
}
|
||||
if !r.gate(target, emptyType, outgoingType) {
|
||||
t.Error("the gate over an empty type and an outgoing type is false, want true")
|
||||
}
|
||||
if r.gate(target, emptyType) {
|
||||
t.Error("the gate over an empty type is true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPlanPoolBucketsCapsEachClass lists at most the limit of names per
|
||||
// class, by name and then id, beside each class's exact count, and no row
|
||||
// for a class nobody falls in. The organizations are created out of name
|
||||
// order, with two of one name, because input that is already sorted cannot
|
||||
// fail.
|
||||
func TestListPlanPoolBucketsCapsEachClass(t *testing.T) {
|
||||
r := newRuleWorld(t)
|
||||
orgType := r.useType()
|
||||
old, target := r.product("Old"), r.product("Target")
|
||||
r.ladder("Old", old)
|
||||
r.ladder("Target", target)
|
||||
|
||||
var ids = map[string][]string{}
|
||||
for _, name := range []string{"delta", "alpha", "echo", "alpha", "charlie"} {
|
||||
o := r.newOrg(name, true)
|
||||
r.grantPosition(o, old, "default")
|
||||
ids[name] = append(ids[name], o.org.OrgID)
|
||||
}
|
||||
for _, name := range []string{"zulu", "yankee", "x-ray"} {
|
||||
r.newOrg(name, false)
|
||||
}
|
||||
|
||||
counts, names, rows := r.preview(orgType, target, 2)
|
||||
if counts[entitlements.BucketOutgoingDefault] != 5 || counts[entitlements.BucketWouldFail] != 3 {
|
||||
t.Errorf("counts = %v, want 5 outgoing and 3 would fail", counts)
|
||||
}
|
||||
if len(rows) != 4 {
|
||||
t.Errorf("%d rows, want 2 of each of the two classes held", len(rows))
|
||||
}
|
||||
for _, bucket := range []entitlements.DefaultBucket{entitlements.BucketWouldInitiate, entitlements.BucketAlreadyAtDefault, entitlements.BucketOtherSource} {
|
||||
if _, ok := counts[bucket]; ok {
|
||||
t.Errorf("a row for the empty class %q", bucket)
|
||||
}
|
||||
}
|
||||
if want := []string{"alpha", "alpha"}; !slices.Equal(names[entitlements.BucketOutgoingDefault], want) {
|
||||
t.Errorf("outgoing names = %v, want %v", names[entitlements.BucketOutgoingDefault], want)
|
||||
}
|
||||
if want := []string{"x-ray", "yankee"}; !slices.Equal(names[entitlements.BucketWouldFail], want) {
|
||||
t.Errorf("would-fail names = %v, want %v", names[entitlements.BucketWouldFail], want)
|
||||
}
|
||||
var listed []string
|
||||
for _, row := range rows {
|
||||
if entitlements.DefaultBucket(row.Bucket) == entitlements.BucketOutgoingDefault {
|
||||
listed = append(listed, row.OrgID)
|
||||
}
|
||||
}
|
||||
if !slices.IsSorted(listed) {
|
||||
t.Errorf("the two organizations of one name are listed %v, not by id", listed)
|
||||
}
|
||||
if !slices.Equal(listed, slices.Sorted(slices.Values(ids["alpha"]))) {
|
||||
t.Errorf("listed %v, want both alphas %v", listed, ids["alpha"])
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (q *Queries) CountActiveAttachmentsPerTier(ctx context.Context, planLadderI
|
||||
}
|
||||
|
||||
const getActiveAttachmentsByPool = `-- name: GetActiveAttachmentsByPool :many
|
||||
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id FROM core.pool_provision_ladders
|
||||
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id, org_type, default_sourced FROM core.pool_provision_ladders
|
||||
WHERE pool_id = $1 AND status = 'active'
|
||||
ORDER BY activated_at DESC
|
||||
`
|
||||
@@ -150,6 +150,8 @@ func (q *Queries) GetActiveAttachmentsByPool(ctx context.Context, poolID string)
|
||||
&i.UpdatedAt,
|
||||
&i.ProductID,
|
||||
&i.ProvisionLadderID,
|
||||
&i.OrgType,
|
||||
&i.DefaultSourced,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,7 +168,7 @@ func (q *Queries) GetActiveAttachmentsByPool(ctx context.Context, poolID string)
|
||||
|
||||
const getActiveLadderAttachmentByPoolLadder = `-- name: GetActiveLadderAttachmentByPoolLadder :one
|
||||
|
||||
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id FROM core.pool_provision_ladders
|
||||
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id, org_type, default_sourced FROM core.pool_provision_ladders
|
||||
WHERE pool_id = $1 AND plan_ladder_id = $2 AND status = 'active'
|
||||
LIMIT 1
|
||||
`
|
||||
@@ -193,70 +195,57 @@ func (q *Queries) GetActiveLadderAttachmentByPoolLadder(ctx context.Context, arg
|
||||
&i.UpdatedAt,
|
||||
&i.ProductID,
|
||||
&i.ProvisionLadderID,
|
||||
&i.OrgType,
|
||||
&i.DefaultSourced,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLivePlanAttachmentsWithSourceByPool = `-- name: GetLivePlanAttachmentsWithSourceByPool :many
|
||||
SELECT l.provision_ladder_id, l.plan_ladder_id, l.provision_id,
|
||||
l.status AS attachment_status,
|
||||
p.product_id, p.grant_id, p.subscription_id, p.purchase_id,
|
||||
g.grant_reason
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
|
||||
WHERE l.pool_id = $1 AND l.status IN ('active', 'suspended')
|
||||
ORDER BY l.activated_at DESC
|
||||
const getOutgoingDefaultExists = `-- name: GetOutgoingDefaultExists :one
|
||||
SELECT core.outgoing_default_exists($1::varchar[], $2::uuid)::boolean AS outgoing_exists
|
||||
`
|
||||
|
||||
type GetLivePlanAttachmentsWithSourceByPoolRow struct {
|
||||
ProvisionLadderID string `json:"provision_ladder_id"`
|
||||
PlanLadderID string `json:"plan_ladder_id"`
|
||||
ProvisionID string `json:"provision_id"`
|
||||
AttachmentStatus string `json:"attachment_status"`
|
||||
ProductID string `json:"product_id"`
|
||||
GrantID uuid.NullUUID `json:"grant_id"`
|
||||
SubscriptionID uuid.NullUUID `json:"subscription_id"`
|
||||
PurchaseID uuid.NullUUID `json:"purchase_id"`
|
||||
GrantReason sql.NullString `json:"grant_reason"`
|
||||
type GetOutgoingDefaultExistsParams struct {
|
||||
OrgTypes []string `json:"org_types"`
|
||||
Target uuid.NullUUID `json:"target"`
|
||||
}
|
||||
|
||||
// Live (active|suspended) plan-ladder attachments for a pool together with
|
||||
// their source provenance, for the org-type default-change classification:
|
||||
// a grant-sourced row with grant_reason = 'default' marks the pool as holding
|
||||
// the outgoing default (bucket 2); any other live row is another source's
|
||||
// plan (bucket 3); no rows at all is a plan-less pool (bucket 1).
|
||||
func (q *Queries) GetLivePlanAttachmentsWithSourceByPool(ctx context.Context, poolID string) ([]GetLivePlanAttachmentsWithSourceByPoolRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getLivePlanAttachmentsWithSourceByPool, poolID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetLivePlanAttachmentsWithSourceByPoolRow{}
|
||||
for rows.Next() {
|
||||
var i GetLivePlanAttachmentsWithSourceByPoolRow
|
||||
if err := rows.Scan(
|
||||
&i.ProvisionLadderID,
|
||||
&i.PlanLadderID,
|
||||
&i.ProvisionID,
|
||||
&i.AttachmentStatus,
|
||||
&i.ProductID,
|
||||
&i.GrantID,
|
||||
&i.SubscriptionID,
|
||||
&i.PurchaseID,
|
||||
&i.GrantReason,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
// Whether any organization of the given types holds a live default-sourced
|
||||
// position that core.plan_position_is_outgoing marks outgoing under the
|
||||
// target (NULL for None): the disposition gate, which reads two entries of
|
||||
// idx_pool_provision_ladders_live_default per type.
|
||||
func (q *Queries) GetOutgoingDefaultExists(ctx context.Context, arg GetOutgoingDefaultExistsParams) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOutgoingDefaultExists, pq.Array(arg.OrgTypes), arg.Target)
|
||||
var outgoing_exists bool
|
||||
err := row.Scan(&outgoing_exists)
|
||||
return outgoing_exists, err
|
||||
}
|
||||
|
||||
const getPlanPoolBucket = `-- name: GetPlanPoolBucket :one
|
||||
SELECT core.plan_pool_bucket(
|
||||
true,
|
||||
COALESCE(bool_or(core.plan_position_is_outgoing(l.default_sourced, l.product_id, $1::uuid)), false),
|
||||
COALESCE(bool_or(l.default_sourced), false),
|
||||
count(l.provision_ladder_id) > 0)::varchar AS bucket
|
||||
FROM core.pool_provision_ladders l
|
||||
WHERE l.pool_id = $2 AND l.status IN ('active', 'suspended')
|
||||
`
|
||||
|
||||
type GetPlanPoolBucketParams struct {
|
||||
Target uuid.NullUUID `json:"target"`
|
||||
PoolID string `json:"pool_id"`
|
||||
}
|
||||
|
||||
// The class of one active default pool under the target (the candidate's
|
||||
// rank-0 product, NULL for None): its live (active|suspended) positions
|
||||
// folded into core.plan_pool_bucket, the rule ListPlanPoolBucketsByOrgType
|
||||
// applies to every pool of a type. A pool with no live position is
|
||||
// would_initiate.
|
||||
func (q *Queries) GetPlanPoolBucket(ctx context.Context, arg GetPlanPoolBucketParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getPlanPoolBucket, arg.Target, arg.PoolID)
|
||||
var bucket string
|
||||
err := row.Scan(&bucket)
|
||||
return bucket, err
|
||||
}
|
||||
|
||||
const listLadderPositionsByPool = `-- name: ListLadderPositionsByPool :many
|
||||
@@ -378,63 +367,36 @@ func (q *Queries) ListLadderPositionsByPool(ctx context.Context, poolID string)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLivePlanPositionsByOrgType = `-- name: ListLivePlanPositionsByOrgType :many
|
||||
SELECT o.org_id, o.name,
|
||||
rp.pool_id,
|
||||
l.provision_ladder_id, l.plan_ladder_id, l.provision_id,
|
||||
l.status AS attachment_status,
|
||||
p.product_id, p.grant_id, p.subscription_id, p.purchase_id,
|
||||
g.grant_reason
|
||||
FROM core.organizations o
|
||||
LEFT JOIN core.resource_pools rp
|
||||
ON rp.org_id = o.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
|
||||
LEFT JOIN core.pool_provision_ladders l
|
||||
ON l.pool_id = rp.pool_id AND l.status IN ('active', 'suspended')
|
||||
LEFT JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
const listLivePlanSourcesByPools = `-- name: ListLivePlanSourcesByPools :many
|
||||
SELECT l.pool_id, p.subscription_id, p.purchase_id, g.grant_reason
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
|
||||
WHERE o.org_type = $1
|
||||
ORDER BY o.name, o.org_id
|
||||
WHERE l.pool_id = ANY($1::uuid[]) AND l.status IN ('active', 'suspended')
|
||||
ORDER BY l.pool_id, l.activated_at DESC, l.provision_ladder_id
|
||||
`
|
||||
|
||||
type ListLivePlanPositionsByOrgTypeRow struct {
|
||||
OrgID string `json:"org_id"`
|
||||
Name string `json:"name"`
|
||||
PoolID uuid.NullUUID `json:"pool_id"`
|
||||
ProvisionLadderID uuid.NullUUID `json:"provision_ladder_id"`
|
||||
PlanLadderID uuid.NullUUID `json:"plan_ladder_id"`
|
||||
ProvisionID uuid.NullUUID `json:"provision_id"`
|
||||
AttachmentStatus sql.NullString `json:"attachment_status"`
|
||||
ProductID uuid.NullUUID `json:"product_id"`
|
||||
GrantID uuid.NullUUID `json:"grant_id"`
|
||||
SubscriptionID uuid.NullUUID `json:"subscription_id"`
|
||||
PurchaseID uuid.NullUUID `json:"purchase_id"`
|
||||
GrantReason sql.NullString `json:"grant_reason"`
|
||||
type ListLivePlanSourcesByPoolsRow struct {
|
||||
PoolID string `json:"pool_id"`
|
||||
SubscriptionID uuid.NullUUID `json:"subscription_id"`
|
||||
PurchaseID uuid.NullUUID `json:"purchase_id"`
|
||||
GrantReason sql.NullString `json:"grant_reason"`
|
||||
}
|
||||
|
||||
// The whole population for the org-type default-change classification in one
|
||||
// round trip: one row per (org, live plan attachment), plus a row with NULL
|
||||
// pool for orgs lacking a default pool and a row with NULL attachment for
|
||||
// plan-less pools (LEFT JOINs). Replaces a per-org query pair, which does not
|
||||
// scale past a few hundred organizations of one type.
|
||||
func (q *Queries) ListLivePlanPositionsByOrgType(ctx context.Context, orgType string) ([]ListLivePlanPositionsByOrgTypeRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listLivePlanPositionsByOrgType, orgType)
|
||||
// What holds the live (active|suspended) positions of each listed pool, for
|
||||
// the preview's other-source lines: the preview names at most 50 such pools,
|
||||
// and reads their sources here in one statement.
|
||||
func (q *Queries) ListLivePlanSourcesByPools(ctx context.Context, poolIds []string) ([]ListLivePlanSourcesByPoolsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listLivePlanSourcesByPools, pq.Array(poolIds))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListLivePlanPositionsByOrgTypeRow{}
|
||||
items := []ListLivePlanSourcesByPoolsRow{}
|
||||
for rows.Next() {
|
||||
var i ListLivePlanPositionsByOrgTypeRow
|
||||
var i ListLivePlanSourcesByPoolsRow
|
||||
if err := rows.Scan(
|
||||
&i.OrgID,
|
||||
&i.Name,
|
||||
&i.PoolID,
|
||||
&i.ProvisionLadderID,
|
||||
&i.PlanLadderID,
|
||||
&i.ProvisionID,
|
||||
&i.AttachmentStatus,
|
||||
&i.ProductID,
|
||||
&i.GrantID,
|
||||
&i.SubscriptionID,
|
||||
&i.PurchaseID,
|
||||
&i.GrantReason,
|
||||
@@ -454,6 +416,7 @@ func (q *Queries) ListLivePlanPositionsByOrgType(ctx context.Context, orgType st
|
||||
|
||||
const listLiveTierHoldersByLadderProduct = `-- name: ListLiveTierHoldersByLadderProduct :many
|
||||
SELECT l.provision_ladder_id, l.provision_id, l.status AS attachment_status,
|
||||
l.default_sourced,
|
||||
p.product_id, p.grant_id, p.subscription_id, p.purchase_id,
|
||||
g.grant_reason,
|
||||
rp.pool_id, o.org_id, o.name
|
||||
@@ -476,6 +439,7 @@ type ListLiveTierHoldersByLadderProductRow struct {
|
||||
ProvisionLadderID string `json:"provision_ladder_id"`
|
||||
ProvisionID string `json:"provision_id"`
|
||||
AttachmentStatus string `json:"attachment_status"`
|
||||
DefaultSourced bool `json:"default_sourced"`
|
||||
ProductID string `json:"product_id"`
|
||||
GrantID uuid.NullUUID `json:"grant_id"`
|
||||
SubscriptionID uuid.NullUUID `json:"subscription_id"`
|
||||
@@ -489,7 +453,7 @@ type ListLiveTierHoldersByLadderProductRow struct {
|
||||
// The tier-removal preview/commit population in one round trip: every live
|
||||
// (active|suspended) junction on this ladder whose provision delivers this
|
||||
// product, with the org name and grant provenance so holders classify
|
||||
// per position source (default-sourced needs a disposition; any other source
|
||||
// per position source (default_sourced needs a disposition; any other source
|
||||
// is align-shrunk and never force-ended).
|
||||
func (q *Queries) ListLiveTierHoldersByLadderProduct(ctx context.Context, arg ListLiveTierHoldersByLadderProductParams) ([]ListLiveTierHoldersByLadderProductRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listLiveTierHoldersByLadderProduct, arg.PlanLadderID, arg.ProductID)
|
||||
@@ -504,6 +468,7 @@ func (q *Queries) ListLiveTierHoldersByLadderProduct(ctx context.Context, arg Li
|
||||
&i.ProvisionLadderID,
|
||||
&i.ProvisionID,
|
||||
&i.AttachmentStatus,
|
||||
&i.DefaultSourced,
|
||||
&i.ProductID,
|
||||
&i.GrantID,
|
||||
&i.SubscriptionID,
|
||||
@@ -525,3 +490,135 @@ func (q *Queries) ListLiveTierHoldersByLadderProduct(ctx context.Context, arg Li
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listOutgoingPlanPositionsByPool = `-- name: ListOutgoingPlanPositionsByPool :many
|
||||
SELECT l.provision_id, p.grant_id, l.product_id
|
||||
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.status IN ('active', 'suspended')
|
||||
AND core.plan_position_is_outgoing(l.default_sourced, l.product_id, $2::uuid)
|
||||
GROUP BY l.provision_id, p.grant_id, l.product_id
|
||||
ORDER BY max(l.activated_at) DESC, l.provision_id
|
||||
`
|
||||
|
||||
type ListOutgoingPlanPositionsByPoolParams struct {
|
||||
PoolID string `json:"pool_id"`
|
||||
Target uuid.NullUUID `json:"target"`
|
||||
}
|
||||
|
||||
type ListOutgoingPlanPositionsByPoolRow struct {
|
||||
ProvisionID string `json:"provision_id"`
|
||||
GrantID uuid.NullUUID `json:"grant_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// The distinct provisions of a pool whose live positions
|
||||
// core.plan_position_is_outgoing marks outgoing under the target: the
|
||||
// incumbents a grandfather or a migrate acts on, with the grant that sources
|
||||
// each and the product it delivers.
|
||||
func (q *Queries) ListOutgoingPlanPositionsByPool(ctx context.Context, arg ListOutgoingPlanPositionsByPoolParams) ([]ListOutgoingPlanPositionsByPoolRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listOutgoingPlanPositionsByPool, arg.PoolID, arg.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListOutgoingPlanPositionsByPoolRow{}
|
||||
for rows.Next() {
|
||||
var i ListOutgoingPlanPositionsByPoolRow
|
||||
if err := rows.Scan(&i.ProvisionID, &i.GrantID, &i.ProductID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPlanPoolBucketsByOrgType = `-- name: ListPlanPoolBucketsByOrgType :many
|
||||
WITH folded AS (
|
||||
SELECT o.org_id, o.name, rp.pool_id,
|
||||
rp.pool_id IS NOT NULL AS has_pool,
|
||||
COALESCE(bool_or(core.plan_position_is_outgoing(l.default_sourced, l.product_id, $2::uuid)), false) AS any_outgoing,
|
||||
COALESCE(bool_or(l.default_sourced), false) AS any_default_sourced,
|
||||
count(l.provision_ladder_id) > 0 AS any_position
|
||||
FROM core.organizations o
|
||||
LEFT JOIN core.resource_pools rp
|
||||
ON rp.org_id = o.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
|
||||
LEFT JOIN core.pool_provision_ladders l
|
||||
ON l.pool_id = rp.pool_id AND l.status IN ('active', 'suspended')
|
||||
WHERE o.org_type = $3::varchar
|
||||
GROUP BY o.org_id, o.name, rp.pool_id
|
||||
), classed AS (
|
||||
SELECT org_id, name, pool_id,
|
||||
core.plan_pool_bucket(has_pool, any_outgoing, any_default_sourced, any_position) AS bucket
|
||||
FROM folded
|
||||
), ranked AS (
|
||||
SELECT bucket, org_id, name, pool_id,
|
||||
count(*) OVER (PARTITION BY bucket) AS bucket_count,
|
||||
row_number() OVER (PARTITION BY bucket ORDER BY name, org_id) AS rn
|
||||
FROM classed
|
||||
)
|
||||
SELECT bucket::varchar AS bucket, bucket_count, org_id, name, pool_id
|
||||
FROM ranked
|
||||
WHERE rn <= $1::int
|
||||
ORDER BY bucket, rn
|
||||
`
|
||||
|
||||
type ListPlanPoolBucketsByOrgTypeParams struct {
|
||||
PerClassLimit int32 `json:"per_class_limit"`
|
||||
Target uuid.NullUUID `json:"target"`
|
||||
OrgType string `json:"org_type"`
|
||||
}
|
||||
|
||||
type ListPlanPoolBucketsByOrgTypeRow struct {
|
||||
Bucket string `json:"bucket"`
|
||||
BucketCount int64 `json:"bucket_count"`
|
||||
OrgID string `json:"org_id"`
|
||||
Name string `json:"name"`
|
||||
PoolID uuid.NullUUID `json:"pool_id"`
|
||||
}
|
||||
|
||||
// The default-change preview's whole read, in one statement whatever the
|
||||
// type's size: each organization of the type with its active default pool,
|
||||
// that pool's live (active|suspended) positions folded into the four
|
||||
// arguments of core.plan_pool_bucket (each bool_or inside COALESCE, because
|
||||
// it is NULL for a pool with no live position and for an organization with
|
||||
// no pool), and for each class its exact count and its first per_class_limit
|
||||
// organizations by name and then id. A class no organization falls in has no
|
||||
// row; the result holds at most five classes of per_class_limit rows. The
|
||||
// target is the candidate's rank-0 product, NULL for None. pool_id is NULL
|
||||
// for an organization with no active default pool.
|
||||
func (q *Queries) ListPlanPoolBucketsByOrgType(ctx context.Context, arg ListPlanPoolBucketsByOrgTypeParams) ([]ListPlanPoolBucketsByOrgTypeRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPlanPoolBucketsByOrgType, arg.PerClassLimit, arg.Target, arg.OrgType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListPlanPoolBucketsByOrgTypeRow{}
|
||||
for rows.Next() {
|
||||
var i ListPlanPoolBucketsByOrgTypeRow
|
||||
if err := rows.Scan(
|
||||
&i.Bucket,
|
||||
&i.BucketCount,
|
||||
&i.OrgID,
|
||||
&i.Name,
|
||||
&i.PoolID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -133,12 +133,6 @@ type Querier interface {
|
||||
// the subscription as holder, whose product is a tier of the ladder, and
|
||||
// that tier's rank. Condition 5 leaves at most one such grant per ladder.
|
||||
GetHeldGrantOnLadder(ctx context.Context, arg GetHeldGrantOnLadderParams) (GetHeldGrantOnLadderRow, error)
|
||||
// Live (active|suspended) plan-ladder attachments for a pool together with
|
||||
// their source provenance, for the org-type default-change classification:
|
||||
// a grant-sourced row with grant_reason = 'default' marks the pool as holding
|
||||
// the outgoing default (bucket 2); any other live row is another source's
|
||||
// plan (bucket 3); no rows at all is a plan-less pool (bucket 1).
|
||||
GetLivePlanAttachmentsWithSourceByPool(ctx context.Context, poolID string) ([]GetLivePlanAttachmentsWithSourceByPoolRow, error)
|
||||
// Live (non-ended) provisions delivering a product. Tier add/remove aligns
|
||||
// each of these to the product's new conferral shape.
|
||||
GetLivePoolProvisionsByProductID(ctx context.Context, productID string) ([]PoolProvision, error)
|
||||
@@ -153,6 +147,17 @@ type Querier interface {
|
||||
// One obligation's state, which the drain reads before it does any work: an
|
||||
// obligation already settled is returned to a redispatched drain untouched.
|
||||
GetObligationStatus(ctx context.Context, arg GetObligationStatusParams) (GetObligationStatusRow, error)
|
||||
// Whether any organization of the given types holds a live default-sourced
|
||||
// position that core.plan_position_is_outgoing marks outgoing under the
|
||||
// target (NULL for None): the disposition gate, which reads two entries of
|
||||
// idx_pool_provision_ladders_live_default per type.
|
||||
GetOutgoingDefaultExists(ctx context.Context, arg GetOutgoingDefaultExistsParams) (bool, error)
|
||||
// The class of one active default pool under the target (the candidate's
|
||||
// rank-0 product, NULL for None): its live (active|suspended) positions
|
||||
// folded into core.plan_pool_bucket, the rule ListPlanPoolBucketsByOrgType
|
||||
// applies to every pool of a type. A pool with no live position is
|
||||
// would_initiate.
|
||||
GetPlanPoolBucket(ctx context.Context, arg GetPlanPoolBucketParams) (string, error)
|
||||
GetPoolProvisionByProvisionID(ctx context.Context, provisionID string) (PoolProvision, error)
|
||||
GetPrimaryPoolAssignmentByWorkspace(ctx context.Context, workspaceID string) (PoolAssignment, error)
|
||||
// A product's lifecycle, read before an extension writes anything, so a
|
||||
@@ -359,16 +364,14 @@ type Querier interface {
|
||||
// cancellation and the first scheduled plan switch with a target price are
|
||||
// the ones the page draws, with the change id as the tie-break.
|
||||
ListLadderPositionsByPool(ctx context.Context, poolID string) ([]ListLadderPositionsByPoolRow, error)
|
||||
// The whole population for the org-type default-change classification in one
|
||||
// round trip: one row per (org, live plan attachment), plus a row with NULL
|
||||
// pool for orgs lacking a default pool and a row with NULL attachment for
|
||||
// plan-less pools (LEFT JOINs). Replaces a per-org query pair, which does not
|
||||
// scale past a few hundred organizations of one type.
|
||||
ListLivePlanPositionsByOrgType(ctx context.Context, orgType string) ([]ListLivePlanPositionsByOrgTypeRow, error)
|
||||
// What holds the live (active|suspended) positions of each listed pool, for
|
||||
// the preview's other-source lines: the preview names at most 50 such pools,
|
||||
// and reads their sources here in one statement.
|
||||
ListLivePlanSourcesByPools(ctx context.Context, poolIds []string) ([]ListLivePlanSourcesByPoolsRow, error)
|
||||
// The tier-removal preview/commit population in one round trip: every live
|
||||
// (active|suspended) junction on this ladder whose provision delivers this
|
||||
// product, with the org name and grant provenance so holders classify
|
||||
// per position source (default-sourced needs a disposition; any other source
|
||||
// per position source (default_sourced needs a disposition; any other source
|
||||
// is align-shrunk and never force-ended).
|
||||
ListLiveTierHoldersByLadderProduct(ctx context.Context, arg ListLiveTierHoldersByLadderProductParams) ([]ListLiveTierHoldersByLadderProductRow, error)
|
||||
// Read-only per-pool numeric state for a batch of pools, each row carrying the
|
||||
@@ -380,10 +383,26 @@ type Querier interface {
|
||||
// The pools of one change whose last attempt recorded an error at or after a
|
||||
// moment, for the drain activity's per-pool error log.
|
||||
ListObligationFailuresSince(ctx context.Context, arg ListObligationFailuresSinceParams) ([]ListObligationFailuresSinceRow, error)
|
||||
// The distinct provisions of a pool whose live positions
|
||||
// core.plan_position_is_outgoing marks outgoing under the target: the
|
||||
// incumbents a grandfather or a migrate acts on, with the grant that sources
|
||||
// each and the product it delivers.
|
||||
ListOutgoingPlanPositionsByPool(ctx context.Context, arg ListOutgoingPlanPositionsByPoolParams) ([]ListOutgoingPlanPositionsByPoolRow, error)
|
||||
// The drain's work list for one change. failed is the dead letter: the drain
|
||||
// never picks one up again, and only RequeueFailedObligations, which the
|
||||
// operator's Retry calls, returns it to pending.
|
||||
ListPendingObligations(ctx context.Context, arg ListPendingObligationsParams) ([]EntitlementSetChangeObligation, error)
|
||||
// The default-change preview's whole read, in one statement whatever the
|
||||
// type's size: each organization of the type with its active default pool,
|
||||
// that pool's live (active|suspended) positions folded into the four
|
||||
// arguments of core.plan_pool_bucket (each bool_or inside COALESCE, because
|
||||
// it is NULL for a pool with no live position and for an organization with
|
||||
// no pool), and for each class its exact count and its first per_class_limit
|
||||
// organizations by name and then id. A class no organization falls in has no
|
||||
// row; the result holds at most five classes of per_class_limit rows. The
|
||||
// target is the candidate's rank-0 product, NULL for None. pool_id is NULL
|
||||
// for an organization with no active default pool.
|
||||
ListPlanPoolBucketsByOrgType(ctx context.Context, arg ListPlanPoolBucketsByOrgTypeParams) ([]ListPlanPoolBucketsByOrgTypeRow, error)
|
||||
// Per-resource usage counters for a pool joined to their materialized
|
||||
// limit (used vs limit per resource key), for the org-detail pools panel
|
||||
// (ux-honest-surfaces: "pool status and usage are visible on the
|
||||
|
||||
@@ -26,35 +26,64 @@ FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = ANY(sqlc.arg(plan_ladder_ids)::uuid[]) AND status = 'active'
|
||||
GROUP BY plan_ladder_id;
|
||||
|
||||
-- name: ListLivePlanPositionsByOrgType :many
|
||||
-- The whole population for the org-type default-change classification in one
|
||||
-- round trip: one row per (org, live plan attachment), plus a row with NULL
|
||||
-- pool for orgs lacking a default pool and a row with NULL attachment for
|
||||
-- plan-less pools (LEFT JOINs). Replaces a per-org query pair, which does not
|
||||
-- scale past a few hundred organizations of one type.
|
||||
SELECT o.org_id, o.name,
|
||||
rp.pool_id,
|
||||
l.provision_ladder_id, l.plan_ladder_id, l.provision_id,
|
||||
l.status AS attachment_status,
|
||||
p.product_id, p.grant_id, p.subscription_id, p.purchase_id,
|
||||
g.grant_reason
|
||||
FROM core.organizations o
|
||||
LEFT JOIN core.resource_pools rp
|
||||
ON rp.org_id = o.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
|
||||
LEFT JOIN core.pool_provision_ladders l
|
||||
ON l.pool_id = rp.pool_id AND l.status IN ('active', 'suspended')
|
||||
LEFT JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
-- name: ListPlanPoolBucketsByOrgType :many
|
||||
-- The default-change preview's whole read, in one statement whatever the
|
||||
-- type's size: each organization of the type with its active default pool,
|
||||
-- that pool's live (active|suspended) positions folded into the four
|
||||
-- arguments of core.plan_pool_bucket (each bool_or inside COALESCE, because
|
||||
-- it is NULL for a pool with no live position and for an organization with
|
||||
-- no pool), and for each class its exact count and its first per_class_limit
|
||||
-- organizations by name and then id. A class no organization falls in has no
|
||||
-- row; the result holds at most five classes of per_class_limit rows. The
|
||||
-- target is the candidate's rank-0 product, NULL for None. pool_id is NULL
|
||||
-- for an organization with no active default pool.
|
||||
WITH folded AS (
|
||||
SELECT o.org_id, o.name, rp.pool_id,
|
||||
rp.pool_id IS NOT NULL AS has_pool,
|
||||
COALESCE(bool_or(core.plan_position_is_outgoing(l.default_sourced, l.product_id, sqlc.narg(target)::uuid)), false) AS any_outgoing,
|
||||
COALESCE(bool_or(l.default_sourced), false) AS any_default_sourced,
|
||||
count(l.provision_ladder_id) > 0 AS any_position
|
||||
FROM core.organizations o
|
||||
LEFT JOIN core.resource_pools rp
|
||||
ON rp.org_id = o.org_id AND rp.pool_type = 'default' AND rp.status = 'active'
|
||||
LEFT JOIN core.pool_provision_ladders l
|
||||
ON l.pool_id = rp.pool_id AND l.status IN ('active', 'suspended')
|
||||
WHERE o.org_type = sqlc.arg(org_type)::varchar
|
||||
GROUP BY o.org_id, o.name, rp.pool_id
|
||||
), classed AS (
|
||||
SELECT org_id, name, pool_id,
|
||||
core.plan_pool_bucket(has_pool, any_outgoing, any_default_sourced, any_position) AS bucket
|
||||
FROM folded
|
||||
), ranked AS (
|
||||
SELECT bucket, org_id, name, pool_id,
|
||||
count(*) OVER (PARTITION BY bucket) AS bucket_count,
|
||||
row_number() OVER (PARTITION BY bucket ORDER BY name, org_id) AS rn
|
||||
FROM classed
|
||||
)
|
||||
SELECT bucket::varchar AS bucket, bucket_count, org_id, name, pool_id
|
||||
FROM ranked
|
||||
WHERE rn <= sqlc.arg(per_class_limit)::int
|
||||
ORDER BY bucket, rn;
|
||||
|
||||
-- name: ListLivePlanSourcesByPools :many
|
||||
-- What holds the live (active|suspended) positions of each listed pool, for
|
||||
-- the preview's other-source lines: the preview names at most 50 such pools,
|
||||
-- and reads their sources here in one statement.
|
||||
SELECT l.pool_id, p.subscription_id, p.purchase_id, g.grant_reason
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
|
||||
WHERE o.org_type = $1
|
||||
ORDER BY o.name, o.org_id;
|
||||
WHERE l.pool_id = ANY(sqlc.arg(pool_ids)::uuid[]) AND l.status IN ('active', 'suspended')
|
||||
ORDER BY l.pool_id, l.activated_at DESC, l.provision_ladder_id;
|
||||
|
||||
-- name: ListLiveTierHoldersByLadderProduct :many
|
||||
-- The tier-removal preview/commit population in one round trip: every live
|
||||
-- (active|suspended) junction on this ladder whose provision delivers this
|
||||
-- product, with the org name and grant provenance so holders classify
|
||||
-- per position source (default-sourced needs a disposition; any other source
|
||||
-- per position source (default_sourced needs a disposition; any other source
|
||||
-- is align-shrunk and never force-ended).
|
||||
SELECT l.provision_ladder_id, l.provision_id, l.status AS attachment_status,
|
||||
l.default_sourced,
|
||||
p.product_id, p.grant_id, p.subscription_id, p.purchase_id,
|
||||
g.grant_reason,
|
||||
rp.pool_id, o.org_id, o.name
|
||||
@@ -67,21 +96,40 @@ WHERE l.plan_ladder_id = $1 AND p.product_id = $2
|
||||
AND l.status IN ('active', 'suspended')
|
||||
ORDER BY o.name, o.org_id;
|
||||
|
||||
-- name: GetLivePlanAttachmentsWithSourceByPool :many
|
||||
-- Live (active|suspended) plan-ladder attachments for a pool together with
|
||||
-- their source provenance, for the org-type default-change classification:
|
||||
-- a grant-sourced row with grant_reason = 'default' marks the pool as holding
|
||||
-- the outgoing default (bucket 2); any other live row is another source's
|
||||
-- plan (bucket 3); no rows at all is a plan-less pool (bucket 1).
|
||||
SELECT l.provision_ladder_id, l.plan_ladder_id, l.provision_id,
|
||||
l.status AS attachment_status,
|
||||
p.product_id, p.grant_id, p.subscription_id, p.purchase_id,
|
||||
g.grant_reason
|
||||
-- name: GetPlanPoolBucket :one
|
||||
-- The class of one active default pool under the target (the candidate's
|
||||
-- rank-0 product, NULL for None): its live (active|suspended) positions
|
||||
-- folded into core.plan_pool_bucket, the rule ListPlanPoolBucketsByOrgType
|
||||
-- applies to every pool of a type. A pool with no live position is
|
||||
-- would_initiate.
|
||||
SELECT core.plan_pool_bucket(
|
||||
true,
|
||||
COALESCE(bool_or(core.plan_position_is_outgoing(l.default_sourced, l.product_id, sqlc.narg(target)::uuid)), false),
|
||||
COALESCE(bool_or(l.default_sourced), false),
|
||||
count(l.provision_ladder_id) > 0)::varchar AS bucket
|
||||
FROM core.pool_provision_ladders l
|
||||
WHERE l.pool_id = sqlc.arg(pool_id) AND l.status IN ('active', 'suspended');
|
||||
|
||||
-- name: ListOutgoingPlanPositionsByPool :many
|
||||
-- The distinct provisions of a pool whose live positions
|
||||
-- core.plan_position_is_outgoing marks outgoing under the target: the
|
||||
-- incumbents a grandfather or a migrate acts on, with the grant that sources
|
||||
-- each and the product it delivers.
|
||||
SELECT l.provision_id, p.grant_id, l.product_id
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.pool_provisions p ON p.provision_id = l.provision_id
|
||||
LEFT JOIN core.grants g ON g.grant_id = p.grant_id
|
||||
WHERE l.pool_id = $1 AND l.status IN ('active', 'suspended')
|
||||
ORDER BY l.activated_at DESC;
|
||||
WHERE l.pool_id = sqlc.arg(pool_id)
|
||||
AND l.status IN ('active', 'suspended')
|
||||
AND core.plan_position_is_outgoing(l.default_sourced, l.product_id, sqlc.narg(target)::uuid)
|
||||
GROUP BY l.provision_id, p.grant_id, l.product_id
|
||||
ORDER BY max(l.activated_at) DESC, l.provision_id;
|
||||
|
||||
-- name: GetOutgoingDefaultExists :one
|
||||
-- Whether any organization of the given types holds a live default-sourced
|
||||
-- position that core.plan_position_is_outgoing marks outgoing under the
|
||||
-- target (NULL for None): the disposition gate, which reads two entries of
|
||||
-- idx_pool_provision_ladders_live_default per type.
|
||||
SELECT core.outgoing_default_exists(sqlc.arg(org_types)::varchar[], sqlc.narg(target)::uuid)::boolean AS outgoing_exists;
|
||||
|
||||
-- name: CountActiveAttachmentsByTier :one
|
||||
SELECT COUNT(*) FROM core.pool_provision_ladders
|
||||
|
||||
@@ -325,7 +325,9 @@ read core.pool_assignments.updated_at
|
||||
read core.pool_assignments.workspace_id
|
||||
read core.pool_provision_ladders.activated_at
|
||||
read core.pool_provision_ladders.created_at
|
||||
read core.pool_provision_ladders.default_sourced
|
||||
read core.pool_provision_ladders.ended_at
|
||||
read core.pool_provision_ladders.org_type
|
||||
read core.pool_provision_ladders.plan_ladder_id
|
||||
read core.pool_provision_ladders.pool_id
|
||||
read core.pool_provision_ladders.product_id
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestPagesUseTheParts(t *testing.T) {
|
||||
{
|
||||
name: "organization type card preview", template: "operator_org_type_card.html", noTitle: true,
|
||||
data: func() OrgTypeCardData {
|
||||
d := OrgTypeCardData{OrgTypeViewModel: OrgTypeViewModel{OrgType: "team", DisplayName: "Team"}, PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Hosting"}, {PlanLadderID: "l2", LadderName: "Studio", RankZeroProductName: "Basic", HasRankZero: true}}, Candidate: "l2", HasCandidate: true, Preview: &OrgTypeChangePreview{CandidateLadderID: "l2", WouldInitiate: []OrgRef{{OrgID: "o1", Name: "Acme"}}, OutgoingDefault: []OrgRef{{OrgID: "o2", Name: "Guild"}}, AlreadyAtDefault: 1, OtherSource: []OrgSourceRef{{OrgRef: OrgRef{OrgID: "o3", Name: "Sol"}, Source: "a grant"}}, WouldFail: []OrgTypeChangeFailure{{OrgID: "o4", OrgName: "Nix", Reason: "no default pool"}}}}
|
||||
d := OrgTypeCardData{OrgTypeViewModel: OrgTypeViewModel{OrgType: "team", DisplayName: "Team"}, PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Hosting"}, {PlanLadderID: "l2", LadderName: "Studio", RankZeroProductName: "Basic", HasRankZero: true}}, Candidate: "l2", HasCandidate: true, Preview: &OrgTypeChangePreview{CandidateLadderID: "l2", WouldInitiate: 1, OutgoingDefaultCount: 1, OutgoingDefault: []OrgRef{{OrgID: "o2", Name: "Guild"}}, AlreadyAtDefault: 1, OtherSourceCount: 1, OtherSource: []OrgSourceRef{{OrgRef: OrgRef{OrgID: "o3", Name: "Sol"}, Source: "a grant"}}, WouldFailCount: 1, WouldFail: []OrgTypeChangeFailure{{OrgID: "o4", OrgName: "Nix", Reason: "no default pool"}}}}
|
||||
d.Form = orgTypeDefaultFormView(NewSafeTemplates(OperatorSet(t), slog.New(slog.NewTextHandler(io.Discard, nil))), d)
|
||||
return d
|
||||
}(),
|
||||
|
||||
@@ -10,9 +10,9 @@ package server_test
|
||||
// idempotent re-runs. DB-backed via TEST_DATABASE_URL (dbtest.Open).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -41,19 +41,21 @@ type otcHarness struct {
|
||||
handler *server.OperatorPartialsHandler
|
||||
ctx context.Context
|
||||
personID string
|
||||
logs *bytes.Buffer // what the handler logged
|
||||
}
|
||||
|
||||
func newOtcHarness(t *testing.T, database *sql.DB, personID string) *otcHarness {
|
||||
t.Helper()
|
||||
sm := scs.New()
|
||||
authCfg := &auth.Config{SessionManager: sm}
|
||||
logs := &bytes.Buffer{}
|
||||
handler, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
|
||||
EntitlementsQ: entitlements.New(database),
|
||||
BillingQ: billing.New(database),
|
||||
Database: database,
|
||||
IdentityQ: identity.New(database),
|
||||
OrgQ: organization.New(database),
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Logger: slog.New(slog.NewTextHandler(logs, nil)),
|
||||
AuthConfig: authCfg,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -66,7 +68,7 @@ func newOtcHarness(t *testing.T, database *sql.DB, personID string) *otcHarness
|
||||
sm.Put(ctx, "authenticated", true)
|
||||
sm.Put(ctx, "person_id", personID)
|
||||
sm.Put(ctx, "roles", []string{server.OperatorRole})
|
||||
return &otcHarness{t: t, handler: handler, ctx: ctx, personID: personID}
|
||||
return &otcHarness{t: t, handler: handler, ctx: ctx, personID: personID, logs: logs}
|
||||
}
|
||||
|
||||
func (h *otcHarness) post(handler http.HandlerFunc, orgType string, form url.Values) (int, string) {
|
||||
|
||||
@@ -105,10 +105,18 @@ type OrgTypeChangeFailure struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// previewListLimit is the most names the preview lists for a class
|
||||
// (design D27): a longer list ends in "And N more.".
|
||||
const previewListLimit = 50
|
||||
|
||||
// OrgTypeChangePreview is the read-only classification of an org
|
||||
// type's existing organizations under a candidate default. It is a
|
||||
// projection: the commit re-derives each pool's bucket inside its own
|
||||
// transaction and reports actuals.
|
||||
//
|
||||
// Every count is the class's exact count, read from the database; a list
|
||||
// holds at most previewListLimit names of its class, so a count is never the
|
||||
// length of a list.
|
||||
type OrgTypeChangePreview struct {
|
||||
CandidateLadderID string // "" = None
|
||||
// RuleLessWarning is the one sentence naming a candidate rank-0 product
|
||||
@@ -116,18 +124,21 @@ type OrgTypeChangePreview struct {
|
||||
// (org-types "The default-change preview names a rank-0 product that
|
||||
// provides nothing"). It is a warning: the commit stays enabled and no
|
||||
// control renders disabled.
|
||||
RuleLessWarning template.HTML
|
||||
WouldInitiate []OrgRef
|
||||
AlreadyAtDefault int // default-sourced positions already delivering the candidate's rank-0
|
||||
OutgoingDefault []OrgRef
|
||||
OtherSource []OrgSourceRef
|
||||
WouldFail []OrgTypeChangeFailure
|
||||
RuleLessWarning template.HTML
|
||||
WouldInitiate int
|
||||
AlreadyAtDefault int // default-sourced positions already delivering the candidate's rank-0
|
||||
OutgoingDefaultCount int
|
||||
OutgoingDefault []OrgRef
|
||||
OtherSourceCount int
|
||||
OtherSource []OrgSourceRef
|
||||
WouldFailCount int
|
||||
WouldFail []OrgTypeChangeFailure
|
||||
}
|
||||
|
||||
// Empty reports a preview that found no existing organizations in any class.
|
||||
func (p *OrgTypeChangePreview) Empty() bool {
|
||||
return len(p.WouldInitiate) == 0 && p.AlreadyAtDefault == 0 && len(p.OutgoingDefault) == 0 &&
|
||||
len(p.OtherSource) == 0 && len(p.WouldFail) == 0
|
||||
return p.WouldInitiate == 0 && p.AlreadyAtDefault == 0 && p.OutgoingDefaultCount == 0 &&
|
||||
p.OtherSourceCount == 0 && p.WouldFailCount == 0
|
||||
}
|
||||
|
||||
// OutgoingDefaultSummary states how many organizations hold the outgoing
|
||||
@@ -135,26 +146,68 @@ func (p *OrgTypeChangePreview) Empty() bool {
|
||||
// count names its noun": "N organizations hold the outgoing default",
|
||||
// singular "1 organization holds the outgoing default").
|
||||
func (p *OrgTypeChangePreview) OutgoingDefaultSummary() string {
|
||||
n := len(p.OutgoingDefault)
|
||||
n := p.OutgoingDefaultCount
|
||||
if n == 1 {
|
||||
return "1 organization holds the outgoing default"
|
||||
}
|
||||
return fmt.Sprintf("%d organizations hold the outgoing default", n)
|
||||
}
|
||||
|
||||
// otherSourceLabel describes what holds a pool's position, for the preview's
|
||||
// other-source line. Multiple live positions yield the distinct labels joined.
|
||||
func otherSourceLabel(rows []entitlements.GetLivePlanAttachmentsWithSourceByPoolRow) string {
|
||||
// OutgoingDefaultMore, OtherSourceMore and WouldFailMore count the names a
|
||||
// class's list leaves out, which "And N more." states (design D27).
|
||||
func (p *OrgTypeChangePreview) OutgoingDefaultMore() int {
|
||||
return p.OutgoingDefaultCount - len(p.OutgoingDefault)
|
||||
}
|
||||
|
||||
func (p *OrgTypeChangePreview) OtherSourceMore() int {
|
||||
return p.OtherSourceCount - len(p.OtherSource)
|
||||
}
|
||||
|
||||
func (p *OrgTypeChangePreview) WouldFailMore() int {
|
||||
return p.WouldFailCount - len(p.WouldFail)
|
||||
}
|
||||
|
||||
// OutgoingDefaultDisclosure and OtherSourceDisclosure label the disclosure
|
||||
// of the class's list (design D27, copy 49).
|
||||
func (p *OrgTypeChangePreview) OutgoingDefaultDisclosure() string {
|
||||
return disclosureLabel(len(p.OutgoingDefault), p.OutgoingDefaultCount)
|
||||
}
|
||||
|
||||
func (p *OrgTypeChangePreview) OtherSourceDisclosure() string {
|
||||
return disclosureLabel(len(p.OtherSource), p.OtherSourceCount)
|
||||
}
|
||||
|
||||
// disclosureLabel reads "show all N" while the list holds every name of its
|
||||
// class and "show 50 of N" when the cap shortened it.
|
||||
func disclosureLabel(shown, total int) string {
|
||||
if shown < total {
|
||||
return fmt.Sprintf("show %d of %d", shown, total)
|
||||
}
|
||||
return fmt.Sprintf("show all %d", total)
|
||||
}
|
||||
|
||||
// positionSource is what holds one live plan position: a subscription, a
|
||||
// grant of some reason, or a purchase.
|
||||
type positionSource struct {
|
||||
SubscriptionID uuid.NullUUID
|
||||
PurchaseID uuid.NullUUID
|
||||
GrantReason sql.NullString
|
||||
}
|
||||
|
||||
// otherSourceLabel describes what holds a pool's positions, for the
|
||||
// preview's other-source line. Multiple live positions yield the distinct
|
||||
// labels joined.
|
||||
func otherSourceLabel(sources []positionSource) string {
|
||||
seen := make(map[string]bool)
|
||||
var labels []string
|
||||
for _, row := range rows {
|
||||
for _, src := range sources {
|
||||
var label string
|
||||
switch {
|
||||
case row.SubscriptionID.Valid:
|
||||
case src.SubscriptionID.Valid:
|
||||
label = "subscription"
|
||||
case row.GrantReason.Valid:
|
||||
label = row.GrantReason.String + " grant"
|
||||
case row.PurchaseID.Valid:
|
||||
case src.GrantReason.Valid:
|
||||
label = src.GrantReason.String + " grant"
|
||||
case src.PurchaseID.Valid:
|
||||
label = "purchase"
|
||||
default:
|
||||
label = "unknown source"
|
||||
@@ -236,7 +289,7 @@ type OrgTypeCardData struct {
|
||||
// yet, or every organization already-at-target / would-initiate / on
|
||||
// another source) shows no radios to decide.
|
||||
func (d OrgTypeCardData) HasOutgoingDefault() bool {
|
||||
return d.Preview != nil && len(d.Preview.OutgoingDefault) > 0
|
||||
return d.Preview != nil && d.Preview.OutgoingDefaultCount > 0
|
||||
}
|
||||
|
||||
// NeedsDisposition reports whether the preview's outgoing-default bucket
|
||||
@@ -256,69 +309,81 @@ type OrgTypesData struct {
|
||||
|
||||
// buildOrgTypeChangePreview computes the read-only classification for every
|
||||
// org of the type against the candidate (and its rank-0 product). It performs
|
||||
// no writes and issues a single set-based query for the whole population —
|
||||
// one row per (org, live attachment), with NULL-pool and NULL-attachment rows
|
||||
// marking would-fail and plan-less orgs respectively.
|
||||
// no writes and reads in two statements whatever the type's size: one for
|
||||
// each class's exact count and its first previewListLimit organizations
|
||||
// (core.plan_pool_bucket decides every class), and one for what holds the
|
||||
// listed other-source pools' positions.
|
||||
func (h *OperatorPartialsHandler) buildOrgTypeChangePreview(ctx context.Context, orgType, candidate, candidateProductID string) (*OrgTypeChangePreview, error) {
|
||||
rows, err := h.EntitlementsQ.ListLivePlanPositionsByOrgType(ctx, orgType)
|
||||
target, err := entitlements.PlanTarget(candidateProductID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list plan positions for type %s: %w", orgType, err)
|
||||
return nil, fmt.Errorf("target product for type %s: %w", orgType, err)
|
||||
}
|
||||
rows, err := h.EntitlementsQ.ListPlanPoolBucketsByOrgType(ctx, entitlements.ListPlanPoolBucketsByOrgTypeParams{
|
||||
OrgType: orgType,
|
||||
Target: target,
|
||||
PerClassLimit: previewListLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pool classes for type %s: %w", orgType, err)
|
||||
}
|
||||
|
||||
preview := &OrgTypeChangePreview{
|
||||
CandidateLadderID: candidate,
|
||||
RuleLessWarning: h.ruleLessWarning(ctx, candidateProductID),
|
||||
}
|
||||
// Rows arrive ordered by org; classify each group as it completes.
|
||||
var (
|
||||
cur OrgRef
|
||||
curHasPool bool
|
||||
attachments []entitlements.GetLivePlanAttachmentsWithSourceByPoolRow
|
||||
)
|
||||
flush := func() {
|
||||
if cur.OrgID == "" {
|
||||
return
|
||||
}
|
||||
if !curHasPool {
|
||||
preview.WouldFail = append(preview.WouldFail, OrgTypeChangeFailure{OrgID: cur.OrgID, OrgName: cur.Name, Reason: "no default pool"})
|
||||
return
|
||||
}
|
||||
switch bucket, _ := entitlements.ClassifyPoolAttachments(attachments, candidateProductID); bucket {
|
||||
case entitlements.BucketWouldInitiate:
|
||||
preview.WouldInitiate = append(preview.WouldInitiate, cur)
|
||||
case entitlements.BucketAlreadyAtDefault:
|
||||
preview.AlreadyAtDefault++
|
||||
case entitlements.BucketOutgoingDefault:
|
||||
preview.OutgoingDefault = append(preview.OutgoingDefault, cur)
|
||||
case entitlements.BucketOtherSource:
|
||||
preview.OtherSource = append(preview.OtherSource, OrgSourceRef{OrgRef: cur, Source: otherSourceLabel(attachments)})
|
||||
}
|
||||
}
|
||||
var otherPools []string // the pool of each OtherSource entry, in order
|
||||
for _, row := range rows {
|
||||
if row.OrgID != cur.OrgID {
|
||||
flush()
|
||||
cur = OrgRef{OrgID: row.OrgID, Name: row.Name}
|
||||
curHasPool = row.PoolID.Valid
|
||||
attachments = attachments[:0]
|
||||
}
|
||||
if row.ProvisionLadderID.Valid {
|
||||
attachments = append(attachments, entitlements.GetLivePlanAttachmentsWithSourceByPoolRow{
|
||||
ProvisionLadderID: row.ProvisionLadderID.UUID.String(),
|
||||
PlanLadderID: row.PlanLadderID.UUID.String(),
|
||||
ProvisionID: row.ProvisionID.UUID.String(),
|
||||
AttachmentStatus: row.AttachmentStatus.String,
|
||||
ProductID: row.ProductID.UUID.String(),
|
||||
GrantID: row.GrantID,
|
||||
SubscriptionID: row.SubscriptionID,
|
||||
PurchaseID: row.PurchaseID,
|
||||
GrantReason: row.GrantReason,
|
||||
})
|
||||
ref := OrgRef{OrgID: row.OrgID, Name: row.Name}
|
||||
count := int(row.BucketCount)
|
||||
switch entitlements.DefaultBucket(row.Bucket) {
|
||||
case entitlements.BucketWouldInitiate:
|
||||
preview.WouldInitiate = count
|
||||
case entitlements.BucketAlreadyAtDefault:
|
||||
preview.AlreadyAtDefault = count
|
||||
case entitlements.BucketOutgoingDefault:
|
||||
preview.OutgoingDefaultCount = count
|
||||
preview.OutgoingDefault = append(preview.OutgoingDefault, ref)
|
||||
case entitlements.BucketOtherSource:
|
||||
preview.OtherSourceCount = count
|
||||
preview.OtherSource = append(preview.OtherSource, OrgSourceRef{OrgRef: ref})
|
||||
otherPools = append(otherPools, row.PoolID.UUID.String())
|
||||
case entitlements.BucketWouldFail:
|
||||
preview.WouldFailCount = count
|
||||
preview.WouldFail = append(preview.WouldFail, OrgTypeChangeFailure{OrgID: row.OrgID, OrgName: row.Name, Reason: "no default pool"})
|
||||
default:
|
||||
return nil, fmt.Errorf("list pool classes for type %s: unknown class %q", orgType, row.Bucket)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if err := h.nameOtherSources(ctx, preview, otherPools); err != nil {
|
||||
return nil, fmt.Errorf("read plan sources for type %s: %w", orgType, err)
|
||||
}
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// nameOtherSources reads what holds the positions of the listed other-source
|
||||
// pools, one pool per OtherSource entry in order, and labels each entry.
|
||||
func (h *OperatorPartialsHandler) nameOtherSources(ctx context.Context, preview *OrgTypeChangePreview, pools []string) error {
|
||||
if len(pools) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := h.EntitlementsQ.ListLivePlanSourcesByPools(ctx, pools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byPool := make(map[string][]positionSource, len(pools))
|
||||
for _, row := range rows {
|
||||
byPool[row.PoolID] = append(byPool[row.PoolID], positionSource{
|
||||
SubscriptionID: row.SubscriptionID,
|
||||
PurchaseID: row.PurchaseID,
|
||||
GrantReason: row.GrantReason,
|
||||
})
|
||||
}
|
||||
for i, poolID := range pools {
|
||||
preview.OtherSource[i].Source = otherSourceLabel(byPool[poolID])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveCandidate validates the posted candidate default for an org type:
|
||||
// well-formed, and (when it names a new ladder) provisionable. A ladder with
|
||||
// no rank-0 tier can't provision new orgs, so it is refused as a NEW default
|
||||
@@ -476,17 +541,31 @@ func (h *OperatorPartialsHandler) CommitOrgTypeDefaultChange(w http.ResponseWrit
|
||||
return
|
||||
}
|
||||
|
||||
// Validation gate: a non-empty outgoing-default bucket requires an
|
||||
// explicit disposition. Rejecting here writes nothing.
|
||||
preview, err := h.buildOrgTypeChangePreview(r.Context(), orgType, candidate, candidateProductID)
|
||||
// Validation gate: an outgoing default anywhere in the type requires an
|
||||
// explicit disposition. The gate is read first, from the database; the
|
||||
// preview is built only for the answer that names the organizations.
|
||||
// Rejecting here writes nothing.
|
||||
required, err := h.dispositionRequired(r.Context(), []string{orgType}, candidateProductID)
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to classify for commit", slog.Any("error", err), slog.String("org_type", orgType))
|
||||
h.Logger.Error("failed to read the disposition gate", slog.Any("error", err), slog.String("org_type", orgType))
|
||||
h.renderOrgTypeCard(w, r, ot, candidate, nil, nil, "Failed to classify existing organizations. Details are in the server logs.")
|
||||
return
|
||||
}
|
||||
if len(preview.OutgoingDefault) > 0 && bucket2 == "" {
|
||||
var preview *OrgTypeChangePreview
|
||||
if required && bucket2 == "" {
|
||||
preview, err = h.buildOrgTypeChangePreview(r.Context(), orgType, candidate, candidateProductID)
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to classify for commit", slog.Any("error", err), slog.String("org_type", orgType))
|
||||
h.renderOrgTypeCard(w, r, ot, candidate, nil, nil, "Failed to classify existing organizations. Details are in the server logs.")
|
||||
return
|
||||
}
|
||||
if preview.OutgoingDefaultCount == 0 {
|
||||
h.logGateWithoutOutgoing([]string{orgType}, candidateProductID)
|
||||
h.renderOrgTypeCard(w, r, ot, candidate, preview, nil, "Failed to update the default plan. Details are in the server logs.")
|
||||
return
|
||||
}
|
||||
h.renderOrgTypeCard(w, r, ot, candidate, preview, nil,
|
||||
fmt.Sprintf("%d organization(s) hold the outgoing default. Choose whether to grandfather or migrate them before committing.", len(preview.OutgoingDefault)))
|
||||
fmt.Sprintf("%d organization(s) hold the outgoing default. Choose whether to grandfather or migrate them before committing.", preview.OutgoingDefaultCount))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -502,6 +581,15 @@ func (h *OperatorPartialsHandler) CommitOrgTypeDefaultChange(w http.ResponseWrit
|
||||
Pools: h.EntitlementsQ,
|
||||
})
|
||||
if err != nil {
|
||||
// The card shows the classification beside the failure; the gate
|
||||
// answered without building one.
|
||||
if preview == nil {
|
||||
built, perr := h.buildOrgTypeChangePreview(r.Context(), orgType, candidate, candidateProductID)
|
||||
if perr != nil {
|
||||
h.Logger.Error("failed to classify beside a failed default change", slog.Any("error", perr), slog.String("org_type", orgType))
|
||||
}
|
||||
preview = built
|
||||
}
|
||||
h.renderDefaultChangeFailure(w, r, ot, candidate, preview, err)
|
||||
return
|
||||
}
|
||||
@@ -522,6 +610,29 @@ func (h *OperatorPartialsHandler) CommitOrgTypeDefaultChange(w http.ResponseWrit
|
||||
h.renderOrgTypeCard(w, r, ot, "", nil, result, "")
|
||||
}
|
||||
|
||||
// dispositionRequired is the commit's gate: whether any organization of the
|
||||
// types holds a live default-sourced position the outgoing rule marks
|
||||
// outgoing under the candidate's rank-0 product ("" for None). It reads the
|
||||
// rule the preview reads, through an index, so it does not grow with the
|
||||
// types.
|
||||
func (h *OperatorPartialsHandler) dispositionRequired(ctx context.Context, orgTypes []string, candidateProductID string) (bool, error) {
|
||||
target, err := entitlements.PlanTarget(candidateProductID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return h.EntitlementsQ.GetOutgoingDefaultExists(ctx, entitlements.GetOutgoingDefaultExistsParams{OrgTypes: orgTypes, Target: target})
|
||||
}
|
||||
|
||||
// logGateWithoutOutgoing logs a gate that found an outgoing default when the
|
||||
// preview built for the answer finds none: a move settled the last outgoing
|
||||
// organization between the gate's read and the preview's. The answer is the
|
||||
// form's generic failure, since the card has no radio to choose a disposition
|
||||
// with, and resubmitting succeeds.
|
||||
func (h *OperatorPartialsHandler) logGateWithoutOutgoing(orgTypes []string, candidateProductID string) {
|
||||
h.Logger.Error("disposition gate found an outgoing default that the rebuilt preview does not",
|
||||
slog.Any("org_types", orgTypes), slog.String("target", candidateProductID))
|
||||
}
|
||||
|
||||
// renderDefaultChangeFailure logs and answers a default change that stopped
|
||||
// before any organization was enacted: the default could not be saved, or it
|
||||
// was saved and the type's organizations could not be listed.
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
)
|
||||
|
||||
// TestOrgTypesPageStatesDegradePath covers UX-11/UX-10 (ux-honest-surfaces
|
||||
@@ -114,3 +117,106 @@ func TestOrgTypeCardWithAPreviewShowsItsCommitBelowTheSelect(t *testing.T) {
|
||||
t.Errorf("expected the order select, message, commit; got %d, %d, %d", selectAt, messageAt, commitAt)
|
||||
}
|
||||
}
|
||||
|
||||
// cappedPreview is a preview whose three named classes hold total
|
||||
// organizations of which shown are named, in the order the read lists them.
|
||||
func cappedPreview(shown, total int) *OrgTypeChangePreview {
|
||||
p := &OrgTypeChangePreview{
|
||||
CandidateLadderID: "l1",
|
||||
OutgoingDefaultCount: total,
|
||||
OtherSourceCount: total,
|
||||
WouldFailCount: total,
|
||||
}
|
||||
for i := range shown {
|
||||
ref := OrgRef{OrgID: fmt.Sprintf("o%d", i), Name: fmt.Sprintf("Org %02d", i)}
|
||||
p.OutgoingDefault = append(p.OutgoingDefault, ref)
|
||||
p.OtherSource = append(p.OtherSource, OrgSourceRef{OrgRef: ref, Source: "subscription"})
|
||||
p.WouldFail = append(p.WouldFail, OrgTypeChangeFailure{OrgID: ref.OrgID, OrgName: ref.Name, Reason: "no default pool"})
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// capLabelCase is a preview whose lists name shown of total organizations,
|
||||
// with the label the disclosure reads and the remainder its lists end in.
|
||||
type capLabelCase struct {
|
||||
name string
|
||||
shown, total int
|
||||
label string
|
||||
more string // "" when the list holds every name
|
||||
}
|
||||
|
||||
// requireCapLabels requires one surface's disclosures, names, remainders and
|
||||
// exact would-fail count to read as the case says.
|
||||
func requireCapLabels(t *testing.T, c capLabelCase, surface, body string, disclosures int) {
|
||||
t.Helper()
|
||||
doc := htmltest.Parse(t, body)
|
||||
details := doc.All("details")
|
||||
if len(details) != disclosures {
|
||||
t.Fatalf("%s: %d disclosures, want %d", surface, len(details), disclosures)
|
||||
}
|
||||
for _, d := range details {
|
||||
if got := d.One(t, "summary").Text(); got != c.label {
|
||||
t.Errorf("%s: disclosure label = %q, want %q", surface, got, c.label)
|
||||
}
|
||||
if got := d.Count("li a"); got != c.shown {
|
||||
t.Errorf("%s: a disclosure names %d organizations, want %d", surface, got, c.shown)
|
||||
}
|
||||
}
|
||||
text := doc.Text()
|
||||
if c.more == "" {
|
||||
if strings.Contains(text, "And ") {
|
||||
t.Errorf("%s: a complete list ends in a remainder: %s", surface, text)
|
||||
}
|
||||
} else if got := strings.Count(text, c.more); got != disclosures+1 {
|
||||
t.Errorf("%s: %q appears %d times, want %d (each shortened list)", surface, c.more, got, disclosures+1)
|
||||
}
|
||||
if want := fmt.Sprintf("%d would fail", c.total); !strings.Contains(text, want) {
|
||||
t.Errorf("%s: the would-fail line does not state %q", surface, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A list that holds every name of its class is labelled "show all N" and
|
||||
// ends with its last name; a list the cap shortened is labelled "show 50 of
|
||||
// N" and ends with "And N more.", on the default change's preview and on the
|
||||
// reorder's (design D27, copy 46 and 49). Every count is the class's exact
|
||||
// count, which the list's length is not.
|
||||
func TestPreviewListsLabelWhatTheCapLeavesOut(t *testing.T) {
|
||||
for _, c := range []capLabelCase{
|
||||
{"every name listed", 2, 2, "show all 2", ""},
|
||||
{"capped", 50, 120, "show 50 of 120", "And 70 more."},
|
||||
{"one name left out", 50, 51, "show 50 of 51", "And 1 more."},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
preview := cappedPreview(c.shown, c.total)
|
||||
reorder := &PendingTierReorder{
|
||||
RankZeroChanged: true, OldRankZeroName: "Old", NewRankZeroName: "New",
|
||||
TypePreviews: []TierReorderTypePreview{{OrgType: "team", DisplayName: "Team", Preview: preview}},
|
||||
}
|
||||
requireCapLabels(t, c, "default change", Render(t, OperatorSet(t), "operator_org_type_preview.html", preview), 2)
|
||||
requireCapLabels(t, c, "reorder", Render(t, OperatorSet(t), "operator_plan_ladder_tier_reorder_message.html", reorder), 2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The outgoing summary and the disposition radios read the class's count,
|
||||
// not its list: a count beyond the list still states its own number.
|
||||
func TestOutgoingDefaultSummaryReadsTheCount(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
count int
|
||||
want string
|
||||
}{
|
||||
{1, "1 organization holds the outgoing default"},
|
||||
{120, "120 organizations hold the outgoing default"},
|
||||
} {
|
||||
p := cappedPreview(min(c.count, previewListLimit), c.count)
|
||||
if got := p.OutgoingDefaultSummary(); got != c.want {
|
||||
t.Errorf("summary for %d = %q, want %q", c.count, got, c.want)
|
||||
}
|
||||
if !(OrgTypeCardData{Preview: p}).HasOutgoingDefault() {
|
||||
t.Errorf("a preview with %d outgoing organizations asks for no disposition", c.count)
|
||||
}
|
||||
}
|
||||
if (OrgTypeCardData{Preview: &OrgTypeChangePreview{}}).HasOutgoingDefault() {
|
||||
t.Error("a preview with no outgoing organization asks for a disposition")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +582,7 @@ func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, l
|
||||
DisplayName: ref.DisplayName,
|
||||
Preview: preview,
|
||||
})
|
||||
if len(preview.OutgoingDefault) > 0 {
|
||||
if preview.OutgoingDefaultCount > 0 {
|
||||
pending.NeedsDisposition = true
|
||||
}
|
||||
}
|
||||
@@ -662,16 +662,32 @@ func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter,
|
||||
|
||||
// Validation gate: an outgoing-default population anywhere requires an
|
||||
// explicit disposition. Rejecting here writes nothing — not even ranks.
|
||||
// pending.NeedsDisposition is freshly re-derived from the current
|
||||
// database state, never the submitted needs_disposition marker (that
|
||||
// marker only decides whether the radio rendered; a stale marker
|
||||
// re-previews here rather than trusting an out-of-date "not needed").
|
||||
if pending.NeedsDisposition && disposition == "" && !errs.Has("bucket2_disposition") {
|
||||
outgoing := 0
|
||||
for _, tp := range pending.TypePreviews {
|
||||
outgoing += len(tp.Preview.OutgoingDefault)
|
||||
// The gate is read from the database, never the submitted
|
||||
// needs_disposition marker (that marker only decides whether the radio
|
||||
// rendered; a stale marker re-previews here rather than trusting an
|
||||
// out-of-date "not needed"). The pending previews were built with the
|
||||
// order; when the gate is true and they count no outgoing organization,
|
||||
// a move settled the last one between the two reads, and the answer is
|
||||
// the generic failure.
|
||||
if disposition == "" && !errs.Has("bucket2_disposition") {
|
||||
required, err := h.reorderDispositionRequired(ctx, pending)
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to read the disposition gate", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
|
||||
return
|
||||
}
|
||||
if required {
|
||||
outgoing := 0
|
||||
for _, tp := range pending.TypePreviews {
|
||||
outgoing += tp.Preview.OutgoingDefaultCount
|
||||
}
|
||||
if outgoing == 0 {
|
||||
h.logGateWithoutOutgoing(pendingOrgTypes(pending), pending.OrderIDs[0])
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
|
||||
return
|
||||
}
|
||||
errs.Field("bucket2_disposition", fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", outgoing))
|
||||
}
|
||||
errs.Field("bucket2_disposition", fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", outgoing))
|
||||
}
|
||||
if errs.Any() {
|
||||
h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending,
|
||||
@@ -708,6 +724,27 @@ func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter,
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, msg, errMsg)
|
||||
}
|
||||
|
||||
// pendingOrgTypes lists the org types the pending reorder reaches: the types
|
||||
// whose default ladder it is, when its rank 0 changes.
|
||||
func pendingOrgTypes(pending *PendingTierReorder) []string {
|
||||
types := make([]string, 0, len(pending.TypePreviews))
|
||||
for _, tp := range pending.TypePreviews {
|
||||
types = append(types, tp.OrgType)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// reorderDispositionRequired is the reorder's gate: whether any organization
|
||||
// of the types the pending rank 0 reaches holds a position the outgoing rule
|
||||
// marks outgoing under the new rank-0 product. A reorder that leaves rank 0
|
||||
// alone, or reaches no type, requires none.
|
||||
func (h *OperatorPartialsHandler) reorderDispositionRequired(ctx context.Context, pending *PendingTierReorder) (bool, error) {
|
||||
if !pending.RankZeroChanged || len(pending.TypePreviews) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return h.dispositionRequired(ctx, pendingOrgTypes(pending), pending.OrderIDs[0])
|
||||
}
|
||||
|
||||
// tierReorderAnswer is what a committed reorder says: the order saved
|
||||
// (noting a stale page), and when the rank 0 changed, either what the
|
||||
// enactment did on the types defaulting to the ladder, as an error alone
|
||||
@@ -875,7 +912,7 @@ func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, l
|
||||
seenOther := make(map[string]bool)
|
||||
for _, row := range rows {
|
||||
ref := OrgRef{OrgID: row.OrgID, Name: row.Name}
|
||||
if row.GrantReason.Valid && row.GrantReason.String == "default" {
|
||||
if row.DefaultSourced {
|
||||
if !seenDefault[row.OrgID] {
|
||||
seenDefault[row.OrgID] = true
|
||||
pending.DefaultSourced = append(pending.DefaultSourced, ref)
|
||||
@@ -886,9 +923,7 @@ func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, l
|
||||
seenOther[row.OrgID] = true
|
||||
pending.OtherSource = append(pending.OtherSource, OrgSourceRef{
|
||||
OrgRef: ref,
|
||||
Source: otherSourceLabel([]entitlements.GetLivePlanAttachmentsWithSourceByPoolRow{{
|
||||
ProductID: row.ProductID,
|
||||
GrantID: row.GrantID,
|
||||
Source: otherSourceLabel([]positionSource{{
|
||||
SubscriptionID: row.SubscriptionID,
|
||||
PurchaseID: row.PurchaseID,
|
||||
GrantReason: row.GrantReason,
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server_test
|
||||
|
||||
// The default change's and the reorder's previews name at most 50
|
||||
// organizations per class and state each class's exact count (openspec
|
||||
// change plan-act-obligations, design D4 and D27, task 3.5), and the commit
|
||||
// decides that a disposition is required with the database's gate, not with
|
||||
// a list's length. Reuses the otc* fixture and harness
|
||||
// (operator_org_type_change_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
)
|
||||
|
||||
// capClassSize is the number of organizations in each class the cap tests
|
||||
// build: three more than the 50 names a list holds.
|
||||
const capClassSize = 53
|
||||
|
||||
// buildCappedType gives the fixture's org type capClassSize organizations on
|
||||
// the outgoing default (ladder A's product), capClassSize holding ladder A's
|
||||
// product through an operator grant, and capClassSize with no default pool.
|
||||
// Their names sort in creation order within a class.
|
||||
func buildCappedType(t *testing.T, database *sql.DB, f otcFixture) {
|
||||
t.Helper()
|
||||
for i := 1; i <= capClassSize; i++ {
|
||||
_, pool := otcOrg(t, database, f, fmt.Sprintf("Outgoing %03d", i), true)
|
||||
otcConferDefaultA(t, database, f, pool)
|
||||
|
||||
org, _ := otcOrg(t, database, f, fmt.Sprintf("Other %03d", i), true)
|
||||
if _, err := entitlements.ConferGrant(context.Background(), database, entitlements.ConferGrantInput{
|
||||
ProductID: f.prodA, OrgID: org, GrantedByPersonID: f.operatorID,
|
||||
GrantReason: "manual", Quantity: 1, ActorType: "operator",
|
||||
ActorID: uuid.NullUUID{UUID: uuid.MustParse(f.operatorID), Valid: true},
|
||||
TransitionReason: "preview cap fixture",
|
||||
}); err != nil {
|
||||
t.Fatalf("confer an operator grant: %v", err)
|
||||
}
|
||||
|
||||
otcOrg(t, database, f, fmt.Sprintf("Pool-less %03d", i), false)
|
||||
}
|
||||
}
|
||||
|
||||
// previewMessage is the message region that holds the preview's lists.
|
||||
func previewMessage(t *testing.T, body string) *htmltest.Node {
|
||||
t.Helper()
|
||||
for _, region := range htmltest.Parse(t, body).All(".app-form-message") {
|
||||
if strings.Contains(region.Text(), "would fail") {
|
||||
return region
|
||||
}
|
||||
}
|
||||
t.Fatalf("no form message holds the preview: %.500s", body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireCappedLists requires the three lists of a preview body to hold 50
|
||||
// names each under capClassSize organizations, with "And 3 more." under each
|
||||
// and the exact count beside it. Both previews open a disclosure for the
|
||||
// outgoing and for the other-source list.
|
||||
func requireCappedLists(t *testing.T, body string, disclosures int) {
|
||||
t.Helper()
|
||||
doc := previewMessage(t, body)
|
||||
details := doc.All("details")
|
||||
if len(details) != disclosures {
|
||||
t.Fatalf("%d disclosures, want %d", len(details), disclosures)
|
||||
}
|
||||
wantLabel := fmt.Sprintf("show 50 of %d", capClassSize)
|
||||
for _, d := range details {
|
||||
if got := d.One(t, "summary").Text(); got != wantLabel {
|
||||
t.Errorf("disclosure label = %q, want %q", got, wantLabel)
|
||||
}
|
||||
if n := d.Count("li a"); n != 50 {
|
||||
t.Errorf("a disclosure lists %d names, want 50", n)
|
||||
}
|
||||
if last := d.All("li"); len(last) == 0 || last[len(last)-1].Text() != "And 3 more." {
|
||||
t.Errorf("a disclosure does not end with %q", "And 3 more.")
|
||||
}
|
||||
}
|
||||
failed := doc.One(t, "li.text-danger")
|
||||
if n := failed.Count("li a"); n != 50 {
|
||||
t.Errorf("the would-fail list names %d organizations, want 50", n)
|
||||
}
|
||||
if want := fmt.Sprintf("%d would fail:", capClassSize); !strings.Contains(failed.Text(), want) {
|
||||
t.Errorf("the would-fail line does not state its exact count %q: %s", want, failed.Text())
|
||||
}
|
||||
if !strings.HasSuffix(failed.Text(), "And 3 more.") {
|
||||
t.Errorf("the would-fail list does not end with %q: %s", "And 3 more.", failed.Text())
|
||||
}
|
||||
text := doc.Text()
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf("%d on a plan from another source; never touched.", capClassSize),
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("the preview does not state %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requireOtherSourceNamed requires the other-source list to name an
|
||||
// organization with the source holding its plan.
|
||||
func requireOtherSourceNamed(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
for _, d := range previewMessage(t, body).All("details") {
|
||||
if strings.Contains(d.Text(), "Other 001 (manual grant)") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("no disclosure names %q: %.500s", "Other 001 (manual grant)", body)
|
||||
}
|
||||
|
||||
// The default change's preview, its radio re-post and its refusal each read
|
||||
// the type in two statements whatever its size, name 50 organizations per
|
||||
// list, state exact counts and end each shortened list with "And 3 more.".
|
||||
func TestOrgTypeDefaultChange_PreviewListsAreCapped(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
f := newOtcFixture(t, database)
|
||||
buildCappedType(t, database, f)
|
||||
counted, stmts := dbtest.Counted(t)
|
||||
h := newOtcHarness(t, counted, f.operatorID)
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
form url.Values
|
||||
status int
|
||||
}{
|
||||
{"the preview", h.handler.PreviewOrgTypeDefaultChange, url.Values{"default_plan_ladder_id": {f.ladderB}}, http.StatusOK},
|
||||
{"the radio re-post", h.handler.PreviewOrgTypeDefaultChange, url.Values{"default_plan_ladder_id": {f.ladderB}, "bucket2_disposition": {"grandfather"}}, http.StatusOK},
|
||||
{"the refusal of a commit with no disposition", h.handler.CommitOrgTypeDefaultChange, url.Values{"default_plan_ladder_id": {f.ladderB}}, http.StatusOK},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
stmts.Reset()
|
||||
code, body := h.post(c.handler, f.orgType, c.form)
|
||||
if code != c.status {
|
||||
t.Fatalf("status = %d, want %d", code, c.status)
|
||||
}
|
||||
by := stmts.ByName()
|
||||
if by["ListPlanPoolBucketsByOrgType"] != 1 || by["ListLivePlanSourcesByPools"] != 1 {
|
||||
t.Errorf("the type was read in %v, want one ListPlanPoolBucketsByOrgType and one ListLivePlanSourcesByPools", by)
|
||||
}
|
||||
requireCappedLists(t, body, 2)
|
||||
requireOtherSourceNamed(t, body)
|
||||
if want := fmt.Sprintf("%d organizations hold the outgoing default", capClassSize); !strings.Contains(body, want) {
|
||||
t.Errorf("the outgoing count is not stated as %q", want)
|
||||
}
|
||||
if strings.Contains(c.name, "refusal") {
|
||||
want := fmt.Sprintf("%d organization(s) hold the outgoing default. Choose whether to grandfather or migrate them before committing.", capClassSize)
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the refusal does not state %q: %.400s", want, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The reorder's preview reads the type in two statements, lists the same
|
||||
// classes under the same cap, and its
|
||||
// refusal of a commit with no disposition counts every organization on the
|
||||
// outgoing default, not the 50 its list names.
|
||||
func TestTierReorder_PreviewListsAreCapped(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
f := newOtcFixture(t, database)
|
||||
prodC := trAddTier(t, database, f.ladderA, "cap-c-"+uuid.NewString()[:8])
|
||||
buildCappedType(t, database, f)
|
||||
counted, stmts := dbtest.Counted(t)
|
||||
h := newOtcHarness(t, counted, f.operatorID)
|
||||
|
||||
stmts.Reset()
|
||||
code, body, _ := h.postLadder(h.handler.PreviewPlanLadderTiersReorder, f.ladderA, url.Values{"product": {prodC, f.prodA}})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("preview status = %d", code)
|
||||
}
|
||||
if by := stmts.ByName(); by["ListPlanPoolBucketsByOrgType"] != 1 || by["ListLivePlanSourcesByPools"] != 1 {
|
||||
t.Errorf("the type was read in %v, want one ListPlanPoolBucketsByOrgType and one ListLivePlanSourcesByPools", by)
|
||||
}
|
||||
requireCappedLists(t, body, 2)
|
||||
requireOtherSourceNamed(t, body)
|
||||
|
||||
code, body, _ = h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}})
|
||||
if code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("commit status = %d, want 422", code)
|
||||
}
|
||||
requireCappedLists(t, body, 2)
|
||||
if want := fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", capClassSize); !strings.Contains(body, want) {
|
||||
t.Errorf("the refusal does not count the %d organizations: %.400s", capClassSize, body)
|
||||
}
|
||||
}
|
||||
|
||||
// strayOutgoingPosition puts a default-sourced position of the fixture's
|
||||
// ladder-A product in a pool of another org type, with the copy of its org
|
||||
// type naming the fixture's: the gate reads the copy and the preview reads
|
||||
// the pool's organization, so the gate finds an outgoing position the
|
||||
// rebuilt preview does not. It stands for a move that settled the last
|
||||
// outgoing organization between the gate's rollback and the rebuild.
|
||||
func strayOutgoingPosition(t *testing.T, database *sql.DB, f otcFixture) {
|
||||
t.Helper()
|
||||
g := newOtcFixture(t, database)
|
||||
_, pool := otcOrg(t, database, g, "Stray holder", true)
|
||||
otcConferDefaultA(t, database, g, pool)
|
||||
res, err := database.ExecContext(context.Background(),
|
||||
`UPDATE core.pool_provision_ladders SET org_type = $1 WHERE pool_id = $2`, f.orgType, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("copy the stray position's org type: %v", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
t.Fatalf("copied the org type of %d positions, want 1", n)
|
||||
}
|
||||
// The row is deliberately wrong; no other test may see it. The guard
|
||||
// triggers watch the source columns, not the copies, so the restore
|
||||
// passes them.
|
||||
t.Cleanup(func() {
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
`UPDATE core.pool_provision_ladders SET org_type = $1 WHERE pool_id = $2`, g.orgType, pool); err != nil {
|
||||
t.Errorf("restore the stray position's org type: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A gate that is true while the rebuilt preview finds nothing outgoing
|
||||
// answers the form's generic failure and logs an Error naming the reached
|
||||
// types and the target. It does not ask for a disposition the card has no
|
||||
// radio for, and it writes nothing.
|
||||
func TestOrgTypeDefaultChange_GateWithoutOutgoingIsAFailureNotARequest(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
f := newOtcFixture(t, database)
|
||||
h := newOtcHarness(t, database, f.operatorID)
|
||||
otcOrg(t, database, f, "Plan-less", true)
|
||||
strayOutgoingPosition(t, database, f)
|
||||
before := otcCounts(t, database, f)
|
||||
|
||||
code, body := h.post(h.handler.CommitOrgTypeDefaultChange, f.orgType, url.Values{"default_plan_ladder_id": {f.ladderB}})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body %.300s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "Failed to update the default plan. Details are in the server logs.") {
|
||||
t.Errorf("the generic failure is not answered: %.500s", body)
|
||||
}
|
||||
if strings.Contains(body, "grandfather or migrate") || strings.Contains(body, "bucket2_disposition") {
|
||||
t.Errorf("a disposition is asked for although the card lists nothing outgoing: %.500s", body)
|
||||
}
|
||||
for _, want := range []string{"level=ERROR", "org_types=[" + f.orgType + "]", "target=" + f.prodB} {
|
||||
if !strings.Contains(h.logs.String(), want) {
|
||||
t.Errorf("the log lacks %q: %s", want, h.logs)
|
||||
}
|
||||
}
|
||||
if after := otcCounts(t, database, f); fmt.Sprint(before) != fmt.Sprint(after) {
|
||||
t.Errorf("the failed commit wrote rows: before=%v after=%v", before, after)
|
||||
}
|
||||
if got := otcDefaultLadder(t, database, f.orgType); got != "" {
|
||||
t.Errorf("the failed commit saved the default %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reorder answers the same way: "Failed to reorder tiers." and an Error
|
||||
// line, and the ranks stay as they were.
|
||||
func TestTierReorder_GateWithoutOutgoingIsAFailureNotARequest(t *testing.T) {
|
||||
database := dbtest.Open(t)
|
||||
f := newOtcFixture(t, database)
|
||||
h := newOtcHarness(t, database, f.operatorID)
|
||||
prodC := trAddTier(t, database, f.ladderA, "gate-c-"+uuid.NewString()[:8])
|
||||
if _, err := organization.New(database).UpdateOrgTypeDefaultPlanLadder(context.Background(), organization.UpdateOrgTypeDefaultPlanLadderParams{
|
||||
OrgType: f.orgType,
|
||||
DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(f.ladderA), Valid: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("set the type's default to ladder A: %v", err)
|
||||
}
|
||||
strayOutgoingPosition(t, database, f)
|
||||
|
||||
code, body, _ := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
||||
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body %.300s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "Failed to reorder tiers.") {
|
||||
t.Errorf("the generic failure is not answered: %.500s", body)
|
||||
}
|
||||
if strings.Contains(body, "Choose whether to grandfather or migrate") {
|
||||
t.Errorf("a disposition is asked for although the preview lists nothing outgoing: %.500s", body)
|
||||
}
|
||||
for _, want := range []string{"level=ERROR", "org_types=[" + f.orgType + "]", "target=" + prodC} {
|
||||
if !strings.Contains(h.logs.String(), want) {
|
||||
t.Errorf("the log lacks %q: %s", want, h.logs)
|
||||
}
|
||||
}
|
||||
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
|
||||
t.Errorf("prodA rank = %d after the failed commit, want 0", r)
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,12 @@ Each numbered section below is one commit, and its items are that commit's parts
|
||||
|
||||
## 3. One rule for an outgoing default and a pool's class
|
||||
|
||||
- [ ] 3.1 Add `internal/db/migrations/00026_outgoing_default_rule.sql` as design.md "Schema" sketches it: `org_type` and `default_sourced` on `core.pool_provision_ladders`, `core.provision_is_default_sourced`, the copy trigger function with its two triggers (`BEFORE INSERT`, and `BEFORE UPDATE OF pool_id, provision_id` with its `WHEN` clause, design D4), the backfill with `trigger_pool_provision_ladders_updated_at` disabled for its statement, `NOT NULL` on both columns, `idx_pool_provision_ladders_live_default`, `core.plan_position_is_outgoing` and `core.plan_pool_bucket` (`IMMUTABLE` SQL with no `SET` clause, so they inline), `core.outgoing_default_exists`, `core.refuse_position_source_change` with its five `BEFORE UPDATE` triggers, one per watched column, each passing the column's name (`core.organizations.org_type`, `core.resource_pools.org_id`, `core.pool_provisions.grant_id`, `core.pool_provisions.pool_id`, `core.grants.grant_reason`, each `WHEN (OLD.col IS DISTINCT FROM NEW.col)`), their grants, and a Down section that drops the five triggers before the function.
|
||||
- [ ] 3.2 List the two columns as `read` in `internal/migrate/testdata/console-reader-columns.txt`; `TestConsoleReaderColumns` passes.
|
||||
- [ ] 3.3 Queries, regenerated (design D4): `ListPlanPoolBucketsByOrgType(org_type, target, per_class_limit)` replaces `ListLivePlanPositionsByOrgType`; `ListLivePlanSourcesByPools(pool_ids)` reads what holds each listed other-source pool's positions; `GetPlanPoolBucket(pool_id, target)` and `ListOutgoingPlanPositionsByPool(pool_id, target)` replace `GetLivePlanAttachmentsWithSourceByPool`; `ListLiveTierHoldersByLadderProduct` selects `default_sourced`; `GetOutgoingDefaultExists(org_types, target)` calls `core.outgoing_default_exists` (named `Get...` so the plan-act goldens treat it as a read). The two classifying queries fold a pool's live positions into `core.plan_pool_bucket`'s four arguments through `core.plan_position_is_outgoing`, each `bool_or` inside `COALESCE(..., false)`. The two replaced queries are deleted in this commit with their last callers, so `scripts/sqlc-unused` passes.
|
||||
- [ ] 3.4 Delete `ClassifyPoolAttachments`; the Go class constants gain `BucketWouldFail` and mirror `core.plan_pool_bucket`'s five results. `buildOrgTypeChangePreview` reads `ListPlanPoolBucketsByOrgType` with a per-class limit of 50 and `ListLivePlanSourcesByPools` for the listed other-source pools, which `otherSourceLabel` labels, and does nothing else with the type (design D4). `OrgTypeChangePreview` gains each class's exact count, and its lists hold at most 50 names; `Empty`, `OutgoingDefaultSummary`, `HasOutgoingDefault`, the default change's 422 sentence and the reorder's field error read the counts, never a list's length (design D27). `operator_org_type_preview.html` and `operator_plan_ladder_tier_reorder_message.html` render the counts, "And N more." under a shortened outgoing, other-source or would-fail list (copy 46) and the disclosure label of copy 49. Every preview render (the preview endpoint, the radio re-post, the default change's and the reorder's 422) goes through this read. `enactDefault` reads `GetPlanPoolBucket` and, for an outgoing pool, `ListOutgoingPlanPositionsByPool`; `lockTierHolders` and the removal preview read `DefaultSourced`. The default change's and the reorder's commit handlers decide that a disposition is required with `GetOutgoingDefaultExists`, and still build the preview for the 422 answer. When the gate is true and the rebuilt preview's outgoing count is zero, they log an Error with the reached types and the target and answer the form's generic failure ("Failed to update the default plan. Details are in the server logs." for the default change, "Failed to reorder tiers." for the reorder), not a request for a disposition.
|
||||
- [ ] 3.5 Tests: `TestClassifyPoolAttachments_*` (`internal/entitlements/default_change_test.go`) are deleted with their function, and their cases become a database table test of `GetPlanPoolBucket` (plan-less, default at the target, default outgoing, other source, a default and another source in one pool, a None target, two outgoing provisions listed once each by `ListOutgoingPlanPositionsByPool`); `TestPlanPoolBucketMirrorsItsConstants` (`core.plan_pool_bucket` called with every combination of its four arguments returns only the Go constants, and each of them for some combination); `TestPositionCopiesMatchTheirSources` (default, legacy, operator-grant and subscription positions on two org types: every row's two copies equal a recomputation from pools, organizations, provisions and grants, including rows written before the migration and filled by the backfill, and after a provision's status changes); `TestDispositionGateAgreesWithThePreview` (a mixed pool, a None target, an all-at-target type, an other-source-only type, a plan-less type, a pool-less organization and an empty type: the preview's read finds an outgoing organization exactly when `core.outgoing_default_exists` is true, and `GetPlanPoolBucket` gives every pool the class the preview's read gave it); `TestPositionSourcesCannotChange` (an update that changes each of the five columns is refused with `restrict_violation` naming that column; an update that assigns the same value passes; inserts are unaffected); the preview, the radio re-post and the 422 of the default change, and the reorder preview, each with more than 50 outgoing, other-source and would-fail organizations: 50 names per list, "And N more.", "show 50 of N" and exact counts, read in two statements per type whatever the type's size; `operator_anatomy_test.go`, `operator_org_types_render_test.go` and `rule_less_warning_test.go`, which build `OrgTypeChangePreview` values, set the counts; a handler test for the gate-true, preview-empty case (the Error line and the generic failure); the migration's up, down and up again.
|
||||
- [ ] 3.6 Goldens: none move, because the changed and new queries are reads (`Get`, `List`), which the plan-act goldens leave out (`isActRead`, `internal/server/plan_acts_golden_db_test.go:364`), the position dump selects explicit columns, and every fixture's lists are shorter than 50; the commit says so after running the suite.
|
||||
- [x] 3.1 Add `internal/db/migrations/00026_outgoing_default_rule.sql` as design.md "Schema" sketches it: `org_type` and `default_sourced` on `core.pool_provision_ladders`, `core.provision_is_default_sourced`, the copy trigger function with its two triggers (`BEFORE INSERT`, and `BEFORE UPDATE OF pool_id, provision_id` with its `WHEN` clause, design D4), the backfill with `trigger_pool_provision_ladders_updated_at` disabled for its statement, `NOT NULL` on both columns, `idx_pool_provision_ladders_live_default`, `core.plan_position_is_outgoing` and `core.plan_pool_bucket` (`IMMUTABLE` SQL with no `SET` clause, so they inline), `core.outgoing_default_exists`, `core.refuse_position_source_change` with its five `BEFORE UPDATE` triggers, one per watched column, each passing the column's name (`core.organizations.org_type`, `core.resource_pools.org_id`, `core.pool_provisions.grant_id`, `core.pool_provisions.pool_id`, `core.grants.grant_reason`, each `WHEN (OLD.col IS DISTINCT FROM NEW.col)`), their grants, and a Down section that drops the five triggers before the function.
|
||||
- [x] 3.2 List the two columns as `read` in `internal/migrate/testdata/console-reader-columns.txt`; `TestConsoleReaderColumns` passes.
|
||||
- [x] 3.3 Queries, regenerated (design D4): `ListPlanPoolBucketsByOrgType(org_type, target, per_class_limit)` replaces `ListLivePlanPositionsByOrgType`; `ListLivePlanSourcesByPools(pool_ids)` reads what holds each listed other-source pool's positions; `GetPlanPoolBucket(pool_id, target)` and `ListOutgoingPlanPositionsByPool(pool_id, target)` replace `GetLivePlanAttachmentsWithSourceByPool`; `ListLiveTierHoldersByLadderProduct` selects `default_sourced`; `GetOutgoingDefaultExists(org_types, target)` calls `core.outgoing_default_exists` (named `Get...` so the plan-act goldens treat it as a read). The two classifying queries fold a pool's live positions into `core.plan_pool_bucket`'s four arguments through `core.plan_position_is_outgoing`, each `bool_or` inside `COALESCE(..., false)`. The two replaced queries are deleted in this commit with their last callers, so `scripts/sqlc-unused` passes.
|
||||
- [x] 3.4 Delete `ClassifyPoolAttachments`; the Go class constants gain `BucketWouldFail` and mirror `core.plan_pool_bucket`'s five results. `buildOrgTypeChangePreview` reads `ListPlanPoolBucketsByOrgType` with a per-class limit of 50 and `ListLivePlanSourcesByPools` for the listed other-source pools, which `otherSourceLabel` labels, and does nothing else with the type (design D4). `OrgTypeChangePreview` gains each class's exact count, and its lists hold at most 50 names; `Empty`, `OutgoingDefaultSummary`, `HasOutgoingDefault`, the default change's 422 sentence and the reorder's field error read the counts, never a list's length (design D27). `operator_org_type_preview.html` and `operator_plan_ladder_tier_reorder_message.html` render the counts, "And N more." under a shortened outgoing, other-source or would-fail list (copy 46) and the disclosure label of copy 49. Every preview render (the preview endpoint, the radio re-post, the default change's and the reorder's 422) goes through this read. `enactDefault` reads `GetPlanPoolBucket` and, for an outgoing pool, `ListOutgoingPlanPositionsByPool`; `lockTierHolders` and the removal preview read `DefaultSourced`. The default change's and the reorder's commit handlers decide that a disposition is required with `GetOutgoingDefaultExists`, and still build the preview for the 422 answer. When the gate is true and the rebuilt preview's outgoing count is zero, they log an Error with the reached types and the target and answer the form's generic failure ("Failed to update the default plan. Details are in the server logs." for the default change, "Failed to reorder tiers." for the reorder), not a request for a disposition.
|
||||
- [x] 3.5 Tests: `TestClassifyPoolAttachments_*` (`internal/entitlements/default_change_test.go`) are deleted with their function, and their cases become a database table test of `GetPlanPoolBucket` (plan-less, default at the target, default outgoing, other source, a default and another source in one pool, a None target, two outgoing provisions listed once each by `ListOutgoingPlanPositionsByPool`); `TestPlanPoolBucketMirrorsItsConstants` (`core.plan_pool_bucket` called with every combination of its four arguments returns only the Go constants, and each of them for some combination); `TestPositionCopiesMatchTheirSources` (default, legacy, operator-grant and subscription positions on two org types: every row's two copies equal a recomputation from pools, organizations, provisions and grants, including rows written before the migration and filled by the backfill, and after a provision's status changes); `TestDispositionGateAgreesWithThePreview` (a mixed pool, a None target, an all-at-target type, an other-source-only type, a plan-less type, a pool-less organization and an empty type: the preview's read finds an outgoing organization exactly when `core.outgoing_default_exists` is true, and `GetPlanPoolBucket` gives every pool the class the preview's read gave it); `TestPositionSourcesCannotChange` (an update that changes each of the five columns is refused with `restrict_violation` naming that column; an update that assigns the same value passes; inserts are unaffected); the preview, the radio re-post and the 422 of the default change, and the reorder preview, each with more than 50 outgoing, other-source and would-fail organizations: 50 names per list, "And N more.", "show 50 of N" and exact counts, read in two statements per type whatever the type's size; `operator_anatomy_test.go`, `operator_org_types_render_test.go` and `rule_less_warning_test.go`, which build `OrgTypeChangePreview` values, set the counts; a handler test for the gate-true, preview-empty case (the Error line and the generic failure); the migration's up, down and up again.
|
||||
- [x] 3.6 Goldens: none move, because the changed and new queries are reads (`Get`, `List`), which the plan-act goldens leave out (`isActRead`, `internal/server/plan_acts_golden_db_test.go:364`), the position dump selects explicit columns, and every fixture's lists are shorter than 50; the commit says so after running the suite.
|
||||
|
||||
## 4. The act record and the tier delete on the runner
|
||||
|
||||
|
||||
Reference in New Issue
Block a user