package httpserver_test import ( "net/http" "net/url" "testing" ) // GET /api/v1/owliver/suggestions. // // The ranking itself is tested in internal/owliver, against no database and no // server. What is tested here is only what the HTTP boundary adds: the session // requirement, the query-string contract, the response envelope, and the fact // that the role deciding which readings exist is the session's rather than // anything the caller can set. const suggestPath = "/api/v1/owliver/suggestions" // suggestURL builds the endpoint's address, escaping as a browser would. func suggestURL(page, query string) string { v := url.Values{} if page != "" { v.Set("page", page) } if query != "" { v.Set("query", query) } return suggestPath + "?" + v.Encode() } // suggestions reads the list out of the data envelope, failing the test if the // response is not shaped as the contract says. func suggestions(t *testing.T, r response) []map[string]any { t.Helper() if r.code != http.StatusOK { t.Fatalf("status %d, body %v", r.code, r.body) } data, ok := r.body["data"].(map[string]any) if !ok { t.Fatalf("data is not an object: %v", r.body) } raw, ok := data["suggestions"].([]any) if !ok { // json null decodes to nil, and an absent key to nothing at all. Both // break a client that iterates the list without checking. t.Fatalf("suggestions is not an array (got %#v)", data["suggestions"]) } out := make([]map[string]any, len(raw)) for i, item := range raw { entry, ok := item.(map[string]any) if !ok { t.Fatalf("suggestion %d is not an object: %#v", i, item) } out[i] = entry } return out } /* ── Authentication ─────────────────────────────────────────────────────── */ // The endpoint is not on the public allowlist. Which readings exist depends on // who is asking, so an anonymous suggestion has no meaning. func TestOwliverSuggestionsRequireASession(t *testing.T) { a := newAPI(t) got := a.doAnon("GET", suggestURL("positions", "pipeline"), nil) if got.code != http.StatusUnauthorized { t.Fatalf("status %d, want 401", got.code) } if code := got.codeOrEmpty(); code != "unauthorized" { t.Fatalf("error code %q, want unauthorized", code) } // A refusal must not describe the catalogue it refused to rank. if _, present := got.body["data"]; present { t.Fatalf("an unauthenticated refusal carried data: %v", got.body) } } /* ── The query string ───────────────────────────────────────────────────── */ func TestOwliverSuggestionsValidation(t *testing.T) { a := newAPI(t) cases := []struct { name string path string want int }{ {"no page", suggestPath, http.StatusBadRequest}, {"blank page", suggestPath + "?page=%20", http.StatusBadRequest}, {"unknown page", suggestURL("nowhere", "pipeline"), http.StatusBadRequest}, {"a route, not a surface", suggestURL("/admin/positions", "pipeline"), http.StatusBadRequest}, {"unknown parameter", suggestURL("positions", "pipeline") + "&role=admin", http.StatusBadRequest}, // A query is optional: with nothing typed there is nothing to rank, and // that is an empty list rather than a refusal. {"no query", suggestURL("positions", ""), http.StatusOK}, {"page alias", suggestURL("hired", "recent"), http.StatusOK}, {"page spelled loosely", suggestURL("Talent Pool", "availability"), http.StatusOK}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := a.do("GET", c.path, nil) if got.code != c.want { t.Fatalf("status %d, want %d (body %v)", got.code, c.want, got.body) } if c.want == http.StatusBadRequest && got.codeOrEmpty() != "invalid_query" { t.Fatalf("error code %q, want invalid_query", got.codeOrEmpty()) } }) } } // The mux answers anything but GET, so the endpoint cannot be reached with a // body that might carry a page, a role or an identity. func TestOwliverSuggestionsAreReadOnly(t *testing.T) { a := newAPI(t) for _, method := range []string{"POST", "PATCH", "DELETE", "PUT"} { got := a.do(method, suggestURL("positions", "pipeline"), map[string]any{"page": "positions"}) if got.code != http.StatusMethodNotAllowed { t.Errorf("%s: status %d, want 405", method, got.code) } } } /* ── The response ───────────────────────────────────────────────────────── */ func TestOwliverSuggestionsResponseShape(t *testing.T) { a := newAPI(t) // the seeded user is an admin got := suggestions(t, a.do("GET", suggestURL("positions", "pipeline"), nil)) if len(got) == 0 { t.Fatal("pipeline on positions returned nothing") } if len(got) > 3 { t.Fatalf("%d suggestions, the cap is 3", len(got)) } seenIntent, seenText := map[string]bool{}, map[string]bool{} for i, s := range got { text, _ := s["text"].(string) intent, _ := s["intent"].(string) if text == "" || intent == "" { t.Fatalf("suggestion %d is incomplete: %v", i, s) } if seenIntent[intent] { t.Fatalf("duplicate intent %q", intent) } if seenText[text] { t.Fatalf("duplicate text %q", text) } seenIntent[intent], seenText[text] = true, true // Nothing internal may ride along: no terms, no resource names, no // scores, no page keys. for key := range s { switch key { case "text", "intent", "capability": default: t.Fatalf("suggestion %d exposes %q: %v", i, key, s) } } } } // Asking for a rendering names it in the answer — and only where the reading // can actually be drawn that way. func TestOwliverSuggestionsCarryARequestedShape(t *testing.T) { a := newAPI(t) got := suggestions(t, a.do("GET", suggestURL("positions", "show hiring activity as a flow"), nil)) if len(got) != 1 { t.Fatalf("got %d suggestions, want 1: %v", len(got), got) } if got[0]["intent"] != "hiring-operations" || got[0]["capability"] != "flow" { t.Fatalf("got %v", got[0]) } // With no shape asked for, the field is absent rather than empty. plain := suggestions(t, a.do("GET", suggestURL("positions", "draft"), nil)) if len(plain) == 0 { t.Fatal("draft on positions returned nothing") } if _, present := plain[0]["capability"]; present { t.Fatalf("capability was sent for an unshaped query: %v", plain[0]) } } // No match is an empty array, not an error and not null. // // "Nothing typed" is deliberately absent from this list. It used to be here, // and it stopped being a case of "no match" when the endpoint gained an // organization context: with nothing typed there is now something to rank — // the state of the data — and TestOwliverHighlightsComeFromTheDatabase covers // it. A query that WAS typed and matches nothing still answers with nothing, // which is the case this test exists for. func TestOwliverSuggestionsEmptyResults(t *testing.T) { a := newAPI(t) for _, c := range []struct{ name, query string }{ {"one character", "p"}, {"irrelevant", "sourdough starter recipe"}, } { t.Run(c.name, func(t *testing.T) { if got := suggestions(t, a.do("GET", suggestURL("positions", c.query), nil)); len(got) != 0 { t.Fatalf("got %v, want none", got) } }) } // A real surface the catalogue holds no readings for is the same answer, // typed against or not. for _, query := range []string{"owliver", ""} { if got := suggestions(t, a.do("GET", suggestURL("settings", query), nil)); len(got) != 0 { t.Fatalf("settings returned %v for query %q", got, query) } } } /* ── Context ────────────────────────────────────────────────────────────── */ // With nothing typed, the suggestions come from what is in PostgreSQL. // // This is the half of the endpoint that a static catalogue cannot serve: the // panel opens having been told nothing, and what it should offer depends on // whether this organization has unfinished drafts, unscored candidates or // positions nobody has applied to. The assertion is not on WHICH readings come // back — that is the catalogue's business and would pin this test to a ranking // weight — but that they are real readings, capped, and that the endpoint // reaches the database at all. func TestOwliverHighlightsComeFromTheDatabase(t *testing.T) { a := newAPI(t) // the harness seeds a populated organization got := suggestions(t, a.do("GET", suggestURL("positions", ""), nil)) if len(got) == 0 { t.Fatal("a seeded organization offered nothing with an empty query") } if len(got) > 3 { t.Fatalf("%d suggestions, the cap is 3", len(got)) } for i, s := range got { text, _ := s["text"].(string) intent, _ := s["intent"].(string) if text == "" || intent == "" { t.Fatalf("suggestion %d is incomplete: %v", i, s) } // A highlight is not a shaped request: nothing was typed, so nothing // asked for a rendering. if _, present := s["capability"]; present { t.Fatalf("suggestion %d carries a shape nobody asked for: %v", i, s) } for key := range s { switch key { case "text", "intent": default: t.Fatalf("suggestion %d exposes %q: %v", i, key, s) } } } } // The ranking answers to the data, so changing the data changes the answer. // // This is the property the whole context read exists for, and the one the panel // depends on: a position created through the API must change what Owliver // offers afterwards. No seeded posting is a draft, so unfinished drafts are a // lever this test owns entirely — one filed here is the only one in the // organization, and the endpoint has to notice it. // // One is the point. A ranking that only reacts to a pile would be a ranking // that never reacts to the thing that just happened, which is exactly the stale // suggestion this replaced. func TestOwliverHighlightsReactToAMutation(t *testing.T) { a := newAPI(t) names := func(list []map[string]any) map[string]bool { out := map[string]bool{} for _, s := range list { id, _ := s["intent"].(string) out[id] = true } return out } before := names(suggestions(t, a.do("GET", suggestURL("positions", ""), nil))) if before["position-drafts"] { t.Skip("the fixture already holds draft positions; this lever is not available") } created := a.do("POST", "/api/v1/job-postings", map[string]any{ "title": "Owliver Context Probe", "status": "draft", }) if created.code != http.StatusCreated { t.Fatalf("creating the draft: status %d, body %v", created.code, created.body) } after := names(suggestions(t, a.do("GET", suggestURL("positions", ""), nil))) if !after["position-drafts"] { t.Fatalf("filing a draft did not surface the drafts reading: %v", after) } } // A talent caller is offered no organization-wide count. // // The counts behind a highlight are org-wide by construction, and talent's rows // are narrowed by the policy table — so answering "eleven candidates are // waiting" to someone entitled to see one of them would leak the other ten // through an integer. Nothing on the operator pages may reach them. func TestOwliverHighlightsAreNotOfferedToTalent(t *testing.T) { r := newRBAC(t) for _, page := range []string{"positions", "candidates", "control-center", "analytics"} { if got := suggestions(t, r.as(r.talA, "GET", suggestURL(page, ""), nil)); len(got) != 0 { t.Fatalf("%s offered talent %v", page, got) } } // An operator on the same pages is offered something, so the assertion // above is about the role rather than about the pages being empty. if got := suggestions(t, r.as(r.admin, "GET", suggestURL("positions", ""), nil)); len(got) == 0 { t.Fatal("an admin was offered nothing either — the fixture proves nothing") } } // The page decides the answer, so the same word must not produce the same list // everywhere. func TestOwliverSuggestionsAreScopedToThePage(t *testing.T) { a := newAPI(t) read := func(page string) []string { out := []string{} for _, s := range suggestions(t, a.do("GET", suggestURL(page, "pipeline"), nil)) { out = append(out, s["intent"].(string)) } return out } positions, candidates := read("positions"), read("candidates") if len(positions) == 0 || len(candidates) == 0 { t.Fatalf("positions=%v candidates=%v", positions, candidates) } if len(positions) == len(candidates) { same := true for i := range positions { if positions[i] != candidates[i] { same = false break } } if same { t.Fatalf("both pages answered pipeline with %v", positions) } } } /* ── Authorization ──────────────────────────────────────────────────────── */ // Who is asking comes from the session, and it decides which readings exist. // // Talent may list job applications — but only their own, by a predicate in the // repository — so the organization-wide readings the operator console offers // are not theirs, and are absent rather than refused. func TestOwliverSuggestionsFollowTheCallersRole(t *testing.T) { r := newRBAC(t) // A query each page can actually answer, so an empty list means the role // was filtered rather than that the words matched nothing. operatorPages := []struct{ page, query string }{ {"control-center", "pipeline attention"}, {"positions", "pipeline attention"}, {"candidates", "candidate score"}, {"hired-history", "recent hires outcomes"}, {"talent-pool", "talent pool availability"}, {"activity", "audit unusual activity"}, } for _, c := range operatorPages { for _, act := range []actor{r.admin, r.empA} { got := suggestions(t, r.as(act, "GET", suggestURL(c.page, c.query), nil)) if len(got) == 0 { t.Errorf("%s was offered nothing on %s for %q", act.name, c.page, c.query) } } if got := suggestions(t, r.as(r.talA, "GET", suggestURL(c.page, c.query), nil)); len(got) != 0 { t.Errorf("talent was offered %v on %s", got, c.page) } } // Still 200 with an empty list, never 403: refusing would tell a caller // which pages hold readings they cannot have. refused := r.as(r.talA, "GET", suggestURL("positions", "pipeline"), nil) if refused.code != http.StatusOK { t.Fatalf("talent got status %d, want 200 with an empty list", refused.code) } } // The role filter is not a blanket refusal for talent: what they may genuinely // ask — about their own account — is still offered. Without this, the test // above would pass with the permission check stubbed out to deny everything. func TestOwliverSuggestionsStillServeTalentTheirOwnReadings(t *testing.T) { r := newRBAC(t) got := suggestions(t, r.as(r.talA, "GET", suggestURL("profile", "permission"), nil)) if len(got) == 0 { t.Fatal("talent was offered nothing about their own account") } if got[0]["intent"] != "profile-permissions" { t.Fatalf("got %v", got[0]) } } // A permission-sensitive reading: Hired History reads the staff table, which // policy.go grants to operators only. Nothing about the request differs — only // the session behind it. func TestOwliverSuggestionsHideReadingsARoleCannotPerform(t *testing.T) { r := newRBAC(t) const path = suggestPath + "?page=hired-history&query=recent+hires" for _, act := range []actor{r.admin, r.empA} { if got := suggestions(t, r.as(act, "GET", path, nil)); len(got) == 0 { t.Errorf("%s was offered no hiring outcomes", act.name) } } if got := suggestions(t, r.as(r.talA, "GET", path, nil)); len(got) != 0 { t.Fatalf("talent was offered readings of the staff table: %v", got) } }