diff --git a/Gemfile b/Gemfile index 5a8ffc43..3cdbf04c 100644 --- a/Gemfile +++ b/Gemfile @@ -20,3 +20,6 @@ gem 'turbolinks' gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] gem 'uglifier' gem 'web-console', group: :development +gem 'factory_bot_rails', group: [:development, :test] +gem "webmock", group: [:development, :test] +gem 'rails-controller-testing', group: [:development, :test] \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 14ec6457..27ddba5c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -69,6 +69,7 @@ GEM addressable (2.8.1) public_suffix (>= 2.0.2, < 6.0) bcrypt (3.1.18) + bigdecimal (3.1.8) bindex (0.8.1) builder (3.2.4) byebug (11.1.3) @@ -91,6 +92,9 @@ GEM execjs coffee-script-source (1.12.2) concurrent-ruby (1.1.10) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) devise (4.8.1) bcrypt (~> 3.0) @@ -102,9 +106,15 @@ GEM digest (3.1.0) erubi (1.11.0) execjs (2.8.1) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) ffi (1.15.5) globalid (1.0.0) activesupport (>= 5.0) + hashdiff (1.1.0) i18n (1.12.0) concurrent-ruby (~> 1.0) jbuilder (2.11.5) @@ -170,6 +180,10 @@ GEM activesupport (= 7.0.4) bundler (>= 1.15.0) railties (= 7.0.4) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -250,6 +264,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.23.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.9) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) @@ -266,12 +284,14 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot_rails jbuilder listen pg pry-rails puma rails (~> 7.0.3) + rails-controller-testing rspec-rails sass-rails selenium-webdriver @@ -280,6 +300,7 @@ DEPENDENCIES tzinfo-data uglifier web-console + webmock RUBY VERSION ruby 3.1.2p20 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d05ea0f5..5dc88019 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,3 +13,57 @@ *= require_tree . *= require_self */ + + #main_component { + width: 65%; + margin: 0 auto; + background-color: #f6f6ef; + padding-left: 1em; + max-width: 1000px; + } + + #login_buttons { + float: right; + margin: 5 auto; + padding: 1em; + } + + .button { + width: 6em; + margin-bottom: .25em; + } + + #title { + font-size: 3em; + color: #ff6600; + } + #header { + padding-bottom: 1em; + padding-top: 1em; + } + + .story_box { + color: grey; + padding-bottom: .5em; + } + + .arrow { + padding-right: .25em; + text-decoration: none; + color: grey + } + + .title_line { + text-decoration: none; + font-weight: bold; + color: black + } + + .counter { + padding-left: .3em; + padding-right: .45em; + } + + .favorited { + color: orangered !important; + } diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb new file mode 100644 index 00000000..32eaa711 --- /dev/null +++ b/app/controllers/favorites_controller.rb @@ -0,0 +1,20 @@ +class FavoritesController < ApplicationController + before_action :restrict_access + + def create + Favorite.find_or_create_by!(story_id: params[:story_id], user_id: current_user.id) + redirect_back(fallback_location: root_path) + end + + def destroy + Favorite.find_by(story_id: params[:story_id], user_id: current_user&.id)&.destroy + redirect_back(fallback_location: root_path) + end + + private + + def restrict_access + redirect_to new_user_session_path unless current_user + end +end + diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index ce3bf586..5a124a3f 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,2 +1,13 @@ class PagesController < ApplicationController + def home + @logged_in = current_user.present? + @stories_data = StoryService.get_stories_data(current_user) + end + + def interesting_stories + @logged_in = current_user.present? + @stories_data = StoryService.get_interesting_stories(current_user) + render :home + end end + diff --git a/app/models/favorite.rb b/app/models/favorite.rb new file mode 100644 index 00000000..43e758d8 --- /dev/null +++ b/app/models/favorite.rb @@ -0,0 +1,6 @@ +class Favorite < ApplicationRecord + belongs_to :user + belongs_to :story + + validates :user_id, :story_id, presence: true +end \ No newline at end of file diff --git a/app/models/story.rb b/app/models/story.rb new file mode 100644 index 00000000..33ef4529 --- /dev/null +++ b/app/models/story.rb @@ -0,0 +1,7 @@ +class Story < ApplicationRecord + has_many :favorites, dependent: :destroy + has_many :users, through: :favorites + + validates :external_story_id, :title, :url, :by, :time, presence: true + +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..2e269dc5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,4 +3,17 @@ class User < ApplicationRecord # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + has_many :favorites, dependent: :destroy + has_many :stories, through: :favorites + + before_create :placeholder_first_name + + private + + def placeholder_first_name + unless first_name + self.first_name = email.split("@").first + end + end end diff --git a/app/services/hacker_news_api.rb b/app/services/hacker_news_api.rb new file mode 100644 index 00000000..183603a2 --- /dev/null +++ b/app/services/hacker_news_api.rb @@ -0,0 +1,70 @@ +class HackerNewsApi + BASE_URL = 'https://hacker-news.firebaseio.com' + RETRY_LIMIT = 3 + + class ApiInternalServiceError < StandardError; end + + attr_accessor :retry_count + + def initialize + @retry_count = RETRY_LIMIT + end + + def self.get_current_stories_ids + new.get_current_stories_ids + end + + def self.get_story_details(story_id) + new.get_story_details(story_id) + end + + def get_current_stories_ids + default_return = [] + uri = URI("#{BASE_URL}/v0/topstories.json") + api_request(uri, default_return) + end + + # Return an object with story data + def get_story_details(story_id) + default_return = {} + return default_return if story_id.blank? + + uri = URI("#{BASE_URL}/v0/item/#{story_id}.json") + api_request(uri, default_return) + end + + private + + def api_request(uri, default_return) + response = response(uri) + handle_response(response) || default_return + rescue + if @retry_count > 0 + Rails.logger.info("Retrying request to #{uri}.") + @retry_count -= 1 + retry + else + Rails.logger.error("Exceeded maximum retries. API request failed with code #{response.code}") + default_return + end + end + + def response(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + headers = {'Accept': 'application/json'} + request = Net::HTTP::Get.new(uri.request_uri, headers) + http.request(request) + end + + def handle_response(response) + case response.code.to_i + when 200..299 + JSON.parse(response.body) + when 400..499 + Rails.logger.error("API request failed with code #{response.code}. Bad Request Error, please check request.") + when 500..599 + raise ApiInternalServiceError + end + end +end \ No newline at end of file diff --git a/app/services/story_service.rb b/app/services/story_service.rb new file mode 100644 index 00000000..1091ee48 --- /dev/null +++ b/app/services/story_service.rb @@ -0,0 +1,104 @@ +class StoryService + DEFAULT_NUMBER_OF_STORIES = 15 + + attr_reader :user + + def initialize(user = nil) + @user = user + end + + def self.get_stories_data(user) + new(user).get_stories_data + end + + def self.get_interesting_stories(user) + new(user).get_interesting_stories + end + + def get_stories_data + # Fetch ids of current stories from api + external_stories_ids = get_stories_ids_from_api + process_external_story_id_data(external_stories_ids).compact + end + + def get_interesting_stories + external_stories_ids = Story.joins(:favorites).distinct.pluck(:external_story_id) + process_external_story_id_data(external_stories_ids).compact + end + + private + + def process_external_story_id_data(external_stories_ids) + external_stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |external_story_id| + story = find_or_fetch_story(external_story_id) + next unless story + + generate_story_data_hash(story) + end + end + + def generate_story_data_hash(story) + { + story: story, + favorite: get_favorite_user_string(story), + count: story_favorite_by_users(story).count, + favorite_by_user: favorite_by_user?(story) + } + end + + def get_favorite_user_string(story) + usernames = story_favorite_by_users(story) + generate_favorite_user_string(usernames) + end + + # Return a story object or nil if story is not found and api request fail + def find_or_fetch_story(external_story_id) + Story.includes(:users).find_by(external_story_id: external_story_id) || + fetch_and_create_story(external_story_id) || + nil + end + + # Call api to get story data and create story if successful + def fetch_and_create_story(external_story_id) + fetched_story_data = get_story_data_from_api(external_story_id) + return nil unless fetched_story_data + + create_story(fetched_story_data) + end + + def create_story(data) + story = Story.new + story.external_story_id = data['id'] + story.title = data['title'] + story.by = data['by'] + story.url = data['url'] + story.time = Time.at(data['time']) + story.save! + story + rescue + nil + end + + def generate_favorite_user_string(usernames) + count = usernames.count + output = usernames.first(3) + output << "and #{count - 3} more" if count > 3 + output.join(", ") + end + + def story_favorite_by_users(story) + story.users.pluck(:first_name) + end + + def favorite_by_user?(story) + story.users.find_by(id: @user&.id).present? + end + + def get_stories_ids_from_api + HackerNewsApi.get_current_stories_ids + end + + def get_story_data_from_api(external_story_id) + HackerNewsApi.get_story_details(external_story_id) + end +end \ No newline at end of file diff --git a/app/views/pages/_story.html.erb b/app/views/pages/_story.html.erb new file mode 100644 index 00000000..24386de0 --- /dev/null +++ b/app/views/pages/_story.html.erb @@ -0,0 +1,22 @@ +
+
+ <%= link_to '⬆', favorite_create_path(story_id: story.id), method: :post, class: "arrow #{favorite_by_user && 'favorited'}" %> + <%= "#{(index + 1).to_s.rjust(2, "0")}. " %> + <%= link_to story.title, story.url, :target => "_blank", class:'title_line' %> +
+ +
+ + + <%= count %> + + <%= "by #{story.by} #{time_ago_in_words(story.time)} ago." %> + +
+
+ <%= link_to '⬇', favorite_destroy_path(story_id: story.id), method: :delete, class: "arrow"%> + + <%= "liked by: #{favorite}" if favorite.present? %> + +
+
\ No newline at end of file diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 8bfd8294..c3fffba5 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1 +1,25 @@ -

Welcome to Top News

+
+ + +
+ <% @stories_data.each.with_index do |story_data, index| %> + <%= render 'story', index: index, **story_data %> + <% end %> +
+
\ No newline at end of file diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 962d4a7c..eaa67d0d 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. Devise.setup do |config| @@ -8,7 +14,11 @@ # confirmation, reset password and unlock tokens in the database. # Devise will use the `secret_key_base` as its `secret_key` # by default. You can change it below and use your own secret key. - # config.secret_key = '3d8fdc791713e92d72d135adbaa58657b3661394b046a293632d59b4cf4ba76fc5369d8f67202bce93a62fc0aacf24878bcd5f7879d859b588919783efcf7eb3' + # config.secret_key = 'e36fe7d0b592f3e4a96141f2d39d1afc28548ecb9f57ddd9ace7c461ac71ba77ba0c7940aa8042e9fd533e1db62b3b31b1efd7d542ddccadf199dffa239aa1c5' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' # ==> Mailer Configuration # Configure the e-mail address which will be shown in Devise::Mailer, @@ -64,7 +74,10 @@ # Tell if authentication through HTTP Auth is enabled. False by default. # It can be set to an array that will enable http authentication only for the # given strategies, for example, `config.http_authenticatable = [:database]` will - # enable it only for database authentication. The supported strategies are: + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: # :database = Support basic authentication with authentication key + password # config.http_authenticatable = false @@ -99,18 +112,21 @@ # config.reload_routes = true # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 11. If + # For bcrypt, this is the cost for hashing the password and defaults to 12. If # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. # # Limiting the stretches to just one in testing will increase the performance of # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use # a value less than 10 in other environments. Note that, for bcrypt (the default # algorithm), the cost increases exponentially with the number of stretches (e.g. # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). - config.stretches = Rails.env.test? ? 1 : 11 + config.stretches = Rails.env.test? ? 1 : 12 # Set up a pepper to generate the hashed password. - # config.pepper = '98f368775fc8e418915aa4d2185d5659bda9b2a2aae35a6bb5c2863536c1c7e1ff8884578d43324639ae4d9bc4c851a7c71739103378841fd7f8f9e0db32daea' + # config.pepper = 'cc12718875ad12bb7bb92291343813b09a3037098a7f034d21ed23511538a0ca8755f5dbcf0f8c7a1539c9eaa271952504213b47bb61943952d9b505f5b6bfd3' # Send a notification to the original email when the user's email is changed. # config.send_email_changed_notification = false @@ -122,8 +138,11 @@ # A period that the user is allowed to access the website even without # confirming their account. For instance, if set to 2.days, the user will be # able to access the website for two days without confirming their account, - # access will be blocked just in the third day. Default is 0.days, meaning - # the user cannot access the website without confirming their account. + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. # config.allow_unconfirmed_access_for = 2.days # A period that the user is allowed to confirm their account before their @@ -244,7 +263,7 @@ # should add them to the navigational formats lists. # # The "*/*" below is required to match Internet Explorer requests. - # config.navigational_formats = ['*/*', :html] + config.navigational_formats = ['*/*', :html] # The default HTTP method used to sign out a resource. Default is :delete. config.sign_out_via = :delete @@ -276,4 +295,17 @@ # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Turbolinks configuration + # If your app is using Turbolinks, Turbolinks::Controller needs to be included to make redirection work correctly: + # + # ActiveSupport.on_load(:devise_failure_app) do + # include Turbolinks::Controller + # end + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true end diff --git a/config/routes.rb b/config/routes.rb index c12ef082..e550a52a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,9 @@ Rails.application.routes.draw do devise_for :users root to: 'pages#home' + + get '/interesting_stories', to: 'pages#interesting_stories', as: :interesting_stories + post '/favorite', to: 'favorites#create', as: :favorite_create + delete '/favorite', to: 'favorites#destroy', as: :favorite_destroy + end diff --git a/db/migrate/20240723010637_create_story.rb b/db/migrate/20240723010637_create_story.rb new file mode 100644 index 00000000..ddb09fe6 --- /dev/null +++ b/db/migrate/20240723010637_create_story.rb @@ -0,0 +1,15 @@ +class CreateStory < ActiveRecord::Migration[7.0] + def change + create_table :stories do |t| + t.integer :external_story_id, null: false + t.string :title, null: false + t.string :url, null: false + t.string :by, null: false + t.datetime :time, null: false + + t.timestamps + end + + add_index :stories, :external_story_id + end +end diff --git a/db/migrate/20240723015449_create_favorite.rb b/db/migrate/20240723015449_create_favorite.rb new file mode 100644 index 00000000..a2366e96 --- /dev/null +++ b/db/migrate/20240723015449_create_favorite.rb @@ -0,0 +1,11 @@ +class CreateFavorite < ActiveRecord::Migration[7.0] + def change + create_table :favorites do |t| + t.integer :user_id, null: false + t.integer :story_id, null: false + t.timestamps + end + + add_index :favorites, [:story_id, :user_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..55c4848e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,29 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2018_02_28_212101) do +ActiveRecord::Schema[7.0].define(version: 2024_07_23_015449) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "favorites", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "story_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["story_id", "user_id"], name: "index_favorites_on_story_id_and_user_id" + end + + create_table "stories", force: :cascade do |t| + t.integer "external_story_id", null: false + t.string "title", null: false + t.string "url", null: false + t.string "by", null: false + t.datetime "time", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_story_id"], name: "index_stories_on_external_story_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" diff --git a/spec/controllers/favorites_controller_spec.rb b/spec/controllers/favorites_controller_spec.rb new file mode 100644 index 00000000..a24147e2 --- /dev/null +++ b/spec/controllers/favorites_controller_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe FavoritesController, type: :request do + let(:user) { create(:user) } + let(:story) { create(:story) } + + shared_examples 'a redirect to login' do + it 'redirects to login page' do + subject + + expect(response).to redirect_to(new_user_session_path) + expect(response.status).to eq(302) + end + end + + describe 'POST #create' do + subject { post path, params: params } + let(:path) { '/favorite' } + let(:params) { {story_id: story.id} } + + before do + sign_in(user) + end + + it 'favorites a story for a user' do + expect { subject }.to change { Favorite.count }.by(1) + end + + it 'redirects to home page' do + subject + expect(response).to redirect_to(root_path) + expect(response.status).to eq(302) + end + + context 'when there is no user signed in' do + before do + sign_out(user) + end + + it_behaves_like 'a redirect to login' + end + + context 'when a user already favored a story' do + before do + Favorite.create!(story_id: story.id, user_id: user.id) + end + + it 'does not create another favorite record' do + expect { subject }.not_to change { Favorite.count } + end + end + end + + describe 'DELETE #destroy' do + subject { delete path, params: params } + let(:path) { '/favorite' } + let(:params) { {story_id: story.id} } + + before do + sign_in(user) + Favorite.create!(story_id: story.id, user_id: user.id) + end + + it 'deletes a favorite association for a user and a story' do + expect { subject }.to change { Favorite.count }.by(-1) + end + + it 'redirects to home page' do + subject + expect(response).to redirect_to(root_path) + expect(response.status).to eq(302) + end + + context 'when there is no user signed in' do + before do + sign_out(user) + end + + it_behaves_like 'a redirect to login' + end + + context 'when the user never had a favored association to the story' do + let(:params) { {story_id: 'unknown_id'} } + before do + Favorite.create!(story_id: story.id, user_id: user.id) + end + + it 'does not delete a favorite record' do + expect { subject }.not_to change { Favorite.count } + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/pages_controller_spec.rb b/spec/controllers/pages_controller_spec.rb new file mode 100644 index 00000000..4ba9cbcd --- /dev/null +++ b/spec/controllers/pages_controller_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe PagesController, type: :request do + describe 'GET #home' do + subject { get root_path } + before do + stub_request(:get, "#{HackerNewsApi::BASE_URL}/v0/topstories.json") + .with(headers: { + 'Accept'=>'application/json', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent'=>'Ruby' + }) + .to_return(status: 200, body: "", headers: {}) + end + + it 'renders the home page' do + subject + + expect(response).to render_template(root_path) + expect(response).to be_successful + end + end + + describe 'GET #interesting_stories' do + subject { get interesting_stories_path } + + it 'renders the home page with correct data' do + subject + + expect(response).to render_template(root_path) + expect(response).to be_successful + end + + end +end \ No newline at end of file diff --git a/spec/factories/story.rb b/spec/factories/story.rb new file mode 100644 index 00000000..03d22ee3 --- /dev/null +++ b/spec/factories/story.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :story do + external_story_id { 1 } + title { 'Testing Story Title' } + url { 'https://reallyrealnews.com/story/1' } + by { 'the news guy' } + time { DateTime.now } + end +end \ No newline at end of file diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 00000000..f2b85fc3 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :user do + first_name {:foo} + last_name { :bar } + email { "f@b.c" } + password { 'foobar123' } + end +end \ No newline at end of file diff --git a/spec/models/favorite_spec.rb b/spec/models/favorite_spec.rb new file mode 100644 index 00000000..732a8299 --- /dev/null +++ b/spec/models/favorite_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe Favorite do + context "creating a new Favorite" do + let(:user) { create(:user) } + let(:story) { create(:story) } + let(:attrs) { {user_id: user.id, story_id: story.id} } + + it "should create a favorite association." do + expect { described_class.create!(attrs) }.to change{ described_class.count }.by(1) + end + + it "should raise error unless required fields are present" do + expect { described_class.create!(attrs.except(:user_id)) }.to raise_error(ActiveRecord::RecordInvalid) + expect { described_class.create!(attrs.except(:story_id)) }.to raise_error(ActiveRecord::RecordInvalid) + end + end +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb new file mode 100644 index 00000000..46548a06 --- /dev/null +++ b/spec/models/story_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Story do + context "creating a new story" do + let(:attrs) do + { + external_story_id: 1, + title: 'Testing Story Title', + url: 'https://reallyrealnews.com/story/1', + by: 'the news guy', + time: DateTime.now + } + end + + it "should create a story" do + expect { described_class.create!(attrs) }.to change{ described_class.count }.by(1) + end + + it "should raise error unless required fields are present" do + expect { described_class.create!(attrs.except(:external_story_id)) }.to raise_error(ActiveRecord::RecordInvalid) + expect { described_class.create!(attrs.except(:title)) }.to raise_error(ActiveRecord::RecordInvalid) + expect { described_class.create!(attrs.except(:url)) }.to raise_error(ActiveRecord::RecordInvalid) + expect { described_class.create!(attrs.except(:by)) }.to raise_error(ActiveRecord::RecordInvalid) + expect { described_class.create!(attrs.except(:time)) }.to raise_error(ActiveRecord::RecordInvalid) + end + + context 'when a story record is destroyed' do + let(:user) { create(:user) } + let(:story) { create(:story)} + + before do + Favorite.create!(user_id: user.id, story_id: story.id) + end + + it 'destroy story\'s favorites association' do + expect(user.favorites.count).to eq(1) + expect { user.destroy }.to change { Favorite.count }.by(-1) + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b51dc1c3..18b6c4f8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -13,5 +13,19 @@ it "should require a password" do expect(User.new(attrs.except(:password))).to be_invalid end + + context 'when a user record is destroyed' do + let(:user) { create(:user) } + let(:story) { create(:story) } + + before do + Favorite.create!(user_id: user.id, story_id: story.id) + end + + it 'destroy user\'s favorites association' do + expect(user.favorites.count).to eq(1) + expect { user.destroy }.to change { Favorite.count }.by(-1) + end + end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bbe1ba57..7636d2ef 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,5 +1,6 @@ # This file is copied to spec/ when you run 'rails generate rspec:install' require 'spec_helper' +require 'support/factory_bot' ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../../config/environment', __FILE__) # Prevent database truncation if the environment is production @@ -54,4 +55,6 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + config.include Devise::Test::IntegrationHelpers, type: :request + config.include Rails.application.routes.url_helpers end diff --git a/spec/services/hacker_news_api_spec.rb b/spec/services/hacker_news_api_spec.rb new file mode 100644 index 00000000..a7d15ca3 --- /dev/null +++ b/spec/services/hacker_news_api_spec.rb @@ -0,0 +1,130 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe HackerNewsApi do + let(:service) { described_class.new } + + describe '#new / initialize' do + it 'is initialize with a retry count' do + expect(service.retry_count).to eq(3) + end + end + + shared_examples 'a successful request' do + it 'returns a request with data in body' do + subject + + expect(response.code).to eq("200") + expect(response.body).to eq(mock_response_body) + end + end + + shared_examples 'a failed request with code 400 to 499' do + let(:status) { 404 } + let(:mock_response_body) { 'not found' } + let(:error_message) { "API request failed with code #{status}. Bad Request Error, please check request." } + it 'logs the error' do + expect(Rails.logger).to receive(:error).once.with(error_message) + + subject + end + end + + shared_examples 'a failed request with code 500 to 599' do + let(:status) { 500 } + let(:mock_response_body) { 'hmmm something is not right' } + let(:error_message) { "Exceeded maximum retries. API request failed with code #{status}" } + + it 'retries the request' do + expect(Rails.logger).to receive(:info).exactly(3).times.with("Retrying request to #{uri}.") + expect { subject }.to change { service.retry_count }.by(-3) + expect(subject).to eq(default_return) + end + + context 'when retries are exhausted' do + before do + service.retry_count = 0 + end + + it 'logs the request as an error' do + expect(Rails.logger).to receive(:error).once.with(error_message) + + expect(subject).to eq(default_return) + end + end + end + + describe '#get_current_stories_ids' do + subject { service.get_current_stories_ids } + let(:uri) { URI("#{HackerNewsApi::BASE_URL}/v0/topstories.json") } + let(:response) { service.send(:response, uri) } + let(:mock_response_body) { '[1, 2, 3, 4, 5]' } + let(:status) { 200 } + let(:default_return) { [] } + + before do + stub_request(:get, uri) + .with(headers: {'Accept': 'application/json'}) + .to_return(status: status, body: mock_response_body) + end + + it 'returns the parsed array data' do + expect(subject).to eq(JSON.parse(mock_response_body)) + end + + it_behaves_like 'a successful request' + + context 'when response code is 400 to 499' do + it_behaves_like 'a failed request with code 400 to 499' + end + + context 'when response code is 500 to 599' do + it_behaves_like 'a failed request with code 500 to 599' + end + end + + describe '#get_story_details' do + subject { service.get_story_details(external_story_id) } + let(:story) { create(:story) } + let(:external_story_id) { story.external_story_id } + let(:uri) { URI("#{HackerNewsApi::BASE_URL}/v0/item/#{external_story_id}.json") } + let(:response) { service.send(:response, uri) } + let(:status) { 200 } + let(:default_return) { {} } + let(:mock_response_body) do + { + 'by': 'a new news person', + 'descendants': 31, + 'id': 424242424, + 'kids': [42, 52], + 'score': 93, + 'time': 1314205301, + 'title': 'a new news story from a new news person', + 'type': 'story', + 'url': 'https://reallyrealnews.com/story/2' + }.to_json + end + + before do + stub_request(:get, uri) + .with(headers: {'Accept': 'application/json'}) + .to_return(status: status, body: mock_response_body) + end + + + it 'returns the parsed object data' do + expect(subject).to eq(JSON.parse(mock_response_body)) + end + + it_behaves_like 'a successful request' + + context 'when response code is 400 to 499' do + it_behaves_like 'a failed request with code 400 to 499' + end + + context 'when response code is 500 to 599' do + it_behaves_like 'a failed request with code 500 to 599' + end + end + +end \ No newline at end of file diff --git a/spec/services/story_service_spec.rb b/spec/services/story_service_spec.rb new file mode 100644 index 00000000..5397a4ed --- /dev/null +++ b/spec/services/story_service_spec.rb @@ -0,0 +1,210 @@ +require 'rails_helper' + +RSpec.describe StoryService do + let(:service) { described_class.new(user) } + let(:user) { create(:user) } + let!(:story_1) { create(:story) } + let!(:story_2) { create(:story, external_story_id: 2) } + let!(:story_3) { create(:story, external_story_id: 3) } + let(:hacker_news_api_stories_id_response) do + [story_1.external_story_id, story_2.external_story_id, story_3.external_story_id] + end + let!(:processed_data) do + [ + {story: story_1, favorite: '', count: 0, favorite_by_user: false}, + {story: story_2, favorite: '', count: 0, favorite_by_user: false}, + {story: story_3, favorite: '', count: 0, favorite_by_user: false} + ] + end + + describe '#new / initialize' do + it 'can initialize with a user' do + expect(service.user).to eq(user) + end + + context 'when a user is not provided' do + let(:user) { nil } + + it 'can initialize without a user' do + expect(service.user).to be_nil + end + end + end + + describe '#get_stories_data' do + subject { service.get_stories_data } + + before do + allow(service).to receive(:get_stories_ids_from_api).and_return(hacker_news_api_stories_id_response) + end + + it 'calls the HackerNewsApi to fetch story ids' do + expect(service).to receive(:get_stories_ids_from_api) + + subject + end + + it 'process the story ids' do + expect(service).to receive(:process_external_story_id_data) + .with(hacker_news_api_stories_id_response) + .and_return(processed_data) + + expect(subject).to eq(processed_data) + end + end + + describe '#get_interesting_stories' do + subject { service.get_interesting_stories } + let(:another_user) { create(:user, first_name: "tester", email: 'faker@l.c')} + let(:processed_data) do + [ + {story: story_1, favorite: 'foo, tester', count: 2, favorite_by_user: true} + ] + end + + before do + Favorite.create!(user_id: user.id, story_id: story_1.id) + Favorite.create!(user_id: another_user.id, story_id: story_1.id) + end + + it 'process the story ids' do + expect(service).to receive(:process_external_story_id_data) + .with([story_1.external_story_id]) + .and_return(processed_data) + + expect(subject).to eq(processed_data) + end + end + + describe '#generate_story_data_hash' do + subject { service.send(:generate_story_data_hash, story_1) } + + it 'generates a hash with story, favorite, count, and favorite_by_user keys' do + expect(subject).to eq(processed_data.first) + expect(subject).to have_key(:story) + expect(subject).to have_key(:favorite) + expect(subject).to have_key(:count) + expect(subject).to have_key(:favorite_by_user) + end + end + + describe '#find_or_fetch_story' do + subject { service.send(:find_or_fetch_story, story_id_for_lookup) } + let(:story_id_for_lookup) { story_1.external_story_id} + + it 'query database and return story if it exists' do + expect(subject).to eq(story_1) + end + + context 'when the story does not exist' do + let(:story_id_for_lookup) { 'some random val' } + + it 'attempt to fetch the data from HackerNewsApi' do + expect(service).to receive(:fetch_and_create_story).with(story_id_for_lookup) + subject + end + + context 'if HackerNewsApi fails to fetch' do + before do + allow(service).to receive(:fetch_and_create_story).with(story_id_for_lookup).and_return(nil) + end + + it 'returns nil' do + expect(subject).to be_nil + end + end + end + end + + describe '#fetch_and_create_story' do + subject { service.send(:fetch_and_create_story, story_1.external_story_id) } + let(:fetched_story_data) do + HashWithIndifferentAccess.new( + 'by': 'a new news person', + 'descendants': 31, + 'id': 424242424, + 'kids': [42, 52], + 'score': 93, + 'time': 1314205301, + 'title': 'a new news story from a new news person', + 'type': 'story', + 'url': 'https://reallyrealnews.com/story/2' + ) + end + + before do + allow(service).to receive(:get_story_data_from_api).with(story_1.external_story_id).and_return(fetched_story_data) + end + + it 'calls the HackerNewsApi to fetch story data' do + expect(service).to receive(:get_story_data_from_api).with(story_1.external_story_id) + + subject + end + + it 'creates a new story with the fetched data' do + expect { subject }.to change { Story.count }.by(1) + end + + context 'when api call fails' do + let(:fetched_story_data) { nil } + + it 'does not create a story' do + expect { subject }.not_to change { Story.count } + end + end + end + + describe '#generate_favorite_user_string' do + subject { service.send(:generate_favorite_user_string, usernames) } + let(:usernames) { ["Donald", "Lawrence", "Marge"] } + + it 'returns a string with the usernames' do + expect(subject).to eq(usernames.join(", ")) + end + + context 'when 4 or more usernames' do + before do + usernames.unshift('Tony') + end + + it 'returns only the first 3 usernames and additional message' do + expect(subject).to eq( "#{usernames.first(3).join(', ')}, and #{usernames.count - 3} more" ) + end + end + end + + describe '#story_favorite_by_users' do + subject { service.send(:story_favorite_by_users, story_1) } + + before do + Favorite.create!(user_id: user.id, story_id: story_1.id) + end + + it 'returns a list of usernames that have favorited this story' do + expect(subject).to include(user.first_name) + end + end + + describe '#favorite_by_user?' do + subject { service.send(:favorite_by_user?, story_to_check)} + let(:story_to_check) { story_1 } + + before do + Favorite.create!(user_id: user.id, story_id: story_1.id) + end + + it 'returns a true boolean when user did favorite this story' do + expect(subject).to be true + end + + context 'when a favorite record is not found' do + let(:story_to_check) { story_2 } + + it 'returns a false boolean' do + expect(subject).to be false + end + end + + end +end \ No newline at end of file diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 00000000..72ba14df --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,5 @@ +require 'factory_bot' + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end \ No newline at end of file