Files
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

341 lines
13 KiB
Go

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)
}
}