Files
member-console/internal/server/operator_grants_list_test.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

557 lines
22 KiB
Go

package server
// operator_grants_list_test.go — DB-backed coverage for
// ListGrantsWithDeliveryPage and its wiring into GetGrantsPage
// (operator-list-scale tasks 2.3 + 3.2, design D4): pins the SQL-vs-Go
// delivery-state agreement, org-name search with a true total, and
// LIMIT/OFFSET paging math.
//
// Fixture approach mirrors operator_grants_delivery_state_test.go
// (package server_test): issue two grants via the real IssueGrant flow,
// extend one (creates a live replacement and supersedes the original), and
// revoke the other with no successor (lands Inactive) — the same shape the
// shared delivery-state derivation classifies as live/superseded/inactive.
// That file's helpers (otcHarness/otcFixture/otcLadder/otcOrg) live in
// package server_test and are unexported, so they are not reachable from
// here; this file needs package-server access (newRollbackTestDB,
// EntitlementsQ, GetGrantsPage) so it rebuilds the same minimal fixture
// shape locally under its own "gl" (grants-list) prefix.
//
// DB-backed via TEST_DATABASE_URL (newRollbackTestDB, same helper
// operator_domains_db_test.go uses); every fixture name/slug carries a
// fresh UUID fragment so this file's rows never collide with another
// test's, even though TEST_DATABASE_URL is a shared, non-transactional
// connection across the whole package.
import (
"context"
"database/sql"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
// glFixture is a committed world for one test: an operator person and two
// single-tier ladders (A, B). Mirrors otcFixture/otcLadder in
// operator_org_type_change_test.go.
type glFixture struct {
operatorID string
ladderA, prodA string
ladderB, prodB string
}
func newGLFixture(t *testing.T, database *sql.DB) glFixture {
t.Helper()
ctx := context.Background()
sfx := uuid.New().String()[:8]
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
iq := identity.New(tx)
user, err := iq.CreateUser(ctx, "gl-u-"+uuid.NewString())
if err != nil {
t.Fatalf("user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID, DisplayName: "GL Operator",
PrimaryEmail: "gl-" + sfx + "@example.com", PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("person: %v", err)
}
f := glFixture{operatorID: person.PersonID}
f.ladderA, f.prodA = glLadder(t, ctx, tx, "gl-a-"+sfx)
f.ladderB, f.prodB = glLadder(t, ctx, tx, "gl-b-"+sfx)
if err := tx.Commit(); err != nil {
t.Fatalf("commit fixture: %v", err)
}
return f
}
// glLadder creates a single-tier ladder (its product at rank 0), returning
// (ladderID, productID).
func glLadder(t *testing.T, ctx context.Context, tx *sql.Tx, name string) (string, string) {
t.Helper()
eq := entitlements.New(tx)
bq := billing.New(tx)
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: name + "-set", IsActive: true})
if err != nil {
t.Fatalf("set %s: %v", name, err)
}
prod, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + "-prod", IsActive: true, IsPublic: false,
EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(set.SetID), Valid: true},
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("product %s: %v", name, err)
}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: name, Name: name, IsActive: true,
})
if err != nil {
t.Fatalf("ladder %s: %v", name, err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: ladder.PlanLadderID, ProductID: prod.ProductID,
}); err != nil {
t.Fatalf("tier %s: %v", name, err)
}
return ladder.PlanLadderID, prod.ProductID
}
// glOrg creates a committed org (org_type 'personal', seeded by migration
// 00003_seed_system_roles_org_types.sql) with a default pool, uniquely
// named/slugged so it never collides with another test's rows.
func glOrg(t *testing.T, database *sql.DB, f glFixture, name string) (orgID, poolID string) {
t.Helper()
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback()
oq := organization.New(tx)
eq := entitlements.New(tx)
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: name, Slug: "gl-" + uuid.New().String()[:12], OrgType: "personal", OwnerPersonID: f.operatorID,
})
if err != nil {
t.Fatalf("org %s: %v", name, err)
}
pool, err := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID, Name: "Default", Slug: "d-" + uuid.New().String()[:8],
PoolType: "default", IsAutoManaged: true,
})
if err != nil {
t.Fatalf("pool for %s: %v", name, err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit org: %v", err)
}
return org.OrgID, pool.PoolID
}
// glInsertGrant inserts a bare core.grants row directly, bypassing
// ConferGrant/pool provisioning — the search and paging tests below only
// need grant/org/product identity, not real delivery state, and a raw
// insert is far cheaper than a full ladder+pool conferral per row.
func glInsertGrant(t *testing.T, database *sql.DB, orgID, productID, personID string) string {
t.Helper()
var grantID string
if err := database.QueryRowContext(context.Background(),
`INSERT INTO core.grants (product_id, granted_to_org_id, granted_by_person_id, grant_reason, quantity)
VALUES ($1, $2, $3, 'manual', 1) RETURNING grant_id`,
productID, orgID, personID,
).Scan(&grantID); err != nil {
t.Fatalf("insert grant: %v", err)
}
return grantID
}
// glHarness bundles a handler under test with an authenticated-operator
// session context. Mirrors otcHarness (operator_org_type_change_test.go,
// package server_test); rebuilt here because this file needs package-server
// access to EntitlementsQ and GetGrantsPage directly.
type glHarness struct {
t *testing.T
handler *OperatorPartialsHandler
ctx context.Context
}
func newGLHarness(t *testing.T, database *sql.DB, personID string) *glHarness {
t.Helper()
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler, err := NewOperatorPartialsHandler(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)),
AuthConfig: authCfg,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("session Load: %v", err)
}
sm.Put(ctx, "authenticated", true)
sm.Put(ctx, "person_id", personID)
sm.Put(ctx, "roles", []string{OperatorRole})
return &glHarness{t: t, handler: handler, ctx: ctx}
}
func (h *glHarness) postOrg(handler http.HandlerFunc, orgID string, form url.Values) (int, string, string) {
h.t.Helper()
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("orgID", orgID)
rec := httptest.NewRecorder()
handler(rec, req)
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
}
func (h *glHarness) postOrgPool(handler http.HandlerFunc, orgID, poolID string, form url.Values) (int, string, string) {
h.t.Helper()
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("orgID", orgID)
req.SetPathValue("poolID", poolID)
rec := httptest.NewRecorder()
handler(rec, req)
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
}
func (h *glHarness) postGrant(handler http.HandlerFunc, grantID string, form url.Values) (int, string, string) {
h.t.Helper()
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("grantID", grantID)
rec := httptest.NewRecorder()
handler(rec, req)
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
}
// getGrantsPage issues a GET against GetGrantsPage with the given raw
// query string (e.g. "?q=marker&state=live&page=2").
func (h *glHarness) getGrantsPage(rawQuery string) (int, string) {
h.t.Helper()
req := httptest.NewRequestWithContext(h.ctx, http.MethodGet, "/operator/grants"+rawQuery, nil)
rec := httptest.NewRecorder()
h.handler.GetGrantsPage(rec, req)
return rec.Code, rec.Body.String()
}
// TestGrantsListDeliveryStateAgreesWithGoDerivation pins design D4's
// central invariant: for every facet value, the rows
// ListGrantsWithDeliveryPage's HAVING clause selects are exactly the rows
// the Go derivation (ListGrantsWithDelivery + buildGrantViewModels) would
// classify under that same state. If the paged query's repeated CASE
// expression ever drifts from the shared one, this test is where it shows.
func TestGrantsListDeliveryStateAgreesWithGoDerivation(t *testing.T) {
database := newRollbackTestDB(t)
f := newGLFixture(t, database)
h := newGLHarness(t, database, f.operatorID)
org, pool := glOrg(t, database, f, "GL Agreement Org "+uuid.New().String()[:8])
// Grant on ladder A: issued, then extended -- ends up Superseded, with
// the extending grant Live and naming it via extends_grant_id.
code, body, trigger := h.postOrg(h.handler.IssueGrant, org, url.Values{
"product_id": {f.prodA}, "reason": {"manual"}, "quantity": {"1"},
})
if code != http.StatusOK || !strings.Contains(trigger, "issued") {
t.Fatalf("issue grant A: status=%d trigger=%q body=%s", code, trigger, body)
}
var grantAID string
if err := database.QueryRow(`SELECT grant_id FROM core.grants WHERE granted_to_org_id = $1 AND product_id = $2`, org, f.prodA).Scan(&grantAID); err != nil {
t.Fatalf("resolve grant A id: %v", err)
}
// Grant on ladder B: issued, then revoked outright -- lands Inactive
// (no successor on record).
code, body, trigger = h.postOrg(h.handler.IssueGrant, org, url.Values{
"product_id": {f.prodB}, "reason": {"manual"}, "quantity": {"1"},
})
if code != http.StatusOK || !strings.Contains(trigger, "issued") {
t.Fatalf("issue grant B: status=%d trigger=%q body=%s", code, trigger, body)
}
var grantBID string
if err := database.QueryRow(`SELECT grant_id FROM core.grants WHERE granted_to_org_id = $1 AND product_id = $2`, org, f.prodB).Scan(&grantBID); err != nil {
t.Fatalf("resolve grant B id: %v", err)
}
// Extension is position-scoped (ux-operator-scale review round): name
// A's provision explicitly. (This file is package server, so it can't
// share server_test's activeProvisionID helper.)
var provisionA string
if err := database.QueryRow(`SELECT provision_id FROM core.pool_provisions WHERE pool_id = $1 AND product_id = $2 AND status = 'active'`, pool, f.prodA).Scan(&provisionA); err != nil {
t.Fatalf("resolve provision A: %v", err)
}
code, body, trigger = h.postOrgPool(h.handler.ExtendGrant, org, pool, url.Values{
"reason": {"extend for agreement test"},
"provision_id": {provisionA},
})
if code != http.StatusOK || !strings.Contains(trigger, "extended") {
t.Fatalf("extend grant A: status=%d trigger=%q body=%s", code, trigger, body)
}
var grantA2ID string
if err := database.QueryRow(`SELECT grant_id FROM core.grants WHERE extends_grant_id = $1`, grantAID).Scan(&grantA2ID); err != nil {
t.Fatalf("resolve extending grant id: %v", err)
}
code, body, trigger = h.postGrant(h.handler.RevokeGrantAndTransition, grantBID, url.Values{"org_id": {org}})
if code != http.StatusOK || !strings.Contains(trigger, "revoked") {
t.Fatalf("revoke grant B: status=%d trigger=%q body=%s", code, trigger, body)
}
ctx := context.Background()
// Go-side derivation: every grant system-wide through the shared
// unpaged query, classified exactly as the org-detail composite and
// buildGrantViewModels would.
allRows, err := h.handler.EntitlementsQ.ListGrantsWithDelivery(ctx, uuid.NullUUID{})
if err != nil {
t.Fatalf("ListGrantsWithDelivery: %v", err)
}
goByState := map[string]map[string]bool{"live": {}, "superseded": {}, "inactive": {}}
for _, row := range allRows {
if s, ok := goByState[row.DeliveryState]; ok {
s[row.GrantID] = true
}
}
// Sanity: the fixture grants landed where the scenario expects, before
// trusting them as the agreement test's anchors.
if !goByState["superseded"][grantAID] {
t.Fatalf("expected grant A (%s) to be Superseded in the Go derivation", grantAID)
}
if !goByState["live"][grantA2ID] {
t.Fatalf("expected the extending grant (%s) to be Live in the Go derivation", grantA2ID)
}
if !goByState["inactive"][grantBID] {
t.Fatalf("expected grant B (%s) to be Inactive in the Go derivation", grantBID)
}
// SQL-side: for each facet value, the paged query's HAVING-filtered
// rows must be exactly the grant IDs the Go derivation classified
// under that state. PageLimit is generous because TEST_DATABASE_URL is
// shared, non-transactional storage across this package's tests --
// every grant any test has left behind is a candidate row, and the
// comparison must cover all of them, not just this fixture's three.
for _, state := range []string{"live", "superseded", "inactive"} {
rows, err := h.handler.EntitlementsQ.ListGrantsWithDeliveryPage(ctx, entitlements.ListGrantsWithDeliveryPageParams{
DeliveryState: sql.NullString{String: state, Valid: true},
PageLimit: 1_000_000,
PageOffset: 0,
})
if err != nil {
t.Fatalf("ListGrantsWithDeliveryPage(%s): %v", state, err)
}
sqlIDs := make(map[string]bool, len(rows))
for _, row := range rows {
if row.DeliveryState != state {
t.Errorf("state=%s: row %s has DeliveryState=%q in its own SELECT list", state, row.GrantID, row.DeliveryState)
}
sqlIDs[row.GrantID] = true
}
goIDs := goByState[state]
if len(sqlIDs) != len(goIDs) {
t.Errorf("state=%s: SQL filter returned %d grants, Go derivation classified %d", state, len(sqlIDs), len(goIDs))
}
for id := range goIDs {
if !sqlIDs[id] {
t.Errorf("state=%s: grant %s classified %s by the Go derivation but missing from the SQL-filtered set", state, id, state)
}
}
for id := range sqlIDs {
if !goIDs[id] {
t.Errorf("state=%s: grant %s returned by the SQL filter but not classified %s by the Go derivation", state, id, state)
}
}
}
// The three fixture grants specifically landed in the SQL-filtered set
// matching their expected state (a more legible failure than the
// set-equality loop above if something regresses).
liveRows, err := h.handler.EntitlementsQ.ListGrantsWithDeliveryPage(ctx, entitlements.ListGrantsWithDeliveryPageParams{
DeliveryState: sql.NullString{String: "live", Valid: true}, PageLimit: 1_000_000,
})
if err != nil {
t.Fatalf("ListGrantsWithDeliveryPage(live): %v", err)
}
if !containsGrantID(liveRows, grantA2ID) {
t.Errorf("live filter: missing the extending grant %s", grantA2ID)
}
supersededRows, err := h.handler.EntitlementsQ.ListGrantsWithDeliveryPage(ctx, entitlements.ListGrantsWithDeliveryPageParams{
DeliveryState: sql.NullString{String: "superseded", Valid: true}, PageLimit: 1_000_000,
})
if err != nil {
t.Fatalf("ListGrantsWithDeliveryPage(superseded): %v", err)
}
if !containsGrantID(supersededRows, grantAID) {
t.Errorf("superseded filter: missing grant A %s", grantAID)
}
inactiveRows, err := h.handler.EntitlementsQ.ListGrantsWithDeliveryPage(ctx, entitlements.ListGrantsWithDeliveryPageParams{
DeliveryState: sql.NullString{String: "inactive", Valid: true}, PageLimit: 1_000_000,
})
if err != nil {
t.Fatalf("ListGrantsWithDeliveryPage(inactive): %v", err)
}
if !containsGrantID(inactiveRows, grantBID) {
t.Errorf("inactive filter: missing grant B %s", grantBID)
}
}
func containsGrantID(rows []entitlements.ListGrantsWithDeliveryPageRow, grantID string) bool {
for _, row := range rows {
if row.GrantID == grantID {
return true
}
}
return false
}
// TestGrantsListSearchNarrowsWithTotals covers operator-list-scale's
// "Search narrows the list server-side" scenario end to end through
// GetGrantsPage: a search term matching two organizations' names (and not
// a third) narrows the rendered rows and reports the true total, both by
// org name and — since the grants module cannot join into billing to
// search product names in SQL — by the Go-resolved product-name path
// (matchingProductIDs).
func TestGrantsListSearchNarrowsWithTotals(t *testing.T) {
database := newRollbackTestDB(t)
f := newGLFixture(t, database)
h := newGLHarness(t, database, f.operatorID)
marker := "Zqm" + uuid.New().String()[:8]
orgAlpha, _ := glOrg(t, database, f, "GL Search Alpha "+marker)
orgBeta, _ := glOrg(t, database, f, "GL Search Beta "+marker)
orgDecoy, _ := glOrg(t, database, f, "GL Search Decoy "+uuid.New().String()[:8])
glInsertGrant(t, database, orgAlpha, f.prodA, f.operatorID)
glInsertGrant(t, database, orgBeta, f.prodB, f.operatorID)
glInsertGrant(t, database, orgDecoy, f.prodA, f.operatorID)
code, body := h.getGrantsPage("?q=" + url.QueryEscape(marker))
if code != http.StatusOK {
t.Fatalf("get grants page: status=%d body=%s", code, body)
}
if !strings.Contains(body, "GL Search Alpha "+marker) {
t.Errorf("expected Alpha org in narrowed results, got:\n%s", body)
}
if !strings.Contains(body, "GL Search Beta "+marker) {
t.Errorf("expected Beta org in narrowed results, got:\n%s", body)
}
if strings.Contains(body, "GL Search Decoy") {
t.Errorf("decoy org must not appear in results narrowed to %q, got:\n%s", marker, body)
}
if !strings.Contains(body, "of 2") {
t.Errorf("expected the true total (2) in the Showing line, got:\n%s", body)
}
// Query-level cross-check: the true total independent of any HTML
// formatting.
rows, err := h.handler.EntitlementsQ.ListGrantsWithDeliveryPage(context.Background(), entitlements.ListGrantsWithDeliveryPageParams{
Q: sql.NullString{String: marker, Valid: true},
PageLimit: 50,
PageOffset: 0,
})
if err != nil {
t.Fatalf("ListGrantsWithDeliveryPage: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows for q=%q, got %d", marker, len(rows))
}
if rows[0].TotalCount != 2 {
t.Errorf("expected TotalCount=2 for q=%q, got %d", marker, rows[0].TotalCount)
}
// Product-name search: a grant on an unmarked org, targeting a product
// whose name carries a marker, must surface via matchingProductIDs even
// though the org name doesn't match.
prodMarker := "Gpm" + uuid.New().String()[:8]
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
_, prodMarkedID := glLadder(t, ctx, tx, "gl-"+prodMarker)
if err := tx.Commit(); err != nil {
t.Fatalf("commit marked product: %v", err)
}
orgPlain, _ := glOrg(t, database, f, "GL Search Plain "+uuid.New().String()[:8])
glInsertGrant(t, database, orgPlain, prodMarkedID, f.operatorID)
code, body = h.getGrantsPage("?q=" + url.QueryEscape(prodMarker))
if code != http.StatusOK {
t.Fatalf("get grants page (product search): status=%d body=%s", code, body)
}
if !strings.Contains(body, "GL Search Plain") {
t.Errorf("expected the product-name match to surface its (unmarked-name) org, got:\n%s", body)
}
}
// TestGrantsListPagingMath pins the query's LIMIT/OFFSET behavior directly
// (operator-list-scale: "A large list pages instead of dumping"). Seeding
// 50+ grants through real conferral is expensive, so per the task's
// allowance this asserts LIMIT/OFFSET semantics on a small, precisely
// scoped set instead: five grants sharing a search marker, paged with
// limit 2, must partition into 2+2+1 rows with no overlap and no omission,
// and every page must report the same true total.
func TestGrantsListPagingMath(t *testing.T) {
database := newRollbackTestDB(t)
f := newGLFixture(t, database)
h := newGLHarness(t, database, f.operatorID)
marker := "GLPage" + uuid.New().String()[:8]
wantIDs := make(map[string]bool, 5)
for i := 0; i < 5; i++ {
org, _ := glOrg(t, database, f, "GL Paging Org "+marker+"-"+string(rune('A'+i)))
grantID := glInsertGrant(t, database, org, f.prodA, f.operatorID)
wantIDs[grantID] = true
}
ctx := context.Background()
seen := map[string]bool{}
var total int64 = -1
offsets := []int32{0, 2, 4}
wantCounts := []int{2, 2, 1}
for i, offset := range offsets {
rows, err := h.handler.EntitlementsQ.ListGrantsWithDeliveryPage(ctx, entitlements.ListGrantsWithDeliveryPageParams{
Q: sql.NullString{String: marker, Valid: true},
PageLimit: 2,
PageOffset: offset,
})
if err != nil {
t.Fatalf("ListGrantsWithDeliveryPage offset=%d: %v", offset, err)
}
if len(rows) != wantCounts[i] {
t.Fatalf("offset=%d: expected %d rows, got %d", offset, wantCounts[i], len(rows))
}
for _, row := range rows {
if total == -1 {
total = row.TotalCount
} else if row.TotalCount != total {
t.Errorf("offset=%d: TotalCount=%d, expected the stable total %d", offset, row.TotalCount, total)
}
if seen[row.GrantID] {
t.Errorf("offset=%d: grant %s returned on more than one page", offset, row.GrantID)
}
seen[row.GrantID] = true
if !wantIDs[row.GrantID] {
t.Errorf("offset=%d: grant %s does not belong to this test's fixture", offset, row.GrantID)
}
}
}
if total != 5 {
t.Errorf("expected true total 5, got %d", total)
}
if len(seen) != len(wantIDs) {
t.Errorf("expected all 5 fixture grants covered across pages, saw %d distinct grants", len(seen))
}
}