| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | require "rails_helper" |
| 4 | |
| 5 | RSpec.describe WebSearchService do |
| 6 | # Builds a real Net::HTTPResponse (the class the adapter checks with |
| 7 | # Net::HTTPSuccess) carrying a canned body. The repo has no webmock/vcr, so |
| 8 | # specs stub the adapter's http_get seam with one of these. |
| 9 | def http_response(code, body) |
| 10 | klass = Net::HTTPResponse::CODE_TO_OBJ.fetch(code.to_s) |
| 11 | response = klass.new("1.1", code.to_s, nil) |
| 12 | response.instance_variable_set(:@read, true) |
| 13 | response.instance_variable_set(:@body, body) |
| 14 | response |
| 15 | end |
| 16 | |
| 17 | def brave_body(results) |
| 18 | { "web" => { "results" => results } }.to_json |
| 19 | end |
| 20 | |
| 21 | before do |
| 22 | allow(ENV).to receive(:[]).and_call_original |
| 23 | allow(ENV).to receive(:fetch).and_call_original |
| 24 | allow(ENV).to receive(:[]).with("BRAVE_SEARCH_API_KEY").and_return("brave-key") |
| 25 | end |
| 26 | |
| 27 | describe ".provider" do |
| 28 | it "defaults to the Brave adapter" do |
| 29 | expect(described_class.provider).to be_a(WebSearchService::Brave) |
| 30 | end |
| 31 | |
| 32 | it "raises a configuration error for an unknown SEARCH_PROVIDER" do |
| 33 | allow(ENV).to receive(:fetch).with("SEARCH_PROVIDER", "brave").and_return("bing") |
| 34 | |
| 35 | expect { described_class.provider } |
| 36 | .to raise_error(WebSearchService::Error, /unknown search provider "bing".*SEARCH_PROVIDER.*brave/im) |
| 37 | end |
| 38 | end |
| 39 | |
| 40 | describe ".search" do |
| 41 | it "clamps count to at most 10 before hitting the provider" do |
| 42 | adapter = instance_double(WebSearchService::Brave) |
| 43 | allow(described_class).to receive(:provider).and_return(adapter) |
| 44 | expect(adapter).to receive(:search).with("rust", count: 10).and_return([]) |
| 45 | |
| 46 | described_class.search("rust", count: 50) |
| 47 | end |
| 48 | end |
| 49 | |
| 50 | describe WebSearchService::Brave do |
| 51 | subject(:adapter) { described_class.new } |
| 52 | |
| 53 | it "requests the Brave endpoint with q, count, and the subscription token" do |
| 54 | captured_uri = nil |
| 55 | captured_key = nil |
| 56 | allow(adapter).to receive(:http_get) do |uri, api_key| |
| 57 | captured_uri = uri |
| 58 | captured_key = api_key |
| 59 | http_response(200, brave_body([])) |
| 60 | end |
| 61 | |
| 62 | adapter.search("ruby net/http", count: 3) |
| 63 | |
| 64 | expect(captured_uri.to_s).to start_with("https://api.search.brave.com/res/v1/web/search?") |
| 65 | expect(URI.decode_www_form(captured_uri.query).to_h) |
| 66 | .to eq("q" => "ruby net/http", "count" => "3") |
| 67 | expect(captured_key).to eq("brave-key") |
| 68 | end |
| 69 | |
| 70 | it "parses web.results[] into {title, url, snippet}" do |
| 71 | body = brave_body([ |
| 72 | { "title" => "Ruby", "url" => "https://ruby-lang.org", "description" => "A language.", |
| 73 | "extra" => "ignored" }, |
| 74 | { "title" => "Rails", "url" => "https://rubyonrails.org", "description" => "A framework." } |
| 75 | ]) |
| 76 | allow(adapter).to receive(:http_get).and_return(http_response(200, body)) |
| 77 | |
| 78 | expect(adapter.search("ruby", count: 5)).to eq([ |
| 79 | { "title" => "Ruby", "url" => "https://ruby-lang.org", "snippet" => "A language." }, |
| 80 | { "title" => "Rails", "url" => "https://rubyonrails.org", "snippet" => "A framework." } |
| 81 | ]) |
| 82 | end |
| 83 | |
| 84 | it "returns an empty list when the response has no web.results" do |
| 85 | allow(adapter).to receive(:http_get).and_return(http_response(200, "{}")) |
| 86 | |
| 87 | expect(adapter.search("nothing", count: 5)).to eq([]) |
| 88 | end |
| 89 | |
| 90 | it "raises when BRAVE_SEARCH_API_KEY is not set, naming the env var" do |
| 91 | allow(ENV).to receive(:[]).with("BRAVE_SEARCH_API_KEY").and_return(nil) |
| 92 | |
| 93 | expect { adapter.search("ruby", count: 5) } |
| 94 | .to raise_error(WebSearchService::Error, /BRAVE_SEARCH_API_KEY/) |
| 95 | end |
| 96 | |
| 97 | it "surfaces an upstream HTTP failure with its status" do |
| 98 | allow(adapter).to receive(:http_get).and_return(http_response(429, "slow down")) |
| 99 | |
| 100 | expect { adapter.search("ruby", count: 5) } |
| 101 | .to raise_error(WebSearchService::Error, /HTTP 429/) |
| 102 | end |
| 103 | |
| 104 | it "raises a readable error for an unparseable body" do |
| 105 | allow(adapter).to receive(:http_get).and_return(http_response(200, "<html>nope</html>")) |
| 106 | |
| 107 | expect { adapter.search("ruby", count: 5) } |
| 108 | .to raise_error(WebSearchService::Error, /unreadable response/) |
| 109 | end |
| 110 | end |
| 111 | end |