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."
172 lines
6.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|