brew/Library/Homebrew/test/livecheck/strategy_spec.rb

267 lines
10 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
require "livecheck/strategy"
RSpec.describe Homebrew::Livecheck::Strategy do
subject(:strategy) { described_class }
livecheck: Add support for POST requests livecheck currently doesn't support `POST` requests but it wasn't entirely clear how best to handle that. I initially approached it as a `Post` strategy but unfortunately that would have required us to handle response body parsing (e.g., JSON, XML, etc.) in some fashion. We could borrow some of the logic from related strategies but we would still be stuck having to update `Post` whenever we add a strategy for a new format. Instead, this implements `POST` support by borrowing ideas from the `using: :post` and `data` `url` options found in formulae. This uses a `post_form` option to handle form data and `post_json` to handle JSON data, encoding the hash argument for each into the appropriate format. The presence of either option means that curl will use a `POST` request. With this approach, we can make a `POST` request using any strategy that calls `Strategy::page_headers` or `::page_content` (directly or indirectly) and everything else works the same as usual. The only change needed in related strategies was to pass the options through to the `Strategy` methods. For example, if we need to parse a JSON response from a `POST` request, we add a `post_data` or `post_json` hash to the `livecheck` block `url` and use `strategy :json` with a `strategy` block. This leans on existing patterns that we're already familiar with and shouldn't require any notable maintenance burden when adding new strategies, so it seems like a better approach than a `Post` strategy.
2025-02-04 10:30:16 -05:00
let(:url) { "https://brew.sh/" }
let(:redirection_url) { "https://brew.sh/redirection" }
let(:post_hash) do
{
empty: "",
boolean: "true",
number: "1",
string: "a + b = c",
}
end
let(:form_string) { "empty=&boolean=true&number=1&string=a+%2B+b+%3D+c" }
let(:json_string) { '{"empty":"","boolean":"true","number":"1","string":"a + b = c"}' }
let(:response_hash) do
response_hash = {}
response_hash[:ok] = {
status_code: "200",
status_text: "OK",
headers: {
"cache-control" => "max-age=604800",
"content-type" => "text/html; charset=UTF-8",
"date" => "Wed, 1 Jan 2020 01:23:45 GMT",
"expires" => "Wed, 31 Jan 2020 01:23:45 GMT",
"last-modified" => "Thu, 1 Jan 2019 01:23:45 GMT",
"content-length" => "123",
},
}
response_hash[:redirection] = {
status_code: "301",
status_text: "Moved Permanently",
headers: {
"cache-control" => "max-age=604800",
"content-type" => "text/html; charset=UTF-8",
"date" => "Wed, 1 Jan 2020 01:23:45 GMT",
"expires" => "Wed, 31 Jan 2020 01:23:45 GMT",
"last-modified" => "Thu, 1 Jan 2019 01:23:45 GMT",
"content-length" => "123",
"location" => redirection_url,
},
}
response_hash
end
let(:body) do
<<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Thank you!</title>
</head>
<body>
<h1>Download</h1>
<p>This download link could have been made publicly available in a reasonable fashion but we appreciate that you jumped through the hoops that we carefully set up!: <a href="https://brew.sh/example-1.2.3.tar.gz">Example v1.2.3</a></p>
<p>The current legacy version is: <a href="https://brew.sh/example-0.1.2.tar.gz">Example v0.1.2</a></p>
</body>
</html>
HTML
end
let(:response_text) do
response_text = {}
response_text[:ok] = <<~EOS
HTTP/1.1 #{response_hash[:ok][:status_code]} #{response_hash[:ok][:status_text]}\r
Cache-Control: #{response_hash[:ok][:headers]["cache-control"]}\r
Content-Type: #{response_hash[:ok][:headers]["content-type"]}\r
Date: #{response_hash[:ok][:headers]["date"]}\r
Expires: #{response_hash[:ok][:headers]["expires"]}\r
Last-Modified: #{response_hash[:ok][:headers]["last-modified"]}\r
Content-Length: #{response_hash[:ok][:headers]["content-length"]}\r
\r
#{body.rstrip}
EOS
response_text[:redirection_to_ok] = response_text[:ok].sub(
"HTTP/1.1 #{response_hash[:ok][:status_code]} #{response_hash[:ok][:status_text]}\r",
"HTTP/1.1 #{response_hash[:redirection][:status_code]} #{response_hash[:redirection][:status_text]}\r\n" \
"Location: #{response_hash[:redirection][:headers]["location"]}\r",
)
response_text
end
describe "::from_symbol" do
it "returns the Strategy module represented by the Symbol argument" do
expect(strategy.from_symbol(:page_match)).to eq(Homebrew::Livecheck::Strategy::PageMatch)
end
it "returns `nil` if the argument is `nil`" do
expect(strategy.from_symbol(nil)).to be_nil
end
end
describe "::from_url" do
let(:sourceforge_url) { "https://sourceforge.net/projects/test" }
context "when a regex or `strategy` block is not provided" do
it "returns an array of usable strategies which doesn't include PageMatch" do
expect(strategy.from_url(sourceforge_url)).to eq([Homebrew::Livecheck::Strategy::Sourceforge])
end
end
context "when a regex or `strategy` block is provided" do
it "returns an array of usable strategies including PageMatch, sorted in descending order by priority" do
expect(strategy.from_url(sourceforge_url, regex_provided: true))
.to eq(
[Homebrew::Livecheck::Strategy::Sourceforge, Homebrew::Livecheck::Strategy::PageMatch],
)
end
end
context "when a `strategy` block is required and one is provided" do
it "returns an array of usable strategies including the specified strategy" do
# The strategies array is naturally in alphabetic order when all
# applicable strategies have the same priority
expect(strategy.from_url(url, livecheck_strategy: :json, block_provided: true))
.to eq([Homebrew::Livecheck::Strategy::Json, Homebrew::Livecheck::Strategy::PageMatch])
expect(strategy.from_url(url, livecheck_strategy: :xml, block_provided: true))
.to eq([Homebrew::Livecheck::Strategy::PageMatch, Homebrew::Livecheck::Strategy::Xml])
expect(strategy.from_url(url, livecheck_strategy: :yaml, block_provided: true))
.to eq([Homebrew::Livecheck::Strategy::PageMatch, Homebrew::Livecheck::Strategy::Yaml])
end
end
context "when a `strategy` block is required and one is not provided" do
it "returns an array of usable strategies not including the specified strategy" do
expect(strategy.from_url(url, livecheck_strategy: :json, block_provided: false)).to eq([])
expect(strategy.from_url(url, livecheck_strategy: :xml, block_provided: false)).to eq([])
expect(strategy.from_url(url, livecheck_strategy: :yaml, block_provided: false)).to eq([])
end
end
end
Standardize valid strategy block return types Valid `strategy` block return types currently vary between strategies. Some only accept a string whereas others accept a string or array of strings. [`strategy` blocks also accept a `nil` return (to simplify early returns) but this was already standardized across strategies.] While some strategies only identify one version by default (where a string is an appropriate return type), it could be that a strategy block identifies more than one version. In this situation, the strategy would need to be modified to accept (and work with) an array from a `strategy` block. Rather than waiting for this to become a problem, this modifies all strategies to standardize on allowing `strategy` blocks to return a string or array of strings (even if only one of these is currently used in practice). Standardizing valid return types helps to further simplify the mental model for `strategy` blocks and reduce cognitive load. This commit extracts related logic from `#find_versions` into methods like `#versions_from_content`, which is conceptually similar to `PageMatch#page_matches` (renamed to `#versions_from_content` for consistency). This allows us to write tests for the related code without having to make network requests (or stub them) at this point. In general, this also helps to better align the structure of strategies and how the various `#find_versions` methods work with versions. There's still more planned work to be done here but this is a step in the right direction.
2021-08-10 11:09:55 -04:00
livecheck: Add support for POST requests livecheck currently doesn't support `POST` requests but it wasn't entirely clear how best to handle that. I initially approached it as a `Post` strategy but unfortunately that would have required us to handle response body parsing (e.g., JSON, XML, etc.) in some fashion. We could borrow some of the logic from related strategies but we would still be stuck having to update `Post` whenever we add a strategy for a new format. Instead, this implements `POST` support by borrowing ideas from the `using: :post` and `data` `url` options found in formulae. This uses a `post_form` option to handle form data and `post_json` to handle JSON data, encoding the hash argument for each into the appropriate format. The presence of either option means that curl will use a `POST` request. With this approach, we can make a `POST` request using any strategy that calls `Strategy::page_headers` or `::page_content` (directly or indirectly) and everything else works the same as usual. The only change needed in related strategies was to pass the options through to the `Strategy` methods. For example, if we need to parse a JSON response from a `POST` request, we add a `post_data` or `post_json` hash to the `livecheck` block `url` and use `strategy :json` with a `strategy` block. This leans on existing patterns that we're already familiar with and shouldn't require any notable maintenance burden when adding new strategies, so it seems like a better approach than a `Post` strategy.
2025-02-04 10:30:16 -05:00
describe "::post_args" do
it "returns an array including `--data` and an encoded form data string" do
expect(strategy.post_args(post_form: post_hash)).to eq(["--data", form_string])
# If both `post_form` and `post_json` are present, only `post_form` will
# be used.
expect(strategy.post_args(post_form: post_hash, post_json: post_hash)).to eq(["--data", form_string])
end
it "returns an array including `--json` and a JSON string" do
expect(strategy.post_args(post_json: post_hash)).to eq(["--json", json_string])
end
it "returns an empty array if `post_form` value is blank" do
expect(strategy.post_args(post_form: {})).to eq([])
end
it "returns an empty array if `post_json` value is blank" do
expect(strategy.post_args(post_json: {})).to eq([])
end
it "returns an empty array if hash argument doesn't have a `post_form` or `post_json` value" do
expect(strategy.post_args).to eq([])
end
end
describe "::page_headers" do
let(:responses) { [response_hash[:ok]] }
it "returns headers from fetched content" do
allow(strategy).to receive(:curl_headers).and_return({ responses:, body: })
expect(strategy.page_headers(url)).to eq([responses.first[:headers]])
end
it "handles `post_form` `url` options" do
allow(strategy).to receive(:curl_headers).and_return({ responses:, body: })
expect(strategy.page_headers(url, url_options: { post_form: post_hash }))
.to eq([responses.first[:headers]])
end
it "returns an empty array if `curl_headers` only raises an `ErrorDuringExecution` error" do
allow(strategy).to receive(:curl_headers).and_raise(ErrorDuringExecution.new([], status: 1))
expect(strategy.page_headers(url)).to eq([])
end
end
describe "::page_content" do
let(:curl_version) { Version.new("8.7.1") }
let(:success_status) { instance_double(Process::Status, success?: true, exitstatus: 0) }
it "returns hash including fetched content" do
allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version)
allow(strategy).to receive(:curl_output).and_return([response_text[:ok], nil, success_status])
expect(strategy.page_content(url)).to eq({ content: body })
end
it "handles `post_form` `url` option" do
allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version)
allow(strategy).to receive(:curl_output).and_return([response_text[:ok], nil, success_status])
expect(strategy.page_content(url, url_options: { post_form: post_hash })).to eq({ content: body })
end
it "handles `post_json` `url` option" do
allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version)
allow(strategy).to receive(:curl_output).and_return([response_text[:ok], nil, success_status])
expect(strategy.page_content(url, url_options: { post_json: post_hash })).to eq({ content: body })
end
it "returns error `messages` from `stderr` in the return hash on failure when `stderr` is not `nil`" do
error_message = "curl: (6) Could not resolve host: brew.sh"
allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version)
allow(strategy).to receive(:curl_output).and_return([
nil,
error_message,
instance_double(Process::Status, success?: false, exitstatus: 6),
])
expect(strategy.page_content(url)).to eq({ messages: [error_message] })
end
it "returns default error `messages` in the return hash on failure when `stderr` is `nil`" do
allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version)
allow(strategy).to receive(:curl_output).and_return([
nil,
nil,
instance_double(Process::Status, success?: false, exitstatus: 1),
])
expect(strategy.page_content(url)).to eq({ messages: ["cURL failed without a detectable error"] })
end
it "returns hash including `final_url` if it differs from initial `url`" do
allow_any_instance_of(Utils::Curl).to receive(:curl_version).and_return(curl_version)
allow(strategy).to receive(:curl_output).and_return([response_text[:redirection_to_ok], nil, success_status])
expect(strategy.page_content(url)).to eq({ content: body, final_url: redirection_url })
end
end
Standardize valid strategy block return types Valid `strategy` block return types currently vary between strategies. Some only accept a string whereas others accept a string or array of strings. [`strategy` blocks also accept a `nil` return (to simplify early returns) but this was already standardized across strategies.] While some strategies only identify one version by default (where a string is an appropriate return type), it could be that a strategy block identifies more than one version. In this situation, the strategy would need to be modified to accept (and work with) an array from a `strategy` block. Rather than waiting for this to become a problem, this modifies all strategies to standardize on allowing `strategy` blocks to return a string or array of strings (even if only one of these is currently used in practice). Standardizing valid return types helps to further simplify the mental model for `strategy` blocks and reduce cognitive load. This commit extracts related logic from `#find_versions` into methods like `#versions_from_content`, which is conceptually similar to `PageMatch#page_matches` (renamed to `#versions_from_content` for consistency). This allows us to write tests for the related code without having to make network requests (or stub them) at this point. In general, this also helps to better align the structure of strategies and how the various `#find_versions` methods work with versions. There's still more planned work to be done here but this is a step in the right direction.
2021-08-10 11:09:55 -04:00
describe "::handle_block_return" do
it "returns an array of version strings when given a valid value" do
expect(strategy.handle_block_return("1.2.3")).to eq(["1.2.3"])
expect(strategy.handle_block_return(["1.2.3", "1.2.4"])).to eq(["1.2.3", "1.2.4"])
end
it "returns an empty array when given a nil value" do
expect(strategy.handle_block_return(nil)).to eq([])
end
it "errors when given an invalid value" do
expect { strategy.handle_block_return(123) }
.to raise_error(TypeError, strategy::INVALID_BLOCK_RETURN_VALUE_MSG)
end
end
end