From ddfb9f8566779a7e91dcb85964a74a54eb6dd9f3 Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:44:02 +0300 Subject: [PATCH 1/7] [0.3.0] Add RSpec test suite and CI workflow (#2) --- .github/workflows/check-source-branch.yml | 13 +++ .github/workflows/ci.yml | 29 +++++ .rubocop.yml | 11 -- .ruby-version | 1 + Gemfile | 5 +- Gemfile.lock | 105 ++++++++++------- README.md | 4 +- genius-api.gemspec | 4 +- lib/extensions/options_helper.rb | 2 +- lib/genius/api/errors.rb | 12 +- lib/genius/api/version.rb | 2 +- spec/api/account_spec.rb | 51 ++++++++ spec/api/annotations_spec.rb | 125 ++++++++++++++++++++ spec/api/artists_spec.rb | 111 ++++++++++++++++++ spec/api/authorization_spec.rb | 18 ++- spec/api/errors_spec.rb | 136 ++++++++++++++++++++++ spec/api/referents_spec.rb | 58 +++++++++ spec/api/search_spec.rb | 68 +++++++++++ spec/api/songs_spec.rb | 105 +++++++++++++++++ spec/api/web_pages_spec.rb | 65 +++++++++++ spec/extensions/options_helper_spec.rb | 31 +++++ spec/extensions/token_ext_spec.rb | 30 +++++ spec/extensions/unescape_spec.rb | 18 +++ 23 files changed, 933 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/check-source-branch.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .ruby-version create mode 100644 spec/api/account_spec.rb create mode 100644 spec/api/annotations_spec.rb create mode 100644 spec/api/artists_spec.rb create mode 100644 spec/api/errors_spec.rb create mode 100644 spec/api/referents_spec.rb create mode 100644 spec/api/search_spec.rb create mode 100644 spec/api/songs_spec.rb create mode 100644 spec/api/web_pages_spec.rb create mode 100644 spec/extensions/options_helper_spec.rb create mode 100644 spec/extensions/token_ext_spec.rb create mode 100644 spec/extensions/unescape_spec.rb diff --git a/.github/workflows/check-source-branch.yml b/.github/workflows/check-source-branch.yml new file mode 100644 index 0000000..c8a4fc2 --- /dev/null +++ b/.github/workflows/check-source-branch.yml @@ -0,0 +1,13 @@ +name: Check source branch +on: + pull_request: + branches: [ master ] +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: | + if [[ ! "${{ github.head_ref }}" =~ ^v ]]; then + echo "PRs to master allowed only from v* branches" + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..26c1818 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: [ 3.0, 3.1, 3.2, 3.3, 3.4, 4.0 ] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + + - name: Bundle install with compatible nokogiri + run: | + gem install bundler + bundle install || (bundle update nokogiri --conservative && bundle install) + + - name: RSpec + run: bundle exec rspec diff --git a/.rubocop.yml b/.rubocop.yml index 8d1fdd3..050a1f3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,2 @@ AllCops: TargetRubyVersion: 2.7 - -Style/StringLiterals: - Enabled: true - EnforcedStyle: double_quotes - -Style/StringLiteralsInInterpolation: - Enabled: true - EnforcedStyle: double_quotes - -Layout/LineLength: - Max: 120 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..6a81b4c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.8 diff --git a/Gemfile b/Gemfile index c050c07..c45085b 100644 --- a/Gemfile +++ b/Gemfile @@ -11,12 +11,15 @@ gem "rspec", "~> 3.4" gem "rubocop", "~> 1.7" -gem "httparty", "~> 0.13.7" +gem "httparty", "~> 0.21" gem "nokogiri" gem "yard" + + + group :test do gem "coderay" gem "dotenv", "~> 2.7.6" diff --git a/Gemfile.lock b/Gemfile.lock index 83880e7..75f0184 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,68 +1,93 @@ PATH remote: . specs: - genius-api (0.2.0) + genius-api (0.3.0) + nokogiri GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) coderay (1.1.3) - diff-lcs (1.5.0) + csv (3.3.5) + diff-lcs (1.6.2) dotenv (2.7.6) - httparty (0.13.7) - json (~> 1.8) + httparty (0.24.2) + csv + mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - json (1.8.6) + json (2.19.9) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + mini_mime (1.1.5) multi_xml (0.6.0) - nokogiri (1.13.9-x86_64-linux) + nokogiri (1.17.2-aarch64-linux) racc (~> 1.4) - parallel (1.22.1) - parser (3.1.2.1) + nokogiri (1.17.2-arm-linux) + racc (~> 1.4) + nokogiri (1.17.2-arm64-darwin) + racc (~> 1.4) + nokogiri (1.17.2-x86-linux) + racc (~> 1.4) + nokogiri (1.17.2-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.17.2-x86_64-linux) + racc (~> 1.4) + parallel (1.28.0) + parser (3.3.11.1) ast (~> 2.4.1) - racc (1.6.0) + racc + prism (1.9.0) + racc (1.8.1) rainbow (3.1.1) - rake (13.0.6) - regexp_parser (2.6.1) - rexml (3.2.5) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.0) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.0) + rake (13.4.2) + regexp_parser (2.12.0) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.0) - rubocop (1.31.0) - parallel (~> 1.10) - parser (>= 3.1.0.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.87.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.18.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.23.0) - parser (>= 3.1.1.0) - ruby-progressbar (1.11.0) - unicode-display_width (2.3.0) - webrick (1.7.0) - yard (0.9.28) - webrick (~> 1.7.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + yard (0.9.44) PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin x86_64-linux DEPENDENCIES coderay dotenv (~> 2.7.6) genius-api! - httparty (~> 0.13.7) + httparty (~> 0.21) nokogiri rake (~> 13.0) rspec (~> 3.4) @@ -70,4 +95,4 @@ DEPENDENCIES yard BUNDLED WITH - 2.3.6 + 2.5.23 diff --git a/README.md b/README.md index 7e33162..5cdbaba 100644 --- a/README.md +++ b/README.md @@ -95,13 +95,13 @@ All docs are available at the separate page: https://unurgunite.github.io/genius - [x] Update `README.md` - [ ] Refactor code base -- [ ] Add tests with RSpec +- [x] Add tests with RSpec (76 examples, 0 failures) - [ ] 100% code coverage with RuboCop - [ ] Refactor code according to the style guides ## Development -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can +After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the diff --git a/genius-api.gemspec b/genius-api.gemspec index c06d772..a9f91a9 100644 --- a/genius-api.gemspec +++ b/genius-api.gemspec @@ -33,8 +33,8 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem # spec.add_dependency "example-gem", "~> 1.0" - spec.add_development_dependency "httparty", "~> 0.13.7" - spec.add_development_dependency "nokogiri" + spec.add_development_dependency "httparty", "~> 0.21" + spec.add_dependency "nokogiri" # For more information and examples about making a new gem, checkout our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/lib/extensions/options_helper.rb b/lib/extensions/options_helper.rb index 4828693..245a6c0 100644 --- a/lib/extensions/options_helper.rb +++ b/lib/extensions/options_helper.rb @@ -9,7 +9,7 @@ class Object # :nodoc: def options_helper(options, arry) params = "" opt = arry - options.each_key do |k, v| + options.each do |k, v| params.insert(params.length, "&#{k}=#{v}") if opt.include? k end params diff --git a/lib/genius/api/errors.rb b/lib/genius/api/errors.rb index 93799d0..0ccb6da 100644 --- a/lib/genius/api/errors.rb +++ b/lib/genius/api/errors.rb @@ -145,11 +145,9 @@ def rescue(klass) # @return [Object] def rescue_from(meths, klass, exception, &handler) meths.each do |meth| - # store the previous implementation old = klass.singleton_method(meth) - # wrap it - klass.define_singleton_method(meth) do |*args| - old.unbind.bind(klass).call(*args) + klass.define_singleton_method(meth) do |*args, **kwargs| + old.unbind.bind(klass).call(*args, **kwargs) rescue exception => e handler.call(e) end @@ -160,11 +158,11 @@ def rescue_from(meths, klass, exception, &handler) class << self def validate_token(token, method_name: nil) - raise TokenError, method_name: method_name if token.nil? || token.size != 64 + raise TokenError.new(method_name: method_name) if token.nil? || token.size != 64 response = HTTParty.get("#{ENDPOINT}=#{token}").body status = JSON.parse(response).dig("meta", "status") - raise TokenError, method_name: method_name unless status == 200 + raise TokenError.new(method_name: method_name) unless status == 200 end # +Genius::Errors.error_handle(token)+ -> true or false @@ -203,7 +201,7 @@ def error_handle(token, method_name: nil) "`Genius::Auth.login=``token''` method and continue", method_name: method_name) elsif token.size != 64 || check_status(token) == false - raise TokenError, method_name: method_name + raise TokenError.new(method_name: method_name) end true end diff --git a/lib/genius/api/version.rb b/lib/genius/api/version.rb index ca7dfb8..69336c6 100644 --- a/lib/genius/api/version.rb +++ b/lib/genius/api/version.rb @@ -3,6 +3,6 @@ module Genius module Api # Version of the gem. - VERSION = "0.2.1" + VERSION = "0.3.0" end end diff --git a/spec/api/account_spec.rb b/spec/api/account_spec.rb new file mode 100644 index 0000000..50170f0 --- /dev/null +++ b/spec/api/account_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Account do + let(:token) { "a" * 64 } + let(:response_body) { { "meta" => { "status" => 200 }, "response" => { "user" => { "name" => "Test" } } }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + describe ".account" do + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + context "when a valid token is provided" do + it "returns account info as a hash" do + result = described_class.account(token: token) + expect(result).to be_a(Hash) + expect(result.dig("meta", "status")).to eq(200) + end + + it "sends a GET request to the account endpoint" do + described_class.account(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/account?access_token=#{token}" + ) + end + end + + context "when token is nil and Auth is not authorized" do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it "returns nil" do + expect(described_class.account(token: nil)).to be_nil + end + end + end + + describe ".me" do + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + it "behaves the same as .account" do + expect(described_class.me(token: token)).to eq(described_class.account(token: token)) + end + end +end diff --git a/spec/api/annotations_spec.rb b/spec/api/annotations_spec.rb new file mode 100644 index 0000000..d55d594 --- /dev/null +++ b/spec/api/annotations_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Annotations do + let(:token) { "a" * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { "meta" => { "status" => 200 } }.to_json } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + allow(HTTParty).to receive(:post).and_return(mock_response) + allow(HTTParty).to receive(:put).and_return(mock_response) + allow(HTTParty).to receive(:delete).and_return(mock_response) + end + + describe ".annotations" do + context "with http_verb 'get'" do + it "sends a GET request" do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "get") + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" + ) + end + + it "returns parsed JSON" do + result = described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "get") + expect(result).to be_a(Hash) + end + end + + context "with http_verb 'post'" do + it "sends a POST request" do + described_class.annotations( + id: annotation_id, action: nil, token: token, http_verb: "post", + options: { markdown: "test", raw_annotatable_url: "https://example.com" } + ) + expect(HTTParty).to have_received(:post) + end + end + + context "with http_verb 'put'" do + it "sends a PUT request" do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "put") + expect(HTTParty).to have_received(:put) + end + + context "with action 'upvote'" do + it "sends a PUT request to the upvote endpoint" do + described_class.annotations(id: annotation_id, action: "upvote", token: token, http_verb: "put") + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/upvote?access_token=#{token}" + ) + end + end + + context "with action 'downvote'" do + it "sends a PUT request to the downvote endpoint" do + described_class.annotations(id: annotation_id, action: "downvote", token: token, http_verb: "put") + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/downvote?access_token=#{token}" + ) + end + end + + context "with action 'unvote'" do + it "sends a PUT request to the unvote endpoint" do + described_class.annotations(id: annotation_id, action: "unvote", token: token, http_verb: "put") + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/unvote?access_token=#{token}" + ) + end + end + + context "with an invalid action" do + it "raises ArgumentError" do + expect { + described_class.annotations(id: annotation_id, action: "invalid", token: token, http_verb: "put") + }.to raise_error(ArgumentError) + end + end + end + + context "with http_verb 'delete'" do + it "sends a DELETE request" do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "delete") + expect(HTTParty).to have_received(:delete).with( + "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" + ) + end + end + + context "when action is provided with a non-PUT verb" do + it "raises ArgumentError" do + expect { + described_class.annotations(id: annotation_id, action: "upvote", token: token, http_verb: "get") + }.to raise_error(ArgumentError, /only PUT accepts/) + end + end + end + + describe "private methods" do + describe ".post_payload" do + it "builds a JSON payload from options" do + payload = described_class.send(:post_payload, options: { + markdown: "hello **world!**", + raw_annotatable_url: "https://example.com", + fragment: "test", + before_html: "

", + after_html: "

", + canonical_url: "https://example.com/canonical", + og_url: "https://example.com/og", + title: "Test Page" + }) + + parsed = JSON.parse(payload) + expect(parsed.dig("annotation", "body", "markdown")).to eq("hello **world!**") + expect(parsed.dig("referent", "raw_annotatable_url")).to eq("https://example.com") + expect(parsed.dig("web_page", "title")).to eq("Test Page") + end + end + end +end diff --git a/spec/api/artists_spec.rb b/spec/api/artists_spec.rb new file mode 100644 index 0000000..3e5563d --- /dev/null +++ b/spec/api/artists_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Artists do + let(:token) { "a" * 64 } + let(:artist_id) { 16_764 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + describe ".artists" do + let(:response_body) do + { + "meta" => { "status" => 200 }, + "response" => { "artist" => { "id" => artist_id, "name" => "Test Artist" } } + }.to_json + end + + context "when a valid token and id are provided" do + it "returns artist data as a hash" do + result = described_class.artists(token: token, id: artist_id) + expect(result).to be_a(Hash) + expect(result.dig("meta", "status")).to eq(200) + end + + it "sends a GET request to the artists endpoint" do + described_class.artists(token: token, id: artist_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" + ) + end + end + + context "when id is nil" do + it "raises ArgumentError" do + expect { described_class.artists(token: token, id: nil) }.to raise_error(ArgumentError) + end + end + end + + describe ".artists_songs" do + let(:response_body) do + { + "meta" => { "status" => 200 }, + "response" => { "songs" => [{ "id" => 1, "title" => "Song 1" }] } + }.to_json + end + + context "when a valid token and id are provided" do + it "returns songs data as a hash" do + result = described_class.artists_songs(token: token, id: artist_id) + expect(result).to be_a(Hash) + expect(result.dig("meta", "status")).to eq(200) + end + + it "sends a GET request to the artists songs endpoint" do + described_class.artists_songs(token: token, id: artist_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" + ) + end + end + + context "when options include sort" do + it "includes sort parameter" do + described_class.artists_songs(token: token, id: artist_id, options: { sort: "popularity" }) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}&sort=popularity" + ) + end + + context "with an invalid sort value" do + it "raises ArgumentError" do + expect { + described_class.artists_songs(token: token, id: artist_id, options: { sort: "invalid" }) + }.to raise_error(ArgumentError) + end + end + end + + context "when per_page is negative" do + it "raises ArgumentError" do + expect { + described_class.artists_songs(token: token, id: artist_id, options: { per_page: -1 }) + }.to raise_error(ArgumentError) + end + end + + context "when page is negative" do + it "raises ArgumentError" do + expect { + described_class.artists_songs(token: token, id: artist_id, options: { page: -1 }) + }.to raise_error(ArgumentError) + end + end + + context "when token is nil and Auth is not authorized" do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it "returns nil" do + expect(described_class.artists_songs(token: nil, id: artist_id)).to be_nil + end + end + end +end diff --git a/spec/api/authorization_spec.rb b/spec/api/authorization_spec.rb index d0829e6..93ce085 100644 --- a/spec/api/authorization_spec.rb +++ b/spec/api/authorization_spec.rb @@ -1,18 +1,21 @@ # frozen_string_literal: true require "rspec" -require "dotenv/load" require "genius/api" describe Genius::Auth do - let!(:auth) { described_class } - # @todo Change .env to .env.local - let(:token) { ENV["TOKEN"] } + let(:auth) { described_class } + let(:valid_token) { "a" * 64 } + + before do + auth.logout! + end describe ".authorized?" do context "when a valid token is provided" do before do - auth.login = token + allow(Genius::Errors).to receive(:validate_token) + auth.login = valid_token end it "returns true" do @@ -21,8 +24,11 @@ end context "when an invalid token is provided" do + before do + allow(Genius::Errors).to receive(:validate_token).and_raise(Genius::Errors::TokenError.new) + end + it "returns false" do - auth.logout! auth.login = "invalid_token" expect(auth.authorized?).to be false end diff --git a/spec/api/errors_spec.rb b/spec/api/errors_spec.rb new file mode 100644 index 0000000..cc41e83 --- /dev/null +++ b/spec/api/errors_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Errors do + let(:valid_token) { "a" * 64 } + let(:short_token) { "short_token" } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + describe Genius::Errors::GeniusExceptionSuperClass do + it "is a subclass of StandardError" do + expect(described_class.superclass).to eq(StandardError) + end + end + + describe Genius::Errors::TokenError do + subject(:error) { described_class.new } + + it "has default message" do + expect(error.msg).to include("Invalid token") + end + + it "has default exception type" do + expect(error.exception_type).to eq("token_error") + end + + context "when method_name is provided" do + subject(:error) { described_class.new(method_name: "Genius::Auth.authorized?") } + + it "includes method_name in the message" do + expect(error.msg).to include("Genius::Auth.authorized?") + end + end + end + + describe Genius::Errors::LyricsNotFoundError do + subject(:error) { described_class.new } + + it "has default message" do + expect(error.msg).to eq("Lyrics not found in current session. Retrying...") + end + + it "has default exception type" do + expect(error.exception_type).to eq("invalid_lyrics") + end + end + + describe Genius::Errors::PageNotFound do + subject(:error) { described_class.new } + + it "has default message" do + expect(error.msg).to eq("Page not found. Try again with another response") + end + + it "has default exception type" do + expect(error.exception_type).to eq("page_not_found") + end + + describe ".page_not_found?" do + it "returns true if html contains 'Page not found'" do + html = Nokogiri::HTML("Page not found") + expect(described_class.page_not_found?(html)).to be true + end + + it "returns false if html does not contain 'Page not found'" do + html = Nokogiri::HTML("OK") + expect(described_class.page_not_found?(html)).to be false + end + end + end + + describe ".validate_token" do + context "when token is nil" do + it "raises TokenError" do + expect { described_class.validate_token(nil) }.to raise_error(Genius::Errors::TokenError) + end + end + + context "when token size is not 64" do + it "raises TokenError" do + expect { described_class.validate_token(short_token) }.to raise_error(Genius::Errors::TokenError) + end + end + + context "when token is valid and API responds with 200" do + let(:response_body) { { "meta" => { "status" => 200 } }.to_json } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + it "does not raise an error" do + expect { described_class.validate_token(valid_token) }.not_to raise_error + end + end + + context "when token is valid but API responds with non-200" do + let(:response_body) { { "meta" => { "status" => 401 } }.to_json } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + it "raises TokenError" do + expect { described_class.validate_token(valid_token) }.to raise_error(Genius::Errors::TokenError) + end + end + end + + describe ".error_handle" do + context "when token is nil" do + it "raises TokenError with a specific message" do + expect { described_class.error_handle(nil) }.to raise_error(Genius::Errors::TokenError) + end + end + + context "when token size is not 64" do + it "raises TokenError" do + expect { described_class.error_handle(short_token) }.to raise_error(Genius::Errors::TokenError) + end + end + + context "when token is valid" do + let(:response_body) { { "meta" => { "status" => 200 } }.to_json } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + it "returns true" do + expect(described_class.error_handle(valid_token)).to be true + end + end + end +end diff --git a/spec/api/referents_spec.rb b/spec/api/referents_spec.rb new file mode 100644 index 0000000..e337760 --- /dev/null +++ b/spec/api/referents_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Referents do + let(:token) { "a" * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { "meta" => { "status" => 200 }, "response" => { "referents" => [] } }.to_json } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + describe ".referents" do + context "when a valid token is provided" do + it "returns referents as a hash" do + result = described_class.referents(token: token) + expect(result).to be_a(Hash) + expect(result.dig("meta", "status")).to eq(200) + end + + it "sends a GET request to the referents endpoint" do + described_class.referents(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/referents?access_token=#{token}" + ) + end + end + + context "when options are provided" do + it "includes query parameters" do + described_class.referents(token: token, options: { per_page: 5, page: 2 }) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/referents?access_token=#{token}&per_page=5&page=2" + ) + end + end + + context "when both web_page and song_id are provided" do + it "raises ArgumentError" do + expect { + described_class.referents(token: token, options: { web_page: 1, song_id: 2 }) + }.to raise_error(ArgumentError) + end + end + + context "when token is nil and Auth is not authorized" do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it "returns nil" do + expect(described_class.referents(token: nil)).to be_nil + end + end + end +end diff --git a/spec/api/search_spec.rb b/spec/api/search_spec.rb new file mode 100644 index 0000000..9de4950 --- /dev/null +++ b/spec/api/search_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Search do + let(:token) { "a" * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + describe ".search" do + let(:response_body) do + { + "meta" => { "status" => 200 }, + "response" => { + "hits" => [ + { "result" => { "title" => "Song A" } }, + { "result" => { "title" => "Song B" } } + ] + } + }.to_json + end + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + context "when a valid token and query are provided" do + it "returns search results as a hash" do + result = described_class.search(token: token, query: "Ariana Grande") + expect(result).to be_a(Hash) + expect(result.dig("meta", "status")).to eq(200) + end + + it "sends a GET request to the search endpoint" do + described_class.search(token: token, query: "Ariana Grande") + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/search?q=Ariana Grande&access_token=#{token}" + ) + end + end + + context "when search_by is provided" do + it "returns filtered results" do + result = described_class.search(token: token, query: "test", search_by: "title") + expect(result).to contain_exactly("Song A", "Song B") + end + end + + context "when token is nil and Auth is not authorized" do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it "returns nil" do + expect(described_class.search(token: nil, query: "test")).to be_nil + end + end + + context "when query is nil" do + it "sends a request with nil query" do + described_class.search(token: token, query: nil) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/search?q=&access_token=#{token}" + ) + end + end + end +end diff --git a/spec/api/songs_spec.rb b/spec/api/songs_spec.rb new file mode 100644 index 0000000..d3877fc --- /dev/null +++ b/spec/api/songs_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::Songs do + let(:token) { "a" * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:song_id) { 294_649 } + + describe ".songs" do + let(:response_body) do + { + "meta" => { "status" => 200 }, + "response" => { "song" => { "id" => song_id, "title" => "Test Song" } } + }.to_json + end + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + context "when a valid token and song_id are provided" do + it "returns song data as a hash" do + result = described_class.songs(token: token, song_id: song_id) + expect(result).to be_a(Hash) + expect(result.dig("response", "song", "id")).to eq(song_id) + end + + it "sends a GET request to the songs endpoint" do + described_class.songs(token: token, song_id: song_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/songs/#{song_id}?access_token=#{token}" + ) + end + end + + context "when combine is true" do + let(:response_body) do + { + "meta" => { "status" => 200 }, + "response" => { "song" => { "id" => song_id } } + }.to_json + end + let(:scripts) do + (0..16).map { |i| "" } + + [""] + end + let(:html_body) do + "#{scripts.join}" + end + + before do + allow(HTTParty).to receive(:get).with("https://genius.com/songs/#{song_id}") + .and_return(html_body) + end + + it "fetches lyrics page and includes lyrics in the response" do + result = described_class.songs(token: token, song_id: song_id, combine: true) + expect(result).to be_a(Hash) + expect(result).to have_key("lyrics") + expect(result["lyrics"]).to have_key("lyrics") + end + end + + context "when token is nil and Auth is not authorized" do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it "returns nil" do + expect(described_class.songs(token: nil, song_id: song_id)).to be_nil + end + end + end + + describe ".get_lyrics" do + let(:html_body) do + <<~HTML + + +
+

Test lyrics content

+
+ + + HTML + end + + before do + allow(HTTParty).to receive(:get).and_return(html_body) + end + + it "extracts lyrics from the page" do + result = described_class.get_lyrics(song_id) + expect(result).to eq("Test lyrics content") + end + + context "when song_id is nil" do + it "raises ArgumentError" do + expect { described_class.get_lyrics(nil) }.to raise_error(ArgumentError) + end + end + end +end diff --git a/spec/api/web_pages_spec.rb b/spec/api/web_pages_spec.rb new file mode 100644 index 0000000..0da9b4f --- /dev/null +++ b/spec/api/web_pages_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rspec" +require "genius/api" + +describe Genius::WebPages do + let(:token) { "a" * 64 } + let(:response_body) do + { + "meta" => { "status" => 200 }, + "response" => { "web_page" => { "id" => 1, "url" => "https://example.com" } } + }.to_json + end + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:get).and_return(response_body) + end + + describe ".lookup" do + context "when a valid token is provided" do + it "returns web page data as a hash" do + result = described_class.lookup(token: token) + expect(result).to be_a(Hash) + expect(result.dig("meta", "status")).to eq(200) + end + + it "sends a GET request to the API root" do + described_class.lookup(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/?access_token=#{token}" + ) + end + end + + context "when options with URL variants are provided" do + it "includes query parameters" do + described_class.lookup( + token: token, + options: { + raw_annotatable_url: "https://example.com/page", + canonical_url: "https://example.com/canonical", + og_url: "https://example.com/og" + } + ) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/?access_token=#{token}" \ + "&raw_annotatable_url=https://example.com/page" \ + "&canonical_url=https://example.com/canonical" \ + "&og_url=https://example.com/og" + ) + end + end + + context "when token is nil and Auth is not authorized" do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it "returns nil" do + expect(described_class.lookup(token: nil)).to be_nil + end + end + end +end diff --git a/spec/extensions/options_helper_spec.rb b/spec/extensions/options_helper_spec.rb new file mode 100644 index 0000000..747f296 --- /dev/null +++ b/spec/extensions/options_helper_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rspec" +require "extensions/extensions" + +describe Object do + describe "#options_helper" do + let(:allowed_keys) { %i[sort per_page page] } + + context "when options contain allowed keys" do + it "builds a query string from allowed options" do + result = subject.options_helper({ sort: "title", per_page: 10, page: 1 }, allowed_keys) + expect(result).to eq("&sort=title&per_page=10&page=1") + end + end + + context "when options contain disallowed keys" do + it "ignores disallowed keys" do + result = subject.options_helper({ sort: "title", foo: "bar" }, allowed_keys) + expect(result).to eq("&sort=title") + end + end + + context "when options is empty" do + it "returns an empty string" do + result = subject.options_helper({}, allowed_keys) + expect(result).to eq("") + end + end + end +end diff --git a/spec/extensions/token_ext_spec.rb b/spec/extensions/token_ext_spec.rb new file mode 100644 index 0000000..17f52d3 --- /dev/null +++ b/spec/extensions/token_ext_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rspec" +require "extensions/extensions" + +describe Object do + describe "#token_ext" do + let(:token) { "a" * 64 } + + context "when a token is provided" do + it "returns the token" do + expect(subject.token_ext(token)).to eq(token) + end + end + + context "when token is nil and Genius::Auth has an instance variable @token" do + before do + Genius::Auth.instance_variable_set(:@token, token) + end + + after do + Genius::Auth.instance_variable_set(:@token, nil) + end + + it "returns the stored token" do + expect(subject.token_ext(nil)).to eq(token) + end + end + end +end diff --git a/spec/extensions/unescape_spec.rb b/spec/extensions/unescape_spec.rb new file mode 100644 index 0000000..7a65c08 --- /dev/null +++ b/spec/extensions/unescape_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rspec" +require "extensions/extensions" + +describe String do + describe "#unescape" do + it "unescapes JSON-escaped double quotes" do + str = 'test \\"string\\"' + expect(str.unescape).to eq('test "string"') + end + + it "handles strings without escaped characters" do + str = "hello world" + expect(str.unescape).to eq("hello world") + end + end +end From 40646e3af0e137125914860ed6d1eebc71987fe7 Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:35:01 +0300 Subject: [PATCH 2/7] [0.3.0] Fixed RuboCop (#3) --- .rubocop.yml | 6 +- .ruby-version | 2 +- Gemfile | 23 +- Gemfile.lock | 9 +- bin/console | 6 +- exe/genius-api | 3 +- genius-api.gemspec | 54 ++--- lib/extensions/deep_find.rb | 29 ++- lib/extensions/extensions.rb | 14 +- lib/extensions/options_helper.rb | 2 +- lib/extensions/unescape.rb | 4 +- lib/genius/api.rb | 24 +-- lib/genius/api/annotations.rb | 48 ++--- lib/genius/api/artists.rb | 2 +- lib/genius/api/errors.rb | 26 +-- lib/genius/api/referents.rb | 4 +- lib/genius/api/songs.rb | 46 ++-- lib/genius/api/version.rb | 2 +- spec/api/account_spec.rb | 82 ++++---- spec/api/annotations_spec.rb | 281 +++++++++++++++---------- spec/api/artists_spec.rb | 223 +++++++++++--------- spec/api/authorization_spec.rb | 55 +++-- spec/api/errors_spec.rb | 188 ++++++++--------- spec/api/referents_spec.rb | 100 ++++----- spec/api/search_spec.rb | 146 ++++++++----- spec/api/songs_spec.rb | 164 +++++++-------- spec/api/web_pages_spec.rb | 94 +++++---- spec/extensions/hash_spec.rb | 120 +++++++---- spec/extensions/options_helper_spec.rb | 28 +-- spec/extensions/token_ext_spec.rb | 16 +- spec/extensions/unescape_spec.rb | 14 +- spec/spec_helper.rb | 2 +- 32 files changed, 970 insertions(+), 847 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 050a1f3..5df708e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,2 +1,6 @@ AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.0 + NewCops: enable + +Gemspec/DevelopmentDependencies: + EnforcedStyle: gemspec diff --git a/.ruby-version b/.ruby-version index 6a81b4c..2451c27 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.8 +3.0.7 diff --git a/Gemfile b/Gemfile index c45085b..7f4f5e9 100644 --- a/Gemfile +++ b/Gemfile @@ -1,26 +1,5 @@ # frozen_string_literal: true -source "https://rubygems.org" +source 'https://rubygems.org' -# Specify your gem's dependencies in genius-api.gemspec gemspec - -gem "rake", "~> 13.0" - -gem "rspec", "~> 3.4" - -gem "rubocop", "~> 1.7" - -gem "httparty", "~> 0.21" - -gem "nokogiri" - -gem "yard" - - - - -group :test do - gem "coderay" - gem "dotenv", "~> 2.7.6" -end diff --git a/Gemfile.lock b/Gemfile.lock index 75f0184..241d6b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: genius-api (0.3.0) + httparty (~> 0.21) nokogiri GEM @@ -20,7 +21,11 @@ GEM language_server-protocol (3.17.0.5) lint_roller (1.1.0) mini_mime (1.1.5) + mini_portile2 (2.8.9) multi_xml (0.6.0) + nokogiri (1.17.2) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) nokogiri (1.17.2-aarch64-linux) racc (~> 1.4) nokogiri (1.17.2-arm-linux) @@ -87,12 +92,10 @@ DEPENDENCIES coderay dotenv (~> 2.7.6) genius-api! - httparty (~> 0.21) - nokogiri rake (~> 13.0) rspec (~> 3.4) rubocop (~> 1.7) yard BUNDLED WITH - 2.5.23 + 4.0.14 diff --git a/bin/console b/bin/console index 8390d65..c3d265b 100755 --- a/bin/console +++ b/bin/console @@ -1,8 +1,8 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require "bundler/setup" -require "genius/api" +require 'bundler/setup' +require 'genius/api' # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. @@ -11,5 +11,5 @@ require "genius/api" # require "pry" # Pry.start -require "irb" +require 'irb' IRB.start(__FILE__) diff --git a/exe/genius-api b/exe/genius-api index c7b203e..da98875 100755 --- a/exe/genius-api +++ b/exe/genius-api @@ -1,3 +1,4 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -require "genius/api" +require 'genius/api' diff --git a/genius-api.gemspec b/genius-api.gemspec index a9f91a9..3dacfb9 100644 --- a/genius-api.gemspec +++ b/genius-api.gemspec @@ -1,41 +1,41 @@ # frozen_string_literal: true -require_relative "lib/genius/api/version" +require_relative 'lib/genius/api/version' Gem::Specification.new do |spec| - spec.name = "genius-api" + spec.name = 'genius-api' spec.version = Genius::Api::VERSION - spec.authors = ["unurgunite"] - spec.email = ["noreply@example.com"] + spec.authors = ['unurgunite'] + + spec.summary = 'Library to work with Genius API' + spec.description = 'Library to work with Genius API, written in Ruby' + spec.homepage = 'https://github.com/unurgunite/genius-api' + spec.license = 'GPL-3.0' + spec.required_ruby_version = Gem::Requirement.new('>= 3.0') + + spec.metadata = { + 'allowed_push_host' => 'https://example.com', + 'homepage_uri' => spec.homepage, + 'source_code_uri' => 'https://github.com/unurgunite/genius-api', + 'changelog_uri' => 'https://github.com/unurgunite/genius-api/blob/master/CHANGELOG.md', + 'rubygems_mfa_required' => 'true' + } - spec.summary = "Library to work with Genius API" - spec.description = "Library to work with Genius API, written in Ruby" - spec.homepage = "https://github.com/unurgunite/genius-api" - spec.license = "GPL-3.0" - spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0") - - spec.metadata["allowed_push_host"] = "https://example.com" - - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "https://github.com/unurgunite/genius-api" - spec.metadata["changelog_uri"] = "https://github.com/unurgunite/genius-api/blob/master/CHANGELOG.md" - - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path(__dir__)) do `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } end - spec.bindir = "exe" + spec.bindir = 'exe' spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] + spec.require_paths = ['lib'] spec.post_install_message = "Thanks for installing!\nA Ruby gem for scraping with Genius API🤓" - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - spec.add_development_dependency "httparty", "~> 0.21" - spec.add_dependency "nokogiri" - - # For more information and examples about making a new gem, checkout our - # guide at: https://bundler.io/guides/creating_gem.html + spec.add_dependency 'httparty', '~> 0.21' + spec.add_dependency 'nokogiri' + spec.add_development_dependency 'coderay' + spec.add_development_dependency 'dotenv', '~> 2.7.6' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rspec', '~> 3.4' + spec.add_development_dependency 'rubocop', '~> 1.7' + spec.add_development_dependency 'yard' end diff --git a/lib/extensions/deep_find.rb b/lib/extensions/deep_find.rb index 3c4dfd0..ffded2c 100644 --- a/lib/extensions/deep_find.rb +++ b/lib/extensions/deep_find.rb @@ -25,18 +25,25 @@ class Hash # :nodoc: # h.deep_find("a") #=> "b", instead ["b", "b"] # @todo change uniq true to uniq false def deep_find(key, uniq: true) - result = [] - result << self[key] - each_value do |hash_value| - values = hash_value.is_a?(Array) ? hash_value : [hash_value] - values.each do |value| - result << value.deep_find(key) if value.is_a? Hash - end - end - result = result.compact.delete_if do |i| - i.is_a?(Array) && i.empty? - end + result = collect_values(key) + result.compact! + result.delete_if { |i| i.is_a?(Array) && i.empty? } result.uniq! if uniq + return nil if result.empty? + result.size == 1 ? result.first : result end + + private + + def collect_values(key) + result = [self[key]] + each_value do |value| + values = value.is_a?(Array) ? value : [value] + values.each do |v| + result << v.deep_find(key) if v.is_a?(Hash) + end + end + result + end end diff --git a/lib/extensions/extensions.rb b/lib/extensions/extensions.rb index 402940b..ac0ae90 100644 --- a/lib/extensions/extensions.rb +++ b/lib/extensions/extensions.rb @@ -2,11 +2,11 @@ # This file is an extension initializer. That means that it includes extension files # during initialization and extension methods are visible from everywhere -require_relative "deep_find" -require_relative "unescape" -require_relative "token_ext" -require_relative "options_helper" +require_relative 'deep_find' +require_relative 'unescape' +require_relative 'token_ext' +require_relative 'options_helper' -require "json" -require "nokogiri" -require "httparty" +require 'json' +require 'nokogiri' +require 'httparty' diff --git a/lib/extensions/options_helper.rb b/lib/extensions/options_helper.rb index 245a6c0..dc3c3b2 100644 --- a/lib/extensions/options_helper.rb +++ b/lib/extensions/options_helper.rb @@ -7,7 +7,7 @@ class Object # :nodoc: # @param [Array] arry Array of possible params for response. # @return [String] def options_helper(options, arry) - params = "" + params = '' opt = arry options.each do |k, v| params.insert(params.length, "&#{k}=#{v}") if opt.include? k diff --git a/lib/extensions/unescape.rb b/lib/extensions/unescape.rb index f32dbe9..a7fcb9a 100644 --- a/lib/extensions/unescape.rb +++ b/lib/extensions/unescape.rb @@ -7,7 +7,7 @@ class String # :nodoc: # # @return [String (frozen)] def unescape - string = gsub(/(? value diff --git a/lib/genius/api/errors.rb b/lib/genius/api/errors.rb index 0ccb6da..122a42d 100644 --- a/lib/genius/api/errors.rb +++ b/lib/genius/api/errors.rb @@ -43,7 +43,7 @@ module Genius # end module Errors # Endpoint for resource. - ENDPOINT = "#{Api::RESOURCE}/account/?access_token" + ENDPOINT = "#{Api::RESOURCE}/account/?access_token".freeze # Abstract class to store all exception classes in a single object. class GeniusExceptionSuperClass < StandardError @@ -62,8 +62,8 @@ class TokenError < GeniusExceptionSuperClass # @param [String (frozen)] msg Exception message. # @param [String (frozen)] exception_type Exception type. # @return [String (frozen)] - def initialize(msg: "Invalid token. The access token provided is expired, revoked, malformed or invalid for " \ - "other reasons.", exception_type: "token_error", method_name: nil) + def initialize(msg: 'Invalid token. The access token provided is expired, revoked, malformed or invalid for ' \ + 'other reasons.', exception_type: 'token_error', method_name: nil) @msg = if method_name.nil? msg else @@ -82,7 +82,7 @@ class LyricsNotFoundError < GeniusExceptionSuperClass # @param [String (frozen)] msg Exception message. # @param [String (frozen)] exception_type Exception type. # @return [String (frozen)] - def initialize(msg: "Lyrics not found in current session. Retrying...", exception_type: "invalid_lyrics") + def initialize(msg: 'Lyrics not found in current session. Retrying...', exception_type: 'invalid_lyrics') @msg = msg @exception_type = exception_type super(msg) @@ -97,7 +97,7 @@ class PageNotFound < GeniusExceptionSuperClass # @param [String (frozen)] msg Exception message. # @param [String (frozen)] exception_type Exception type. # @return [String (frozen)] - def initialize(msg: "Page not found. Try again with another response", exception_type: "page_not_found") + def initialize(msg: 'Page not found. Try again with another response', exception_type: 'page_not_found') @msg = msg @exception_type = exception_type super(msg) @@ -161,7 +161,7 @@ def validate_token(token, method_name: nil) raise TokenError.new(method_name: method_name) if token.nil? || token.size != 64 response = HTTParty.get("#{ENDPOINT}=#{token}").body - status = JSON.parse(response).dig("meta", "status") + status = JSON.parse(response).dig('meta', 'status') raise TokenError.new(method_name: method_name) unless status == 200 end @@ -195,12 +195,12 @@ def validate_token(token, method_name: nil) # puts e.message # puts e.exception_type # end - def error_handle(token, method_name: nil) + def error_handle?(token, method_name: nil) if token.nil? - raise TokenError.new(msg: "Token is required for this method. Please, add token via " \ - "`Genius::Auth.login=``token''` method and continue", + raise TokenError.new(msg: 'Token is required for this method. Please, add token via ' \ + "`Genius::Auth.login=``token''` method and continue", method_name: method_name) - elsif token.size != 64 || check_status(token) == false + elsif token.size != 64 || check_status?(token) == false raise TokenError.new(method_name: method_name) end true @@ -222,13 +222,13 @@ def error_handle(token, method_name: nil) # @return [Boolean] # # @see .error_handle - def check_status(token) + def check_status?(token) return false if token.size != 64 || token.nil? response = HTTParty.get("#{ENDPOINT}=#{token}").body - raise TokenError unless JSON.parse(response).dig("meta", "status") + raise TokenError unless JSON.parse(response).dig('meta', 'status') - status = JSON.parse(response).dig("meta", "status") + status = JSON.parse(response).dig('meta', 'status') status == 200 end end diff --git a/lib/genius/api/referents.rb b/lib/genius/api/referents.rb index 292a914..030af3f 100644 --- a/lib/genius/api/referents.rb +++ b/lib/genius/api/referents.rb @@ -10,7 +10,7 @@ module Genius module Referents class << self # Endpoint of the resource - ENDPOINT = "#{Api::RESOURCE}/referents" + ENDPOINT = "#{Api::RESOURCE}/referents".freeze # +Genius::Referents.referents+ -> Hash # # Referents by content item or user responsible for an included @@ -39,7 +39,7 @@ def referents(token: nil, options: {}) Errors.validate_token(token) unless token.nil? if options.key?(:web_page) && options.key?(:song_id) - raise ArgumentError, "You may pass only one of song_id and web_page_id, not both!" + raise ArgumentError, 'You may pass only one of song_id and web_page_id, not both!' end params = options_helper(options, %i[created_by_id text_format per_page page]) diff --git a/lib/genius/api/songs.rb b/lib/genius/api/songs.rb index 6871ab9..8dd653e 100644 --- a/lib/genius/api/songs.rb +++ b/lib/genius/api/songs.rb @@ -31,29 +31,33 @@ def songs(token: nil, song_id: nil, combine: false) response = HTTParty.get("#{Api::RESOURCE}/songs/#{song_id}?access_token=#{token_ext(token)}").body response = JSON.parse response - if combine - begin - output_html = Nokogiri::HTML(HTTParty.get("https://genius.com/songs/#{song_id}")) - raise PageNotFound if PageNotFound.page_not_found?(output_html) + combine ? merge_lyrics(song_id, response) : response + end + + private - # @todo: maybe need some optimisations - unformed_json = output_html.css("script")[17] - .text.match(/window\.__PRELOADED_STATE__\s=\sJSON.parse\('(?(.+?))'\);/) - raise LyricsNotFoundError if unformed_json.nil? + def merge_lyrics(song_id, response) + output_html = Nokogiri::HTML(HTTParty.get("https://genius.com/songs/#{song_id}")) + raise PageNotFound if PageNotFound.page_not_found?(output_html) - formatted_json = unformed_json[:json] - lyrics_json = JSON.parse(formatted_json.unescape) - response["lyrics"] = lyrics_json - return response - rescue LyricsNotFoundError - retry - rescue PageNotFound => e - "Error description: #{e.msg}\nException type: #{e.exception_type}" - end - end + response['lyrics'] = parse_preloaded_state(output_html) response + rescue LyricsNotFoundError + retry + rescue PageNotFound => e + "Error description: #{e.msg}\nException type: #{e.exception_type}" end + def parse_preloaded_state(output_html) + unformed_json = output_html.css('script')[17] + .text.match(/window\.__PRELOADED_STATE__\s=\sJSON.parse\('(?(?:.+?))'\);/) + raise LyricsNotFoundError if unformed_json.nil? + + JSON.parse(unformed_json[:json].unescape) + end + + public + # +Genius::Songs.get_lyrics+ -> Hash # # +Genius::Songs.get_lyrics+ method is used for extracting lyrics in @@ -63,13 +67,13 @@ def songs(token: nil, song_id: nil, combine: false) # @raise [ArgumentError] if +song_id+ is blank. # @return [Hash] def get_lyrics(song_id) - raise ArgumentError, "`song_id` should be not blank!" if song_id.nil? + raise ArgumentError, '`song_id` should be not blank!' if song_id.nil? response = HTTParty.get("https://genius.com/songs/#{song_id}") document = Nokogiri::HTML(response) - # @todo: something wrong with lyrics attribute value + # @todo: something wrong with lyrics attribute value lyrics_path = document.xpath("//*[@class='Lyrics__Container-sc-1ynbvzw-6 YYrds']") - lyrics_path.at_css("p").content + lyrics_path.at_css('p').content rescue NoMethodError retry end diff --git a/lib/genius/api/version.rb b/lib/genius/api/version.rb index 69336c6..f856244 100644 --- a/lib/genius/api/version.rb +++ b/lib/genius/api/version.rb @@ -3,6 +3,6 @@ module Genius module Api # Version of the gem. - VERSION = "0.3.0" + VERSION = '0.3.0' end end diff --git a/spec/api/account_spec.rb b/spec/api/account_spec.rb index 50170f0..e306915 100644 --- a/spec/api/account_spec.rb +++ b/spec/api/account_spec.rb @@ -1,51 +1,51 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Account do - let(:token) { "a" * 64 } - let(:response_body) { { "meta" => { "status" => 200 }, "response" => { "user" => { "name" => "Test" } } }.to_json } +describe Genius::Account, ' .account with valid token' do + let(:token) { 'a' * 64 } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json } let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - describe ".account" do - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end - - context "when a valid token is provided" do - it "returns account info as a hash" do - result = described_class.account(token: token) - expect(result).to be_a(Hash) - expect(result.dig("meta", "status")).to eq(200) - end - - it "sends a GET request to the account endpoint" do - described_class.account(token: token) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/account?access_token=#{token}" - ) - end - end - - context "when token is nil and Auth is not authorized" do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end - - it "returns nil" do - expect(described_class.account(token: nil)).to be_nil - end - end + before do + allow(HTTParty).to receive(:get).and_return(mock_response) end - describe ".me" do - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end + it 'returns account info as a hash' do + result = described_class.account(token: token) + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + + it 'sends a GET request to the account endpoint' do + described_class.account(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/account?access_token=#{token}" + ) + end +end + +describe Genius::Account, ' .account without token' do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it 'returns nil' do + expect(described_class.account(token: nil)).to be_nil + end +end + +describe Genius::Account, ' .me' do + let(:token) { 'a' * 64 } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end - it "behaves the same as .account" do - expect(described_class.me(token: token)).to eq(described_class.account(token: token)) - end + it 'behaves the same as .account' do + expect(described_class.me(token: token)).to eq(described_class.account(token: token)) end end diff --git a/spec/api/annotations_spec.rb b/spec/api/annotations_spec.rb index d55d594..d16a087 100644 --- a/spec/api/annotations_spec.rb +++ b/spec/api/annotations_spec.rb @@ -1,125 +1,184 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Annotations do - let(:token) { "a" * 64 } +describe Genius::Annotations, 'get request' do + let(:token) { 'a' * 64 } let(:annotation_id) { 10_225_840 } let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { "meta" => { "status" => 200 } }.to_json } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'sends a GET request' do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get') + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" + ) + end + + it 'returns parsed JSON' do + result = described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get') + expect(result).to be_a(Hash) + end +end + +describe Genius::Annotations, 'post request' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } before do - allow(HTTParty).to receive(:get).and_return(mock_response) + allow(Genius::Errors).to receive(:validate_token) allow(HTTParty).to receive(:post).and_return(mock_response) + end + + it 'sends a POST request' do + described_class.annotations( + id: annotation_id, action: nil, token: token, http_verb: 'post', + options: { markdown: 'test', raw_annotatable_url: 'https://example.com' } + ) + expect(HTTParty).to have_received(:post) + end +end + +describe Genius::Annotations, ' put request without action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request' do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put) + end +end + +describe Genius::Annotations, ' put request with action upvote' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request to the upvote endpoint' do + described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/upvote?access_token=#{token}" + ) + end +end + +describe Genius::Annotations, ' put request with action downvote' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request to the downvote endpoint' do + described_class.annotations(id: annotation_id, action: 'downvote', token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/downvote?access_token=#{token}" + ) + end +end + +describe Genius::Annotations, ' put request with action unvote' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request to the unvote endpoint' do + described_class.annotations(id: annotation_id, action: 'unvote', token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/unvote?access_token=#{token}" + ) + end +end + +describe Genius::Annotations, ' put request with invalid action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.annotations(id: annotation_id, action: 'invalid', token: token, http_verb: 'put') + end.to raise_error(ArgumentError) + end +end + +describe Genius::Annotations, ' delete request' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) allow(HTTParty).to receive(:delete).and_return(mock_response) end - describe ".annotations" do - context "with http_verb 'get'" do - it "sends a GET request" do - described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "get") - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" - ) - end - - it "returns parsed JSON" do - result = described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "get") - expect(result).to be_a(Hash) - end - end - - context "with http_verb 'post'" do - it "sends a POST request" do - described_class.annotations( - id: annotation_id, action: nil, token: token, http_verb: "post", - options: { markdown: "test", raw_annotatable_url: "https://example.com" } - ) - expect(HTTParty).to have_received(:post) - end - end - - context "with http_verb 'put'" do - it "sends a PUT request" do - described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "put") - expect(HTTParty).to have_received(:put) - end - - context "with action 'upvote'" do - it "sends a PUT request to the upvote endpoint" do - described_class.annotations(id: annotation_id, action: "upvote", token: token, http_verb: "put") - expect(HTTParty).to have_received(:put).with( - "https://api.genius.com/annotations/#{annotation_id}/upvote?access_token=#{token}" - ) - end - end - - context "with action 'downvote'" do - it "sends a PUT request to the downvote endpoint" do - described_class.annotations(id: annotation_id, action: "downvote", token: token, http_verb: "put") - expect(HTTParty).to have_received(:put).with( - "https://api.genius.com/annotations/#{annotation_id}/downvote?access_token=#{token}" - ) - end - end - - context "with action 'unvote'" do - it "sends a PUT request to the unvote endpoint" do - described_class.annotations(id: annotation_id, action: "unvote", token: token, http_verb: "put") - expect(HTTParty).to have_received(:put).with( - "https://api.genius.com/annotations/#{annotation_id}/unvote?access_token=#{token}" - ) - end - end - - context "with an invalid action" do - it "raises ArgumentError" do - expect { - described_class.annotations(id: annotation_id, action: "invalid", token: token, http_verb: "put") - }.to raise_error(ArgumentError) - end - end - end - - context "with http_verb 'delete'" do - it "sends a DELETE request" do - described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: "delete") - expect(HTTParty).to have_received(:delete).with( - "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" - ) - end - end - - context "when action is provided with a non-PUT verb" do - it "raises ArgumentError" do - expect { - described_class.annotations(id: annotation_id, action: "upvote", token: token, http_verb: "get") - }.to raise_error(ArgumentError, /only PUT accepts/) - end - end - end - - describe "private methods" do - describe ".post_payload" do - it "builds a JSON payload from options" do - payload = described_class.send(:post_payload, options: { - markdown: "hello **world!**", - raw_annotatable_url: "https://example.com", - fragment: "test", - before_html: "

", - after_html: "

", - canonical_url: "https://example.com/canonical", - og_url: "https://example.com/og", - title: "Test Page" - }) - - parsed = JSON.parse(payload) - expect(parsed.dig("annotation", "body", "markdown")).to eq("hello **world!**") - expect(parsed.dig("referent", "raw_annotatable_url")).to eq("https://example.com") - expect(parsed.dig("web_page", "title")).to eq("Test Page") - end - end + it 'sends a DELETE request' do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'delete') + expect(HTTParty).to have_received(:delete).with( + "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" + ) + end +end + +describe Genius::Annotations, ' action with non-PUT verb' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'get') + end.to raise_error(ArgumentError, /only PUT accepts/) + end +end + +describe Genius::Annotations, ' .post_payload' do + it 'builds a JSON payload from options' do + payload = described_class.send(:post_payload, options: { + markdown: 'hello **world!**', + raw_annotatable_url: 'https://example.com', + fragment: 'test', + before_html: '

', + after_html: '

', + canonical_url: 'https://example.com/canonical', + og_url: 'https://example.com/og', + title: 'Test Page' + }) + + parsed = JSON.parse(payload) + expect(parsed.dig('annotation', 'body', 'markdown')).to eq('hello **world!**') + expect(parsed.dig('referent', 'raw_annotatable_url')).to eq('https://example.com') + expect(parsed.dig('web_page', 'title')).to eq('Test Page') end end diff --git a/spec/api/artists_spec.rb b/spec/api/artists_spec.rb index 3e5563d..0b2d61b 100644 --- a/spec/api/artists_spec.rb +++ b/spec/api/artists_spec.rb @@ -1,111 +1,136 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Artists do - let(:token) { "a" * 64 } +describe Genius::Artists, ' .artists with valid data' do + let(:token) { 'a' * 64 } let(:artist_id) { 16_764 } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'artist' => { 'id' => artist_id, 'name' => 'Test Artist' } } + }.to_json + end let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - before do - allow(HTTParty).to receive(:get).and_return(mock_response) + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'returns artist data as a hash' do + result = described_class.artists(token: token, id: artist_id) + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + + it 'sends a GET request to the artists endpoint' do + described_class.artists(token: token, id: artist_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" + ) + end +end + +describe Genius::Artists, ' .artists with nil id' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect { described_class.artists(token: token, id: nil) }.to raise_error(ArgumentError) + end +end + +describe Genius::Artists, ' .artists_songs with valid data' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'songs' => [{ 'id' => 1, 'title' => 'Song 1' }] } + }.to_json + end + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'returns songs data as a hash' do + result = described_class.artists_songs(token: token, id: artist_id) + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + + it 'sends a GET request to the artists songs endpoint' do + described_class.artists_songs(token: token, id: artist_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" + ) + end +end + +describe Genius::Artists, ' .artists_songs with sort option' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'includes sort parameter' do + described_class.artists_songs(token: token, id: artist_id, options: { sort: 'popularity' }) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}&sort=popularity" + ) end +end + +describe Genius::Artists, ' .artists_songs with invalid sort' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.artists_songs(token: token, id: artist_id, options: { sort: 'invalid' }) + end.to raise_error(ArgumentError) + end +end + +describe Genius::Artists, ' .artists_songs with negative per_page' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } - describe ".artists" do - let(:response_body) do - { - "meta" => { "status" => 200 }, - "response" => { "artist" => { "id" => artist_id, "name" => "Test Artist" } } - }.to_json - end - - context "when a valid token and id are provided" do - it "returns artist data as a hash" do - result = described_class.artists(token: token, id: artist_id) - expect(result).to be_a(Hash) - expect(result.dig("meta", "status")).to eq(200) - end - - it "sends a GET request to the artists endpoint" do - described_class.artists(token: token, id: artist_id) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" - ) - end - end - - context "when id is nil" do - it "raises ArgumentError" do - expect { described_class.artists(token: token, id: nil) }.to raise_error(ArgumentError) - end - end + it 'raises ArgumentError' do + expect do + described_class.artists_songs(token: token, id: artist_id, options: { per_page: -1 }) + end.to raise_error(ArgumentError) + end +end + +describe Genius::Artists, ' .artists_songs with negative page' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.artists_songs(token: token, id: artist_id, options: { page: -1 }) + end.to raise_error(ArgumentError) + end +end + +describe Genius::Artists, ' .artists_songs without token' do + let(:artist_id) { 16_764 } + + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) end - describe ".artists_songs" do - let(:response_body) do - { - "meta" => { "status" => 200 }, - "response" => { "songs" => [{ "id" => 1, "title" => "Song 1" }] } - }.to_json - end - - context "when a valid token and id are provided" do - it "returns songs data as a hash" do - result = described_class.artists_songs(token: token, id: artist_id) - expect(result).to be_a(Hash) - expect(result.dig("meta", "status")).to eq(200) - end - - it "sends a GET request to the artists songs endpoint" do - described_class.artists_songs(token: token, id: artist_id) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" - ) - end - end - - context "when options include sort" do - it "includes sort parameter" do - described_class.artists_songs(token: token, id: artist_id, options: { sort: "popularity" }) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/artists/#{artist_id}?access_token=#{token}&sort=popularity" - ) - end - - context "with an invalid sort value" do - it "raises ArgumentError" do - expect { - described_class.artists_songs(token: token, id: artist_id, options: { sort: "invalid" }) - }.to raise_error(ArgumentError) - end - end - end - - context "when per_page is negative" do - it "raises ArgumentError" do - expect { - described_class.artists_songs(token: token, id: artist_id, options: { per_page: -1 }) - }.to raise_error(ArgumentError) - end - end - - context "when page is negative" do - it "raises ArgumentError" do - expect { - described_class.artists_songs(token: token, id: artist_id, options: { page: -1 }) - }.to raise_error(ArgumentError) - end - end - - context "when token is nil and Auth is not authorized" do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end - - it "returns nil" do - expect(described_class.artists_songs(token: nil, id: artist_id)).to be_nil - end - end + it 'returns nil' do + expect(described_class.artists_songs(token: nil, id: artist_id)).to be_nil end end diff --git a/spec/api/authorization_spec.rb b/spec/api/authorization_spec.rb index 93ce085..e256d69 100644 --- a/spec/api/authorization_spec.rb +++ b/spec/api/authorization_spec.rb @@ -1,44 +1,37 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Auth do - let(:auth) { described_class } - let(:valid_token) { "a" * 64 } +describe Genius::Auth, ' .authorized? with valid token' do + let(:valid_token) { 'a' * 64 } before do - auth.logout! + described_class.logout! + allow(Genius::Errors).to receive(:validate_token) + described_class.login = valid_token end - describe ".authorized?" do - context "when a valid token is provided" do - before do - allow(Genius::Errors).to receive(:validate_token) - auth.login = valid_token - end - - it "returns true" do - expect(auth.authorized?).to be true - end - end + it 'returns true' do + expect(described_class.authorized?).to be true + end +end - context "when an invalid token is provided" do - before do - allow(Genius::Errors).to receive(:validate_token).and_raise(Genius::Errors::TokenError.new) - end +describe Genius::Auth, ' .authorized? with invalid token' do + before do + described_class.logout! + allow(Genius::Errors).to receive(:validate_token).and_raise(Genius::Errors::TokenError.new) + end - it "returns false" do - auth.login = "invalid_token" - expect(auth.authorized?).to be false - end - end + it 'returns false' do + described_class.login = 'invalid_token' + expect(described_class.authorized?).to be false end +end - describe ".logout!" do - it "sets the token to nil" do - auth.logout! - expect(auth.instance_variable_get(:@token)).to be_nil - end +describe Genius::Auth, ' .logout!' do + it 'sets the token to nil' do + described_class.logout! + expect(described_class.instance_variable_get(:@token)).to be_nil end end diff --git a/spec/api/errors_spec.rb b/spec/api/errors_spec.rb index cc41e83..49ae6ec 100644 --- a/spec/api/errors_spec.rb +++ b/spec/api/errors_spec.rb @@ -1,136 +1,130 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Errors do - let(:valid_token) { "a" * 64 } - let(:short_token) { "short_token" } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } +describe Genius::Errors::GeniusExceptionSuperClass do + it 'is a subclass of StandardError' do + expect(described_class.superclass).to eq(StandardError) + end +end - describe Genius::Errors::GeniusExceptionSuperClass do - it "is a subclass of StandardError" do - expect(described_class.superclass).to eq(StandardError) - end +describe Genius::Errors::TokenError do + subject(:error) { described_class.new } + + it 'has default message' do + expect(error.msg).to include('Invalid token') end - describe Genius::Errors::TokenError do - subject(:error) { described_class.new } + it 'has default exception type' do + expect(error.exception_type).to eq('token_error') + end - it "has default message" do - expect(error.msg).to include("Invalid token") - end + context 'when method_name is provided' do + subject(:error) { described_class.new(method_name: 'Genius::Auth.authorized?') } - it "has default exception type" do - expect(error.exception_type).to eq("token_error") + it 'includes method_name in the message' do + expect(error.msg).to include('Genius::Auth.authorized?') end + end +end - context "when method_name is provided" do - subject(:error) { described_class.new(method_name: "Genius::Auth.authorized?") } +describe Genius::Errors::LyricsNotFoundError do + subject(:error) { described_class.new } - it "includes method_name in the message" do - expect(error.msg).to include("Genius::Auth.authorized?") - end - end + it 'has default message' do + expect(error.msg).to eq('Lyrics not found in current session. Retrying...') end - describe Genius::Errors::LyricsNotFoundError do - subject(:error) { described_class.new } + it 'has default exception type' do + expect(error.exception_type).to eq('invalid_lyrics') + end +end - it "has default message" do - expect(error.msg).to eq("Lyrics not found in current session. Retrying...") - end +describe Genius::Errors::PageNotFound do + subject(:error) { described_class.new } - it "has default exception type" do - expect(error.exception_type).to eq("invalid_lyrics") - end + it 'has default message' do + expect(error.msg).to eq('Page not found. Try again with another response') end - describe Genius::Errors::PageNotFound do - subject(:error) { described_class.new } + it 'has default exception type' do + expect(error.exception_type).to eq('page_not_found') + end - it "has default message" do - expect(error.msg).to eq("Page not found. Try again with another response") + describe '.page_not_found?' do + it "returns true if html contains 'Page not found'" do + html = Nokogiri::HTML('Page not found') + expect(described_class.page_not_found?(html)).to be true end - it "has default exception type" do - expect(error.exception_type).to eq("page_not_found") + it "returns false if html does not contain 'Page not found'" do + html = Nokogiri::HTML('OK') + expect(described_class.page_not_found?(html)).to be false end + end +end - describe ".page_not_found?" do - it "returns true if html contains 'Page not found'" do - html = Nokogiri::HTML("Page not found") - expect(described_class.page_not_found?(html)).to be true - end - - it "returns false if html does not contain 'Page not found'" do - html = Nokogiri::HTML("OK") - expect(described_class.page_not_found?(html)).to be false - end - end +describe Genius::Errors, ' .validate_token with nil token' do + it 'raises TokenError' do + expect { Genius::Errors.validate_token(nil) }.to raise_error(Genius::Errors::TokenError) end +end - describe ".validate_token" do - context "when token is nil" do - it "raises TokenError" do - expect { described_class.validate_token(nil) }.to raise_error(Genius::Errors::TokenError) - end - end +describe Genius::Errors, ' .validate_token with short token' do + let(:short_token) { 'short_token' } - context "when token size is not 64" do - it "raises TokenError" do - expect { described_class.validate_token(short_token) }.to raise_error(Genius::Errors::TokenError) - end - end + it 'raises TokenError' do + expect { Genius::Errors.validate_token(short_token) }.to raise_error(Genius::Errors::TokenError) + end +end - context "when token is valid and API responds with 200" do - let(:response_body) { { "meta" => { "status" => 200 } }.to_json } +describe Genius::Errors, ' .validate_token with valid token and 200 response' do + let(:valid_token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it "does not raise an error" do - expect { described_class.validate_token(valid_token) }.not_to raise_error - end - end + it 'does not raise an error' do + expect { Genius::Errors.validate_token(valid_token) }.not_to raise_error + end +end - context "when token is valid but API responds with non-200" do - let(:response_body) { { "meta" => { "status" => 401 } }.to_json } +describe Genius::Errors, ' .validate_token with valid token and non-200 response' do + let(:valid_token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 401 } }.to_json } - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it "raises TokenError" do - expect { described_class.validate_token(valid_token) }.to raise_error(Genius::Errors::TokenError) - end - end + it 'raises TokenError' do + expect { Genius::Errors.validate_token(valid_token) }.to raise_error(Genius::Errors::TokenError) end +end - describe ".error_handle" do - context "when token is nil" do - it "raises TokenError with a specific message" do - expect { described_class.error_handle(nil) }.to raise_error(Genius::Errors::TokenError) - end - end +describe Genius::Errors, ' .error_handle? with nil token' do + it 'raises TokenError with a specific message' do + expect { Genius::Errors.error_handle?(nil) }.to raise_error(Genius::Errors::TokenError) + end +end - context "when token size is not 64" do - it "raises TokenError" do - expect { described_class.error_handle(short_token) }.to raise_error(Genius::Errors::TokenError) - end - end +describe Genius::Errors, ' .error_handle? with short token' do + let(:short_token) { 'short_token' } - context "when token is valid" do - let(:response_body) { { "meta" => { "status" => 200 } }.to_json } + it 'raises TokenError' do + expect { Genius::Errors.error_handle?(short_token) }.to raise_error(Genius::Errors::TokenError) + end +end - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end +describe Genius::Errors, ' .error_handle? with valid token' do + let(:valid_token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - it "returns true" do - expect(described_class.error_handle(valid_token)).to be true - end - end + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'returns true' do + expect(Genius::Errors.error_handle?(valid_token)).to be true end end diff --git a/spec/api/referents_spec.rb b/spec/api/referents_spec.rb index e337760..4428a51 100644 --- a/spec/api/referents_spec.rb +++ b/spec/api/referents_spec.rb @@ -1,58 +1,62 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Referents do - let(:token) { "a" * 64 } +describe Genius::Referents, ' .referents with valid token' do + let(:token) { 'a' * 64 } let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { "meta" => { "status" => 200 }, "response" => { "referents" => [] } }.to_json } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'returns referents as a hash' do + result = described_class.referents(token: token) + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + + it 'sends a GET request to the referents endpoint' do + described_class.referents(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/referents?access_token=#{token}" + ) + end +end + +describe Genius::Referents, ' .referents with options' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'includes query parameters' do + described_class.referents(token: token, options: { per_page: 5, page: 2 }) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/referents?access_token=#{token}&per_page=5&page=2" + ) + end +end + +describe Genius::Referents, ' .referents with both web_page and song_id' do + let(:token) { 'a' * 64 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.referents(token: token, options: { web_page: 1, song_id: 2 }) + end.to raise_error(ArgumentError) + end +end + +describe Genius::Referents, ' .referents without token' do before do - allow(HTTParty).to receive(:get).and_return(mock_response) + allow(Genius::Auth).to receive(:authorized?).and_return(false) end - describe ".referents" do - context "when a valid token is provided" do - it "returns referents as a hash" do - result = described_class.referents(token: token) - expect(result).to be_a(Hash) - expect(result.dig("meta", "status")).to eq(200) - end - - it "sends a GET request to the referents endpoint" do - described_class.referents(token: token) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/referents?access_token=#{token}" - ) - end - end - - context "when options are provided" do - it "includes query parameters" do - described_class.referents(token: token, options: { per_page: 5, page: 2 }) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/referents?access_token=#{token}&per_page=5&page=2" - ) - end - end - - context "when both web_page and song_id are provided" do - it "raises ArgumentError" do - expect { - described_class.referents(token: token, options: { web_page: 1, song_id: 2 }) - }.to raise_error(ArgumentError) - end - end - - context "when token is nil and Auth is not authorized" do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end - - it "returns nil" do - expect(described_class.referents(token: nil)).to be_nil - end - end + it 'returns nil' do + expect(described_class.referents(token: nil)).to be_nil end end diff --git a/spec/api/search_spec.rb b/spec/api/search_spec.rb index 9de4950..8abcfdd 100644 --- a/spec/api/search_spec.rb +++ b/spec/api/search_spec.rb @@ -1,68 +1,106 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Search do - let(:token) { "a" * 64 } +describe Genius::Search, ' .search with valid token and query' do + let(:token) { 'a' * 64 } let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - describe ".search" do - let(:response_body) do - { - "meta" => { "status" => 200 }, - "response" => { - "hits" => [ - { "result" => { "title" => "Song A" } }, - { "result" => { "title" => "Song B" } } - ] - } - }.to_json - end + it 'returns search results as a hash' do + result = described_class.search(token: token, query: 'Ariana Grande') + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end + it 'sends a GET request to the search endpoint' do + described_class.search(token: token, query: 'Ariana Grande') + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/search?q=Ariana Grande&access_token=#{token}" + ) + end +end - context "when a valid token and query are provided" do - it "returns search results as a hash" do - result = described_class.search(token: token, query: "Ariana Grande") - expect(result).to be_a(Hash) - expect(result.dig("meta", "status")).to eq(200) - end +describe Genius::Search, ' .search with search_by' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end - it "sends a GET request to the search endpoint" do - described_class.search(token: token, query: "Ariana Grande") - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/search?q=Ariana Grande&access_token=#{token}" - ) - end - end + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - context "when search_by is provided" do - it "returns filtered results" do - result = described_class.search(token: token, query: "test", search_by: "title") - expect(result).to contain_exactly("Song A", "Song B") - end - end + it 'returns filtered results' do + result = described_class.search(token: token, query: 'test', search_by: 'title') + expect(result).to contain_exactly('Song A', 'Song B') + end +end - context "when token is nil and Auth is not authorized" do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end +describe Genius::Search, ' .search without token' do + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end + + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it 'returns nil' do + expect(described_class.search(token: nil, query: 'test')).to be_nil + end +end + +describe Genius::Search, ' .search with nil query' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end - it "returns nil" do - expect(described_class.search(token: nil, query: "test")).to be_nil - end - end + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - context "when query is nil" do - it "sends a request with nil query" do - described_class.search(token: token, query: nil) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/search?q=&access_token=#{token}" - ) - end - end + it 'sends a request with nil query' do + described_class.search(token: token, query: nil) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/search?q=&access_token=#{token}" + ) end end diff --git a/spec/api/songs_spec.rb b/spec/api/songs_spec.rb index d3877fc..7e912b6 100644 --- a/spec/api/songs_spec.rb +++ b/spec/api/songs_spec.rb @@ -1,105 +1,95 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::Songs do - let(:token) { "a" * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } +describe Genius::Songs, ' .songs with valid data' do + let(:token) { 'a' * 64 } let(:song_id) { 294_649 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'song' => { 'id' => song_id, 'title' => 'Test Song' } } + }.to_json + end - describe ".songs" do - let(:response_body) do - { - "meta" => { "status" => 200 }, - "response" => { "song" => { "id" => song_id, "title" => "Test Song" } } - }.to_json - end - - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end - - context "when a valid token and song_id are provided" do - it "returns song data as a hash" do - result = described_class.songs(token: token, song_id: song_id) - expect(result).to be_a(Hash) - expect(result.dig("response", "song", "id")).to eq(song_id) - end + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it "sends a GET request to the songs endpoint" do - described_class.songs(token: token, song_id: song_id) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/songs/#{song_id}?access_token=#{token}" - ) - end - end + it 'returns song data as a hash' do + result = described_class.songs(token: token, song_id: song_id) + expect(result).to be_a(Hash) + expect(result.dig('response', 'song', 'id')).to eq(song_id) + end - context "when combine is true" do - let(:response_body) do - { - "meta" => { "status" => 200 }, - "response" => { "song" => { "id" => song_id } } - }.to_json - end - let(:scripts) do - (0..16).map { |i| "" } + - [""] - end - let(:html_body) do - "#{scripts.join}" - end + it 'sends a GET request to the songs endpoint' do + described_class.songs(token: token, song_id: song_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/songs/#{song_id}?access_token=#{token}" + ) + end +end - before do - allow(HTTParty).to receive(:get).with("https://genius.com/songs/#{song_id}") - .and_return(html_body) - end +describe Genius::Songs, ' .songs with combine true' do + let(:token) { 'a' * 64 } + let(:song_id) { 294_649 } + let(:mock_response) { instance_double(HTTParty::Response, body: { 'meta' => { 'status' => 200 }, 'response' => { 'song' => { 'id' => song_id } } }.to_json) } + let(:scripts) do + (0..16).map { |i| "" } + + [""] + end + let(:html_body) { "#{scripts.join}" } + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:get).with("https://api.genius.com/songs/#{song_id}?access_token=#{token}") + .and_return(mock_response) + allow(HTTParty).to receive(:get).with("https://genius.com/songs/#{song_id}") + .and_return(html_body) + end + it 'fetches lyrics page and includes lyrics in the response' do + result = described_class.songs(token: token, song_id: song_id, combine: true) + expect(result).to be_a(Hash) + expect(result).to have_key('lyrics') + expect(result['lyrics']).to have_key('lyrics') + end +end - it "fetches lyrics page and includes lyrics in the response" do - result = described_class.songs(token: token, song_id: song_id, combine: true) - expect(result).to be_a(Hash) - expect(result).to have_key("lyrics") - expect(result["lyrics"]).to have_key("lyrics") - end - end +describe Genius::Songs, ' .songs without token' do + let(:song_id) { 294_649 } - context "when token is nil and Auth is not authorized" do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end - it "returns nil" do - expect(described_class.songs(token: nil, song_id: song_id)).to be_nil - end - end + it 'returns nil' do + expect(described_class.songs(token: nil, song_id: song_id)).to be_nil end +end - describe ".get_lyrics" do - let(:html_body) do - <<~HTML - - -
-

Test lyrics content

-
- - - HTML - end +describe Genius::Songs, ' .get_lyrics' do + let(:song_id) { 294_649 } + let(:html_body) do + <<~HTML + + +
+

Test lyrics content

+
+ + + HTML + end - before do - allow(HTTParty).to receive(:get).and_return(html_body) - end + before { allow(HTTParty).to receive(:get).and_return(html_body) } - it "extracts lyrics from the page" do - result = described_class.get_lyrics(song_id) - expect(result).to eq("Test lyrics content") - end + it 'extracts lyrics from the page' do + result = described_class.get_lyrics(song_id) + expect(result).to eq('Test lyrics content') + end +end - context "when song_id is nil" do - it "raises ArgumentError" do - expect { described_class.get_lyrics(nil) }.to raise_error(ArgumentError) - end - end +describe Genius::Songs, ' .get_lyrics with nil song_id' do + it 'raises ArgumentError' do + expect { described_class.get_lyrics(nil) }.to raise_error(ArgumentError) end end diff --git a/spec/api/web_pages_spec.rb b/spec/api/web_pages_spec.rb index 0da9b4f..208d98d 100644 --- a/spec/api/web_pages_spec.rb +++ b/spec/api/web_pages_spec.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -require "rspec" -require "genius/api" +require 'rspec' +require 'genius/api' -describe Genius::WebPages do - let(:token) { "a" * 64 } +describe Genius::WebPages, ' .lookup with valid token' do + let(:token) { 'a' * 64 } let(:response_body) do { - "meta" => { "status" => 200 }, - "response" => { "web_page" => { "id" => 1, "url" => "https://example.com" } } + 'meta' => { 'status' => 200 }, + 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } }.to_json end @@ -17,49 +17,51 @@ allow(HTTParty).to receive(:get).and_return(response_body) end - describe ".lookup" do - context "when a valid token is provided" do - it "returns web page data as a hash" do - result = described_class.lookup(token: token) - expect(result).to be_a(Hash) - expect(result.dig("meta", "status")).to eq(200) - end + it 'returns web page data as a hash' do + result = described_class.lookup(token: token) + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end - it "sends a GET request to the API root" do - described_class.lookup(token: token) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/?access_token=#{token}" - ) - end - end + it 'sends a GET request to the API root' do + described_class.lookup(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/?access_token=#{token}" + ) + end +end - context "when options with URL variants are provided" do - it "includes query parameters" do - described_class.lookup( - token: token, - options: { - raw_annotatable_url: "https://example.com/page", - canonical_url: "https://example.com/canonical", - og_url: "https://example.com/og" - } - ) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/?access_token=#{token}" \ - "&raw_annotatable_url=https://example.com/page" \ - "&canonical_url=https://example.com/canonical" \ - "&og_url=https://example.com/og" - ) - end - end +describe Genius::WebPages, ' .lookup with URL variants' do + let(:token) { 'a' * 64 } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } }.to_json } + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:get).and_return(response_body) + end + it 'includes query parameters' do + described_class.lookup( + token: token, + options: { + raw_annotatable_url: 'https://example.com/page', + canonical_url: 'https://example.com/canonical', + og_url: 'https://example.com/og' + } + ) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/?access_token=#{token}" \ + '&raw_annotatable_url=https://example.com/page' \ + '&canonical_url=https://example.com/canonical' \ + '&og_url=https://example.com/og' + ) + end +end - context "when token is nil and Auth is not authorized" do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end +describe Genius::WebPages, ' .lookup without token' do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end - it "returns nil" do - expect(described_class.lookup(token: nil)).to be_nil - end - end + it 'returns nil' do + expect(described_class.lookup(token: nil)).to be_nil end end diff --git a/spec/extensions/hash_spec.rb b/spec/extensions/hash_spec.rb index 86041f4..8b60089 100644 --- a/spec/extensions/hash_spec.rb +++ b/spec/extensions/hash_spec.rb @@ -1,45 +1,83 @@ # frozen_string_literal: true -require "rspec" -require "extensions/extensions" - -describe Hash do - describe "#deep_find" do - let(:musicians) do - { "Travis Scott" => { "28" => ["Highest in the Room", "Franchise"] }, - "Adele" => { "19" => ["Day Dreamer", "Best for Last"] }, - "Ed Sheeran" => { "28" => ["Shape of You", "Castle on the Hill"] } } - end - let(:new_hash) do - { "a" => "b", "c" => { "a" => "b" } } - end - - context "when the option uniq is set to true" do - it "returns an array of unique values for the given key" do - expect(musicians.deep_find("19", uniq: true)).to eq(["Day Dreamer", "Best for Last"]) - expect(musicians.deep_find("28", - uniq: true)).to eq([["Highest in the Room", "Franchise"], - ["Shape of You", "Castle on the Hill"]]) - expect(new_hash.deep_find("a", uniq: true)).to eq("b") - end - end - - context "when the option uniq is set to false" do - it "returns an array of all values for the given key, including duplicates" do - expect(musicians.deep_find("19", uniq: false)).to eq(["Day Dreamer", "Best for Last"]) - expect(musicians.deep_find("28", - uniq: false)).to eq([["Highest in the Room", "Franchise"], - ["Shape of You", "Castle on the Hill"]]) - expect(new_hash.deep_find("a", uniq: false)).to eq(%w[b b]) - end - end - - it "returns the value of the given key if it exists in the top level of the hash" do - expect(musicians.deep_find("Adele")).to eq({ "19" => ["Day Dreamer", "Best for Last"] }) - end - - it "returns nil if the given key does not exist in the hash" do - expect(musicians.deep_find("30")).to eq [] - end +require 'rspec' +require 'extensions/deep_find' + +describe Hash, '#deep_find basic lookup' do + subject(:hash) do + { + key1: 'value1', + key2: { + nested_key: 'nested_value', + key3: { + deep_key: 'deep_value' + } + } + } + end + + it 'finds a value at the top level' do + expect(hash.deep_find(:key1)).to eq('value1') + end + + it 'finds a value at a nested level' do + expect(hash.deep_find(:nested_key)).to eq('nested_value') + end + + it 'finds a value at a deep level' do + expect(hash.deep_find(:deep_key)).to eq('deep_value') + end + + it 'returns nil for missing keys' do + expect(hash.deep_find(:nonexistent)).to be_nil + end +end + +describe Hash, '#deep_find with array containing hashes' do + subject(:hash) do + { + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' } + ] + } + end + + it 'finds values inside arrays of hashes' do + expect(hash.deep_find(:name)).to contain_exactly('foo', 'bar') + end +end + +describe Hash, '#deep_find with nil values' do + it 'returns nil' do + expect({ a: nil }.deep_find(:a)).to be_nil + end +end + +describe Hash, '#deep_find with array' do + subject(:hash) { { key: [{ nested: 'value' }] } } + + it 'returns match for nil target' do + expect(hash.deep_find(:nested)).to eq('value') + end +end + +describe Hash, '#deep_find with integer key' do + subject(:hash) { { 1 => 'one', 2 => 'two' } } + + it 'finds integer keys' do + expect(hash.deep_find(1)).to eq('one') + end +end + +describe Hash, '#deep_find with empty hash' do + it 'returns nil' do + expect({}.deep_find(:anything)).to be_nil + end +end + +describe Hash, '#deep_find with empty array' do + it 'returns nil' do + expect({ items: [] }.deep_find(:anything)).to be_nil end end diff --git a/spec/extensions/options_helper_spec.rb b/spec/extensions/options_helper_spec.rb index 747f296..7866bbb 100644 --- a/spec/extensions/options_helper_spec.rb +++ b/spec/extensions/options_helper_spec.rb @@ -1,30 +1,30 @@ # frozen_string_literal: true -require "rspec" -require "extensions/extensions" +require 'rspec' +require 'extensions/extensions' describe Object do - describe "#options_helper" do + describe '#options_helper' do let(:allowed_keys) { %i[sort per_page page] } - context "when options contain allowed keys" do - it "builds a query string from allowed options" do - result = subject.options_helper({ sort: "title", per_page: 10, page: 1 }, allowed_keys) - expect(result).to eq("&sort=title&per_page=10&page=1") + context 'when options contain allowed keys' do + it 'builds a query string from allowed options' do + result = subject.options_helper({ sort: 'title', per_page: 10, page: 1 }, allowed_keys) + expect(result).to eq('&sort=title&per_page=10&page=1') end end - context "when options contain disallowed keys" do - it "ignores disallowed keys" do - result = subject.options_helper({ sort: "title", foo: "bar" }, allowed_keys) - expect(result).to eq("&sort=title") + context 'when options contain disallowed keys' do + it 'ignores disallowed keys' do + result = subject.options_helper({ sort: 'title', foo: 'bar' }, allowed_keys) + expect(result).to eq('&sort=title') end end - context "when options is empty" do - it "returns an empty string" do + context 'when options is empty' do + it 'returns an empty string' do result = subject.options_helper({}, allowed_keys) - expect(result).to eq("") + expect(result).to eq('') end end end diff --git a/spec/extensions/token_ext_spec.rb b/spec/extensions/token_ext_spec.rb index 17f52d3..5793c64 100644 --- a/spec/extensions/token_ext_spec.rb +++ b/spec/extensions/token_ext_spec.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true -require "rspec" -require "extensions/extensions" +require 'rspec' +require 'extensions/extensions' describe Object do - describe "#token_ext" do - let(:token) { "a" * 64 } + describe '#token_ext' do + let(:token) { 'a' * 64 } - context "when a token is provided" do - it "returns the token" do + context 'when a token is provided' do + it 'returns the token' do expect(subject.token_ext(token)).to eq(token) end end - context "when token is nil and Genius::Auth has an instance variable @token" do + context 'when token is nil and Genius::Auth has an instance variable @token' do before do Genius::Auth.instance_variable_set(:@token, token) end @@ -22,7 +22,7 @@ Genius::Auth.instance_variable_set(:@token, nil) end - it "returns the stored token" do + it 'returns the stored token' do expect(subject.token_ext(nil)).to eq(token) end end diff --git a/spec/extensions/unescape_spec.rb b/spec/extensions/unescape_spec.rb index 7a65c08..2bda3a7 100644 --- a/spec/extensions/unescape_spec.rb +++ b/spec/extensions/unescape_spec.rb @@ -1,18 +1,18 @@ # frozen_string_literal: true -require "rspec" -require "extensions/extensions" +require 'rspec' +require 'extensions/extensions' describe String do - describe "#unescape" do - it "unescapes JSON-escaped double quotes" do + describe '#unescape' do + it 'unescapes JSON-escaped double quotes' do str = 'test \\"string\\"' expect(str.unescape).to eq('test "string"') end - it "handles strings without escaped characters" do - str = "hello world" - expect(str.unescape).to eq("hello world") + it 'handles strings without escaped characters' do + str = 'hello world' + expect(str.unescape).to eq('hello world') end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 398d9b0..68b44f9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -$LOAD_PATH << "./genius" +$LOAD_PATH << './genius' # This file was generated by the `rspec --init` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. # The generated `.rspec` file contains `--require spec_helper` which will cause From 91135b8d9c848e5bf9c6aca1a5093da1d09d3581 Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:41:33 +0300 Subject: [PATCH 3/7] [0.3.0] Added RBS template files (#5) --- .github/workflows/check-source-branch.yml | 11 +- .github/workflows/ci.yml | 24 +- .gitignore | 1 + .rubocop.yml | 16 +- .ruby-version | 2 +- Gemfile | 2 + Gemfile.lock | 101 +++++- Steepfile | 13 + genius-api.gemspec | 10 +- lib/genius/api/account.rb | 2 +- lib/genius/api/annotations.rb | 2 +- lib/genius/api/artists.rb | 2 +- lib/genius/api/authorization.rb | 2 +- lib/genius/api/errors.rb | 8 +- lib/genius/api/referents.rb | 2 +- lib/genius/api/search.rb | 2 +- lib/genius/api/songs.rb | 12 +- lib/genius/api/web_pages.rb | 4 +- rbs_collection.lock.yaml | 232 ++++++++++++++ rbs_collection.yaml | 14 + sig/lib/extensions/deep_find.rbs | 30 ++ sig/lib/extensions/options_helper.rbs | 3 + sig/lib/extensions/token_ext.rbs | 3 + sig/lib/extensions/unescape.rbs | 3 + sig/lib/genius/api.rbs | 9 + sig/lib/genius/api/account.rbs | 23 ++ sig/lib/genius/api/annotations.rbs | 13 + sig/lib/genius/api/artists.rbs | 15 + sig/lib/genius/api/authorization.rbs | 11 + sig/lib/genius/api/errors.rbs | 46 +++ sig/lib/genius/api/referents.rbs | 7 + sig/lib/genius/api/search.rbs | 5 + sig/lib/genius/api/songs.rbs | 15 + sig/lib/genius/api/version.rbs | 5 + sig/lib/genius/api/web_pages.rbs | 5 + spec/api/account_spec.rb | 89 +++--- spec/api/annotations_spec.rb | 372 +++++++++++----------- spec/api/artists_spec.rb | 265 +++++++-------- spec/api/authorization_spec.rb | 53 +-- spec/api/errors_spec.rb | 183 +++++------ spec/api/referents_spec.rb | 95 +++--- spec/api/search_spec.rb | 181 ++++++----- spec/api/songs_spec.rb | 163 +++++----- spec/api/web_pages_spec.rb | 120 +++---- spec/extensions/hash_spec.rb | 129 ++++---- spec/extensions/options_helper_spec.rb | 9 +- spec/extensions/token_ext_spec.rb | 7 +- 47 files changed, 1472 insertions(+), 849 deletions(-) create mode 100644 Steepfile create mode 100644 rbs_collection.lock.yaml create mode 100644 rbs_collection.yaml create mode 100644 sig/lib/extensions/deep_find.rbs create mode 100644 sig/lib/extensions/options_helper.rbs create mode 100644 sig/lib/extensions/token_ext.rbs create mode 100644 sig/lib/extensions/unescape.rbs create mode 100644 sig/lib/genius/api.rbs create mode 100644 sig/lib/genius/api/account.rbs create mode 100644 sig/lib/genius/api/annotations.rbs create mode 100644 sig/lib/genius/api/artists.rbs create mode 100644 sig/lib/genius/api/authorization.rbs create mode 100644 sig/lib/genius/api/errors.rbs create mode 100644 sig/lib/genius/api/referents.rbs create mode 100644 sig/lib/genius/api/search.rbs create mode 100644 sig/lib/genius/api/songs.rbs create mode 100644 sig/lib/genius/api/version.rbs create mode 100644 sig/lib/genius/api/web_pages.rbs diff --git a/.github/workflows/check-source-branch.yml b/.github/workflows/check-source-branch.yml index c8a4fc2..1369b96 100644 --- a/.github/workflows/check-source-branch.yml +++ b/.github/workflows/check-source-branch.yml @@ -1,13 +1,8 @@ name: Check source branch on: pull_request: - branches: [ master ] + branches: [master] + jobs: check: - runs-on: ubuntu-latest - steps: - - run: | - if [[ ! "${{ github.head_ref }}" =~ ^v ]]; then - echo "PRs to master allowed only from v* branches" - exit 1 - fi + uses: FlorexLabs/shared-workflows/.github/workflows/check-source-branch.yml@master diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26c1818..792209f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,9 @@ name: CI on: push: - branches: [ master ] + branches: [master, v*] pull_request: + branches: [master, v*] jobs: test: @@ -11,19 +12,28 @@ jobs: strategy: fail-fast: false matrix: - ruby: [ 3.0, 3.1, 3.2, 3.3, 3.4, 4.0 ] + ruby: ['3.2', '3.3', '3.4', '4.0'] steps: - uses: actions/checkout@v4 - - name: Set up Ruby ${{ matrix.ruby }} + + - name: Install Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Install RBS collection + run: bundle exec rbs collection install - - name: Bundle install with compatible nokogiri - run: | - gem install bundler - bundle install || (bundle update nokogiri --conservative && bundle install) + - name: RuboCop + run: bundle exec rubocop - name: RSpec run: bundle exec rspec + + - name: RBS validate + run: bundle exec rbs validate + + - name: Steep check + run: bundle exec steep check diff --git a/.gitignore b/.gitignore index ec19925..49d936f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ rakelib .env Rakefile +.gem_rbs_collection/ diff --git a/.rubocop.yml b/.rubocop.yml index 5df708e..4cf369c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,20 @@ +plugins: + - rubocop-performance + - rubocop-rspec + - rubocop-sorted_methods_by_call + - rubocop-rake + AllCops: - TargetRubyVersion: 3.0 + TargetRubyVersion: 3.2 NewCops: enable +Performance/BindCall: + Enabled: false + Gemspec/DevelopmentDependencies: EnforcedStyle: gemspec + +RSpec/SpecFilePathFormat: + Exclude: + - spec/api/* + - spec/extensions/* diff --git a/.ruby-version b/.ruby-version index 2451c27..17ce918 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.7 +3.2.11 diff --git a/Gemfile b/Gemfile index 7f4f5e9..fc9495f 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,5 @@ source 'https://rubygems.org' gemspec + +gem 'parallel', '< 2.0' # 2.0+ requires Ruby >= 3.3 diff --git a/Gemfile.lock b/Gemfile.lock index 241d6b4..0e2a24d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,10 +9,21 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.3) + bigdecimal (4.1.2) coderay (1.1.3) + concurrent-ruby (1.3.6) csv (3.3.5) diff-lcs (1.6.2) dotenv (2.7.6) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fileutils (1.8.0) httparty (0.24.2) csv mini_mime (>= 1.0.0) @@ -20,23 +31,29 @@ GEM json (2.19.9) language_server-protocol (3.17.0.5) lint_roller (1.1.0) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) mini_mime (1.1.5) - mini_portile2 (2.8.9) - multi_xml (0.6.0) - nokogiri (1.17.2) - mini_portile2 (~> 2.8.2) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.17.2-aarch64-linux) + nokogiri (1.19.3-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.17.2-arm-linux) + nokogiri (1.19.3-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.17.2-arm64-darwin) + nokogiri (1.19.3-arm-linux-musl) racc (~> 1.4) - nokogiri (1.17.2-x86-linux) + nokogiri (1.19.3-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.2-x86_64-darwin) + nokogiri (1.19.3-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.2-x86_64-linux) + nokogiri (1.19.3-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) parallel (1.28.0) parser (3.3.11.1) @@ -46,6 +63,13 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.4.2) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rbs (4.0.2) + logger + prism (>= 1.6.0) + tsort regexp_parser (2.12.0) rspec (3.13.2) rspec-core (~> 3.13.0) @@ -74,28 +98,73 @@ GEM rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rake (0.7.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + rubocop-sorted_methods_by_call (1.2.2) + lint_roller + rubocop (>= 1.72.0) ruby-progressbar (1.13.0) + securerandom (0.4.1) + steep (2.0.0) + concurrent-ruby (>= 1.1.10) + csv (>= 3.0.9) + fileutils (>= 1.1.0) + json (>= 2.1.0) + language_server-protocol (>= 3.17.0.4, < 4.0) + listen (~> 3.0) + logger (>= 1.3.0) + parser (>= 3.2) + prism (>= 0.25.0) + rainbow (>= 2.2.2, < 4.0) + rbs (~> 4.0) + securerandom (>= 0.1) + strscan (>= 1.0.0) + terminal-table (>= 2, < 5) + uri (>= 0.12.0) + strscan (3.1.8) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + tsort (0.2.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) + uri (1.1.1) yard (0.9.44) PLATFORMS - aarch64-linux - arm-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl arm64-darwin - x86-linux x86_64-darwin - x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES coderay dotenv (~> 2.7.6) genius-api! + parallel (< 2.0) rake (~> 13.0) + rbs rspec (~> 3.4) - rubocop (~> 1.7) + rubocop + rubocop-performance + rubocop-rake + rubocop-rspec + rubocop-sorted_methods_by_call + steep yard BUNDLED WITH - 4.0.14 + 4.0.14 diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..ee037f0 --- /dev/null +++ b/Steepfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +D = Steep::Diagnostic + +target :lib do + signature 'sig' + check 'lib' + ignore 'lib/extensions/extensions.rb' + + configure_code_diagnostics do |config| + config[D::Ruby::NoMethod] = :information + end +end diff --git a/genius-api.gemspec b/genius-api.gemspec index 3dacfb9..69f8a3e 100644 --- a/genius-api.gemspec +++ b/genius-api.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |spec| spec.description = 'Library to work with Genius API, written in Ruby' spec.homepage = 'https://github.com/unurgunite/genius-api' spec.license = 'GPL-3.0' - spec.required_ruby_version = Gem::Requirement.new('>= 3.0') + spec.required_ruby_version = Gem::Requirement.new('>= 3.2') spec.metadata = { 'allowed_push_host' => 'https://example.com', @@ -35,7 +35,13 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'coderay' spec.add_development_dependency 'dotenv', '~> 2.7.6' spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rbs' spec.add_development_dependency 'rspec', '~> 3.4' - spec.add_development_dependency 'rubocop', '~> 1.7' + spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'rubocop-performance' + spec.add_development_dependency 'rubocop-rake' + spec.add_development_dependency 'rubocop-rspec' + spec.add_development_dependency 'rubocop-sorted_methods_by_call' + spec.add_development_dependency 'steep' spec.add_development_dependency 'yard' end diff --git a/lib/genius/api/account.rb b/lib/genius/api/account.rb index 1a0a447..3424115 100644 --- a/lib/genius/api/account.rb +++ b/lib/genius/api/account.rb @@ -30,7 +30,7 @@ def account(token: nil) alias me account - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/annotations.rb b/lib/genius/api/annotations.rb index 1145c17..ffb9bba 100644 --- a/lib/genius/api/annotations.rb +++ b/lib/genius/api/annotations.rb @@ -214,7 +214,7 @@ def post_payload(options: {}) }.to_json end - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/artists.rb b/lib/genius/api/artists.rb index c822d14..77bd551 100644 --- a/lib/genius/api/artists.rb +++ b/lib/genius/api/artists.rb @@ -94,7 +94,7 @@ def validate_page_per_page(page_per_page) raise ArgumentError, "`per_page` or `page` can't be negative." if page_per_page&.negative? end - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/authorization.rb b/lib/genius/api/authorization.rb index 17e1090..81cd066 100644 --- a/lib/genius/api/authorization.rb +++ b/lib/genius/api/authorization.rb @@ -53,7 +53,7 @@ def logout! alias login= token= - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/errors.rb b/lib/genius/api/errors.rb index 122a42d..84aec05 100644 --- a/lib/genius/api/errors.rb +++ b/lib/genius/api/errors.rb @@ -112,7 +112,7 @@ def initialize(msg: 'Page not found. Try again with another response', exception # @return [TrueClass] if genius page is not found # @return [FalseClass] if genius page is found def self.page_not_found?(html) - html.text.match?(/Page not found/) + html.text.include?('Page not found') end end @@ -143,13 +143,13 @@ def rescue(klass) # @param [Object] exception Exception class. # @param [Proc] handler Body of rescue block. # @return [Object] - def rescue_from(meths, klass, exception, &handler) + def rescue_from(meths, klass, exception, &) meths.each do |meth| old = klass.singleton_method(meth) klass.define_singleton_method(meth) do |*args, **kwargs| - old.unbind.bind(klass).call(*args, **kwargs) + old.unbind.bind(klass).call(*args, **kwargs) # steep:ignore rescue exception => e - handler.call(e) + yield(e) end end end diff --git a/lib/genius/api/referents.rb b/lib/genius/api/referents.rb index 030af3f..1130983 100644 --- a/lib/genius/api/referents.rb +++ b/lib/genius/api/referents.rb @@ -48,7 +48,7 @@ def referents(token: nil, options: {}) JSON.parse(response) end - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/search.rb b/lib/genius/api/search.rb index 5360efa..621b180 100644 --- a/lib/genius/api/search.rb +++ b/lib/genius/api/search.rb @@ -44,7 +44,7 @@ def search(token: nil, query: nil, search_by: nil) search_by ? search.deep_find(search_by) : search end - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/songs.rb b/lib/genius/api/songs.rb index 8dd653e..00ba5fa 100644 --- a/lib/genius/api/songs.rb +++ b/lib/genius/api/songs.rb @@ -31,27 +31,27 @@ def songs(token: nil, song_id: nil, combine: false) response = HTTParty.get("#{Api::RESOURCE}/songs/#{song_id}?access_token=#{token_ext(token)}").body response = JSON.parse response - combine ? merge_lyrics(song_id, response) : response + combine && song_id ? merge_lyrics(song_id, response) : response end private def merge_lyrics(song_id, response) output_html = Nokogiri::HTML(HTTParty.get("https://genius.com/songs/#{song_id}")) - raise PageNotFound if PageNotFound.page_not_found?(output_html) + raise Errors::PageNotFound if Errors::PageNotFound.page_not_found?(output_html) response['lyrics'] = parse_preloaded_state(output_html) response - rescue LyricsNotFoundError + rescue Errors::LyricsNotFoundError retry - rescue PageNotFound => e + rescue Errors::PageNotFound => e "Error description: #{e.msg}\nException type: #{e.exception_type}" end def parse_preloaded_state(output_html) unformed_json = output_html.css('script')[17] .text.match(/window\.__PRELOADED_STATE__\s=\sJSON.parse\('(?(?:.+?))'\);/) - raise LyricsNotFoundError if unformed_json.nil? + raise Errors::LyricsNotFoundError if unformed_json.nil? JSON.parse(unformed_json[:json].unescape) end @@ -78,7 +78,7 @@ def get_lyrics(song_id) retry end - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/lib/genius/api/web_pages.rb b/lib/genius/api/web_pages.rb index 65bd56e..33df173 100644 --- a/lib/genius/api/web_pages.rb +++ b/lib/genius/api/web_pages.rb @@ -32,11 +32,11 @@ def lookup(token: nil, options: {}) Errors.validate_token(token) unless token.nil? params = options_helper(options, %i[raw_annotatable_url canonical_url og_url]) - response = HTTParty.get("#{Api::RESOURCE}/?access_token=#{token_ext(token)}#{params}") + response = HTTParty.get("#{Api::RESOURCE}/?access_token=#{token_ext(token)}#{params}").body JSON.parse(response) end - Genius::Errors::DynamicRescue.rescue(const_get(Module.nesting[1].name)) + Genius::Errors::DynamicRescue.rescue(Module.nesting[1]) end end end diff --git a/rbs_collection.lock.yaml b/rbs_collection.lock.yaml new file mode 100644 index 0000000..d282d3f --- /dev/null +++ b/rbs_collection.lock.yaml @@ -0,0 +1,232 @@ +--- +path: ".gem_rbs_collection" +gems: +- name: ast + version: '2.4' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: bigdecimal + version: 4.1.2 + source: + type: rubygems +- name: concurrent-ruby + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: csv + version: '3.3' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: diff-lcs + version: '1.5' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: ffi + version: 1.17.4 + source: + type: rubygems +- name: fileutils + version: '0' + source: + type: stdlib +- name: forwardable + version: '0' + source: + type: stdlib +- name: httparty + version: '0.18' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: json + version: '0' + source: + type: stdlib +- name: lint_roller + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: listen + version: '3.9' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: logger + version: '1.7' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: mini_mime + version: '0.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: monitor + version: '0' + source: + type: stdlib +- name: multi_xml + version: 0.9.1 + source: + type: rubygems +- name: net-http + version: '0' + source: + type: stdlib +- name: net-protocol + version: '0' + source: + type: stdlib +- name: nokogiri + version: '1.11' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: optparse + version: '0' + source: + type: stdlib +- name: parallel + version: '1.20' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: parser + version: '3.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: prism + version: 1.9.0 + source: + type: rubygems +- name: rainbow + version: '3.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rake + version: '13.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: random-formatter + version: '0' + source: + type: stdlib +- name: rbs + version: 4.0.2 + source: + type: rubygems +- name: rdoc + version: '0' + source: + type: stdlib +- name: regexp_parser + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: ripper + version: '0' + source: + type: stdlib +- name: rubocop + version: '1.57' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rubocop-ast + version: '1.46' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: securerandom + version: '0' + source: + type: stdlib +- name: stringio + version: '0' + source: + type: stdlib +- name: strscan + version: '0' + source: + type: stdlib +- name: timeout + version: '0' + source: + type: stdlib +- name: tsort + version: '0' + source: + type: stdlib +- name: uri + version: '0' + source: + type: stdlib +- name: yard + version: '0.9' + source: + type: git + name: ruby/gem_rbs_collection + revision: 3f5e8df1ce89ea06067fa42263012c968b4e583e + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +gemfile_lock_path: Gemfile.lock diff --git a/rbs_collection.yaml b/rbs_collection.yaml new file mode 100644 index 0000000..f8c7ed5 --- /dev/null +++ b/rbs_collection.yaml @@ -0,0 +1,14 @@ +# Download sources +sources: + - type: git + name: ruby/gem_rbs_collection + remote: https://github.com/ruby/gem_rbs_collection.git + revision: main + repo_dir: gems + +# A directory to install the downloaded RBSs +path: .gem_rbs_collection + +gems: + - name: activesupport + ignore: true diff --git a/sig/lib/extensions/deep_find.rbs b/sig/lib/extensions/deep_find.rbs new file mode 100644 index 0000000..1fcdc6c --- /dev/null +++ b/sig/lib/extensions/deep_find.rbs @@ -0,0 +1,30 @@ +class Hash[unchecked out K, unchecked out V] + # +Hash#deep_find+ -> value + # + # @param [Object] key A key, which value should be found + # @param [FalseClass] uniq A flag to make values unique in an array + # @return [Object] output depends on key value + # This method is an extension for Hash core class to search for a value of a key in N-nested + # hash. It provides search for multiple values if key appears more than once. For e.g.: + # + # @example + # musicians = { "Travis Scott" => { "28" => ["Highest in the Room", "Franchise"] }, + # "Adele" => { "19" => ["Day Dreamer", "Best for Last"] }, + # "Ed Sheeran" => { "28" => ["Shape of You", "Castle on the Hill"] } } + # musicians.deep_find("19") #=> ["Day Dreamer", "Best for Last"] + # musicians.deep_find("Adele") #=> {"19"=>["Day Dreamer", "Best for Last"]} + # musicians.deep_find("28") #=> [["Highest in the Room", "Franchise"], ["Shape of You", "Castle on the Hill"]] + # + # If values are identical, they will be returned in a single copy. You can disable this + # feature with special param +uniq+, which is +true+ by default. For e.g.: + # + # @example + # h = {"a" => "b", "c" => {"a" => "b"}} + # h.deep_find("a") #=> "b", instead ["b", "b"] + # @todo change uniq true to uniq false + def deep_find: (untyped key, ?uniq: bool) -> (nil | untyped) + + private + + def collect_values: (untyped key) -> Array[untyped] +end diff --git a/sig/lib/extensions/options_helper.rbs b/sig/lib/extensions/options_helper.rbs new file mode 100644 index 0000000..9591356 --- /dev/null +++ b/sig/lib/extensions/options_helper.rbs @@ -0,0 +1,3 @@ +class Object + def options_helper: (Hash[Symbol, untyped] options, Array[Symbol] arry) -> String +end diff --git a/sig/lib/extensions/token_ext.rbs b/sig/lib/extensions/token_ext.rbs new file mode 100644 index 0000000..0f26285 --- /dev/null +++ b/sig/lib/extensions/token_ext.rbs @@ -0,0 +1,3 @@ +class Object + def token_ext: (String? token) -> String +end diff --git a/sig/lib/extensions/unescape.rbs b/sig/lib/extensions/unescape.rbs new file mode 100644 index 0000000..1f91e2b --- /dev/null +++ b/sig/lib/extensions/unescape.rbs @@ -0,0 +1,3 @@ +class String + def unescape: -> String +end diff --git a/sig/lib/genius/api.rbs b/sig/lib/genius/api.rbs new file mode 100644 index 0000000..3935ac4 --- /dev/null +++ b/sig/lib/genius/api.rbs @@ -0,0 +1,9 @@ +# Base module which contains all of other methods/classes/etc. +module Genius + # +Genius::Api+ is a base module with different constants. + module Api + # +Genius::Api::RESOURCE+ constant contains reference to + # {Genius API}[https://api.genius.com] resource. + RESOURCE: "https://api.genius.com" + end +end diff --git a/sig/lib/genius/api/account.rbs b/sig/lib/genius/api/account.rbs new file mode 100644 index 0000000..23bd23d --- /dev/null +++ b/sig/lib/genius/api/account.rbs @@ -0,0 +1,23 @@ +module Genius + # +Genius::Account+ module provides methods to work with Genius account + module Account + # +Genius::Account.account+ -> value + # + # An alias to {Genius::Account.account me} method + # + # @param [String] token Token to access https://api.genius.com. + # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. + # @return [Hash] + # @return [NilClass] if TokenError exception raised. + # This method is a standard Genius API {request}[https://docs.genius.com/#search-h2] to get + # account info. Output +JSON+ is translated to Hash structure to make it easy to work with account fields. + # + # @example + # Genius::Auth.login="yuiaYqbncErCVwItjQxFspNWUZLhGpXrPbkvgbgHSEKJRAlToamzMfdOeDB" + # Genius::Account.account #=> {"meta"=>{"status"=>200}, "response"=>{"user"=>{...}}} + # @todo somehow refactor 50/52 exceptions + def self.account: (?token: String?) -> (Hash[String, untyped] | nil) + + alias self.me self.account + end +end diff --git a/sig/lib/genius/api/annotations.rbs b/sig/lib/genius/api/annotations.rbs new file mode 100644 index 0000000..0dced45 --- /dev/null +++ b/sig/lib/genius/api/annotations.rbs @@ -0,0 +1,13 @@ +module Genius + module Annotations + def self.annotations: (id: Integer, action: String?, token: String?, http_verb: String, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil) + + private + + def self.request: (id: Integer, action: String?, token: String?, http_verb: String, options: Hash[Symbol, untyped]) -> HTTParty::Response + + def self.put_request: (id: Integer, action: String?, token: String?, options: Hash[Symbol, untyped]) -> HTTParty::Response + + def self.post_payload: (?options: Hash[Symbol, untyped]) -> String + end +end diff --git a/sig/lib/genius/api/artists.rbs b/sig/lib/genius/api/artists.rbs new file mode 100644 index 0000000..c40ed47 --- /dev/null +++ b/sig/lib/genius/api/artists.rbs @@ -0,0 +1,15 @@ +module Genius + module Artists + def self.artists: (?token: String?, ?id: Integer?) -> (Hash[String, untyped] | nil) + + def self.artists_songs: (?token: String?, ?id: Integer?, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil) + + private + + def self.validate: (Array[String] sort_values, **untyped options) -> void + + def self.validate_sort: (String? sort, Array[String] sort_values) -> void + + def self.validate_page_per_page: (Integer? page_per_page) -> void + end +end diff --git a/sig/lib/genius/api/authorization.rbs b/sig/lib/genius/api/authorization.rbs new file mode 100644 index 0000000..346e037 --- /dev/null +++ b/sig/lib/genius/api/authorization.rbs @@ -0,0 +1,11 @@ +module Genius + module Auth + def self.token=: (String token) -> String + + def self.authorized?: (?String token, ?method_name: String) -> bool + + def self.logout!: -> nil + + # alias login= token= + end +end diff --git a/sig/lib/genius/api/errors.rbs b/sig/lib/genius/api/errors.rbs new file mode 100644 index 0000000..21fbc67 --- /dev/null +++ b/sig/lib/genius/api/errors.rbs @@ -0,0 +1,46 @@ +module Genius + module Errors + ENDPOINT: String + + class GeniusExceptionSuperClass < StandardError + end + + class TokenError < GeniusExceptionSuperClass + attr_reader msg: String + attr_reader exception_type: String + attr_reader method_name: String? + + def initialize: (?msg: String, ?exception_type: String, ?method_name: String?) -> void + end + + class LyricsNotFoundError < GeniusExceptionSuperClass + attr_reader msg: String + attr_reader exception_type: String + + def initialize: (?msg: String, ?exception_type: String) -> void + end + + class PageNotFound < GeniusExceptionSuperClass + attr_reader msg: String + attr_reader exception_type: String + + def initialize: (?msg: String, ?exception_type: String) -> void + + def self.page_not_found?: (untyped html) -> bool + end + + module DynamicRescue + def self.rescue: (Module klass) -> Array[Symbol] + + def self.rescue_from: (Array[Symbol] meths, Module klass, Module exception) { (untyped) -> void } -> Array[Symbol] + end + + def self.validate_token: (String? token, ?method_name: String?) -> void + + def self.error_handle?: (String? token, ?method_name: String?) -> bool + + private + + def self.check_status?: (String token) -> bool + end +end diff --git a/sig/lib/genius/api/referents.rbs b/sig/lib/genius/api/referents.rbs new file mode 100644 index 0000000..bc2f079 --- /dev/null +++ b/sig/lib/genius/api/referents.rbs @@ -0,0 +1,7 @@ +module Genius + module Referents + ENDPOINT: String + + def self.referents: (?token: String?, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil) + end +end diff --git a/sig/lib/genius/api/search.rbs b/sig/lib/genius/api/search.rbs new file mode 100644 index 0000000..f43359e --- /dev/null +++ b/sig/lib/genius/api/search.rbs @@ -0,0 +1,5 @@ +module Genius + module Search + def self.search: (?token: String?, ?query: String?, ?search_by: String?) -> (Hash[String, untyped] | String | nil) + end +end diff --git a/sig/lib/genius/api/songs.rbs b/sig/lib/genius/api/songs.rbs new file mode 100644 index 0000000..0e6b175 --- /dev/null +++ b/sig/lib/genius/api/songs.rbs @@ -0,0 +1,15 @@ +module Genius + module Songs + def self.songs: (?token: String?, ?song_id: Integer?, ?combine: bool) -> (Hash[String, untyped] | String | nil) + + private + + def self.merge_lyrics: (Integer song_id, Hash[String, untyped] response) -> (Hash[String, untyped] | String) + + def self.parse_preloaded_state: (untyped output_html) -> Hash[String, untyped] + + public + + def self.get_lyrics: (Integer song_id) -> String + end +end diff --git a/sig/lib/genius/api/version.rbs b/sig/lib/genius/api/version.rbs new file mode 100644 index 0000000..42bb2c0 --- /dev/null +++ b/sig/lib/genius/api/version.rbs @@ -0,0 +1,5 @@ +module Genius + module Api + VERSION: String + end +end diff --git a/sig/lib/genius/api/web_pages.rbs b/sig/lib/genius/api/web_pages.rbs new file mode 100644 index 0000000..96896c9 --- /dev/null +++ b/sig/lib/genius/api/web_pages.rbs @@ -0,0 +1,5 @@ +module Genius + module WebPages + def self.lookup: (?token: String?, ?options: Hash[Symbol, untyped]) -> (Hash[String, untyped] | nil) + end +end diff --git a/spec/api/account_spec.rb b/spec/api/account_spec.rb index e306915..65e86b2 100644 --- a/spec/api/account_spec.rb +++ b/spec/api/account_spec.rb @@ -1,51 +1,58 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Account, ' .account with valid token' do - let(:token) { 'a' * 64 } - let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end - - it 'returns account info as a hash' do - result = described_class.account(token: token) - expect(result).to be_a(Hash) - expect(result.dig('meta', 'status')).to eq(200) - end - - it 'sends a GET request to the account endpoint' do - described_class.account(token: token) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/account?access_token=#{token}" - ) - end -end - -describe Genius::Account, ' .account without token' do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end - - it 'returns nil' do - expect(described_class.account(token: nil)).to be_nil +describe Genius::Account do + describe '.account' do + context 'with valid token' do + let(:token) { 'a' * 64 } + let(:response_body) do + { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json + end + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + it 'returns account info as a hash' do + result = described_class.account(token: token) + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + end + + it 'sends a GET request to the account endpoint' do + described_class.account(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/account?access_token=#{token}" + ) + end + end + + context 'without token' do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it 'returns nil' do + expect(described_class.account(token: nil)).to be_nil + end + end end -end -describe Genius::Account, ' .me' do - let(:token) { 'a' * 64 } - let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + describe '.me' do + let(:token) { 'a' * 64 } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'user' => { 'name' => 'Test' } } }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - before do - allow(HTTParty).to receive(:get).and_return(mock_response) - end + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + end - it 'behaves the same as .account' do - expect(described_class.me(token: token)).to eq(described_class.account(token: token)) + it 'returns the same result as .account' do + expect(described_class.me(token: token)).to eq(described_class.account(token: token)) + end end end diff --git a/spec/api/annotations_spec.rb b/spec/api/annotations_spec.rb index d16a087..84dd824 100644 --- a/spec/api/annotations_spec.rb +++ b/spec/api/annotations_spec.rb @@ -1,184 +1,200 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Annotations, 'get request' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before { allow(HTTParty).to receive(:get).and_return(mock_response) } - - it 'sends a GET request' do - described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get') - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" - ) - end - - it 'returns parsed JSON' do - result = described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get') - expect(result).to be_a(Hash) - end -end - -describe Genius::Annotations, 'post request' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:post).and_return(mock_response) - end - - it 'sends a POST request' do - described_class.annotations( - id: annotation_id, action: nil, token: token, http_verb: 'post', - options: { markdown: 'test', raw_annotatable_url: 'https://example.com' } - ) - expect(HTTParty).to have_received(:post) - end -end - -describe Genius::Annotations, ' put request without action' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:put).and_return(mock_response) - end - - it 'sends a PUT request' do - described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'put') - expect(HTTParty).to have_received(:put) - end -end - -describe Genius::Annotations, ' put request with action upvote' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:put).and_return(mock_response) - end - - it 'sends a PUT request to the upvote endpoint' do - described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'put') - expect(HTTParty).to have_received(:put).with( - "https://api.genius.com/annotations/#{annotation_id}/upvote?access_token=#{token}" - ) - end -end - -describe Genius::Annotations, ' put request with action downvote' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:put).and_return(mock_response) - end - - it 'sends a PUT request to the downvote endpoint' do - described_class.annotations(id: annotation_id, action: 'downvote', token: token, http_verb: 'put') - expect(HTTParty).to have_received(:put).with( - "https://api.genius.com/annotations/#{annotation_id}/downvote?access_token=#{token}" - ) - end -end - -describe Genius::Annotations, ' put request with action unvote' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:put).and_return(mock_response) - end - - it 'sends a PUT request to the unvote endpoint' do - described_class.annotations(id: annotation_id, action: 'unvote', token: token, http_verb: 'put') - expect(HTTParty).to have_received(:put).with( - "https://api.genius.com/annotations/#{annotation_id}/unvote?access_token=#{token}" - ) - end -end - -describe Genius::Annotations, ' put request with invalid action' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - - before { allow(Genius::Errors).to receive(:validate_token) } - - it 'raises ArgumentError' do - expect do - described_class.annotations(id: annotation_id, action: 'invalid', token: token, http_verb: 'put') - end.to raise_error(ArgumentError) - end -end - -describe Genius::Annotations, ' delete request' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:delete).and_return(mock_response) - end - - it 'sends a DELETE request' do - described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'delete') - expect(HTTParty).to have_received(:delete).with( - "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" - ) - end -end - -describe Genius::Annotations, ' action with non-PUT verb' do - let(:token) { 'a' * 64 } - let(:annotation_id) { 10_225_840 } - - before { allow(Genius::Errors).to receive(:validate_token) } - - it 'raises ArgumentError' do - expect do - described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'get') - end.to raise_error(ArgumentError, /only PUT accepts/) - end -end - -describe Genius::Annotations, ' .post_payload' do - it 'builds a JSON payload from options' do - payload = described_class.send(:post_payload, options: { - markdown: 'hello **world!**', - raw_annotatable_url: 'https://example.com', - fragment: 'test', - before_html: '

', - after_html: '

', - canonical_url: 'https://example.com/canonical', - og_url: 'https://example.com/og', - title: 'Test Page' - }) - - parsed = JSON.parse(payload) - expect(parsed.dig('annotation', 'body', 'markdown')).to eq('hello **world!**') - expect(parsed.dig('referent', 'raw_annotatable_url')).to eq('https://example.com') - expect(parsed.dig('web_page', 'title')).to eq('Test Page') +describe Genius::Annotations do + describe '.annotations' do + context 'with GET verb' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'sends a GET request' do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get') + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" + ) + end + + it 'returns parsed JSON' do + result = described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'get') + expect(result).to be_a(Hash) + end + end + + context 'with POST verb' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:post).and_return(mock_response) + end + + it 'sends a POST request' do + described_class.annotations( + id: annotation_id, action: nil, token: token, http_verb: 'post', + options: { markdown: 'test', raw_annotatable_url: 'https://example.com' } + ) + expect(HTTParty).to have_received(:post) + end + end + + context 'with PUT verb and no action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request' do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put) + end + end + + context 'with PUT verb and upvote action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request to the upvote endpoint' do + described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/upvote?access_token=#{token}" + ) + end + end + + context 'with PUT verb and downvote action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request to the downvote endpoint' do + described_class.annotations(id: annotation_id, action: 'downvote', token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/downvote?access_token=#{token}" + ) + end + end + + context 'with PUT verb and unvote action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:put).and_return(mock_response) + end + + it 'sends a PUT request to the unvote endpoint' do + described_class.annotations(id: annotation_id, action: 'unvote', token: token, http_verb: 'put') + expect(HTTParty).to have_received(:put).with( + "https://api.genius.com/annotations/#{annotation_id}/unvote?access_token=#{token}" + ) + end + end + + context 'with PUT verb and invalid action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.annotations(id: annotation_id, action: 'invalid', token: token, http_verb: 'put') + end.to raise_error(ArgumentError) + end + end + + context 'with DELETE verb' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:delete).and_return(mock_response) + end + + it 'sends a DELETE request' do + described_class.annotations(id: annotation_id, action: nil, token: token, http_verb: 'delete') + expect(HTTParty).to have_received(:delete).with( + "https://api.genius.com/annotations/#{annotation_id}?access_token=#{token}" + ) + end + end + + context 'with non-PUT verb and action' do + let(:token) { 'a' * 64 } + let(:annotation_id) { 10_225_840 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.annotations(id: annotation_id, action: 'upvote', token: token, http_verb: 'get') + end.to raise_error(ArgumentError, /only PUT accepts/) + end + end + end + + describe '.post_payload' do + let(:post_options) do + { + markdown: 'hello **world!**', + raw_annotatable_url: 'https://example.com', + fragment: 'test', + before_html: '

', + after_html: '

', + canonical_url: 'https://example.com/canonical', + og_url: 'https://example.com/og', + title: 'Test Page' + } + end + + it 'builds a JSON payload from options' do + payload = described_class.send(:post_payload, options: post_options) + parsed = JSON.parse(payload) + expect(parsed.dig('annotation', 'body', 'markdown')).to eq('hello **world!**') + end + + it 'includes referent url in the payload' do + payload = described_class.send(:post_payload, options: post_options) + parsed = JSON.parse(payload) + expect(parsed.dig('referent', 'raw_annotatable_url')).to eq('https://example.com') + end + + it 'includes web page title in the payload' do + payload = described_class.send(:post_payload, options: post_options) + parsed = JSON.parse(payload) + expect(parsed.dig('web_page', 'title')).to eq('Test Page') + end end end diff --git a/spec/api/artists_spec.rb b/spec/api/artists_spec.rb index 0b2d61b..600de93 100644 --- a/spec/api/artists_spec.rb +++ b/spec/api/artists_spec.rb @@ -1,136 +1,145 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Artists, ' .artists with valid data' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { 'artist' => { 'id' => artist_id, 'name' => 'Test Artist' } } - }.to_json +describe Genius::Artists do + describe '.artists' do + context 'with valid data' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'artist' => { 'id' => artist_id, 'name' => 'Test Artist' } } + }.to_json + end + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'returns artist data as a hash' do + result = described_class.artists(token: token, id: artist_id) + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + end + + it 'sends a GET request to the artists endpoint' do + described_class.artists(token: token, id: artist_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" + ) + end + end + + context 'with nil id' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect { described_class.artists(token: token, id: nil) }.to raise_error(ArgumentError) + end + end end - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - before { allow(HTTParty).to receive(:get).and_return(mock_response) } - - it 'returns artist data as a hash' do - result = described_class.artists(token: token, id: artist_id) - expect(result).to be_a(Hash) - expect(result.dig('meta', 'status')).to eq(200) - end - - it 'sends a GET request to the artists endpoint' do - described_class.artists(token: token, id: artist_id) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" - ) - end -end - -describe Genius::Artists, ' .artists with nil id' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - - before { allow(Genius::Errors).to receive(:validate_token) } - - it 'raises ArgumentError' do - expect { described_class.artists(token: token, id: nil) }.to raise_error(ArgumentError) - end -end - -describe Genius::Artists, ' .artists_songs with valid data' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { 'songs' => [{ 'id' => 1, 'title' => 'Song 1' }] } - }.to_json - end - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - - before { allow(HTTParty).to receive(:get).and_return(mock_response) } - - it 'returns songs data as a hash' do - result = described_class.artists_songs(token: token, id: artist_id) - expect(result).to be_a(Hash) - expect(result.dig('meta', 'status')).to eq(200) - end - - it 'sends a GET request to the artists songs endpoint' do - described_class.artists_songs(token: token, id: artist_id) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" - ) - end -end - -describe Genius::Artists, ' .artists_songs with sort option' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - - before { allow(HTTParty).to receive(:get).and_return(mock_response) } - - it 'includes sort parameter' do - described_class.artists_songs(token: token, id: artist_id, options: { sort: 'popularity' }) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/artists/#{artist_id}?access_token=#{token}&sort=popularity" - ) - end -end - -describe Genius::Artists, ' .artists_songs with invalid sort' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - - before { allow(Genius::Errors).to receive(:validate_token) } - - it 'raises ArgumentError' do - expect do - described_class.artists_songs(token: token, id: artist_id, options: { sort: 'invalid' }) - end.to raise_error(ArgumentError) - end -end - -describe Genius::Artists, ' .artists_songs with negative per_page' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - - before { allow(Genius::Errors).to receive(:validate_token) } - - it 'raises ArgumentError' do - expect do - described_class.artists_songs(token: token, id: artist_id, options: { per_page: -1 }) - end.to raise_error(ArgumentError) - end -end - -describe Genius::Artists, ' .artists_songs with negative page' do - let(:token) { 'a' * 64 } - let(:artist_id) { 16_764 } - - before { allow(Genius::Errors).to receive(:validate_token) } - - it 'raises ArgumentError' do - expect do - described_class.artists_songs(token: token, id: artist_id, options: { page: -1 }) - end.to raise_error(ArgumentError) - end -end - -describe Genius::Artists, ' .artists_songs without token' do - let(:artist_id) { 16_764 } - - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end - - it 'returns nil' do - expect(described_class.artists_songs(token: nil, id: artist_id)).to be_nil + describe '.artists_songs' do + context 'with valid data' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'songs' => [{ 'id' => 1, 'title' => 'Song 1' }] } + }.to_json + end + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'returns songs data as a hash' do + result = described_class.artists_songs(token: token, id: artist_id) + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + end + + it 'sends a GET request to the artists songs endpoint' do + described_class.artists_songs(token: token, id: artist_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}" + ) + end + end + + context 'with sort option' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + + before { allow(HTTParty).to receive(:get).and_return(mock_response) } + + it 'includes sort parameter' do + described_class.artists_songs(token: token, id: artist_id, options: { sort: 'popularity' }) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/artists/#{artist_id}?access_token=#{token}&sort=popularity" + ) + end + end + + context 'with invalid sort' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.artists_songs(token: token, id: artist_id, options: { sort: 'invalid' }) + end.to raise_error(ArgumentError) + end + end + + context 'with negative per_page' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.artists_songs(token: token, id: artist_id, options: { per_page: -1 }) + end.to raise_error(ArgumentError) + end + end + + context 'with negative page' do + let(:token) { 'a' * 64 } + let(:artist_id) { 16_764 } + + before { allow(Genius::Errors).to receive(:validate_token) } + + it 'raises ArgumentError' do + expect do + described_class.artists_songs(token: token, id: artist_id, options: { page: -1 }) + end.to raise_error(ArgumentError) + end + end + + context 'without token' do + let(:artist_id) { 16_764 } + + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end + + it 'returns nil' do + expect(described_class.artists_songs(token: nil, id: artist_id)).to be_nil + end + end end end diff --git a/spec/api/authorization_spec.rb b/spec/api/authorization_spec.rb index e256d69..7512a90 100644 --- a/spec/api/authorization_spec.rb +++ b/spec/api/authorization_spec.rb @@ -1,37 +1,40 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Auth, ' .authorized? with valid token' do - let(:valid_token) { 'a' * 64 } +describe Genius::Auth do + describe '.authorized?' do + context 'with valid token' do + let(:valid_token) { 'a' * 64 } - before do - described_class.logout! - allow(Genius::Errors).to receive(:validate_token) - described_class.login = valid_token - end + before do + described_class.logout! + allow(Genius::Errors).to receive(:validate_token) + described_class.login = valid_token + end - it 'returns true' do - expect(described_class.authorized?).to be true - end -end + it 'returns true' do + expect(described_class.authorized?).to be true + end + end -describe Genius::Auth, ' .authorized? with invalid token' do - before do - described_class.logout! - allow(Genius::Errors).to receive(:validate_token).and_raise(Genius::Errors::TokenError.new) - end + context 'with invalid token' do + before do + described_class.logout! + allow(Genius::Errors).to receive(:validate_token).and_raise(Genius::Errors::TokenError.new) + end - it 'returns false' do - described_class.login = 'invalid_token' - expect(described_class.authorized?).to be false + it 'returns false' do + described_class.login = 'invalid_token' + expect(described_class.authorized?).to be false + end + end end -end -describe Genius::Auth, ' .logout!' do - it 'sets the token to nil' do - described_class.logout! - expect(described_class.instance_variable_get(:@token)).to be_nil + describe '.logout!' do + it 'sets the token to nil' do + described_class.logout! + expect(described_class.instance_variable_get(:@token)).to be_nil + end end end diff --git a/spec/api/errors_spec.rb b/spec/api/errors_spec.rb index 49ae6ec..089b8e5 100644 --- a/spec/api/errors_spec.rb +++ b/spec/api/errors_spec.rb @@ -1,130 +1,135 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Errors::GeniusExceptionSuperClass do - it 'is a subclass of StandardError' do - expect(described_class.superclass).to eq(StandardError) +describe Genius::Errors do + describe Genius::Errors::GeniusExceptionSuperClass do + it 'is a subclass of StandardError' do + expect(described_class.superclass).to eq(StandardError) + end end -end -describe Genius::Errors::TokenError do - subject(:error) { described_class.new } + describe Genius::Errors::TokenError do + subject(:error) { described_class.new } - it 'has default message' do - expect(error.msg).to include('Invalid token') - end + it 'has default message' do + expect(error.msg).to include('Invalid token') + end - it 'has default exception type' do - expect(error.exception_type).to eq('token_error') - end + it 'has default exception type' do + expect(error.exception_type).to eq('token_error') + end - context 'when method_name is provided' do - subject(:error) { described_class.new(method_name: 'Genius::Auth.authorized?') } + context 'when method_name is provided' do + subject(:error) { described_class.new(method_name: 'Genius::Auth.authorized?') } - it 'includes method_name in the message' do - expect(error.msg).to include('Genius::Auth.authorized?') + it 'includes method_name in the message' do + expect(error.msg).to include('Genius::Auth.authorized?') + end end end -end -describe Genius::Errors::LyricsNotFoundError do - subject(:error) { described_class.new } + describe Genius::Errors::LyricsNotFoundError do + subject(:error) { described_class.new } - it 'has default message' do - expect(error.msg).to eq('Lyrics not found in current session. Retrying...') - end + it 'has default message' do + expect(error.msg).to eq('Lyrics not found in current session. Retrying...') + end - it 'has default exception type' do - expect(error.exception_type).to eq('invalid_lyrics') + it 'has default exception type' do + expect(error.exception_type).to eq('invalid_lyrics') + end end -end -describe Genius::Errors::PageNotFound do - subject(:error) { described_class.new } + describe Genius::Errors::PageNotFound do + subject(:error) { described_class.new } - it 'has default message' do - expect(error.msg).to eq('Page not found. Try again with another response') - end - - it 'has default exception type' do - expect(error.exception_type).to eq('page_not_found') - end + it 'has default message' do + expect(error.msg).to eq('Page not found. Try again with another response') + end - describe '.page_not_found?' do - it "returns true if html contains 'Page not found'" do - html = Nokogiri::HTML('Page not found') - expect(described_class.page_not_found?(html)).to be true + it 'has default exception type' do + expect(error.exception_type).to eq('page_not_found') end - it "returns false if html does not contain 'Page not found'" do - html = Nokogiri::HTML('OK') - expect(described_class.page_not_found?(html)).to be false + describe '.page_not_found?' do + it "returns true if html contains 'Page not found'" do + html = Nokogiri::HTML('Page not found') + expect(described_class.page_not_found?(html)).to be true + end + + it "returns false if html does not contain 'Page not found'" do + html = Nokogiri::HTML('OK') + expect(described_class.page_not_found?(html)).to be false + end end end -end -describe Genius::Errors, ' .validate_token with nil token' do - it 'raises TokenError' do - expect { Genius::Errors.validate_token(nil) }.to raise_error(Genius::Errors::TokenError) - end -end + describe '.validate_token' do + context 'with nil token' do + it 'raises TokenError' do + expect { described_class.validate_token(nil) }.to raise_error(Genius::Errors::TokenError) + end + end -describe Genius::Errors, ' .validate_token with short token' do - let(:short_token) { 'short_token' } + context 'with short token' do + let(:short_token) { 'short_token' } - it 'raises TokenError' do - expect { Genius::Errors.validate_token(short_token) }.to raise_error(Genius::Errors::TokenError) - end -end + it 'raises TokenError' do + expect { described_class.validate_token(short_token) }.to raise_error(Genius::Errors::TokenError) + end + end -describe Genius::Errors, ' .validate_token with valid token and 200 response' do - let(:valid_token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + context 'with valid token and 200 response' do + let(:valid_token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'does not raise an error' do - expect { Genius::Errors.validate_token(valid_token) }.not_to raise_error - end -end + it 'does not raise an error' do + expect { described_class.validate_token(valid_token) }.not_to raise_error + end + end -describe Genius::Errors, ' .validate_token with valid token and non-200 response' do - let(:valid_token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 401 } }.to_json } + context 'with valid token and non-200 response' do + let(:valid_token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 401 } }.to_json } - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'raises TokenError' do - expect { Genius::Errors.validate_token(valid_token) }.to raise_error(Genius::Errors::TokenError) + it 'raises TokenError' do + expect { described_class.validate_token(valid_token) }.to raise_error(Genius::Errors::TokenError) + end + end end -end -describe Genius::Errors, ' .error_handle? with nil token' do - it 'raises TokenError with a specific message' do - expect { Genius::Errors.error_handle?(nil) }.to raise_error(Genius::Errors::TokenError) - end -end + describe '.error_handle?' do + context 'with nil token' do + it 'raises TokenError with a specific message' do + expect { described_class.error_handle?(nil) }.to raise_error(Genius::Errors::TokenError) + end + end -describe Genius::Errors, ' .error_handle? with short token' do - let(:short_token) { 'short_token' } + context 'with short token' do + let(:short_token) { 'short_token' } - it 'raises TokenError' do - expect { Genius::Errors.error_handle?(short_token) }.to raise_error(Genius::Errors::TokenError) - end -end + it 'raises TokenError' do + expect { described_class.error_handle?(short_token) }.to raise_error(Genius::Errors::TokenError) + end + end -describe Genius::Errors, ' .error_handle? with valid token' do - let(:valid_token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } + context 'with valid token' do + let(:valid_token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 } }.to_json } - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'returns true' do - expect(Genius::Errors.error_handle?(valid_token)).to be true + it 'returns true' do + expect(described_class.error_handle?(valid_token)).to be true + end + end end end diff --git a/spec/api/referents_spec.rb b/spec/api/referents_spec.rb index 4428a51..f23ca20 100644 --- a/spec/api/referents_spec.rb +++ b/spec/api/referents_spec.rb @@ -1,62 +1,67 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Referents, ' .referents with valid token' do - let(:token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json } +describe Genius::Referents do + describe '.referents' do + context 'with valid token' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json } - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'returns referents as a hash' do - result = described_class.referents(token: token) - expect(result).to be_a(Hash) - expect(result.dig('meta', 'status')).to eq(200) - end + it 'returns referents as a hash' do + result = described_class.referents(token: token) + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + end - it 'sends a GET request to the referents endpoint' do - described_class.referents(token: token) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/referents?access_token=#{token}" - ) - end -end + it 'sends a GET request to the referents endpoint' do + described_class.referents(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/referents?access_token=#{token}" + ) + end + end -describe Genius::Referents, ' .referents with options' do - let(:token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json } + context 'with options' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'referents' => [] } }.to_json } - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'includes query parameters' do - described_class.referents(token: token, options: { per_page: 5, page: 2 }) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/referents?access_token=#{token}&per_page=5&page=2" - ) - end -end + it 'includes query parameters' do + described_class.referents(token: token, options: { per_page: 5, page: 2 }) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/referents?access_token=#{token}&per_page=5&page=2" + ) + end + end -describe Genius::Referents, ' .referents with both web_page and song_id' do - let(:token) { 'a' * 64 } + context 'with both web_page and song_id' do + let(:token) { 'a' * 64 } - before { allow(Genius::Errors).to receive(:validate_token) } + before { allow(Genius::Errors).to receive(:validate_token) } - it 'raises ArgumentError' do - expect do - described_class.referents(token: token, options: { web_page: 1, song_id: 2 }) - end.to raise_error(ArgumentError) - end -end + it 'raises ArgumentError' do + expect do + described_class.referents(token: token, options: { web_page: 1, song_id: 2 }) + end.to raise_error(ArgumentError) + end + end -describe Genius::Referents, ' .referents without token' do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end + context 'without token' do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end - it 'returns nil' do - expect(described_class.referents(token: nil)).to be_nil + it 'returns nil' do + expect(described_class.referents(token: nil)).to be_nil + end + end end end diff --git a/spec/api/search_spec.rb b/spec/api/search_spec.rb index 8abcfdd..11343c9 100644 --- a/spec/api/search_spec.rb +++ b/spec/api/search_spec.rb @@ -1,106 +1,111 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Search, ' .search with valid token and query' do - let(:token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { - 'hits' => [ - { 'result' => { 'title' => 'Song A' } }, - { 'result' => { 'title' => 'Song B' } } - ] - } - }.to_json - end +describe Genius::Search do + describe '.search' do + context 'with valid token and query' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'returns search results as a hash' do - result = described_class.search(token: token, query: 'Ariana Grande') - expect(result).to be_a(Hash) - expect(result.dig('meta', 'status')).to eq(200) - end + it 'returns search results as a hash' do + result = described_class.search(token: token, query: 'Ariana Grande') + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + end - it 'sends a GET request to the search endpoint' do - described_class.search(token: token, query: 'Ariana Grande') - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/search?q=Ariana Grande&access_token=#{token}" - ) - end -end + it 'sends a GET request to the search endpoint' do + described_class.search(token: token, query: 'Ariana Grande') + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/search?q=Ariana Grande&access_token=#{token}" + ) + end + end -describe Genius::Search, ' .search with search_by' do - let(:token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { - 'hits' => [ - { 'result' => { 'title' => 'Song A' } }, - { 'result' => { 'title' => 'Song B' } } - ] - } - }.to_json - end + context 'with search_by' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'returns filtered results' do - result = described_class.search(token: token, query: 'test', search_by: 'title') - expect(result).to contain_exactly('Song A', 'Song B') - end -end + it 'returns filtered results' do + result = described_class.search(token: token, query: 'test', search_by: 'title') + expect(result).to contain_exactly('Song A', 'Song B') + end + end -describe Genius::Search, ' .search without token' do - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { - 'hits' => [ - { 'result' => { 'title' => 'Song A' } }, - { 'result' => { 'title' => 'Song B' } } - ] - } - }.to_json - end + context 'without token' do + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end - it 'returns nil' do - expect(described_class.search(token: nil, query: 'test')).to be_nil - end -end + it 'returns nil' do + expect(described_class.search(token: nil, query: 'test')).to be_nil + end + end -describe Genius::Search, ' .search with nil query' do - let(:token) { 'a' * 64 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { - 'hits' => [ - { 'result' => { 'title' => 'Song A' } }, - { 'result' => { 'title' => 'Song B' } } - ] - } - }.to_json - end + context 'with nil query' do + let(:token) { 'a' * 64 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { + 'hits' => [ + { 'result' => { 'title' => 'Song A' } }, + { 'result' => { 'title' => 'Song B' } } + ] + } + }.to_json + end - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'sends a request with nil query' do - described_class.search(token: token, query: nil) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/search?q=&access_token=#{token}" - ) + it 'sends a request with nil query' do + described_class.search(token: token, query: nil) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/search?q=&access_token=#{token}" + ) + end + end end end diff --git a/spec/api/songs_spec.rb b/spec/api/songs_spec.rb index 7e912b6..2ab6aff 100644 --- a/spec/api/songs_spec.rb +++ b/spec/api/songs_spec.rb @@ -1,95 +1,104 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::Songs, ' .songs with valid data' do - let(:token) { 'a' * 64 } - let(:song_id) { 294_649 } - let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { 'song' => { 'id' => song_id, 'title' => 'Test Song' } } - }.to_json - end +describe Genius::Songs do + describe '.songs' do + context 'with valid data' do + let(:token) { 'a' * 64 } + let(:song_id) { 294_649 } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'song' => { 'id' => song_id, 'title' => 'Test Song' } } + }.to_json + end - before { allow(HTTParty).to receive(:get).and_return(mock_response) } + before { allow(HTTParty).to receive(:get).and_return(mock_response) } - it 'returns song data as a hash' do - result = described_class.songs(token: token, song_id: song_id) - expect(result).to be_a(Hash) - expect(result.dig('response', 'song', 'id')).to eq(song_id) - end + it 'returns song data as a hash' do + result = described_class.songs(token: token, song_id: song_id) + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('response', 'song', 'id')).to eq(song_id) + end + end - it 'sends a GET request to the songs endpoint' do - described_class.songs(token: token, song_id: song_id) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/songs/#{song_id}?access_token=#{token}" - ) - end -end + it 'sends a GET request to the songs endpoint' do + described_class.songs(token: token, song_id: song_id) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/songs/#{song_id}?access_token=#{token}" + ) + end + end -describe Genius::Songs, ' .songs with combine true' do - let(:token) { 'a' * 64 } - let(:song_id) { 294_649 } - let(:mock_response) { instance_double(HTTParty::Response, body: { 'meta' => { 'status' => 200 }, 'response' => { 'song' => { 'id' => song_id } } }.to_json) } - let(:scripts) do - (0..16).map { |i| "" } + - [""] - end - let(:html_body) { "#{scripts.join}" } - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:get).with("https://api.genius.com/songs/#{song_id}?access_token=#{token}") - .and_return(mock_response) - allow(HTTParty).to receive(:get).with("https://genius.com/songs/#{song_id}") - .and_return(html_body) - end - it 'fetches lyrics page and includes lyrics in the response' do - result = described_class.songs(token: token, song_id: song_id, combine: true) - expect(result).to be_a(Hash) - expect(result).to have_key('lyrics') - expect(result['lyrics']).to have_key('lyrics') - end -end + context 'with combine true' do + let(:token) { 'a' * 64 } + let(:song_id) { 294_649 } + let(:mock_response) { instance_double(HTTParty::Response, body: { 'meta' => { 'status' => 200 }, 'response' => { 'song' => { 'id' => song_id } } }.to_json) } + let(:html_body) do + scripts = (0..16).map { |i| "" } + + [""] + "#{scripts.join}" + end + let(:result) { described_class.songs(token: token, song_id: song_id, combine: true) } -describe Genius::Songs, ' .songs without token' do - let(:song_id) { 294_649 } + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:get).with("https://api.genius.com/songs/#{song_id}?access_token=#{token}") + .and_return(mock_response) + allow(HTTParty).to receive(:get).with("https://genius.com/songs/#{song_id}") + .and_return(html_body) + end - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end + it 'fetches lyrics page and includes lyrics in the response' do + aggregate_failures do + expect(result).to be_a(Hash) + expect(result).to have_key('lyrics') + expect(result['lyrics']).to have_key('lyrics') + end + end + end - it 'returns nil' do - expect(described_class.songs(token: nil, song_id: song_id)).to be_nil - end -end + context 'without token' do + let(:song_id) { 294_649 } + + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end -describe Genius::Songs, ' .get_lyrics' do - let(:song_id) { 294_649 } - let(:html_body) do - <<~HTML - - -
-

Test lyrics content

-
- - - HTML + it 'returns nil' do + expect(described_class.songs(token: nil, song_id: song_id)).to be_nil + end + end end - before { allow(HTTParty).to receive(:get).and_return(html_body) } + describe '.get_lyrics' do + let(:song_id) { 294_649 } + let(:html_body) do + <<~HTML + + +
+

Test lyrics content

+
+ + + HTML + end - it 'extracts lyrics from the page' do - result = described_class.get_lyrics(song_id) - expect(result).to eq('Test lyrics content') - end -end + before { allow(HTTParty).to receive(:get).and_return(html_body) } + + it 'extracts lyrics from the page' do + result = described_class.get_lyrics(song_id) + expect(result).to eq('Test lyrics content') + end -describe Genius::Songs, ' .get_lyrics with nil song_id' do - it 'raises ArgumentError' do - expect { described_class.get_lyrics(nil) }.to raise_error(ArgumentError) + context 'with nil song_id' do + it 'raises ArgumentError' do + expect { described_class.get_lyrics(nil) }.to raise_error(ArgumentError) + end + end end end diff --git a/spec/api/web_pages_spec.rb b/spec/api/web_pages_spec.rb index 208d98d..b2433ab 100644 --- a/spec/api/web_pages_spec.rb +++ b/spec/api/web_pages_spec.rb @@ -1,67 +1,77 @@ # frozen_string_literal: true -require 'rspec' require 'genius/api' -describe Genius::WebPages, ' .lookup with valid token' do - let(:token) { 'a' * 64 } - let(:response_body) do - { - 'meta' => { 'status' => 200 }, - 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } - }.to_json - end +describe Genius::WebPages do + describe '.lookup' do + context 'with valid token' do + let(:token) { 'a' * 64 } + let(:response_body) do + { + 'meta' => { 'status' => 200 }, + 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } + }.to_json + end + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:get).and_return(response_body) - end + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:get).and_return(mock_response) + end - it 'returns web page data as a hash' do - result = described_class.lookup(token: token) - expect(result).to be_a(Hash) - expect(result.dig('meta', 'status')).to eq(200) - end + it 'returns web page data as a hash' do + result = described_class.lookup(token: token) + aggregate_failures do + expect(result).to be_a(Hash) + expect(result.dig('meta', 'status')).to eq(200) + end + end - it 'sends a GET request to the API root' do - described_class.lookup(token: token) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/?access_token=#{token}" - ) - end -end + it 'sends a GET request to the API root' do + described_class.lookup(token: token) + expect(HTTParty).to have_received(:get).with( + "https://api.genius.com/?access_token=#{token}" + ) + end + end -describe Genius::WebPages, ' .lookup with URL variants' do - let(:token) { 'a' * 64 } - let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } }.to_json } - before do - allow(Genius::Errors).to receive(:validate_token) - allow(HTTParty).to receive(:get).and_return(response_body) - end - it 'includes query parameters' do - described_class.lookup( - token: token, - options: { - raw_annotatable_url: 'https://example.com/page', - canonical_url: 'https://example.com/canonical', - og_url: 'https://example.com/og' - } - ) - expect(HTTParty).to have_received(:get).with( - "https://api.genius.com/?access_token=#{token}" \ - '&raw_annotatable_url=https://example.com/page' \ - '&canonical_url=https://example.com/canonical' \ - '&og_url=https://example.com/og' - ) - end -end + context 'with URL variants' do + let(:token) { 'a' * 64 } + let(:response_body) { { 'meta' => { 'status' => 200 }, 'response' => { 'web_page' => { 'id' => 1, 'url' => 'https://example.com' } } }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body) } + let(:url_options) do + { + raw_annotatable_url: 'https://example.com/page', + canonical_url: 'https://example.com/canonical', + og_url: 'https://example.com/og' + } + end + let(:expected_url) do + "https://api.genius.com/?access_token=#{token}" \ + '&raw_annotatable_url=https://example.com/page' \ + '&canonical_url=https://example.com/canonical' \ + '&og_url=https://example.com/og' + end -describe Genius::WebPages, ' .lookup without token' do - before do - allow(Genius::Auth).to receive(:authorized?).and_return(false) - end + before do + allow(Genius::Errors).to receive(:validate_token) + allow(HTTParty).to receive(:get).and_return(mock_response) + end + + it 'includes query parameters in the request' do + described_class.lookup(token: token, options: url_options) + expect(HTTParty).to have_received(:get).with(expected_url) + end + end + + context 'without token' do + before do + allow(Genius::Auth).to receive(:authorized?).and_return(false) + end - it 'returns nil' do - expect(described_class.lookup(token: nil)).to be_nil + it 'returns nil' do + expect(described_class.lookup(token: nil)).to be_nil + end + end end end diff --git a/spec/extensions/hash_spec.rb b/spec/extensions/hash_spec.rb index 8b60089..6b22045 100644 --- a/spec/extensions/hash_spec.rb +++ b/spec/extensions/hash_spec.rb @@ -1,83 +1,86 @@ # frozen_string_literal: true -require 'rspec' require 'extensions/deep_find' -describe Hash, '#deep_find basic lookup' do - subject(:hash) do - { - key1: 'value1', - key2: { - nested_key: 'nested_value', - key3: { - deep_key: 'deep_value' +describe Hash do + describe '#deep_find' do + context 'with basic lookup' do + subject(:hash) do + { + key1: 'value1', + key2: { + nested_key: 'nested_value', + key3: { + deep_key: 'deep_value' + } + } } - } - } - end + end - it 'finds a value at the top level' do - expect(hash.deep_find(:key1)).to eq('value1') - end + it 'finds a value at the top level' do + expect(hash.deep_find(:key1)).to eq('value1') + end - it 'finds a value at a nested level' do - expect(hash.deep_find(:nested_key)).to eq('nested_value') - end + it 'finds a value at a nested level' do + expect(hash.deep_find(:nested_key)).to eq('nested_value') + end - it 'finds a value at a deep level' do - expect(hash.deep_find(:deep_key)).to eq('deep_value') - end + it 'finds a value at a deep level' do + expect(hash.deep_find(:deep_key)).to eq('deep_value') + end - it 'returns nil for missing keys' do - expect(hash.deep_find(:nonexistent)).to be_nil - end -end + it 'returns nil for missing keys' do + expect(hash.deep_find(:nonexistent)).to be_nil + end + end -describe Hash, '#deep_find with array containing hashes' do - subject(:hash) do - { - items: [ - { id: 1, name: 'foo' }, - { id: 2, name: 'bar' } - ] - } - end + context 'with array containing hashes' do + subject(:hash) do + { + items: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' } + ] + } + end - it 'finds values inside arrays of hashes' do - expect(hash.deep_find(:name)).to contain_exactly('foo', 'bar') - end -end + it 'finds values inside arrays of hashes' do + expect(hash.deep_find(:name)).to contain_exactly('foo', 'bar') + end + end -describe Hash, '#deep_find with nil values' do - it 'returns nil' do - expect({ a: nil }.deep_find(:a)).to be_nil - end -end + context 'with nil values' do + it 'returns nil' do + expect({ a: nil }.deep_find(:a)).to be_nil + end + end -describe Hash, '#deep_find with array' do - subject(:hash) { { key: [{ nested: 'value' }] } } + context 'with array' do + subject(:hash) { { key: [{ nested: 'value' }] } } - it 'returns match for nil target' do - expect(hash.deep_find(:nested)).to eq('value') - end -end + it 'returns match for nil target' do + expect(hash.deep_find(:nested)).to eq('value') + end + end -describe Hash, '#deep_find with integer key' do - subject(:hash) { { 1 => 'one', 2 => 'two' } } + context 'with integer key' do + subject(:hash) { { 1 => 'one', 2 => 'two' } } - it 'finds integer keys' do - expect(hash.deep_find(1)).to eq('one') - end -end + it 'finds integer keys' do + expect(hash.deep_find(1)).to eq('one') + end + end -describe Hash, '#deep_find with empty hash' do - it 'returns nil' do - expect({}.deep_find(:anything)).to be_nil - end -end + context 'with empty hash' do + it 'returns nil' do + expect({}.deep_find(:anything)).to be_nil + end + end -describe Hash, '#deep_find with empty array' do - it 'returns nil' do - expect({ items: [] }.deep_find(:anything)).to be_nil + context 'with empty array' do + it 'returns nil' do + expect({ items: [] }.deep_find(:anything)).to be_nil + end + end end end diff --git a/spec/extensions/options_helper_spec.rb b/spec/extensions/options_helper_spec.rb index 7866bbb..11c7e66 100644 --- a/spec/extensions/options_helper_spec.rb +++ b/spec/extensions/options_helper_spec.rb @@ -1,29 +1,30 @@ # frozen_string_literal: true -require 'rspec' require 'extensions/extensions' describe Object do describe '#options_helper' do + subject(:helper) { described_class.new } + let(:allowed_keys) { %i[sort per_page page] } context 'when options contain allowed keys' do it 'builds a query string from allowed options' do - result = subject.options_helper({ sort: 'title', per_page: 10, page: 1 }, allowed_keys) + result = helper.options_helper({ sort: 'title', per_page: 10, page: 1 }, allowed_keys) expect(result).to eq('&sort=title&per_page=10&page=1') end end context 'when options contain disallowed keys' do it 'ignores disallowed keys' do - result = subject.options_helper({ sort: 'title', foo: 'bar' }, allowed_keys) + result = helper.options_helper({ sort: 'title', foo: 'bar' }, allowed_keys) expect(result).to eq('&sort=title') end end context 'when options is empty' do it 'returns an empty string' do - result = subject.options_helper({}, allowed_keys) + result = helper.options_helper({}, allowed_keys) expect(result).to eq('') end end diff --git a/spec/extensions/token_ext_spec.rb b/spec/extensions/token_ext_spec.rb index 5793c64..0e9dd41 100644 --- a/spec/extensions/token_ext_spec.rb +++ b/spec/extensions/token_ext_spec.rb @@ -1,15 +1,16 @@ # frozen_string_literal: true -require 'rspec' require 'extensions/extensions' describe Object do describe '#token_ext' do + subject(:helper) { described_class.new } + let(:token) { 'a' * 64 } context 'when a token is provided' do it 'returns the token' do - expect(subject.token_ext(token)).to eq(token) + expect(helper.token_ext(token)).to eq(token) end end @@ -23,7 +24,7 @@ end it 'returns the stored token' do - expect(subject.token_ext(nil)).to eq(token) + expect(helper.token_ext(nil)).to eq(token) end end end From ba39844fcc3d8e0d7c7758a301d06eec1ed798c6 Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:41:43 +0300 Subject: [PATCH 4/7] [0.3.0] Added docscribe gem and configure with RBS integration (#6) --- .github/workflows/ci.yml | 8 ++------ Gemfile.lock | 6 +++++- genius-api.gemspec | 1 + 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 792209f..6e24d92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,6 @@ name: CI -on: - push: - branches: [master, v*] - pull_request: - branches: [master, v*] +on: [ push, pull_request ] jobs: test: @@ -12,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.2', '3.3', '3.4', '4.0'] + ruby: [ '3.2', '3.3', '3.4', '4.0' ] steps: - uses: actions/checkout@v4 diff --git a/Gemfile.lock b/Gemfile.lock index 0e2a24d..bd354d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,6 +14,9 @@ GEM concurrent-ruby (1.3.6) csv (3.3.5) diff-lcs (1.6.2) + docscribe (1.4.1) + parser (>= 3.3) + prism (~> 1.8) dotenv (2.7.6) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) @@ -152,6 +155,7 @@ PLATFORMS DEPENDENCIES coderay + docscribe dotenv (~> 2.7.6) genius-api! parallel (< 2.0) @@ -167,4 +171,4 @@ DEPENDENCIES yard BUNDLED WITH - 4.0.14 + 4.0.14 diff --git a/genius-api.gemspec b/genius-api.gemspec index 69f8a3e..2e4cbda 100644 --- a/genius-api.gemspec +++ b/genius-api.gemspec @@ -33,6 +33,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'httparty', '~> 0.21' spec.add_dependency 'nokogiri' spec.add_development_dependency 'coderay' + spec.add_development_dependency 'docscribe' spec.add_development_dependency 'dotenv', '~> 2.7.6' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rbs' From 35189c058c20711310fc52273ceca91e235b3fed Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:30:22 +0300 Subject: [PATCH 5/7] [0.3.0] Fixed Docscribe types (#7) --- .gitignore | 1 + docscribe.yml | 9 ++ lib/extensions/deep_find.rb | 31 ++---- lib/extensions/token_ext.rb | 2 +- lib/extensions/unescape.rb | 2 +- lib/genius/api/account.rb | 5 +- lib/genius/api/annotations.rb | 182 +++++--------------------------- lib/genius/api/artists.rb | 68 +++++------- lib/genius/api/authorization.rb | 28 ++--- lib/genius/api/errors.rb | 113 ++++++++------------ lib/genius/api/referents.rb | 28 ++--- lib/genius/api/search.rb | 34 +----- lib/genius/api/songs.rb | 48 ++++----- lib/genius/api/web_pages.rb | 24 +---- 14 files changed, 165 insertions(+), 410 deletions(-) create mode 100644 docscribe.yml diff --git a/.gitignore b/.gitignore index 49d936f..472090d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ rakelib .env Rakefile .gem_rbs_collection/ +/docs/ diff --git a/docscribe.yml b/docscribe.yml new file mode 100644 index 0000000..1ef2d24 --- /dev/null +++ b/docscribe.yml @@ -0,0 +1,9 @@ +--- +rbs: + enabled: true + collection: true + collapse_generics: true + +filter: + exclude: + - Genius::Errors::DynamicRescue.rescue_from diff --git a/lib/extensions/deep_find.rb b/lib/extensions/deep_find.rb index ffded2c..b48c44e 100644 --- a/lib/extensions/deep_find.rb +++ b/lib/extensions/deep_find.rb @@ -1,29 +1,11 @@ # frozen_string_literal: true class Hash # :nodoc: - # +Hash#deep_find+ -> value + # Searches for a key in nested hashes and arrays. Returns matching values or +nil+. # - # @param [Object] key A key, which value should be found - # @param [FalseClass] uniq A flag to make values unique in an array - # @return [Object] output depends on key value - # This method is an extension for Hash core class to search for a value of a key in N-nested - # hash. It provides search for multiple values if key appears more than once. For e.g.: - # - # @example - # musicians = { "Travis Scott" => { "28" => ["Highest in the Room", "Franchise"] }, - # "Adele" => { "19" => ["Day Dreamer", "Best for Last"] }, - # "Ed Sheeran" => { "28" => ["Shape of You", "Castle on the Hill"] } } - # musicians.deep_find("19") #=> ["Day Dreamer", "Best for Last"] - # musicians.deep_find("Adele") #=> {"19"=>["Day Dreamer", "Best for Last"]} - # musicians.deep_find("28") #=> [["Highest in the Room", "Franchise"], ["Shape of You", "Castle on the Hill"]] - # - # If values are identical, they will be returned in a single copy. You can disable this - # feature with special param +uniq+, which is +true+ by default. For e.g.: - # - # @example - # h = {"a" => "b", "c" => {"a" => "b"}} - # h.deep_find("a") #=> "b", instead ["b", "b"] - # @todo change uniq true to uniq false + # @param [Object] key Key to search for. + # @param [Boolean] uniq If +true+, deduplicates results. + # @return [nil, Object] def deep_find(key, uniq: true) result = collect_values(key) result.compact! @@ -36,6 +18,11 @@ def deep_find(key, uniq: true) private + # Recursively collects values for a key from nested hashes. + # + # @private + # @param [Object] key Key to search for. + # @return [Array] def collect_values(key) result = [self[key]] each_value do |value| diff --git a/lib/extensions/token_ext.rb b/lib/extensions/token_ext.rb index 2bae846..90afafc 100644 --- a/lib/extensions/token_ext.rb +++ b/lib/extensions/token_ext.rb @@ -4,7 +4,7 @@ class Object # :nodoc: # +Object#token_ext+ -> String # # Helper method to check if token is correct - # @param [String] token Token to access https://api.genius.com. + # @param [String?] token Token to access https://api.genius.com. # @return [String] def token_ext(token) token || Genius::Auth.instance_variable_get(:@token) diff --git a/lib/extensions/unescape.rb b/lib/extensions/unescape.rb index a7fcb9a..63df72b 100644 --- a/lib/extensions/unescape.rb +++ b/lib/extensions/unescape.rb @@ -5,7 +5,7 @@ class String # :nodoc: # # String#unescape method unescapes input JSON strings. # - # @return [String (frozen)] + # @return [String] def unescape string = gsub(/(? true or false - # - # @param [Object] id Identification of annotations resource. - # @param [Object] action Action to do during PUT request. Possible actions: nil, upvote, downvote, unvote. - # @param [String] token Token to access https://api.genius.com. - # @param [String (frozen)] http_verb HTTP verb for request. Possible verbs: get, post, put, delete. - # @param [Hash] options Options for PUT response. - # @option options [String] :markdown The text for the note, in - # {markdown}[https://help.github.com/articles/github-flavored-markdown/] - # @option options [String] :raw_annotatable_url The original URL of the page. - # @option options [String] :fragment The highlighted fragment. - # @option options [String] :before_html The HTML before the highlighted fragment (prefer up to 200 characters). - # @option options [String] :after_html The HTML after the highlighted fragment (prefer up to 200 characters). - # @option options [String] :canonical_url The href property of the tag - # on the page. Including it will help make sure newly created annotation appear on the correct page. - # @option options [String] :og_url The content property of the tag on - # the page. Including it will help make sure newly created annotation appear on the correct page. - # @option options [String] :title The title of the page. - # @raise [ArgumentError] if +action+ got incorrect value. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [NilClass] if TokenError exceptions raised. - # - # +GET /annotations/:id+
- # Data for a specific annotation. - # - # @example Example usage - # Genius::Annotations.annotations(id: 10225840) - # - # +POST /annotations+
- # Requires scope: _create_annotation_ - # - # Creates a new annotation on a public web page. The returned value will be the new annotation object, in the - # same form as would be returned by GET /annotation/:id with the new annotation's ID. Requires JSON payload. - # @example Example Payload - # { - # "annotation": { - # "body": { - # "markdown": "hello **world!**" - # } - # }, - # "referent": { - # "raw_annotatable_url": "http://seejohncode.com/2014/01/27/vim-commands-piping/", - # "fragment": "execute commands", - # "context_for_display": { - # "before_html": "You may know that you can ", - # "after_html": " from inside of vim, with a vim command:" - # } - # }, - # "web_page": { - # "canonical_url": null, - # "og_url": null, - # "title": "Secret of Mana" - # } - # } - # - # Example usage - # Genius::Annotations.annotation(id:, http_verb: "post", markdown: "Foo **Bar**", - # raw_annotatable_url: "https://example.com") - # will reproduce JSON object via +Genius::Annotations.post_payload+ method. According to last example it will - # return - # { - # annotation: { - # body: { - # markdown: "Foo **Bar**" - # } - # }, - # referent: { - # raw_annotatable_url: "https://example.com", - # fragment: null, - # context_for_display: { - # before_html: null, - # after_html: null - # } - # }, - # web_page: { - # canonical_url: null, - # og_url: null, - # title: null - # } - # } - # - # There is a full list of possible params: - # * annotation - # * body - # * markdown - The text for the note, in - # [markdown](https://help.github.com/articles/github-flavored-markdown/) _(Required)_ - # * referent - # * raw_annotatable_url - The original URL of the page _(Required)_ - # - # * fragment - The highlighted fragment _(Required)_ - # * context_for_display - # - # * before_html - The HTML before the highlighted fragment (prefer up to 200 characters) - # - # * after_html - The HTML after the highlighted fragment (prefer up to 200 characters) - # * web_page At least one required - # - # * canonical_url - The href property of the ++ tag on the page. Including it will - # help make sure newly created annotation appear on the correct page - # - # * og_url - The content property of the tag on the page. Including it will help make sure newly created - # annotation appear on the correct page - # - # * title - The title of the page - # - # +PUT /annotations/:id+
- # Requires scope: _manage_annotation_
- # Updates an annotation created by the authenticated user. Accepts the same parameters as POST /annotation above. - # - # @example Example usage - # Genius::Annotations.annotations(id: 10225840, http_verb: "put") - # - # +DELETE /annotations/:id+
- # Requires scope: _manage_annotation_
- # Deletes an annotation created by the authenticated user. - # - # @example Example usage - # Genius::Annotations.annotations(id: 10225840, http_verb: "delete") - # - # +PUT /annotations/:id/upvote+
- # Requires scope: _vote_
- # Votes positively for the annotation on behalf of the authenticated user. - # - # @example Example usage - # Genius::Annotations.annotations(id: 10225840, http_verb: "put", action: "upvote") - # - # +PUT /annotations/:id/downvote+
- # Requires scope: _vote_
- # Votes negatively for the annotation on behalf of the authenticated user. - # - # @example Example usage - # Genius::Annotations.annotations(id: 10225840, http_verb: "put", action: "vote") - # - # +PUT /annotations/:id/unvote+
- # Requires scope: _vote_
- # Removes the authenticated user's vote (up or down) for the annotation. - # - # @example Example usage - # Genius::Annotations.annotations(id: 10225840, http_verb: "put", action: "vote") + # Data for a specific annotation. Supports GET, POST, PUT, DELETE verbs with optional voting actions. + # + # @param [Integer] id ID of the annotation. + # @param [String?] action Action for PUT request: +nil+, +upvote+, +downvote+, or +unvote+. + # @param [String?] token Token to access https://api.genius.com. + # @param [String] http_verb HTTP verb: +get+, +post+, +put+, +delete+. + # @param [Hash] options Options for POST/PUT payload. + # @raise [ArgumentError] if +action+ is set for non-PUT request. + # @return [Hash, nil] def annotations(id:, action:, token:, http_verb: 'get', options: {}) return if token.nil? && !Auth.authorized?.nil? @@ -155,10 +25,16 @@ def annotations(id:, action:, token:, http_verb: 'get', options: {}) private - # +Genius::Annotations.request+ -> HTTParty::Response + # Sends an HTTP request based on the verb and returns the raw response. # # @private - # @see .annotations + # @param [Integer] id ID of the annotation. + # @param [String?] action Action for PUT request. + # @param [String?] token Token to access https://api.genius.com. + # @param [String] http_verb HTTP verb. + # @param [Hash] options Options for POST/PUT payload. + # @raise [ArgumentError] if HTTP verb is invalid. + # @return [HTTParty::Response] def request(id:, action:, token:, http_verb:, options:) case http_verb when 'get' then HTTParty.get("#{Api::RESOURCE}/annotations/#{id}?access_token=#{token_ext(token)}") @@ -170,10 +46,15 @@ def request(id:, action:, token:, http_verb:, options:) end end - # +Genius::Annotations.put_request+ -> HTTParty::Response + # Sends a PUT request with optional voting action. # # @private - # @see .annotations + # @param [Integer] id ID of the annotation. + # @param [String?] action Action: +nil+, +upvote+, +downvote+, or +unvote+. + # @param [String?] token Token to access https://api.genius.com. + # @param [Hash] options Options for PUT payload. + # @raise [ArgumentError] if +action+ is invalid. + # @return [HTTParty::Response] def put_request(id:, action:, token:, options:) case action when nil then HTTParty.put("#{Api::RESOURCE}/annotations/#{id}/#{action}?access_token=#{token_ext(token)}", @@ -186,21 +67,10 @@ def put_request(id:, action:, token:, options:) end end - # +Genius::Annotations.post_payload+ -> String + # Builds a JSON payload for POST and PUT requests from options. # # @private - # @param [Hash] options Options for PUT response. - # @option options [String] :markdown The text for the note, in - # {markdown}[https://help.github.com/articles/github-flavored-markdown/]. - # @option options [String] :raw_annotatable_url The original URL of the page. - # @option options [String] :fragment The highlighted fragment. - # @option options [String] :before_html The HTML before the highlighted fragment (prefer up to 200 characters). - # @option options [String] :after_html The HTML after the highlighted fragment (prefer up to 200 characters). - # @option options [String] :canonical_url The href property of the tag - # on the page. Including it will help make sure newly created annotation appear on the correct page. - # @option options [String] :og_url The content property of the tag on - # the page. Including it will help make sure newly created annotation appear on the correct page. - # @option options [String] :title The title of the page. + # @param [Hash] options Options containing +:markdown+, +:raw_annotatable_url+, +:fragment+, etc. # @return [String] def post_payload(options: {}) { diff --git a/lib/genius/api/artists.rb b/lib/genius/api/artists.rb index 77bd551..d1e00f9 100644 --- a/lib/genius/api/artists.rb +++ b/lib/genius/api/artists.rb @@ -5,16 +5,12 @@ module Genius # usually a musician or group of musicians. module Artists class << self - # +Genius::Artists.artists+ -> Hash - # # Data for a specific artist. # - # @param [String] token Token to access https://api.genius.com. - # @param [String] id ID of the song. - # @raise [ArgumentError] if +id+ is +nil+. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [Hash] - # @return [NilClass] if TokenError exception raised. + # @param [String?] token Token to access https://api.genius.com. + # @param [Integer?] id ID of the artist. + # @raise [ArgumentError] if +id+ is nil. + # @return [Hash, nil] def artists(token: nil, id: nil) Auth.authorized?(method_name: "#{Module.nesting[1].name}.#{__method__}") if token.nil? Errors.validate_token(token) unless token.nil? @@ -24,21 +20,12 @@ def artists(token: nil, id: nil) JSON.parse(response) end - # +Genius::Artists.artists_songs+ -> Hash | NilClass - # - # Documents (songs) for the artist specified. By default, 20 items are returned for each request. + # Songs for the artist specified. By default 20 items per request. # - # @param [String] token Token to access https://api.genius.com. - # @param [String] id ID of the song. - # @param [Hash] options - # @option options [Integer] :per_page Number of results to return per request. - # @option options [Integer] :page Paginated offset, (e.g., +per_page=5&page=3+ returns songs 11-15). - # @option options [String] :sort +title+ (default) or +popularity+. - # @raise [ArgumentError] if +sort+ got incorrect value. - # @raise [ArgumentError] if +per_page+ or +page+ are negative. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [Hash] - # @return [NilClass] if TokenError exception raised. + # @param [String?] token Token to access https://api.genius.com. + # @param [Integer?] id ID of the artist. + # @param [Hash] options Optional query params: +:sort+, +:per_page+, +:page+. + # @return [Hash, nil] def artists_songs(token: nil, id: nil, options: {}) return if token.nil? && !Auth.authorized?.nil? @@ -54,42 +41,37 @@ def artists_songs(token: nil, id: nil, options: {}) private - # +Genius::Artists.validate+ -> value - # - # A helper method which validates some options for artists endpoint. + # Validates sort, per_page, and page options for artists endpoint. # - # @param [Array] sort_values - # @param [Hash] options - # @return [NilClass] + # @private + # @param [Array] sort_values Allowed sort values. + # @param [Object] options Options with +:sort+, +:per_page+, +:page+. + # @return [void] def validate(sort_values, **options) validate_sort(options[:sort], sort_values) validate_page_per_page(options[:per_page]) validate_page_per_page(options[:page]) end - # +Genius::Artists.validate_sort+ -> value + # Validates the sort option against allowed values. # - # A helper method which validates sort options for artists endpoint. - # - # @see Artists.artists_songs - # @param [String] sort - # @param [Array] sort_values Possible values for sort. - # @raise [ArgumentError] if sort is invalid value. - # @return [Object] + # @private + # @param [String?] sort Sort value to validate. + # @param [Array] sort_values Allowed sort values. + # @raise [ArgumentError] if +sort+ is not in +sort_values+. + # @return [void] def validate_sort(sort, sort_values) return unless sort && !sort_values.include?(sort) raise ArgumentError, "`sort` can't be #{sort}. Possible values: #{sort_values.join(', ')}." end - # +Genius::Artists.validate_page_per_page+ -> value - # - # A helper method which validates per_page or page option for artists endpoint. + # Validates that per_page or page is not negative. # - # @see Artists.artists_songs - # @param [Integer] page_per_page - # @raise [ArgumentError] if per_page or page does not exist or negative. - # @return [NilClass] + # @private + # @param [Integer?] page_per_page Value to validate. + # @raise [ArgumentError] if value is negative. + # @return [void] def validate_page_per_page(page_per_page) raise ArgumentError, "`per_page` or `page` can't be negative." if page_per_page&.negative? end diff --git a/lib/genius/api/authorization.rb b/lib/genius/api/authorization.rb index 81cd066..6da7186 100644 --- a/lib/genius/api/authorization.rb +++ b/lib/genius/api/authorization.rb @@ -8,31 +8,22 @@ module Genius # Genius::Auth.login="yuiaYqbncErCVwItjQxFspNWUZLhGpXrPbkvgbgHSEKJRAlToamzMfdOeDB" module Auth class << self - # +Genius::Auth.token=+ -> true or false - # - # +Genius::Auth.token=+ is a setter which handles all possible exceptions - # under the hood during authentication. It means that you should never use - # +token=+ method unless you actually know that your credentials are - # valid (not recommended). + # Sets the authentication token after validation. # # @param [String] token Token to access https://api.genius.com. - # @raise [TokenError] if +token+ is invalid. + # @raise [Genius::Errors::TokenError] if +token+ is invalid. # @return [String] - # @see .authorized? def token=(token) Genius::Errors.validate_token(token) @token = token end - # +Genius::Auth.authorized?+ -> true or false - # - # +authorized?+ method checks if user in current session is authorized. + # Checks if the current token is authorized. Returns +false+ on validation failure. # - # @param [NilClass|String] method_name Optional param to pass method name - # where exception was raised. - # @raise [TokenError] if +token+ is invalid. + # @param [String] token Token to validate. + # @param [String] method_name Method name for error messages. + # @raise [Genius::Errors::TokenError] # @return [Boolean] - # @todo somehow detect exceptions as boolean type def authorized?(token = @token, method_name: "#{Module.nesting[1].name}.#{__method__}") Errors.validate_token(token, method_name: method_name) rescue Genius::Errors::TokenError @@ -41,12 +32,9 @@ def authorized?(token = @token, method_name: "#{Module.nesting[1].name}.#{__meth true end - # +Genius::Auth.logout!+ -> NilClass - # - # +logout!+ method modifies a +token+ object and revoke session by - # setting +nil+ to the +token+. + # Revokes the current session by setting the token to +nil+. # - # @return [NilClass] + # @return [nil] def logout! @token = nil unless @token.nil? end diff --git a/lib/genius/api/errors.rb b/lib/genius/api/errors.rb index 84aec05..8b22644 100644 --- a/lib/genius/api/errors.rb +++ b/lib/genius/api/errors.rb @@ -59,9 +59,12 @@ class GeniusExceptionSuperClass < StandardError class TokenError < GeniusExceptionSuperClass attr_reader :msg, :exception_type, :method_name - # @param [String (frozen)] msg Exception message. - # @param [String (frozen)] exception_type Exception type. - # @return [String (frozen)] + # Initializes a token validation error with optional method name hint. + # + # @param [String] msg Error message. + # @param [String] exception_type Error type identifier. + # @param [String?] method_name Optional method name for user hint. + # @return [void] def initialize(msg: 'Invalid token. The access token provided is expired, revoked, malformed or invalid for ' \ 'other reasons.', exception_type: 'token_error', method_name: nil) @msg = if method_name.nil? @@ -79,9 +82,11 @@ def initialize(msg: 'Invalid token. The access token provided is expired, revoke class LyricsNotFoundError < GeniusExceptionSuperClass attr_reader :msg, :exception_type - # @param [String (frozen)] msg Exception message. - # @param [String (frozen)] exception_type Exception type. - # @return [String (frozen)] + # Initializes a lyrics-not-found error. + # + # @param [String] msg Error message. + # @param [String] exception_type Error type identifier. + # @return [void] def initialize(msg: 'Lyrics not found in current session. Retrying...', exception_type: 'invalid_lyrics') @msg = msg @exception_type = exception_type @@ -94,23 +99,21 @@ def initialize(msg: 'Lyrics not found in current session. Retrying...', exceptio class PageNotFound < GeniusExceptionSuperClass attr_reader :msg, :exception_type - # @param [String (frozen)] msg Exception message. - # @param [String (frozen)] exception_type Exception type. - # @return [String (frozen)] + # Initializes a page-not-found error. + # + # @param [String] msg Error message. + # @param [String] exception_type Error type identifier. + # @return [void] def initialize(msg: 'Page not found. Try again with another response', exception_type: 'page_not_found') @msg = msg @exception_type = exception_type super(msg) end - # +Genius::Errors::PageNotFound.page_not_found?+ -> true or false - # - # +PageNotFound.page_not_found?+ method is used to be a predicate for - # handling 404 error. + # Checks if the HTML response indicates a page-not-found error. # - # @param [Object] html - # @return [TrueClass] if genius page is not found - # @return [FalseClass] if genius page is found + # @param [Object] html Parsed HTML document. + # @return [Boolean] def self.page_not_found?(html) html.text.include?('Page not found') end @@ -121,16 +124,10 @@ def self.page_not_found?(html) # +Genius::Errors+ scope. module DynamicRescue class << self - # +Genius::Errors::DynamicRescue.rescue+ -> value - # - # +Genius::Errors::DynamicRescue.rescue_from+ is a helper method, - # which, according to reflection, redefine singleton method for - # specified module, adding to it exception handler for DRY pattern. - # - # @todo: add docs + # Wraps singleton methods of +klass+ with exception handling via {DynamicRescue.rescue_from}. # - # @param [Object] klass Class name of structure - module/class/etc. - # @return [Object] + # @param [Module] klass Module whose singleton methods to wrap. + # @return [Array] def rescue(klass) DynamicRescue.rescue_from klass.singleton_methods, klass, GeniusExceptionSuperClass do |e| puts "Error description: #{e.msg}\nException type: #{e.exception_type}" @@ -138,11 +135,13 @@ def rescue(klass) end end - # @param [Object] meths List of methods to redefine. - # @param [Object] klass Class name of structure - module/class/etc. - # @param [Object] exception Exception class. - # @param [Proc] handler Body of rescue block. - # @return [Object] + # Redefines each method in +meths+ on +klass+ to rescue +exception+ and yield to the block. + # + # @param [Array] meths Method names to wrap. + # @param [Module] klass Module to redefine methods on. + # @param [Module] exception Exception class to rescue. + # @raise [StandardError] + # @return [Array] def rescue_from(meths, klass, exception, &) meths.each do |meth| old = klass.singleton_method(meth) @@ -157,6 +156,12 @@ def rescue_from(meths, klass, exception, &) end class << self + # Validates the access token by checking length and making a test request to the API. + # + # @param [String?] token Token to validate. + # @param [String?] method_name Optional method name for error hints. + # @raise [StandardError] if +token+ is nil, wrong length, or invalid. + # @return [void] def validate_token(token, method_name: nil) raise TokenError.new(method_name: method_name) if token.nil? || token.size != 64 @@ -165,36 +170,13 @@ def validate_token(token, method_name: nil) raise TokenError.new(method_name: method_name) unless status == 200 end - # +Genius::Errors.error_handle(token)+ -> true or false + # Validates token and raises on failure. Returns +true+ if valid. # - # @deprecated Since 0.2.1 - # @param [String] token Token to access https://api.genius.com. - # @param [NilClass or String] method_name Optional param to pass method - # name where exception was raised. + # @deprecated Use {.validate_token} instead. + # @param [String?] token Token to validate. + # @param [String?] method_name Optional method name for error hints. + # @raise [StandardError] if token is invalid. # @return [Boolean] - # - # @example - # begin - # Genius::Errors.validate_token(token) - # rescue Genius::Errors::TokenError => e - # puts e.message - # puts e.exception_type - # end - # This method is necessary to handle all errors during validation. - # +token+ param is not optional and it is needed to validate token - # itself. +method_name+ param optional and it to passes - # method name in error exception for dynamical error message, and - # because of unimportance this method is - # +nil+ by default. If you are ready to pass method, it will look like - # this: - # - # @example - # begin - # Genius::Errors.error_handle(token, method_name: __method__) - # rescue Genius::Errors::TokenError => e - # puts e.message - # puts e.exception_type - # end def error_handle?(token, method_name: nil) if token.nil? raise TokenError.new(msg: 'Token is required for this method. Please, add token via ' \ @@ -208,20 +190,13 @@ def error_handle?(token, method_name: nil) private - # +Genius::Errors.check_status(token)+ -> true or false - # - # @deprecated Since 0.2.1 - # This method was made to check token state. Token must be 64-sized - # string and could be validated only if response status equals 200. - # More description in {docs}[https://docs.genius.com/] and - # {api-clients page}[https://genius.com/api-clients] or in - # {TokenError documentation}[Genius::Auth.TokenError]. + # Checks if the token returns a 200 status from the API. # + # @deprecated Use {.validate_token} instead. # @private - # @param [String] token Token to access https://api.genius.com. + # @param [String] token Token to check. + # @raise [TokenError] if the response status is not 200. # @return [Boolean] - # - # @see .error_handle def check_status?(token) return false if token.size != 64 || token.nil? diff --git a/lib/genius/api/referents.rb b/lib/genius/api/referents.rb index 1130983..e67c65f 100644 --- a/lib/genius/api/referents.rb +++ b/lib/genius/api/referents.rb @@ -11,29 +11,13 @@ module Referents class << self # Endpoint of the resource ENDPOINT = "#{Api::RESOURCE}/referents".freeze - # +Genius::Referents.referents+ -> Hash + # Referents by content item or user. Pass only one of +:song_id+ and +:web_page+. # - # Referents by content item or user responsible for an included - # annotation. You may pass only one of song_id and web_page_id, not both. - # - # @param [Hash] options - # @option options [Integer] :created_by_id ID of a user to get - # referents for. - # @option options [String] :text_format Format for text bodies related - # to the document. One or more of +dom+, +plain+, and +html+, - # separated by commas (defaults to +dom+). See details of each option - # {here}[https://docs.genius.com/#response-format-h1]. - # @option options [Integer] :web_page_id ID of a web page to get - # referents for. - # @option options [Integer] :song_id ID of a song to get referents for. - # @option options [Integer] :per_page Number of results to return per - # request. - # @option options [Integer] :page Paginated offset, - # (e.g., per_page=5&page=3 returns songs 11-15). - # @raise [ArgumentError] if +song_id+ and +web_page+ are presented in - # the same scope. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [Hash] + # @param [String?] token Token to access https://api.genius.com. + # @param [Hash] options Options: +:created_by_id+, +:text_format+, +:web_page_id+, + # +:song_id+, +:per_page+, +:page+. + # @raise [ArgumentError] if both +:song_id+ and +:web_page+ are present. + # @return [Hash, nil] def referents(token: nil, options: {}) return if token.nil? && !Auth.authorized?.nil? diff --git a/lib/genius/api/search.rb b/lib/genius/api/search.rb index 621b180..d0b9276 100644 --- a/lib/genius/api/search.rb +++ b/lib/genius/api/search.rb @@ -4,36 +4,12 @@ module Genius # +Genius::Search+ module provides methods to work with Genius search database module Search class << self - # +Genius::Search.search+ -> NilClass + # Searches Genius for songs, artists, and other content. Optionally filters results by key using +deep_find+. # - # @param [String] token Token to access https://api.genius.com. - # @param [String] query Search query. - # @param [Object] search_by Optional parameter to search by key in - # output +JSON+. - # @raise [ArgumentError] if +query+ got incorrect value. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [String] if +search_by+ is +TrueClass+. - # @return [Hash] if +search_by+ is +FalseClass+. - # @return [NilClass] if TokenError exception raised. - # @todo: refactor example group in method documentation - # This method is a standard Genius API - # {method}[https://docs.genius.com/#search-h2] and it is needed to send - # a request to the server and get information about artists, tracks and - # everything else that may be inside the response body. According to - # https://docs.genius.com/#search-h2, token is required to be in - # response, but you can use this method without use of it! =) - # - # @example - # Genius::Search.search(query: "Ariana Grande") #=> {..., "full_title"=>" thank u, next by Ariana Grande", ...} - # - # Also, you can use this method with +search_by+ param, which is - # needed to search interested data through returned +JSON+. It uses - # +deep_find+ extension under the hood. - # - # @example - # Genius::Search.search(query: "Bones", search_by: "title") #=> ["Dirt", "HDMI", "RestInPeace", "Sodium"] - # - # @see #deep_find + # @param [String?] token Token to access https://api.genius.com. + # @param [String?] query Search query. + # @param [String?] search_by Key to filter results with {Hash#deep_find}. + # @return [Hash, String, nil] def search(token: nil, query: nil, search_by: nil) return if token.nil? && !Auth.authorized?.nil? diff --git a/lib/genius/api/songs.rb b/lib/genius/api/songs.rb index 00ba5fa..017049e 100644 --- a/lib/genius/api/songs.rb +++ b/lib/genius/api/songs.rb @@ -6,24 +6,12 @@ module Songs class << self include Genius::Errors - # +Genius::Songs.songs+ -> NilClass + # Returns song data by ID. Optionally merges lyrics from the Genius page when +combine+ is +true+. # - # This method provides info about song by its id. It is not the same - # with +Genius::Search.search+ method, because it modify a +JSON+ only - # for concrete song id, not for whole search database, which is returned - # in +Genius::Search.search+. - # - # @param [String] token Token to access https://api.genius.com. - # @param [Integer] song_id Song id. - # @raise [PageNotFound] if page is not found. - # @raise [LyricsNotFound] if output JSON is nil. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [String] the error message if +lyrics+ param is +true+. - # @return [Hash] if +lyrics+ param is +false+. - # @return [NilClass] if TokenError exception raised. - # - # @example - # Genius::Songs.songs(song_id: 294649) #=> {"some_kind_of_hash"} + # @param [String?] token Token to access https://api.genius.com. + # @param [Integer?] song_id ID of the song. + # @param [Boolean] combine If +true+, fetches and merges lyrics into the response. + # @return [Hash, String, nil] def songs(token: nil, song_id: nil, combine: false) return if token.nil? && !Auth.authorized?.nil? @@ -36,6 +24,14 @@ def songs(token: nil, song_id: nil, combine: false) private + # Fetches the Genius HTML page for a song and merges lyrics into the API response. + # + # @private + # @param [Integer] song_id ID of the song. + # @param [Hash] response Original API response hash. + # @raise [Errors::PageNotFound] if the song page is not found. + # @raise [Errors::LyricsNotFoundError] if lyrics cannot be parsed. + # @return [Hash, String] def merge_lyrics(song_id, response) output_html = Nokogiri::HTML(HTTParty.get("https://genius.com/songs/#{song_id}")) raise Errors::PageNotFound if Errors::PageNotFound.page_not_found?(output_html) @@ -48,6 +44,12 @@ def merge_lyrics(song_id, response) "Error description: #{e.msg}\nException type: #{e.exception_type}" end + # Extracts the preloaded state JSON from the Genius page HTML. + # + # @private + # @param [Object] output_html Parsed Nokogiri HTML document. + # @raise [Errors::LyricsNotFoundError] if the preloaded state script is not found. + # @return [Hash] def parse_preloaded_state(output_html) unformed_json = output_html.css('script')[17] .text.match(/window\.__PRELOADED_STATE__\s=\sJSON.parse\('(?(?:.+?))'\);/) @@ -58,14 +60,12 @@ def parse_preloaded_state(output_html) public - # +Genius::Songs.get_lyrics+ -> Hash - # - # +Genius::Songs.get_lyrics+ method is used for extracting lyrics in - # plain text format. + # Extracts lyrics as plain text from the Genius song page. # - # @param [Integer] song_id Song id. - # @raise [ArgumentError] if +song_id+ is blank. - # @return [Hash] + # @param [Integer] song_id ID of the song. + # @raise [ArgumentError] if +song_id+ is nil. + # @raise [NoMethodError] + # @return [String] def get_lyrics(song_id) raise ArgumentError, '`song_id` should be not blank!' if song_id.nil? diff --git a/lib/genius/api/web_pages.rb b/lib/genius/api/web_pages.rb index 33df173..085e3e5 100644 --- a/lib/genius/api/web_pages.rb +++ b/lib/genius/api/web_pages.rb @@ -5,27 +5,11 @@ module Genius # be attached. Web pages map 1-to-1 with unique, canonical URLs. module WebPages class << self - # +Genius::WebPages.lookup+ -> value + # Looks up a web page by URL variants and returns Genius metadata. # - # Information about a web page retrieved by the page's full URL - # (including protocol). The returned data includes Genius's ID for the - # page, which may be used to look up associated referents with the - # {/referents}[https://docs.genius.com/#/referents-index] endpoint. - # - # Data is only available for pages that already have at least one - # annotation. - # - # Provide as many of the following variants of the URL as possible: - # @param [Hash] options - # @option options [String] :raw_annotatable_url The URL as it would - # appear in a browser. - # @option options [String] :canonical_url The URL as specified by an - # appropriate tag in a page's . - # @option options [String] :og_url The URL as specified by an - # og:url tag in a page's . - # @raise [ArgumentError] if +song_id+ is blank. - # @raise [TokenError] if +token+ or +Genius::Auth.token+ are invalid. - # @return [Hash] + # @param [String?] token Token to access https://api.genius.com. + # @param [Hash] options URL variants: +:raw_annotatable_url+, +:canonical_url+, +:og_url+. + # @return [Hash, nil] def lookup(token: nil, options: {}) return if token.nil? && !Auth.authorized?.nil? From 989638a9bd86e627b8af552c2a2415b22f96502f Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:46:55 +0300 Subject: [PATCH 6/7] [0.3.0] Add `CHANGELOG`, update `README`/`CONTRIBUTING`, add community files (#8) --- .github/ISSUE_TEMPLATE/bug_report.md | 36 +++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 24 ++++ .github/PULL_REQUEST_TEMPLATE.md | 23 ++++ CHANGELOG.md | 32 ++++- CODE_OF_CONDUCT.md | 48 +++++-- CONTRIBUTING.md | 59 +++++++- README.md | 160 +++++++++++++++------- SECURITY.md | 14 ++ 9 files changed, 339 insertions(+), 62 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7581674 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Report a bug to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## Description + + + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + + + +## Actual behavior + + + +## Environment + +- Ruby version: +- Gem version: +- OS: + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..f547360 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Genius API Documentation + url: https://github.com/unurgunite/genius-api + about: Genius API documentation diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8220a9f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest a new feature or improvement +title: '' +labels: enhancement +assignees: '' + +--- + +## Problem + + + +## Proposed solution + + + +## Alternatives + + + +## Additional context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..782967b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Summary + + + +## Related issues + + + +## Checklist + +- [ ] `rubocop` — 0 offenses +- [ ] `rspec` — all tests pass +- [ ] `rbs validate` — OK +- [ ] `steep check` — no type errors +- [ ] `docscribe lib` — OK + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactoring +- [ ] Documentation +- [ ] CI / infrastructure diff --git a/CHANGELOG.md b/CHANGELOG.md index b10f498..4acc888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,37 @@ ## [Unreleased] -## [0.1.0] - 2021-03-23 +## [0.3.0] - 2026-06-12 -- Initial release +### Added + +- Ruby >= 3.2 requirement +- RBS type signatures for all API modules and extensions +- RBS collection via `rbs_collection.yaml` +- Steep type checker setup +- `docscribe` gem for automated YARD documentation generation +- `.docscribe.yml` config with RBS integration (`rbs.enabled: true`, `rbs.collection: true`, `collapse_generics: true`) +- YARD documentation (100% documented, 0 warnings) +- CI/CD pipeline with GitHub Actions (rubocop, rspec, docscribe checks) +- CHANGELOG.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md +- `bin/release` release script + +### Changed + +- Updated `actions/checkout` to v4, `ruby/setup-ruby` to v1 +- Updated `dotenv`, `parallel`, `rspec`, `rubocop`, `yard`, `coderay` dependencies +- Improved YARD annotations with meaningful descriptions and RBS-derived types +- README.md: updated requirements, added RBS/Steep/docscribe sections + +### Fixed + +- RuboCop offenses across all source files +- Anonymous block parameter YARD warning in `errors.rb` +- Type annotations aligned with RBS signatures ## [0.2.0] - 2022-04-03 - Updated documentation. Added first tests + +## [0.1.0] - 2021-03-23 + +- Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 51126f5..fc88789 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,7 +2,10 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for +everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity +and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, +or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. @@ -29,45 +32,62 @@ Examples of unacceptable behavior include: ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take +appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, +issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. ## Scope -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing +the community in public spaces. Examples of representing our community include using an official e-mail address, posting +via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at the [issues page](https://github.com/unurgunite/genius-api/issues). All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible +for enforcement at the [issues page](https://github.com/unurgunite/genius-api/issues). All complaints will be reviewed +and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem +in violation of this Code of Conduct: ### 1. Correction -**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the +community. -**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation +and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. -**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including +unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding +interactions in community spaces as well as external channels like social media. Violating these terms may lead to a +temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified +period of time. No public or private interaction with the people involved, including unsolicited interaction with those +enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban -**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate +behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. @@ -76,9 +96,11 @@ Community leaders will follow these Community Impact Guidelines in determining t This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. -Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). +Community Impact Guidelines were inspired +by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. +https://www.contributor-covenant.org/faq. Translations are available +at https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1a6c8c..c11b190 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,11 @@ This file includes code style references which can help when contributing to pro 3. [Define methods][4.3] 5. [Contributing][5] 1. [Git commits][5.1] - 2. [Rubocop][5.2] + 2. [RuboCop][5.2] + 3. [RSpec][5.3] + 4. [Docscribe][5.4] + 5. [RBS][5.5] + 6. [Steep][5.6] 6. [TODO][6] 7. [License][7] @@ -256,6 +260,51 @@ See: [RuboCop documentation](https://docs.rubocop.org/rubocop/index.html) Before pushing, you must be sure that the code you write is correct, so it should always be formatted through a static analyzer. Look towards the `rubocop -D` and `rubocop -A` commands. +### RSpec + +Before pushing, run the test suite to ensure nothing is broken: + +```shell +bundle exec rspec +``` + +The test suite currently has 84 examples with 0 failures. + +### Docscribe + +Docscribe keeps YARD annotations in sync with the codebase. After making changes to method signatures, run: + +```shell +bundle exec docscribe lib +``` + +This will check for missing or outdated documentation. To regenerate annotations automatically: + +```shell +bundle exec docscribe -A --rbs-collection lib +``` + +The project's docscribe configuration is in `docscribe.yml` at the project root. + +### RBS + +RBS type signatures are located in `sig/` directory. After adding or changing methods, update the corresponding `.rbs` +files. Validate signatures with: + +```shell +bundle exec rbs validate +``` + +### Steep + +Steep performs static type checking using RBS signatures. Run the type checker: + +```shell +bundle exec steep check +``` + +All type errors must be resolved before committing. + ## TODO - [ ] Look at the documentation with fresh eyes @@ -306,6 +355,14 @@ the [CC BY-SA 4.0 License](https://creativecommons.org/licenses/by-sa/4.0/) [5.2]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#rubocop +[5.3]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#rspec + +[5.4]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#docscribe + +[5.5]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#rbs + +[5.6]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#steep + [6]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#todo [7]:https://github.com/unurgunite/genius-api/blob/master/CONTRIBUTING.md#license diff --git a/README.md b/README.md index 5cdbaba..41efd6e 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,21 @@ API and much more! 2. [Automatic installation][2.1.2] 2. [Build via bundler][2.2] 3. [Usage][3] -4. [Todo][4] +4. [Requirements][4] + 1. [Common usage][4.1] + 2. [Development purposes][4.2] 5. [Development][5] -6. [Requirements][6] - 1. [Common usage][6.1] - 2. [Development purposes][6.2] -7. [Project style guide][7] -8. [Contributing][8] -9. [License][9] -10. [Code of Conduct][10] +6. [Type checking][6] + 1. [RBS][6.1] + 2. [Steep][6.2] +7. [Documentation][7] + 1. [YARD][7.1] + 2. [Docscribe][7.2] +8. [Project style guide][8] +9. [Contributing][9] +10. [License][10] +11. [Code of Conduct][11] +12. [TODO][12] ## Overview @@ -91,13 +97,35 @@ gem install genius-api All docs are available at the separate page: https://unurgunite.github.io/genius-api_docs/ -## TODO +## Requirements -- [x] Update `README.md` -- [ ] Refactor code base -- [x] Add tests with RSpec (76 examples, 0 failures) -- [ ] 100% code coverage with RuboCop -- [ ] Refactor code according to the style guides +This section will show dependencies which are used in the project. This section splits in two other sections — +requirements for common use and requirements for the development purposes. + +### Common use + +The `genius-api` gem requires Ruby >= 3.2 and is built on top of two other gems: + +| Dependencies | Description | +|-----------------|---------------------------------------------------------------------------------------------| +| [HTTParty][101] | The HTTParty gem is used to send requests to the REST client of the https://api.genius.com/ | +| [Nokogiri][102] | The Nokogiri gem is used to represent XML objects as Ruby structures. | + +### Development purposes + +For the development purposes `genius-api` gem uses: + +| Dependencies | Description | +|-----------------|------------------------------------------------------------------------------------------| +| [RSpec][201] | The RSpec gem is used for test which are located in a separate folder under `spec` name. | +| [RuboCop][202] | The RuboCop gem is used for code formatting. | +| [Rake][203] | The Rake gem is used for building tasks as generating documentation. | +| [Dotenv][204] | The Dotenv gem is used for setting variables for test environment (`token`, for e.g.). | +| [Coderay][205] | The Coderay gem is used for colorizing Rspec output. | +| [YARD][206] | The YARD gem is used for the documentation. | +| [RBS][207] | The RBS gem is used for Ruby type signatures. | +| [Steep][208] | The Steep gem is used for static type checking. | +| [Docscribe][209]| The Docscribe gem is used for automated YARD documentation generation. | ## Development @@ -108,32 +136,53 @@ To install this gem onto your local machine, run `bundle exec rake install`. To version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). -## Requirements +## Type checking -This section will show dependencies which are used in the project. This section splits in two other sections — -requirements for common use and requirements for the development purposes. +This project uses RBS for type signatures and Steep for static type checking. -### Common use +### RBS -The `genius-api` gem is built on top of two other gems: +RBS type signatures are located in `sig/` directory. To validate them: -| Dependencies | Description | -|-----------------|---------------------------------------------------------------------------------------------| -| [HTTParty][101] | The HTTParty gem is used to send requests to the REST client of the https://api.genius.com/ | -| [Nokogiri][102] | The Nokogiri gem is used to represent XML objects as Ruby structures. | +```shell +bundle exec rbs validate +``` -### Development purposes +### Steep -For the development purposes `genius-api` gem uses: +To run the Steep type checker: + +```shell +bundle exec steep check +``` + +## Documentation + +This project uses YARD for documentation with Docscribe for automated annotation generation. + +### YARD + +To generate YARD documentation: + +```shell +bundle exec yard doc -o docs +``` + +Generated docs will be placed in `docs/` (gitignored). -| Dependencies | Description | -|----------------|------------------------------------------------------------------------------------------| -| [RSpec][201] | The RSpec gem is used for test which are located in a separate folder under `spec` name. | -| [RuboCop][202] | The RuboCop gem is used for code formatting. | -| [Rake][203] | The Rake gem is used for building tasks as generating documentation. | -| [Dotenv][204] | The Dotenv gem is used for setting variables for test environment (`token`, for e.g.). | -| [Coderay][205] | The Coderay gem is used for colorizing Rspec output. | -| [YARD][206] | The YARD gem is used for the documentation. | +### Docscribe + +Docscribe keeps YARD annotations in sync with the codebase. To check for missing or outdated docs: + +```shell +bundle exec docscribe lib +``` + +To regenerate annotations using RBS type information: + +```shell +bundle exec docscribe -A --rbs-collection lib +``` ## Project style guide @@ -158,11 +207,6 @@ git push origin refactor And then make new pull request with additional notes of what you have done. The better the changes are scheduled, the faster the PR will be checked. -## Code of Conduct - -Everyone interacting in the `Genius::Api` project's codebases, issue trackers, chat rooms and mailing lists is expected -to follow the [code of conduct](https://github.com/unurgunite/genius-api/blob/master/CODE_OF_CONDUCT.md). - ## License The gem is available as open source under the terms of the [GPLv3 License](https://opensource.org/licenses/GPL-3.0). The @@ -179,6 +223,12 @@ the [New BSD License](https://opensource.org/licenses/BSD-3-Clause) ![CC BY-SA 4.0](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nc.svg) ![BSD license logo](https://upload.wikimedia.org/wikipedia/commons/4/42/License_icon-bsd-88x31.png) +## TODO + +- [ ] Refactor code base +- [ ] 100% code coverage with RuboCop +- [ ] Refactor code according to the style guides + [1]:https://github.com/unurgunite/genius-api#overview [2]:https://github.com/unurgunite/genius-api#installation @@ -193,23 +243,35 @@ the [New BSD License](https://opensource.org/licenses/BSD-3-Clause) [3]:https://github.com/unurgunite/genius-api#usage -[4]:https://github.com/unurgunite/genius-api#todo +[4]:https://github.com/unurgunite/genius-api#requirements + +[4.1]:https://github.com/unurgunite/genius-api#common-usage + +[4.2]:https://github.com/unurgunite/genius-api#development-purposes [5]:https://github.com/unurgunite/genius-api#development -[6]:https://github.com/unurgunite/genius-api#requirements +[6]:https://github.com/unurgunite/genius-api#type-checking + +[6.1]:https://github.com/unurgunite/genius-api#rbs + +[6.2]:https://github.com/unurgunite/genius-api#steep + +[7]:https://github.com/unurgunite/genius-api#documentation -[6.1]:https://github.com/unurgunite/genius-api#common-usage +[7.1]:https://github.com/unurgunite/genius-api#yard -[6.2]:https://github.com/unurgunite/genius-api#development-purposes +[7.2]:https://github.com/unurgunite/genius-api#docscribe -[7]:https://github.com/unurgunite/genius-api#project-style-guide +[8]:https://github.com/unurgunite/genius-api#project-style-guide -[8]:https://github.com/unurgunite/genius-api#contributing +[9]:https://github.com/unurgunite/genius-api#contributing -[9]:https://github.com/unurgunite/genius-api#license +[10]:https://github.com/unurgunite/genius-api#license -[10]:https://github.com/unurgunite/genius-api#code-of-conduct +[11]:https://github.com/unurgunite/genius-api#code-of-conduct + +[12]:https://github.com/unurgunite/genius-api#todo [101]:https://rubygems.org/gems/httparty @@ -226,3 +288,9 @@ the [New BSD License](https://opensource.org/licenses/BSD-3-Clause) [205]:https://rubygems.org/gems/coderay [206]:https://rubygems.org/gems/yard + +[207]:https://rubygems.org/gems/rbs + +[208]:https://rubygems.org/gems/steep + +[209]:https://rubygems.org/gems/docscribe diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..60b4ea2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 0.3.x | :white_check_mark: | +| < 0.3.0 | :x: | + +## Reporting a Vulnerability + +If you discover a security vulnerability, please do **not** open a public issue. Instead, send a private email to the maintainers at the address listed in the gem specification. + +We will acknowledge receipt within 48 hours and provide an estimated timeline for a fix. From 9810aa7ed8a1fe68618a32bd3a11e5e66db12abf Mon Sep 17 00:00:00 2001 From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:44:26 +0300 Subject: [PATCH 7/7] [0.3.0] Add docscribe step to CI pipeline (#9) --- .github/workflows/ci.yml | 3 +++ Gemfile.lock | 2 +- bin/release | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100755 bin/release diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e24d92..42750c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,8 @@ jobs: - name: RBS validate run: bundle exec rbs validate + - name: Docscribe + run: bundle exec docscribe lib --rbs-collection + - name: Steep check run: bundle exec steep check diff --git a/Gemfile.lock b/Gemfile.lock index bd354d7..d05b811 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,7 +112,7 @@ GEM lint_roller (~> 1.1) regexp_parser (>= 2.0) rubocop (~> 1.86, >= 1.86.2) - rubocop-sorted_methods_by_call (1.2.2) + rubocop-sorted_methods_by_call (1.2.3) lint_roller rubocop (>= 1.72.0) ruby-progressbar (1.13.0) diff --git a/bin/release b/bin/release new file mode 100755 index 0000000..2e1da4b --- /dev/null +++ b/bin/release @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail + +bash <(curl -sSL https://raw.githubusercontent.com/unurgunite/release_gem/refs/heads/master/release)