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 }