Files
member-console/internal/server/operator_lookup_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

477 lines
18 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// Landing-surface lookup (entity-keys D8): a single typed input
// resolves to a person detail, an organization composite, a disambiguation
// listing, or a no-match notice. Persons resolve first because every
// personal organization's name contains its owner's display name, so a
// bare name match must land on the person rather than the organization.
// DB-backed via TEST_DATABASE_URL, mirroring operator_organizations_list_test.go's
// setup (newRollbackTestDB, a plain http.ServeMux with routes registered, and
// a role-carrying session context).
import (
"context"
"database/sql"
"fmt"
"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"
"git.coopcloud.tech/wiki-cafe/member-console/test/e2e/screens"
)
// lookupEnv is the surface under test: the OperatorHandler with its routes
// registered, plus one authenticated operator session to drive it with.
type lookupEnv struct {
t *testing.T
database *sql.DB
mux *http.ServeMux
operator context.Context
}
func newLookupEnv(t *testing.T) *lookupEnv {
t.Helper()
database := newRollbackTestDB(t)
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler, err := NewOperatorHandler(OperatorHandlerConfig{
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: authCfg,
OrgQ: organization.New(database),
IdentityQ: identity.New(database),
// The key branch reaches the catalog tables as well as
// organizations (entity-keys §5), so the handler under test needs
// the same queriers the running app gives it.
BillingQ: billing.New(database),
EntitlementsQ: entitlements.New(database),
})
if err != nil {
t.Fatalf("NewOperatorHandler: %v", err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("session Load: %v", err)
}
sm.Put(ctx, "authenticated", true)
sm.Put(ctx, "roles", []string{OperatorRole})
return &lookupEnv{t: t, database: database, mux: mux, operator: ctx}
}
// lookup issues the scoped htmx GET the declared search form renders
// (form-library, spec operator-panel-navigation) and returns the response
// code, the HX-Redirect header (empty when absent), and the body.
func (e *lookupEnv) lookup(term string) (int, string, string) {
e.t.Helper()
req := httptest.NewRequestWithContext(e.operator, http.MethodGet, "/operator/lookup?"+url.Values{"term": {term}}.Encode(), nil)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
return rec.Code, rec.Header().Get("HX-Redirect"), rec.Body.String()
}
// lookupNative issues the plain GET a no-JS submission of the search form's
// native action produces: no HX-Request header, so a unique match answers
// with a real 303 redirect instead of HX-Redirect, and an ambiguous or
// no-match term gets the full landing page back, not the bare fragment
// (spec operator-panel-navigation, "The lookup resolves without
// JavaScript").
func (e *lookupEnv) lookupNative(term string) (int, string, string) {
e.t.Helper()
req := httptest.NewRequestWithContext(e.operator, http.MethodGet, "/operator/lookup?"+url.Values{"term": {term}}.Encode(), nil)
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
return rec.Code, rec.Header().Get("Location"), rec.Body.String()
}
// seedLookupPerson inserts the user -> person chain a fixture row needs.
func (e *lookupEnv) seedLookupPerson(displayName, email string) string {
e.t.Helper()
ctx := context.Background()
sub := uuid.New().String()
var personID string
var userID string
if err := e.database.QueryRowContext(ctx,
`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+sub).Scan(&userID); err != nil {
e.t.Fatalf("fixture user: %v", err)
}
if err := e.database.QueryRowContext(ctx,
`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, displayName, email).Scan(&personID); err != nil {
e.t.Fatalf("fixture person: %v", err)
}
return personID
}
// seedLookupOrg inserts an organization owned by a freshly-created person
// whose display name shares no substring with the organization's name (so a
// term that matches the organization does not also match its owner,
// spuriously triggering the person-first branch). Distinct from a personal
// organization's real-world naming (owner name embedded in org name), which
// TestOperatorLookupPersonFirstPrecedence covers on its own.
func (e *lookupEnv) seedLookupOrg(orgType, name string) string {
e.t.Helper()
ownerID := e.seedLookupPerson("Unrelated Owner "+uuid.New().String()[:8], fmt.Sprintf("owner-%s@example.com", marker()))
ctx := context.Background()
var orgID string
if err := e.database.QueryRowContext(ctx,
`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
name, orgType, ownerID).Scan(&orgID); err != nil {
e.t.Fatalf("fixture org: %v", err)
}
return orgID
}
// TestOperatorLookupPersonFirstPrecedence covers design D2: when a term
// matches both a person and an organization (as it always does for a
// personal organization, whose name embeds its owner's display name), the
// person resolves and the organization branch never runs.
func TestOperatorLookupPersonFirstPrecedence(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
personName := "Priya Lookup " + mk
personID := env.seedLookupPerson(personName, fmt.Sprintf("priya-%s@example.com", mk))
// An organization whose name embeds the person's display name, exactly
// as a personal organization's name would (auto-provisioning).
orgID := env.seedOrgWithOwner(orgType, personName+"'s Organization", personID)
code, redirect, _ := env.lookup(personName)
if code != http.StatusOK {
t.Fatalf("lookup GET = %d, want %d", code, http.StatusOK)
}
if redirect != "/operator/persons/"+personID {
t.Errorf("expected person-first redirect to /operator/persons/%s, got redirect=%q", personID, redirect)
}
if strings.Contains(redirect, "/operator/organizations/"+orgID) {
t.Error("lookup redirected to the organization instead of the person it names")
}
}
// TestOperatorLookupSingleOrganizationRedirects covers design D2: a term
// matching no person but exactly one organization's name redirects straight
// to that organization's composite.
func TestOperatorLookupSingleOrganizationRedirects(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
orgName := "Quailwood Collective " + mk
orgID := env.seedLookupOrg(orgType, orgName)
code, redirect, _ := env.lookup(orgName)
if code != http.StatusOK {
t.Fatalf("lookup GET = %d, want %d", code, http.StatusOK)
}
if redirect != "/operator/organizations/"+orgID {
t.Errorf("expected redirect to /operator/organizations/%s, got redirect=%q", orgID, redirect)
}
}
// TestOperatorLookupNativeFallbackResolves covers spec
// operator-panel-navigation, "The lookup resolves without JavaScript": a
// plain GET carrying no HX-Request header (the search form's native no-JS
// submission) reaches the same match and lands on the same record's page
// via an ordinary 303, not HX-Redirect.
func TestOperatorLookupNativeFallbackResolves(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
orgName := "Native Fallback Org " + mk
orgID := env.seedLookupOrg(orgType, orgName)
code, location, _ := env.lookupNative(orgName)
if code != http.StatusSeeOther {
t.Fatalf("native lookup = %d, want %d", code, http.StatusSeeOther)
}
if location != "/operator/organizations/"+orgID {
t.Errorf("native lookup Location = %q, want /operator/organizations/%s", location, orgID)
}
}
// TestOperatorLookupMultipleOrganizationsDisambiguate covers design D2:
// several organization-name matches (and no person match) render the
// disambiguation listing, each candidate linking to its own composite.
func TestOperatorLookupMultipleOrganizationsDisambiguate(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
needle := "Briarwood" + mk
orgA := env.seedLookupOrg(orgType, needle+" Cooperative")
orgB := env.seedLookupOrg(orgType, needle+" Guild")
code, redirect, body := env.lookup(needle)
if code != http.StatusOK {
t.Fatalf("lookup GET = %d, want %d", code, http.StatusOK)
}
if redirect != "" {
t.Errorf("expected no redirect on an ambiguous match, got redirect=%q", redirect)
}
for _, orgID := range []string{orgA, orgB} {
if !strings.Contains(body, `href="/operator/organizations/`+orgID+`"`) {
t.Errorf("expected a disambiguation link to /operator/organizations/%s, got:\n%s", orgID, body)
}
}
}
// keyMarker turns a test marker into something the key grammar accepts
// (`^[a-z][a-z0-9_]*$`, entity-keys §4). marker() may contain characters the
// CHECK constraint rejects, and a key that does not parse would make the
// fixture, not the handler, the thing under test.
func keyMarker(prefix, marker string) string {
var b strings.Builder
b.WriteString(prefix)
for _, r := range strings.ToLower(marker) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return b.String()
}
// seedLookupInvoice inserts a billing account for orgID and one invoice on
// it carrying the given number, returning the invoice's ID.
func (e *lookupEnv) seedLookupInvoice(orgID, invoiceNumber string) string {
e.t.Helper()
ctx := context.Background()
var accountID string
if err := e.database.QueryRowContext(ctx,
`INSERT INTO core.accounts (org_id, name, status) VALUES ($1,$2,'active') RETURNING billing_account_id`,
orgID, "Lookup Account "+marker()).Scan(&accountID); err != nil {
e.t.Fatalf("fixture billing account: %v", err)
}
var invoiceID string
if err := e.database.QueryRowContext(ctx,
`INSERT INTO core.invoices (billing_account_id, status, currency, invoice_number) VALUES ($1,'open','usd',$2) RETURNING invoice_id`,
accountID, invoiceNumber).Scan(&invoiceID); err != nil {
e.t.Fatalf("fixture invoice: %v", err)
}
return invoiceID
}
// TestOperatorLookupInvoiceNumberResolves covers design D4: a term equal to
// exactly one invoice's number redirects straight to that invoice's detail
// page, ahead of the person and organization checks.
func TestOperatorLookupInvoiceNumberResolves(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
orgID := env.seedLookupOrg(orgType, "Invoice Lookup Org "+mk)
number := keyMarker("INV-", mk)
invoiceID := env.seedLookupInvoice(orgID, number)
code, redirect, _ := env.lookup(number)
if code != http.StatusOK {
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
}
if redirect != "/operator/billing/invoices/"+invoiceID {
t.Errorf("expected redirect to /operator/billing/invoices/%s, got redirect=%q", invoiceID, redirect)
}
}
// TestOperatorLookupInvoiceNumberDisambiguates covers design D4: an invoice
// number is unique only per billing account, so the same number can name
// several invoices across different organizations' accounts — the
// disambiguation list shows each match's number, organization, and date.
func TestOperatorLookupInvoiceNumberDisambiguates(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
orgA := env.seedLookupOrg(orgType, "Invoice Disambig Org A "+mk)
orgB := env.seedLookupOrg(orgType, "Invoice Disambig Org B "+mk)
number := keyMarker("INV-", mk)
invoiceA := env.seedLookupInvoice(orgA, number)
invoiceB := env.seedLookupInvoice(orgB, number)
code, redirect, body := env.lookup(number)
if code != http.StatusOK {
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
}
if redirect != "" {
t.Errorf("expected no redirect on an ambiguous invoice number, got redirect=%q", redirect)
}
for _, invoiceID := range []string{invoiceA, invoiceB} {
if !strings.Contains(body, `href="/operator/billing/invoices/`+invoiceID+`"`) {
t.Errorf("expected a disambiguation link to /operator/billing/invoices/%s, got:\n%s", invoiceID, body)
}
}
}
// TestOperatorLookupNoMatchNamesRegistryClasses covers design D4: the
// no-match sentence names every record class the resolver registry
// resolves, generated from the registry rather than hand-typed, so it can
// never disagree with the code.
func TestOperatorLookupNoMatchNamesRegistryClasses(t *testing.T) {
env := newLookupEnv(t)
term := keyMarker("lk_absent2_", marker())
_, redirect, body := env.lookup(term)
if redirect != "" {
t.Fatalf("expected no redirect for an unmatched term, got %q", redirect)
}
for _, class := range []string{"person", "organization", "product", "entitlement set", "plan ladder", "invoice"} {
if !strings.Contains(body, class) {
t.Errorf("no-match notice missing record class %q, got:\n%s", class, body)
}
}
}
// TestLookupRegistryCoversManifest covers design D4: "A test SHALL assert
// that every detail route in the capture manifest has a resolver, so a
// record class joins the set when it gains a page." Every screens.Manifest
// entry with an Instance.LinkPrefix under an operator detail route must
// have a matching entry in lookupRegistry.
func TestLookupRegistryCoversManifest(t *testing.T) {
registered := map[string]bool{}
for _, c := range lookupRegistry {
registered[c.LinkPrefix] = true
}
for _, screen := range screens.Manifest {
if screen.Instance == nil || screen.Surface != "operator" {
continue
}
prefix := screen.Instance.LinkPrefix
if !registered[prefix] {
t.Errorf("manifest detail route %q (screen %q) has no resolver in lookupRegistry", prefix, screen.Name)
}
}
}
// TestOperatorLookupExactKeyResolves covers entity-keys §5: a term that is an
// exact key match resolves to that row's detail page, for each of the four
// keyed entities an operator can reach one for. Keys resolve before the name
// search, so each fixture below also carries a name nothing in the term
// matches -- the redirect proves the key branch ran, not the name branch.
func TestOperatorLookupExactKeyResolves(t *testing.T) {
env := newLookupEnv(t)
mk := marker()
orgType := env.seedOrgTypeForLookup(mk)
ctx := context.Background()
orgKey := keyMarker("lk_org_", mk)
orgID := env.seedLookupOrg(orgType, "Sablewood Union "+mk)
if _, err := env.database.ExecContext(ctx,
`UPDATE core.organizations SET key = $1 WHERE org_id = $2`, orgKey, orgID); err != nil {
t.Fatalf("fixture org key: %v", err)
}
ladderKey := keyMarker("lk_ladder_", mk)
var ladderID string
if err := env.database.QueryRowContext(ctx,
`INSERT INTO core.plan_ladders (name, key) VALUES ($1, $2) RETURNING plan_ladder_id`,
"Lookup Ladder "+mk, ladderKey).Scan(&ladderID); err != nil {
t.Fatalf("fixture ladder: %v", err)
}
setKey := keyMarker("lk_set_", mk)
var setID string
if err := env.database.QueryRowContext(ctx,
`INSERT INTO core.entitlement_sets (name, key) VALUES ($1, $2) RETURNING set_id`,
"Lookup Set "+mk, setKey).Scan(&setID); err != nil {
t.Fatalf("fixture entitlement set: %v", err)
}
productKey := keyMarker("lk_product_", mk)
var productID string
if err := env.database.QueryRowContext(ctx,
`INSERT INTO core.products (name, key) VALUES ($1, $2) RETURNING product_id`,
"Lookup Product "+mk, productKey).Scan(&productID); err != nil {
t.Fatalf("fixture product: %v", err)
}
cases := []struct {
name string
term string
want string
}{
{"organization", orgKey, "/operator/organizations/" + orgID},
{"plan ladder", ladderKey, "/operator/plan-ladders/" + ladderID},
{"entitlement set", setKey, "/operator/entitlement-sets/" + setID},
{"product", productKey, "/operator/products/" + productID},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, redirect, body := env.lookup(tc.term)
if code != http.StatusOK {
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
}
if redirect != tc.want {
t.Errorf("key %q redirected to %q, want %q (body: %s)", tc.term, redirect, tc.want, body)
}
})
}
}
// TestOperatorLookupUnknownKeyFallsThroughToNoMatch covers the other half of
// §5's ordering: a term shaped like a key that matches no key is not an
// error, it simply continues to the name search and, matching nothing there
// either, reaches the no-match notice.
func TestOperatorLookupUnknownKeyFallsThroughToNoMatch(t *testing.T) {
env := newLookupEnv(t)
term := keyMarker("lk_absent_", marker())
code, redirect, body := env.lookup(term)
if code != http.StatusOK {
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
}
if redirect != "" {
t.Errorf("an unmatched key redirected to %q; it should fall through", redirect)
}
if !strings.Contains(body, term) {
t.Errorf("expected the no-match notice to name the term %q, got:\n%s", term, body)
}
}
// seedOrgTypeForLookup inserts a fresh, uniquely-keyed org type so a lookup
// test never collides with another test's or another run's rows.
func (e *lookupEnv) seedOrgTypeForLookup(marker string) string {
e.t.Helper()
orgType := "t" + uuid.New().String()[:12] // core.org_types.org_type is VARCHAR(20)
if _, err := e.database.ExecContext(context.Background(),
`INSERT INTO core.org_types (org_type, display_name, is_active) VALUES ($1, $2, true)`,
orgType, "Lookup Fixture "+marker); err != nil {
e.t.Fatalf("fixture org type: %v", err)
}
return orgType
}
// seedOrgWithOwner inserts an organization owned by an already-seeded person
// (rather than minting a fresh owner, as seedLookupOrg does), so a test can
// build the person/organization name overlap a personal organization has.
func (e *lookupEnv) seedOrgWithOwner(orgType, name, ownerPersonID string) string {
e.t.Helper()
var orgID string
if err := e.database.QueryRowContext(context.Background(),
`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
name, orgType, ownerPersonID).Scan(&orgID); err != nil {
e.t.Fatalf("fixture org: %v", err)
}
return orgID
}