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"%>
+
+
\ 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