package server // DB-backed handler tests for the People directory (operator-people-directory // D6 / operator-list-scale UX-4): GetPersonsPage listing newest-joined // first, server-side search over display name and email with true totals, // pagination past 50 rows, and the reserved system person's "System // identity" badge. // // DB-backed via TEST_DATABASE_URL (newRollbackTestDB, shared with // operator_domains_db_test.go — core schema is enough, no domains fixtures // needed here). The route is not yet registered (the orchestrator wires // /operator/persons into RegisterRoutes separately), so GetPersonsPage is // exercised directly rather than through a mux, the same way // TestGrantDeliveryState_EndToEnd exercises GetOrgEnrollment outside route // registration. // // Every fixture person's email carries a per-test unique marker so // assertions about "true totals" hold even though newRollbackTestDB's // underlying database is shared with any other test in this binary run — // searching by the marker scopes a query to exactly the rows this test // created, regardless of what else landed in core.persons. import ( "context" "database/sql" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "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/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant" ) // newPersonsTestHandler builds an OperatorPartialsHandler wired with real // IdentityQ/OrgQ against database — the two queriers GetPersonsPage and its // system-person detection read. func newPersonsTestHandler(t *testing.T, database *sql.DB) *OperatorPartialsHandler { t.Helper() sm := scs.New() handler, err := NewOperatorPartialsHandler(OperatorPartialsConfig{ Database: database, IdentityQ: identity.New(database), OrgQ: organization.New(database), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), AuthConfig: &auth.Config{SessionManager: sm}, }) if err != nil { t.Fatalf("NewOperatorPartialsHandler: %v", err) } return handler } // personsGet issues a GET against GetPersonsPage directly (the route isn't // registered by this lane) with the given raw query string (e.g. // "q=ada&page=2"). Uses a freshly loaded session so buildOperatorPageData's // SessionManager reads don't panic on an unloaded context. func personsGet(t *testing.T, handler *OperatorPartialsHandler, rawQuery string) (int, string) { t.Helper() sm := handler.AuthConfig.SessionManager ctx, err := sm.Load(context.Background(), "") if err != nil { t.Fatalf("session Load: %v", err) } target := "/operator/persons" if rawQuery != "" { target += "?" + rawQuery } req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil) rec := httptest.NewRecorder() handler.GetPersonsPage(rec, req) return rec.Code, rec.Body.String() } // createDirectoryPerson inserts a user+person fixture with an explicit // created_at, so ordering is deterministic instead of racing on NOW(). func createDirectoryPerson(t *testing.T, database *sql.DB, displayName, email string, verified bool, createdAt time.Time) string { t.Helper() ctx := context.Background() var userID string if err := database.QueryRowContext(ctx, `INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+uuid.NewString(), ).Scan(&userID); err != nil { t.Fatalf("insert user fixture: %v", err) } var personID string if err := database.QueryRowContext(ctx, `INSERT INTO core.persons (user_id, display_name, primary_email, primary_email_verified, created_at) VALUES ($1, $2, $3, $4, $5) RETURNING person_id`, userID, displayName, email, verified, createdAt, ).Scan(&personID); err != nil { t.Fatalf("insert person fixture: %v", err) } return personID } // rowLinksTo reports whether the rendered body contains a directory row // linking to personID's detail page. func rowLinksTo(body, personID string) bool { return strings.Contains(body, fmt.Sprintf(`href="/operator/persons/%s"`, personID)) } // TestPersonsDirectory_NewestJoinedFirst covers operator-people-directory's // "Recent joiners lead" scenario: the directory orders created_at DESC, so // the most recently joined person of a fixture set renders before older // ones, and each row links to that person's detail page. func TestPersonsDirectory_NewestJoinedFirst(t *testing.T) { database := newRollbackTestDB(t) marker := "njf-" + uuid.NewString()[:8] base := time.Now().Add(-time.Hour) oldest := createDirectoryPerson(t, database, "Ola Oldest", fmt.Sprintf("ola@%s.example", marker), true, base) middle := createDirectoryPerson(t, database, "Mia Middle", fmt.Sprintf("mia@%s.example", marker), true, base.Add(10*time.Minute)) newest := createDirectoryPerson(t, database, "Nia Newest", fmt.Sprintf("nia@%s.example", marker), true, base.Add(20*time.Minute)) handler := newPersonsTestHandler(t, database) code, body := personsGet(t, handler, "q="+marker) if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !strings.Contains(body, "of 3") { t.Errorf("expected the true total (3) in the pager, got:\n%s", body) } idxNewest := strings.Index(body, "Nia Newest") idxMiddle := strings.Index(body, "Mia Middle") idxOldest := strings.Index(body, "Ola Oldest") if idxNewest < 0 || idxMiddle < 0 || idxOldest < 0 { t.Fatalf("expected all three fixture names to render, got:\n%s", body) } if !(idxNewest < idxMiddle && idxMiddle < idxOldest) { t.Errorf("expected newest-joined-first ordering (Nia, Mia, Ola), got positions %d, %d, %d in:\n%s", idxNewest, idxMiddle, idxOldest, body) } for _, id := range []string{oldest, middle, newest} { if !rowLinksTo(body, id) { t.Errorf("expected a row linking to /operator/persons/%s, got:\n%s", id, body) } } } // TestPersonsDirectory_SearchNarrowsByNameAndEmail covers "Search narrows // the list server-side" for both named search fields (operator-list-scale: // "people: display name and email"): a term matching only a display name, // and a term matching only an email, each narrow to exactly one row with a // true total of 1. func TestPersonsDirectory_SearchNarrowsByNameAndEmail(t *testing.T) { database := newRollbackTestDB(t) // Distinct markers per field: nameMarker lives only in Ada's display // name, emailMarker only in Grace's email domain, so a search on one // can never accidentally match the other fixture through the other // field. nameMarker := "nsearch-" + uuid.NewString()[:8] emailMarker := "esearch-" + uuid.NewString()[:8] now := time.Now() ada := createDirectoryPerson(t, database, fmt.Sprintf("Ada %s Lovelace", nameMarker), "ada@example.invalid", true, now) grace := createDirectoryPerson(t, database, "Grace Hopper", fmt.Sprintf("ghopper@%s.example", emailMarker), false, now.Add(time.Minute)) handler := newPersonsTestHandler(t, database) // Name-field match. code, body := personsGet(t, handler, "q="+nameMarker) if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !strings.Contains(body, "of 1") { t.Errorf("expected a true total of 1 for the name search, got:\n%s", body) } if !rowLinksTo(body, ada) || rowLinksTo(body, grace) { t.Errorf("name search should match only Ada, got:\n%s", body) } // Email-field match. code, body = personsGet(t, handler, "q="+emailMarker) if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !strings.Contains(body, "of 1") { t.Errorf("expected a true total of 1 for the email search, got:\n%s", body) } if !rowLinksTo(body, grace) || rowLinksTo(body, ada) { t.Errorf("email search should match only Grace, got:\n%s", body) } // A term matching neither field renders the no-match state, not the // true-empty state (empty-state-guidance / operator-list-scale "No // matches is not empty"). code, body = personsGet(t, handler, "q="+nameMarker+"-nomatch-zzz") if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !strings.Contains(body, "No rows match") { t.Errorf("expected the no-match state, got:\n%s", body) } if strings.Contains(body, "No one has signed in yet") { t.Errorf("no-match must not render as the true-empty state, got:\n%s", body) } } // TestPersonsDirectory_PagingBeyondFiftyRows covers "A large list pages // instead of dumping" and "Malformed page values clamp": 55 fixture rows // split across two pages of the fixed 50-row page size, and an // out-of-range page number clamps back to a valid page instead of // erroring or rendering empty. func TestPersonsDirectory_PagingBeyondFiftyRows(t *testing.T) { database := newRollbackTestDB(t) marker := "page-" + uuid.NewString()[:8] base := time.Now().Add(-24 * time.Hour) const total = 55 var newestID, oldestID string for i := 0; i < total; i++ { id := createDirectoryPerson(t, database, fmt.Sprintf("Fixture Person %02d", i), fmt.Sprintf("p%02d@%s.example", i, marker), true, base.Add(time.Duration(i)*time.Minute)) if i == total-1 { newestID = id // latest created_at -> page 1, row 1 } if i == 0 { oldestID = id // earliest created_at -> last row of page 2 } } handler := newPersonsTestHandler(t, database) // Page 1 (default): 50 rows, true total 55. code, body := personsGet(t, handler, "q="+marker) if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !strings.Contains(body, "of 55") { t.Errorf("expected the true total 55 on page 1, got:\n%s", body) } if !strings.Contains(body, "Page 1 of 2") { t.Errorf("expected pager 'Page 1 of 2', got:\n%s", body) } if !rowLinksTo(body, newestID) { t.Errorf("expected the newest fixture row on page 1, got:\n%s", body) } if rowLinksTo(body, oldestID) { t.Errorf("did not expect the oldest fixture row (page 2) on page 1, got:\n%s", body) } // Page 2: the remaining 5 rows. code, body = personsGet(t, handler, "q="+marker+"&page=2") if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !strings.Contains(body, "Page 2 of 2") { t.Errorf("expected pager 'Page 2 of 2', got:\n%s", body) } if !rowLinksTo(body, oldestID) { t.Errorf("expected the oldest fixture row on page 2, got:\n%s", body) } if rowLinksTo(body, newestID) { t.Errorf("did not expect the newest fixture row (page 1) on page 2, got:\n%s", body) } // Out-of-range page clamps back to a valid page rather than erroring. code, body = personsGet(t, handler, "q="+marker+"&page=9999") if code != http.StatusOK { t.Fatalf("out-of-range page: status = %d, body: %s", code, body) } if !strings.Contains(body, "Page 1 of 2") { t.Errorf("expected an out-of-range page to clamp to page 1, got:\n%s", body) } // A malformed page value clamps rather than erroring. code, body = personsGet(t, handler, "q="+marker+"&page=not-a-number") if code != http.StatusOK { t.Fatalf("malformed page: status = %d, body: %s", code, body) } if !strings.Contains(body, "Page 1 of 2") { t.Errorf("expected a malformed page to clamp to page 1, got:\n%s", body) } } // TestPersonsDirectory_SystemPersonExcluded covers operator-people-directory's // "The system person does not appear" scenario (maintainer decision // 2026-08-23, revising the earlier labeled-row treatment): the reserved // person that owns the System tenant is infrastructure, never "joined", // and is excluded from the directory and its totals — even when a search // term matches it exactly — while ordinary persons render normally. func TestPersonsDirectory_SystemPersonExcluded(t *testing.T) { database := newRollbackTestDB(t) ctx := context.Background() if _, err := systemtenant.Ensure(ctx, database); err != nil { t.Fatalf("systemtenant.Ensure: %v", err) } var sysPersonID string if err := database.QueryRowContext(ctx, `SELECT owner_person_id FROM core.organizations WHERE org_type = $1`, systemtenant.OrgType, ).Scan(&sysPersonID); err != nil { t.Fatalf("resolve system person id: %v", err) } marker := "sys-" + uuid.NewString()[:8] ordinary := createDirectoryPerson(t, database, "Ordinary Operator Fixture", fmt.Sprintf("op@%s.example", marker), true, time.Now()) handler := newPersonsTestHandler(t, database) // The system person's own reserved, RFC 2606 ".invalid" email is an // exact search term that could only match that one row — the exclusion // must hold even then, rendering the no-match state with a zero total. code, body := personsGet(t, handler, "q=member-console.invalid") if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if rowLinksTo(body, sysPersonID) { t.Errorf("the system person must not appear in the directory, got:\n%s", body) } if !strings.Contains(body, "No rows match") { t.Errorf("a search matching only the excluded system person must render the no-match state, got:\n%s", body) } // An ordinary person renders normally, with no system marker anywhere. code, body = personsGet(t, handler, "q="+marker) if code != http.StatusOK { t.Fatalf("status = %d, body: %s", code, body) } if !rowLinksTo(body, ordinary) { t.Fatalf("expected the ordinary fixture's row, got:\n%s", body) } if strings.Contains(body, "System identity") { t.Errorf("the retired System identity badge must not render, got:\n%s", body) } }