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.
341 lines
13 KiB
Go
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)
|
|
}
|
|
}
|