Files
member-console/internal/entitlements/numeric_usage_reconcile_test.go
T
cgalo5758 12f1d3fc00 Fix the four Slice 3 walk findings and page every operator list
Archives openspec change slice3-walk-fixes and syncs its five delta
specs (fedwiki-sites, entitlements, operator-panel-navigation,
operator-list-scale, ui-quality-gate).

- FedWiki site usage is read from active site rows in both quota
  readers; the reservation counter converges on the rows: raise-only
  after farm sync and inside the create quota check, exact at boot.
  The understated production counters repair on the first boot.
- The People tile caption excludes the reserved system person through
  the same query parameter the directory uses.
- The operator Domains live-claims list is a governed list: pages of
  50, true total, search over root name and organization, a
  pending/active facet.
- New lint rule table-without-list-controls refuses an unpaged
  page-body table unless it carries a list-scale exempt marker with a
  reason; six curated or detail tables carry one. Its first run caught
  the operator FedWiki sites list, which is now governed the same way.
- Entitlement-set rule copy: "Per unit", "Multiplied by the quantity
  purchased or granted."
2026-09-12 01:16:17 -05:00

172 lines
6.1 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// usageFixture builds workspace → pool → numeric entitlement → usage counter
// inside the caller's transaction and returns the workspace and pool ids.
// withUsage false leaves the pool without a counter row, which is how a pool
// under no materialized numeric entitlement looks.
func usageFixture(t *testing.T, tx *sql.Tx, limit, usage int64, withUsage bool) (workspaceID, poolID string) {
t.Helper()
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
scan := func(q string, args ...any) string {
var id string
if err := tx.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
t.Fatalf("fixture %q: %v", q, err)
}
return id
}
userID := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+uniq)
personID := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, "Tester", uniq+"@example.com")
orgID := scan(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,'personal',$2) RETURNING org_id`,
"Org "+uniq, personID)
workspaceID = scan(`INSERT INTO core.workspaces (org_id, name) VALUES ($1,'WS') RETURNING workspace_id`, orgID)
poolID = scan(`INSERT INTO core.resource_pools (org_id, name, pool_type) VALUES ($1,'Pool','default') RETURNING pool_id`, orgID)
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.pool_assignments (pool_id, workspace_id, is_primary, status) VALUES ($1,$2,TRUE,'active')`,
poolID, workspaceID); err != nil {
t.Fatalf("fixture pool_assignment: %v", err)
}
entID := scan(`INSERT INTO core.numeric_entitlements (pool_id, resource_key, entitlement_type, resource_limit)
VALUES ($1,'fedwiki_sites','limit',$2) RETURNING entitlement_id`, poolID, limit)
if withUsage {
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.numeric_entitlement_usage (entitlement_id, pool_id, resource_key, current_usage)
VALUES ($1,$2,'fedwiki_sites',$3)`, entID, poolID, usage); err != nil {
t.Fatalf("fixture usage: %v", err)
}
}
return workspaceID, poolID
}
// TestNumericUsageReconcile covers the entitlements delta's "Usage counter
// reconciliation from owned rows": the raise-only reconcile never lowers, it
// lifts an understated counter (after which the gated increment refuses), the
// exact set brings an overstated counter down at boot, and neither operation
// creates a usage row.
func TestNumericUsageReconcile(t *testing.T) {
database := testDB(t)
ctx := context.Background()
begin := func(t *testing.T) (*sql.Tx, *entitlements.Queries) {
t.Helper()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { tx.Rollback() })
return tx, entitlements.New(tx)
}
read := func(t *testing.T, tx *sql.Tx, poolID string) int64 {
t.Helper()
var u int64
if err := tx.QueryRowContext(ctx,
`SELECT current_usage FROM core.numeric_entitlement_usage WHERE pool_id=$1 AND resource_key='fedwiki_sites'`,
poolID).Scan(&u); err != nil {
t.Fatalf("read usage: %v", err)
}
return u
}
t.Run("raise-only never lowers", func(t *testing.T) {
tx, q := begin(t)
_, poolID := usageFixture(t, tx, 10, 5, true)
if _, err := q.RaiseNumericUsageTo(ctx, entitlements.RaiseNumericUsageToParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 4,
}); err != nil {
t.Fatalf("raise: %v", err)
}
if got := read(t, tx, poolID); got != 5 {
t.Errorf("current_usage = %d, want 5 (raise-only must not lower)", got)
}
})
t.Run("raise lifts an understated counter and the gated increment then refuses", func(t *testing.T) {
tx, q := begin(t)
workspaceID, poolID := usageFixture(t, tx, 64, 1, true)
if _, err := q.RaiseNumericUsageTo(ctx, entitlements.RaiseNumericUsageToParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 64,
}); err != nil {
t.Fatalf("raise: %v", err)
}
if got := read(t, tx, poolID); got != 64 {
t.Fatalf("current_usage = %d, want 64", got)
}
res, err := q.AtomicIncrementUsage(ctx, entitlements.AtomicIncrementUsageParams{
WorkspaceID: workspaceID, ResourceKey: "fedwiki_sites",
})
if err != nil {
t.Fatalf("increment: %v", err)
}
rows, err := res.RowsAffected()
if err != nil {
t.Fatalf("rows affected: %v", err)
}
if rows != 0 {
t.Errorf("gated increment affected %d rows at the limit, want 0", rows)
}
})
t.Run("exact set at boot brings an overstated counter down", func(t *testing.T) {
tx, q := begin(t)
_, poolID := usageFixture(t, tx, 64, 20, true)
if _, err := q.SetNumericUsage(ctx, entitlements.SetNumericUsageParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 12,
}); err != nil {
t.Fatalf("set: %v", err)
}
if got := read(t, tx, poolID); got != 12 {
t.Errorf("current_usage = %d, want 12", got)
}
})
t.Run("neither operation creates a usage row", func(t *testing.T) {
tx, q := begin(t)
_, poolID := usageFixture(t, tx, 5, 0, false)
for _, c := range []struct {
name string
run func() (sql.Result, error)
}{
{"raise", func() (sql.Result, error) {
return q.RaiseNumericUsageTo(ctx, entitlements.RaiseNumericUsageToParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 3,
})
}},
{"set", func() (sql.Result, error) {
return q.SetNumericUsage(ctx, entitlements.SetNumericUsageParams{
PoolID: poolID, ResourceKey: "fedwiki_sites", Observed: 3,
})
}},
} {
res, err := c.run()
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if rows, err := res.RowsAffected(); err != nil || rows != 0 {
t.Errorf("%s affected %d rows (err %v), want 0", c.name, rows, err)
}
}
var u int64
err := tx.QueryRowContext(ctx,
`SELECT current_usage FROM core.numeric_entitlement_usage WHERE pool_id=$1`, poolID).Scan(&u)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("a usage row exists after reconcile (usage %d, err %v); the materializer owns row creation", u, err)
}
})
}