Files
member-console/internal/server/operator_lookup_test.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

327 lines
12 KiB
Go

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"
)
// 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 posts the given term to /operator/lookup 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()
form := url.Values{"term": {term}}
req := httptest.NewRequestWithContext(e.operator, http.MethodPost, "/operator/lookup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
return rec.Code, rec.Header().Get("HX-Redirect"), 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)
}
}
// 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()
}
// 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
}