Files
member-console/internal/integration/registration_db_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

180 lines
7.4 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package integration_test
import (
"context"
"database/sql"
"os"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
stripemod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
)
// TestRegisterProviders_DB exercises the full boot registration path against a
// real database: the same migration assembly as cmd/start (so version numbering
// is self-consistent), then RegisterProviders with the real FedWiki + Stripe
// sources. It asserts the registry rows, the operation set, and the cross-schema
// stamp of core.resource_keys.provider — the path that would os.Exit(1)
// at boot if broken.
//
// Uses the full module assembly; run it against an isolated/fresh database, not
// alongside the partial-assembly entitlements/provisioning suites on a shared DB
// (see the test-isolation issue in status/issues.md).
func TestRegisterProviders_DB(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
sources := migrate.Sources()
if err := db.RunMigrations(database, sources); err != nil {
t.Fatalf("run migrations: %v", err)
}
ctx := context.Background()
srcs := []integration.ProviderSource{fwmod.ProviderSource(), stripemod.ProviderSource(), dcmod.ProviderSource()}
if err := integration.RegisterProviders(ctx, database, srcs); err != nil {
t.Fatalf("RegisterProviders: %v", err)
}
// Provider rows + kinds.
for key, wantKind := range map[string]string{"fedwiki": "provisioning", "stripe": "payment", "discourse": "provisioning"} {
var kind string
if err := database.QueryRowContext(ctx,
"SELECT provider_kind FROM core.providers WHERE provider=$1", key).Scan(&kind); err != nil {
t.Fatalf("query provider %q: %v", key, err)
}
if kind != wantKind {
t.Errorf("provider %q kind = %q, want %q", key, kind, wantKind)
}
}
// FedWiki declares exactly create/set_status/delete/list/describe.
var opCount int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.provider_operations WHERE provider='fedwiki'").Scan(&opCount); err != nil {
t.Fatalf("count fedwiki operations: %v", err)
}
if opCount != 5 {
t.Errorf("fedwiki operation count = %d, want 5 (create/set_status/delete/list/describe)", opCount)
}
// FedWiki declares lifecycle states active/readonly/archived; Stripe (no
// set_status) declares none.
var fwStates, stripeStates int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.provider_states WHERE provider='fedwiki'").Scan(&fwStates); err != nil {
t.Fatalf("count fedwiki states: %v", err)
}
if fwStates != 3 {
t.Errorf("fedwiki state count = %d, want 3 (active/readonly/archived)", fwStates)
}
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.provider_states WHERE provider='stripe'").Scan(&stripeStates); err != nil {
t.Fatalf("count stripe states: %v", err)
}
if stripeStates != 0 {
t.Errorf("stripe state count = %d, want 0 (no set_status)", stripeStates)
}
// FedWiki's operator surface path is registered (drives the registry-driven nav).
var surface sql.NullString
if err := database.QueryRowContext(ctx,
"SELECT operator_surface_path FROM core.providers WHERE provider='fedwiki'").Scan(&surface); err != nil {
t.Fatalf("query fedwiki surface path: %v", err)
}
if !surface.Valid || surface.String != "/operator/integrations/fedwiki" {
t.Errorf("fedwiki operator_surface_path = %v, want '/operator/integrations/fedwiki'", surface)
}
// The cross-schema stamp landed on the renamed key.
var provider sql.NullString
if err := database.QueryRowContext(ctx,
"SELECT provider FROM core.resource_keys WHERE resource_key='fedwiki_sites'").Scan(&provider); err != nil {
t.Fatalf("query fedwiki_sites provider: %v", err)
}
if !provider.Valid || provider.String != "fedwiki" {
t.Errorf("fedwiki_sites.provider = %v, want 'fedwiki'", provider)
}
var behavior string
var consequence sql.NullString
if err := database.QueryRowContext(ctx,
"SELECT over_limit_behavior, over_limit_consequence FROM core.resource_keys WHERE resource_key='fedwiki_sites'").Scan(&behavior, &consequence); err != nil {
t.Fatalf("query fedwiki_sites over-limit declaration: %v", err)
}
if behavior != "park" || consequence.String != "Sites over the limit become read-only at the next sync." {
t.Errorf("fedwiki_sites over-limit = %q / %q, want park with its sentence", behavior, consequence.String)
}
// The bare 'sites' key is gone (renamed by the fwmod migration). Use a
// query parameter, not a literal, so this absence check doesn't trip the
// retired-resource-key lint.
var staleCount int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.resource_keys WHERE resource_key = $1", "sites").Scan(&staleCount); err != nil {
t.Fatalf("count stale resource key: %v", err)
}
if staleCount != 0 {
t.Errorf("bare 'sites' resource key still present (count=%d)", staleCount)
}
// Discourse declares only the read class (list/describe) and no states:
// group membership converges via reconciliation, not operator-invoked
// lifecycle verbs (see the discourse-integration change's design D6).
var dcOps, dcStates int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.provider_operations WHERE provider='discourse'").Scan(&dcOps); err != nil {
t.Fatalf("count discourse operations: %v", err)
}
if dcOps != 2 {
t.Errorf("discourse operation count = %d, want 2 (list/describe)", dcOps)
}
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM core.provider_states WHERE provider='discourse'").Scan(&dcStates); err != nil {
t.Fatalf("count discourse states: %v", err)
}
if dcStates != 0 {
t.Errorf("discourse state count = %d, want 0 (no set_status)", dcStates)
}
// Discourse's operator surface path drives the registry-driven nav.
var dcSurface sql.NullString
if err := database.QueryRowContext(ctx,
"SELECT operator_surface_path FROM core.providers WHERE provider='discourse'").Scan(&dcSurface); err != nil {
t.Fatalf("query discourse surface path: %v", err)
}
if !dcSurface.Valid || dcSurface.String != "/operator/integrations/discourse" {
t.Errorf("discourse operator_surface_path = %v, want '/operator/integrations/discourse'", dcSurface)
}
// The stamp landed on the key seeded by the discourse stream's own 00001.
var dcProvider sql.NullString
if err := database.QueryRowContext(ctx,
"SELECT provider FROM core.resource_keys WHERE resource_key='discourse_posting'").Scan(&dcProvider); err != nil {
t.Fatalf("query discourse_posting provider: %v", err)
}
if !dcProvider.Valid || dcProvider.String != "discourse" {
t.Errorf("discourse_posting.provider = %v, want 'discourse'", dcProvider)
}
// Idempotent: a second registration is a clean no-op.
if err := integration.RegisterProviders(ctx, database, srcs); err != nil {
t.Fatalf("second RegisterProviders (idempotency): %v", err)
}
}