package search
import (
"encoding/json"
"encoding/xml"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
// newTestHandler builds a router with the search handler mounted at /.
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
svc, err := NewService("gh", "https://example.com")
if err != nil {
t.Fatalf("NewService: %v", err)
}
r := chi.NewRouter()
NewHandler(svc).RegisterRoutes(r)
return r
}
func TestSearchRedirects(t *testing.T) {
h := newTestHandler(t)
cases := []struct {
name string
query string
wantLoc string
}{
{"unknown prefix uses default gh", "foo bar", "https://github.com/search?q=foo+bar"},
{"ud prefix routes to urban dictionary", "ud meme", "https://www.urbandictionary.com/define.php?term=meme"},
{"steam prefix routes to steam", "steam half life", "https://store.steampowered.com/search/?term=half+life"},
{"colon prefix", "ud:lit", "https://www.urbandictionary.com/define.php?term=lit"},
{"empty query goes to default home", "", "https://github.com/"},
{"bare prefix goes to provider home", "ud", "https://www.urbandictionary.com/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), "GET", "/search?q="+encodeQ(tc.query), http.NoBody)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rr.Code)
}
if got := rr.Header().Get("Location"); got != tc.wantLoc {
t.Errorf("Location = %q\n want %q", got, tc.wantLoc)
}
})
}
}
// encodeQ percent-encodes spaces only — enough for these tests.
func encodeQ(s string) string {
return strings.ReplaceAll(s, " ", "+")
}
func TestListProvidersJSON(t *testing.T) {
h := newTestHandler(t)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/providers", http.NoBody))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("content-type = %q", got)
}
var body struct {
Default string `json:"default"`
Providers []struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
HomeURL string `json:"home_url"`
OpenSearchURL string `json:"opensearch_url"`
} `json:"providers"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Default != "gh" {
t.Errorf("default = %q", body.Default)
}
if len(body.Providers) != 3 {
t.Fatalf("expected 3 providers, got %d", len(body.Providers))
}
// Order should match AllProviders().
wantIDs := []string{"ud", "gh", "steam"}
for i, want := range wantIDs {
if body.Providers[i].ID != want {
t.Errorf("providers[%d].ID = %q, want %q", i, body.Providers[i].ID, want)
}
}
// Per-provider OSD URL is built from the public URL.
if body.Providers[0].OpenSearchURL != "https://example.com/opensearch/ud.xml" {
t.Errorf("opensearch_url = %q", body.Providers[0].OpenSearchURL)
}
}
func TestOpenSearchRouterXML(t *testing.T) {
h := newTestHandler(t)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch.xml", http.NoBody))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got != "application/opensearchdescription+xml" {
t.Errorf("content-type = %q", got)
}
body := rr.Body.String()
if !strings.Contains(body, "<?xml") {
t.Errorf("body missing XML prolog: %q", body)
}
if !strings.Contains(body, "https://example.com/search?q={searchTerms}") {
t.Errorf("body missing router template: %q", body)
}
var osd OpenSearchDescription
if err := xml.Unmarshal(rr.Body.Bytes(), &osd); err != nil {
t.Fatalf("unmarshal OSD: %v", err)
}
if osd.ShortName != "huntsman" {
t.Errorf("ShortName = %q", osd.ShortName)
}
}
func TestOpenSearchProviderXML(t *testing.T) {
h := newTestHandler(t)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch/ud.xml", http.NoBody))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
var osd OpenSearchDescription
if err := xml.Unmarshal(rr.Body.Bytes(), &osd); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if osd.ShortName != "Urban Dictionary" {
t.Errorf("ShortName = %q", osd.ShortName)
}
if len(osd.URLs) != 1 || !strings.Contains(osd.URLs[0].Template, "{searchTerms}") {
t.Errorf("template missing {searchTerms}: %+v", osd.URLs)
}
if osd.Image == nil || osd.Image.Value == "" {
t.Errorf("expected Image with icon URL")
}
}
func TestOpenSearchProviderUnknown(t *testing.T) {
h := newTestHandler(t)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch/yahoo.xml", http.NoBody))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
t.Errorf("content-type = %q", got)
}
}