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
+", - 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
-Test lyrics content
+', - 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
-Test lyrics content
+ 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+ 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 [Arrayper_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\('(? 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)


+## 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)