From a868b8462ad87937f1e6ebf373be67b1dafd4ac1 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 20:38:29 -0400 Subject: [PATCH 01/21] working from controller --- app/controllers/pages_controller.rb | 45 +++++++++++++++++++++++++++++ app/views/pages/_story.html.erb | 10 +++++++ app/views/pages/home.html.erb | 8 +++++ 3 files changed, 63 insertions(+) create mode 100644 app/views/pages/_story.html.erb diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index ce3bf586..776167c1 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,2 +1,47 @@ +require 'net/http' + class PagesController < ApplicationController + def home + + # api call for news list + # looped api call for each story? + @logged_in = current_user.present? + ids = get_top_stories_id.first(20) + @stories = ids.map { |id| get_story_details(id) } + end + + def get_top_stories_id + uri = URI("https://hacker-news.firebaseio.com/v0/topstories.json") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) + + if response.code == "200" + data = JSON.parse(response.body) + else + puts "API request failed with code #{response.code}" + end + end + + def get_story_details(story_id) + return nil if story_id.blank? + + # find or create by this story id + # return a story object, with if you liked it and if others liked it. + + uri = URI("https://hacker-news.firebaseio.com/v0/item/#{story_id}.json") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) + + if response.code == "200" + data = JSON.parse(response.body) + else + puts "API request failed with code #{response.code}" + end + end + end + diff --git a/app/views/pages/_story.html.erb b/app/views/pages/_story.html.erb new file mode 100644 index 00000000..177c8a6e --- /dev/null +++ b/app/views/pages/_story.html.erb @@ -0,0 +1,10 @@ + +<%= "#{(index + 1).to_s.rjust(2, "0")}. " %> +<% "starring feature, hijack this url and send back javascript?" %> +<%= link_to story['title'], story['url'], :target => "_blank" %> +
+<%= "By #{story['by']} #{time_ago_in_words(Time.at(story['time']).to_datetime)} ago." %> +
+<%= "Starred by: #{['user_1', 'user_2', 'user_3', 'user_4', nil, nil, nil].sample(3).compact.join(", ")}" %> +
+
\ No newline at end of file diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 8bfd8294..60f98963 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1 +1,9 @@

Welcome to Top News

+<% if @logged_in%> +<%= button_to "Logout", destroy_user_session_path, method: :delete %> +<% else%> +<%= button_to "Login", new_user_session_path %> +<%end%> +<% @stories.each.with_index do |story, index| %> + <%= render "story", story: story, index: index %> +<% end%> \ No newline at end of file From baa5166f39c82effd575973b727d627ac441804b Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 21:05:57 -0400 Subject: [PATCH 02/21] refactor api call to service --- app/controllers/pages_controller.rb | 39 ++--------------------------- app/services/hacker_news_api.rb | 38 ++++++++++++++++++++++++++++ app/services/stories_service.rb | 14 +++++++++++ 3 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 app/services/hacker_news_api.rb create mode 100644 app/services/stories_service.rb diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 776167c1..4ce90c6f 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,4 +1,4 @@ -require 'net/http' +# require 'net/http' class PagesController < ApplicationController def home @@ -6,42 +6,7 @@ def home # api call for news list # looped api call for each story? @logged_in = current_user.present? - ids = get_top_stories_id.first(20) - @stories = ids.map { |id| get_story_details(id) } + @stories = StoriesService.get_stories_data end - - def get_top_stories_id - uri = URI("https://hacker-news.firebaseio.com/v0/topstories.json") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - request = Net::HTTP::Get.new(uri.request_uri) - response = http.request(request) - - if response.code == "200" - data = JSON.parse(response.body) - else - puts "API request failed with code #{response.code}" - end - end - - def get_story_details(story_id) - return nil if story_id.blank? - - # find or create by this story id - # return a story object, with if you liked it and if others liked it. - - uri = URI("https://hacker-news.firebaseio.com/v0/item/#{story_id}.json") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - request = Net::HTTP::Get.new(uri.request_uri) - response = http.request(request) - - if response.code == "200" - data = JSON.parse(response.body) - else - puts "API request failed with code #{response.code}" - 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..8dc512e1 --- /dev/null +++ b/app/services/hacker_news_api.rb @@ -0,0 +1,38 @@ +# require 'net/http' + +class HackerNewsApi + def self.get_current_stories_ids + uri = URI("https://hacker-news.firebaseio.com/v0/topstories.json") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) + + handle_response(response) + end + + def self.get_story_details(story_id) + return nil if story_id.blank? + + uri = URI("https://hacker-news.firebaseio.com/v0/item/#{story_id}.json") + response = response(uri) + + handle_response(response) + end + + def self.response(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Get.new(uri.request_uri) + http.request(request) + end + + def self.handle_response(response) + if response.code == "200" + data = JSON.parse(response.body) + else + puts "API request failed with code #{response.code}" + end + end + +end \ No newline at end of file diff --git a/app/services/stories_service.rb b/app/services/stories_service.rb new file mode 100644 index 00000000..4722c0d1 --- /dev/null +++ b/app/services/stories_service.rb @@ -0,0 +1,14 @@ +module StoriesService + DEFAULT_NUMBER_OF_STORIES = 20 + + def self.get_stories_data + stories_ids = HackerNewsApi.get_current_stories_ids + stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |id| + find_or_fetch_story(id) + end + end + + def self.find_or_fetch_story(story_id) + HackerNewsApi.get_story_details(story_id) + end +end \ No newline at end of file From 061491b28729769527842b6766ebad20e4c407b2 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:29:38 -0400 Subject: [PATCH 03/21] add story --- app/models/story.rb | 4 ++++ db/migrate/20240723010637_create_story.rb | 15 +++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 app/models/story.rb create mode 100644 db/migrate/20240723010637_create_story.rb diff --git a/app/models/story.rb b/app/models/story.rb new file mode 100644 index 00000000..1933c325 --- /dev/null +++ b/app/models/story.rb @@ -0,0 +1,4 @@ +class Story < ApplicationRecord + has_many :favorites + has_many :users, through: :favorites +end \ No newline at end of file diff --git a/db/migrate/20240723010637_create_story.rb b/db/migrate/20240723010637_create_story.rb new file mode 100644 index 00000000..0e12e703 --- /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 :story_id + t.string :title + t.string :url + t.string :by + t.datetime :time + + t.timestamps + end + + add_index :stories, :story_id + end +end From d24a0831ca141c73bfaf4c4db220715bcb965f8c Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:29:51 -0400 Subject: [PATCH 04/21] add fav --- app/models/favorite.rb | 4 ++++ db/migrate/20240723015449_create_favorite.rb | 11 +++++++++++ 2 files changed, 15 insertions(+) create mode 100644 app/models/favorite.rb create mode 100644 db/migrate/20240723015449_create_favorite.rb diff --git a/app/models/favorite.rb b/app/models/favorite.rb new file mode 100644 index 00000000..6c118522 --- /dev/null +++ b/app/models/favorite.rb @@ -0,0 +1,4 @@ +class Favorite < ApplicationRecord + belongs_to :user + belongs_to :story +end \ No newline at end of file diff --git a/db/migrate/20240723015449_create_favorite.rb b/db/migrate/20240723015449_create_favorite.rb new file mode 100644 index 00000000..abb44727 --- /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 + t.integer :story_id + t.timestamps + end + + add_index :favorites, [:story_id, :user_id] + end +end From eb0cb9746f5a2b1c5d443e1bcd24811cc82efb57 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:30:04 -0400 Subject: [PATCH 05/21] add user connection --- app/models/user.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..9a58ff17 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,4 +3,9 @@ class User < ApplicationRecord # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + + has_many :favorites + has_many :stories, through: :favorites + end From df114015b345817b4760ae9f2ad32a6f7f064e07 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:30:24 -0400 Subject: [PATCH 06/21] add fav routes --- config/routes.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/routes.rb b/config/routes.rb index c12ef082..3fc8af4f 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' + # resource :favorite, only: [:create, :destroy] + + post '/favorite', to: 'favorites#create', as: :favorite_create + delete '/favorite', to: 'favorites#destroy', as: :favorite_destroy + end From a993d62cf114a01c761cc901fc6b7357106d9ffd Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:31:18 -0400 Subject: [PATCH 07/21] add fav controller, clean up pages con --- app/controllers/favorites_controller.rb | 12 ++++++++++++ app/controllers/pages_controller.rb | 7 +------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 app/controllers/favorites_controller.rb diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb new file mode 100644 index 00000000..5f976edc --- /dev/null +++ b/app/controllers/favorites_controller.rb @@ -0,0 +1,12 @@ +class FavoritesController < ApplicationController + def create + Favorite.find_or_create_by!(story_id: params[:story_id], user_id: current_user.id) + redirect_to root_path + end + + def destroy + Favorite.find_by(story_id: params[:story_id], user_id: current_user.id)&.destroy + redirect_to root_path + end +end + diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 4ce90c6f..bbf582a2 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,12 +1,7 @@ -# require 'net/http' - class PagesController < ApplicationController def home - - # api call for news list - # looped api call for each story? @logged_in = current_user.present? - @stories = StoriesService.get_stories_data + @stories_data = StoriesService.get_stories_data end end From 6fed5f2d978c33525f3107914c857150e0e6090d Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:34:07 -0400 Subject: [PATCH 08/21] service clean up --- app/services/hacker_news_api.rb | 2 +- app/services/stories_service.rb | 44 ++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/app/services/hacker_news_api.rb b/app/services/hacker_news_api.rb index 8dc512e1..e13fca9d 100644 --- a/app/services/hacker_news_api.rb +++ b/app/services/hacker_news_api.rb @@ -17,7 +17,7 @@ def self.get_story_details(story_id) uri = URI("https://hacker-news.firebaseio.com/v0/item/#{story_id}.json") response = response(uri) - handle_response(response) + data = handle_response(response) end def self.response(uri) diff --git a/app/services/stories_service.rb b/app/services/stories_service.rb index 4722c0d1..a3deb9ad 100644 --- a/app/services/stories_service.rb +++ b/app/services/stories_service.rb @@ -1,14 +1,50 @@ module StoriesService - DEFAULT_NUMBER_OF_STORIES = 20 + DEFAULT_NUMBER_OF_STORIES = 15 def self.get_stories_data stories_ids = HackerNewsApi.get_current_stories_ids stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |id| - find_or_fetch_story(id) - end + story = find_or_fetch_story(id) + next unless story + + user_names = story.users.pluck(:first_name) + { + story: story, + favorite: generate_favorite_user_string(user_names) + } + end.compact end def self.find_or_fetch_story(story_id) - HackerNewsApi.get_story_details(story_id) + Story.find_by(story_id: story_id) || + fetch_and_generate_story(story_id) || + nil + end + + def self.fetch_and_generate_story(story_id) + fetched_story_data = HackerNewsApi.get_story_details(story_id) + return nil unless fetched_story_data + + generate_story(fetched_story_data) + end + + def self.generate_story(data) + story = Story.new + story.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 self.generate_favorite_user_string(user_names) + count = user_names.count + output = user_names.first(3) + output << " and #{count - 3} more" if count > 3 + output.join(", ") end end \ No newline at end of file From 803d92350e3cda1e47c9b0b360bbf258d46437de Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:34:20 -0400 Subject: [PATCH 09/21] views clean up --- app/views/pages/_story.html.erb | 13 ++++++++----- app/views/pages/home.html.erb | 11 ++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/views/pages/_story.html.erb b/app/views/pages/_story.html.erb index 177c8a6e..92171c75 100644 --- a/app/views/pages/_story.html.erb +++ b/app/views/pages/_story.html.erb @@ -1,10 +1,13 @@ +<%= link_to '⬆️', favorite_create_path(story_id: story.id), method: :post if @logged_in %> <%= "#{(index + 1).to_s.rjust(2, "0")}. " %> -<% "starring feature, hijack this url and send back javascript?" %> -<%= link_to story['title'], story['url'], :target => "_blank" %> +<%= link_to story.title, story.url, :target => "_blank" %> +<%= "By #{story.by} #{time_ago_in_words(story.time)} ago." %>
-<%= "By #{story['by']} #{time_ago_in_words(Time.at(story['time']).to_datetime)} ago." %> -
-<%= "Starred by: #{['user_1', 'user_2', 'user_3', 'user_4', nil, nil, nil].sample(3).compact.join(", ")}" %> + +<%= link_to '⬇️', favorite_destroy_path(story_id: story.id), method: :delete if @logged_in %> +<% if favorite.present? %> + <%= "Liked by: #{favorite}" %> +<% end %>

\ No newline at end of file diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 60f98963..abf29e04 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1,9 +1,10 @@

Welcome to Top News

+ <% if @logged_in%> -<%= button_to "Logout", destroy_user_session_path, method: :delete %> -<% else%> -<%= button_to "Login", new_user_session_path %> + <%= button_to "Logout", destroy_user_session_path, method: :delete %> +<% else %> + <%= button_to "Login", new_user_session_path %> <%end%> -<% @stories.each.with_index do |story, index| %> - <%= render "story", story: story, index: index %> +<% @stories_data.each.with_index do |story_data, index| %> + <%= render "story", story: story_data[:story], favorite: story_data[:favorite], index: index %> <% end%> \ No newline at end of file From 2206abfdb36bf18c0cda16c173dafc3dec2da90a Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Mon, 22 Jul 2024 23:34:30 -0400 Subject: [PATCH 10/21] bp --- config/initializers/devise.rb | 48 +++++++++++++++++++++++++++++------ db/schema.rb | 21 ++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) 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/db/schema.rb b/db/schema.rb index acc34f3b..5832a416 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" + t.integer "story_id" + 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 "story_id" + t.string "title" + t.string "url" + t.string "by" + t.datetime "time" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["story_id"], name: "index_stories_on_story_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" From 74841db9a8e31298d232ceebb472f2902de23c57 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Tue, 23 Jul 2024 20:25:27 -0400 Subject: [PATCH 11/21] mvp --- app/assets/stylesheets/application.css | 50 +++++++++++++++++ app/controllers/favorites_controller.rb | 8 ++- app/controllers/pages_controller.rb | 2 +- app/services/hacker_news_api.rb | 74 ++++++++++++++++++------- app/services/stories_service.rb | 61 +++++++++++++++----- app/views/pages/_story.html.erb | 32 +++++++---- app/views/pages/home.html.erb | 26 ++++++--- 7 files changed, 196 insertions(+), 57 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d05ea0f5..70eed576 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,3 +13,53 @@ *= require_tree . *= require_self */ + + #main_component { + width: 65%; + margin: 0 auto; + text-align: left; + background-color: #f6f6ef; + padding-left: 1em; + max-width: 1000px; + } + + #login_buttons { + float: right; + margin: 5 auto; + padding: 1em; + } + + #title { + font-size: 3em; + color: #ff6600; + } + #header { + padding-bottom: 1em; + } + + .story_box { + color: grey; + padding-bottom: 1em; + } + + .arrow { + padding-right: .25em; + text-decoration: none; + color: grey + } + + .title_line { + text-decoration: none; + font-weight: bold; + position: relative; + color: black + } + + .counter { + padding-left: .3em; + padding-right: .45em; + } + + .upvoted { + color: orangered !important; + } diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index 5f976edc..c57720af 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -1,12 +1,18 @@ 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_to root_path end def destroy - Favorite.find_by(story_id: params[:story_id], user_id: current_user.id)&.destroy + Favorite.find_by(story_id: params[:story_id], user_id: current_user&.id)&.destroy redirect_to root_path end + + 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 bbf582a2..d71d87db 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,7 +1,7 @@ class PagesController < ApplicationController def home @logged_in = current_user.present? - @stories_data = StoriesService.get_stories_data + @stories_data = StoriesService.get_stories_data(current_user) end end diff --git a/app/services/hacker_news_api.rb b/app/services/hacker_news_api.rb index e13fca9d..c936164d 100644 --- a/app/services/hacker_news_api.rb +++ b/app/services/hacker_news_api.rb @@ -1,38 +1,70 @@ -# require 'net/http' - class HackerNewsApi - def self.get_current_stories_ids - uri = URI("https://hacker-news.firebaseio.com/v0/topstories.json") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - request = Net::HTTP::Get.new(uri.request_uri) - response = http.request(request) + BASE_URL = 'https://hacker-news.firebaseio.com' + RETRY_LIMIT = 3 + + class ApiInternalServiceError < StandardError; end - handle_response(response) + 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) - return nil if story_id.blank? + 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("https://hacker-news.firebaseio.com/v0/item/#{story_id}.json") - response = response(uri) + uri = URI("#{BASE_URL}/v0/item/#{story_id}.json") + api_request(uri, default_return) + end + + private - data = handle_response(response) + def api_request(uri, default_return) + response = response(uri) + handle_response(response) || default_return + rescue + puts "There was an error when attempting to reach #{uri}." + if @retry_count > 0 + puts "Retrying request." + @retry_count -= 1 + retry + else + puts "Exceeded maximum retries. Default values returned." + Rails.logger.error("API request failed with code #{response.code}") + default_return + end end - def self.response(uri) + def response(uri) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - request = Net::HTTP::Get.new(uri.request_uri) + headers = {'Accept': 'application/json'} + request = Net::HTTP::Get.new(uri.request_uri, headers) http.request(request) end - def self.handle_response(response) - if response.code == "200" - data = JSON.parse(response.body) - else - puts "API request failed with code #{response.code}" + def handle_response(response) + case response.code.to_i + when 200..299 + JSON.parse(response.body) + when 400..499 + Rails.logger.info("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/stories_service.rb b/app/services/stories_service.rb index a3deb9ad..402fb5d0 100644 --- a/app/services/stories_service.rb +++ b/app/services/stories_service.rb @@ -1,34 +1,63 @@ -module StoriesService +class StoriesService DEFAULT_NUMBER_OF_STORIES = 15 - def self.get_stories_data + def initialize(user_id = nil) + @user_id = user_id + end + + def self.get_stories_data(user_id) + new(user_id).get_stories_data + end + + def self.get_favorite_user_string(story) + new.get_favorite_user_string(story) + end + + def get_stories_data stories_ids = HackerNewsApi.get_current_stories_ids + story_data(stories_ids).compact + end + + def get_favorite_user_string(story) + user_names = story_favorite_by_users(story) + generate_favorite_user_string(user_names) + end + + private + + def story_data(stories_ids) stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |id| story = find_or_fetch_story(id) next unless story - user_names = story.users.pluck(:first_name) - { - story: story, - favorite: generate_favorite_user_string(user_names) - } - end.compact + generate_story_data_hash(story) + end end - def self.find_or_fetch_story(story_id) + def generate_story_data_hash(story) + { + story: story, + favorite: get_favorite_user_string(story), + count: story_favorite_by_users(story).count, + favorite_by_user: story.users.find_by(id: @user_id).present? + } + end + + # Return a story object or nil if story is not found and api request fail + def find_or_fetch_story(story_id) Story.find_by(story_id: story_id) || - fetch_and_generate_story(story_id) || + fetch_and_create_story(story_id) || nil end - def self.fetch_and_generate_story(story_id) + def fetch_and_create_story(story_id) fetched_story_data = HackerNewsApi.get_story_details(story_id) return nil unless fetched_story_data - generate_story(fetched_story_data) + create_story(fetched_story_data) end - def self.generate_story(data) + def create_story(data) story = Story.new story.story_id = data['id'] story.title = data['title'] @@ -41,10 +70,14 @@ def self.generate_story(data) nil end - def self.generate_favorite_user_string(user_names) + def generate_favorite_user_string(user_names) count = user_names.count output = user_names.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 end \ No newline at end of file diff --git a/app/views/pages/_story.html.erb b/app/views/pages/_story.html.erb index 92171c75..a648289d 100644 --- a/app/views/pages/_story.html.erb +++ b/app/views/pages/_story.html.erb @@ -1,13 +1,23 @@ +
+
+ <%= link_to '⬆', favorite_create_path(story_id: story.id), method: :post, class: "arrow #{favorite_by_user && 'upvoted'}" %> + <%= "#{(index + 1).to_s.rjust(2, "0")}. " %> + <%= link_to story.title, story.url, :target => "_blank", class:'title_line' %> +
-<%= link_to '⬆️', favorite_create_path(story_id: story.id), method: :post if @logged_in %> -<%= "#{(index + 1).to_s.rjust(2, "0")}. " %> -<%= link_to story.title, story.url, :target => "_blank" %> -<%= "By #{story.by} #{time_ago_in_words(story.time)} ago." %> -
+
+ + + <%= count %> + + <%= "by #{story.by} #{time_ago_in_words(story.time)} ago." %> + +
-<%= link_to '⬇️', favorite_destroy_path(story_id: story.id), method: :delete if @logged_in %> -<% if favorite.present? %> - <%= "Liked by: #{favorite}" %> -<% end %> -
-
\ No newline at end of file +
+ <%= 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 abf29e04..35067a36 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1,10 +1,18 @@ -

Welcome to Top News

+
+ -<% if @logged_in%> - <%= button_to "Logout", destroy_user_session_path, method: :delete %> -<% else %> - <%= button_to "Login", new_user_session_path %> -<%end%> -<% @stories_data.each.with_index do |story_data, index| %> - <%= render "story", story: story_data[:story], favorite: story_data[:favorite], index: index %> -<% end%> \ No newline at end of file + <% @stories_data.each.with_index do |story_data, index| %> + <%= render "story", story: story_data[:story], favorite: story_data[:favorite], count: story_data[:count], favorite_by_user: story_data[:favorite_by_user], index: index %> + <% end %> +
\ No newline at end of file From fad731033e7f76af019fb1a0be0f832874edd725 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 14:10:17 -0400 Subject: [PATCH 12/21] clean up, add model specs --- Gemfile | 1 + Gemfile.lock | 6 +++ app/assets/stylesheets/application.css | 32 ++++++++------- app/models/favorite.rb | 2 + app/models/story.rb | 4 +- app/models/user.rb | 12 +++++- app/services/stories_service.rb | 32 ++++++++------- app/views/pages/_story.html.erb | 3 +- app/views/pages/home.html.erb | 15 ++++--- config/environments/test.rb | 2 +- db/migrate/20240723010637_create_story.rb | 12 +++--- db/migrate/20240723015449_create_favorite.rb | 4 +- db/schema.rb | 16 ++++---- spec/factories/story.rb | 9 +++++ spec/factories/users.rb | 8 ++++ spec/models/favorite_spec.rb | 18 +++++++++ spec/models/story_spec.rb | 41 ++++++++++++++++++++ spec/models/user_spec.rb | 14 +++++++ spec/rails_helper.rb | 1 + spec/support/factory_bot.rb | 5 +++ 20 files changed, 180 insertions(+), 57 deletions(-) create mode 100644 spec/factories/story.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/models/favorite_spec.rb create mode 100644 spec/models/story_spec.rb create mode 100644 spec/support/factory_bot.rb diff --git a/Gemfile b/Gemfile index 5a8ffc43..27409fde 100644 --- a/Gemfile +++ b/Gemfile @@ -20,3 +20,4 @@ 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] diff --git a/Gemfile.lock b/Gemfile.lock index 14ec6457..4591eded 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,6 +102,11 @@ 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) @@ -266,6 +271,7 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot_rails jbuilder listen pg diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 70eed576..5dc88019 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -17,7 +17,6 @@ #main_component { width: 65%; margin: 0 auto; - text-align: left; background-color: #f6f6ef; padding-left: 1em; max-width: 1000px; @@ -29,37 +28,42 @@ padding: 1em; } + .button { + width: 6em; + margin-bottom: .25em; + } + #title { font-size: 3em; color: #ff6600; } #header { padding-bottom: 1em; + padding-top: 1em; } - .story_box { + .story_box { color: grey; - padding-bottom: 1em; - } + padding-bottom: .5em; + } - .arrow { + .arrow { padding-right: .25em; text-decoration: none; color: grey - } + } - .title_line { + .title_line { text-decoration: none; font-weight: bold; - position: relative; color: black - } + } - .counter { + .counter { padding-left: .3em; padding-right: .45em; - } + } - .upvoted { - color: orangered !important; - } + .favorited { + color: orangered !important; + } diff --git a/app/models/favorite.rb b/app/models/favorite.rb index 6c118522..43e758d8 100644 --- a/app/models/favorite.rb +++ b/app/models/favorite.rb @@ -1,4 +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 index 1933c325..1bc5cac2 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -1,4 +1,6 @@ class Story < ApplicationRecord - has_many :favorites + 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 9a58ff17..2e269dc5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,8 +4,16 @@ class User < ApplicationRecord devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable - - has_many :favorites + 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/stories_service.rb b/app/services/stories_service.rb index 402fb5d0..90defff6 100644 --- a/app/services/stories_service.rb +++ b/app/services/stories_service.rb @@ -9,20 +9,12 @@ def self.get_stories_data(user_id) new(user_id).get_stories_data end - def self.get_favorite_user_string(story) - new.get_favorite_user_string(story) - end - def get_stories_data + # Fetch ids of current stories from api stories_ids = HackerNewsApi.get_current_stories_ids story_data(stories_ids).compact end - def get_favorite_user_string(story) - user_names = story_favorite_by_users(story) - generate_favorite_user_string(user_names) - end - private def story_data(stories_ids) @@ -39,17 +31,23 @@ def generate_story_data_hash(story) story: story, favorite: get_favorite_user_string(story), count: story_favorite_by_users(story).count, - favorite_by_user: story.users.find_by(id: @user_id).present? + 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(story_id) - Story.find_by(story_id: story_id) || + Story.find_by(external_story_id: story_id) || fetch_and_create_story(story_id) || nil end + # Call api to get story data and create story if successful def fetch_and_create_story(story_id) fetched_story_data = HackerNewsApi.get_story_details(story_id) return nil unless fetched_story_data @@ -59,7 +57,7 @@ def fetch_and_create_story(story_id) def create_story(data) story = Story.new - story.story_id = data['id'] + story.external_story_id = data['id'] story.title = data['title'] story.by = data['by'] story.url = data['url'] @@ -70,9 +68,9 @@ def create_story(data) nil end - def generate_favorite_user_string(user_names) - count = user_names.count - output = user_names.first(3) + def generate_favorite_user_string(usernames) + count = usernames.count + output = usernames.first(3) output << " and #{count - 3} more" if count > 3 output.join(", ") end @@ -80,4 +78,8 @@ def generate_favorite_user_string(user_names) 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 end \ No newline at end of file diff --git a/app/views/pages/_story.html.erb b/app/views/pages/_story.html.erb index a648289d..24386de0 100644 --- a/app/views/pages/_story.html.erb +++ b/app/views/pages/_story.html.erb @@ -1,6 +1,6 @@
- <%= link_to '⬆', favorite_create_path(story_id: story.id), method: :post, class: "arrow #{favorite_by_user && 'upvoted'}" %> + <%= 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' %>
@@ -13,7 +13,6 @@ <%= "by #{story.by} #{time_ago_in_words(story.time)} ago." %>
-
<%= link_to '⬇', favorite_destroy_path(story_id: story.id), method: :delete, class: "arrow"%> diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 35067a36..81b11bbe 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1,10 +1,11 @@
\ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index 8e5cbde5..7b6dc4c0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -5,7 +5,7 @@ # test suite. You never need to work with it otherwise. Remember that # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! - config.cache_classes = true + config.cache_classes = false # Do not eager load code on boot. This avoids loading your whole application # just for the purpose of running a single test. If you are using a tool that diff --git a/db/migrate/20240723010637_create_story.rb b/db/migrate/20240723010637_create_story.rb index 0e12e703..ddb09fe6 100644 --- a/db/migrate/20240723010637_create_story.rb +++ b/db/migrate/20240723010637_create_story.rb @@ -1,15 +1,15 @@ class CreateStory < ActiveRecord::Migration[7.0] def change create_table :stories do |t| - t.integer :story_id - t.string :title - t.string :url - t.string :by - t.datetime :time + 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, :story_id + add_index :stories, :external_story_id end end diff --git a/db/migrate/20240723015449_create_favorite.rb b/db/migrate/20240723015449_create_favorite.rb index abb44727..a2366e96 100644 --- a/db/migrate/20240723015449_create_favorite.rb +++ b/db/migrate/20240723015449_create_favorite.rb @@ -1,8 +1,8 @@ class CreateFavorite < ActiveRecord::Migration[7.0] def change create_table :favorites do |t| - t.integer :user_id - t.integer :story_id + t.integer :user_id, null: false + t.integer :story_id, null: false t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 5832a416..55c4848e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -15,22 +15,22 @@ enable_extension "plpgsql" create_table "favorites", force: :cascade do |t| - t.integer "user_id" - t.integer "story_id" + 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 "story_id" - t.string "title" - t.string "url" - t.string "by" - t.datetime "time" + 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 ["story_id"], name: "index_stories_on_story_id" + t.index ["external_story_id"], name: "index_stories_on_external_story_id" end create_table "users", force: :cascade do |t| 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..b02546df --- /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 assocation." 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..cac08e28 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 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 From 602e8119ae9b39214419efc8ab33ab2190326023 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 16:09:07 -0400 Subject: [PATCH 13/21] story service specs --- app/controllers/pages_controller.rb | 2 +- app/models/story.rb | 1 + .../{stories_service.rb => story_service.rb} | 30 ++- spec/services/story_service_spec.rb | 188 ++++++++++++++++++ 4 files changed, 210 insertions(+), 11 deletions(-) rename app/services/{stories_service.rb => story_service.rb} (70%) create mode 100644 spec/services/story_service_spec.rb diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index d71d87db..a23aa8f1 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,7 +1,7 @@ class PagesController < ApplicationController def home @logged_in = current_user.present? - @stories_data = StoriesService.get_stories_data(current_user) + @stories_data = StoryService.get_stories_data(current_user) end end diff --git a/app/models/story.rb b/app/models/story.rb index 1bc5cac2..33ef4529 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -3,4 +3,5 @@ class Story < ApplicationRecord 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/services/stories_service.rb b/app/services/story_service.rb similarity index 70% rename from app/services/stories_service.rb rename to app/services/story_service.rb index 90defff6..b1c4b282 100644 --- a/app/services/stories_service.rb +++ b/app/services/story_service.rb @@ -1,6 +1,8 @@ -class StoriesService +class StoryService DEFAULT_NUMBER_OF_STORIES = 15 + attr_reader :user_id + def initialize(user_id = nil) @user_id = user_id end @@ -11,13 +13,13 @@ def self.get_stories_data(user_id) def get_stories_data # Fetch ids of current stories from api - stories_ids = HackerNewsApi.get_current_stories_ids - story_data(stories_ids).compact + stories_ids = get_stories_ids_from_api + process_external_story_id_data(stories_ids).compact end private - def story_data(stories_ids) + def process_external_story_id_data(stories_ids) stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |id| story = find_or_fetch_story(id) next unless story @@ -41,15 +43,15 @@ def get_favorite_user_string(story) end # Return a story object or nil if story is not found and api request fail - def find_or_fetch_story(story_id) - Story.find_by(external_story_id: story_id) || - fetch_and_create_story(story_id) || + def find_or_fetch_story(external_story_id) + Story.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(story_id) - fetched_story_data = HackerNewsApi.get_story_details(story_id) + 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) @@ -71,7 +73,7 @@ def create_story(data) def generate_favorite_user_string(usernames) count = usernames.count output = usernames.first(3) - output << " and #{count - 3} more" if count > 3 + output << "and #{count - 3} more" if count > 3 output.join(", ") end @@ -82,4 +84,12 @@ def story_favorite_by_users(story) 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/spec/services/story_service_spec.rb b/spec/services/story_service_spec.rb new file mode 100644 index 00000000..9ec98597 --- /dev/null +++ b/spec/services/story_service_spec.rb @@ -0,0 +1,188 @@ +require 'rails_helper' + +RSpec.describe StoryService do + let(:service) { described_class.new(user_id) } + let(:user) { create(:user) } + let(:user_id) { user.id } + 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 / initalize' do + it 'can initialize with a user_id' do + expect(service.user_id).to eq(user.id) + end + + context 'when a user_id is not provided' do + let(:user_id) { nil } + + it 'can initialize without a user_id' do + expect(service.user_id).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 '#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 exist' do + expect(subject).to eq(story_1) + end + + context 'if 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 additonal 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 From 6c6ed23262f4b25b08c11a7980cad775f3af9a0d Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 18:29:35 -0400 Subject: [PATCH 14/21] hacker news api specs --- Gemfile | 1 + Gemfile.lock | 10 ++ app/services/hacker_news_api.rb | 11 ++- app/services/story_service.rb | 10 +- spec/services/hacker_news_api_spec.rb | 131 ++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 spec/services/hacker_news_api_spec.rb diff --git a/Gemfile b/Gemfile index 27409fde..53efb2d9 100644 --- a/Gemfile +++ b/Gemfile @@ -21,3 +21,4 @@ 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] diff --git a/Gemfile.lock b/Gemfile.lock index 4591eded..9d9f6702 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) @@ -110,6 +114,7 @@ GEM 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) @@ -255,6 +260,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) @@ -286,6 +295,7 @@ DEPENDENCIES tzinfo-data uglifier web-console + webmock RUBY VERSION ruby 3.1.2p20 diff --git a/app/services/hacker_news_api.rb b/app/services/hacker_news_api.rb index c936164d..a1b3eaca 100644 --- a/app/services/hacker_news_api.rb +++ b/app/services/hacker_news_api.rb @@ -4,6 +4,8 @@ class HackerNewsApi class ApiInternalServiceError < StandardError; end + attr_accessor :retry_count + def initialize @retry_count = RETRY_LIMIT end @@ -37,14 +39,12 @@ def api_request(uri, default_return) response = response(uri) handle_response(response) || default_return rescue - puts "There was an error when attempting to reach #{uri}." if @retry_count > 0 - puts "Retrying request." + Rails.logger.info("Retrying request to #{uri}.") @retry_count -= 1 retry else - puts "Exceeded maximum retries. Default values returned." - Rails.logger.error("API request failed with code #{response.code}") + Rails.logger.error("Exceeded maximum retries. API request failed with code #{response.code}") default_return end end @@ -60,9 +60,10 @@ def response(uri) def handle_response(response) case response.code.to_i when 200..299 + binding.pry JSON.parse(response.body) when 400..499 - Rails.logger.info("API request failed with code #{response.code}. Bad Request Error, please check request.") + Rails.logger.error("API request failed with code #{response.code}. Bad Request Error, please check request.") when 500..599 raise ApiInternalServiceError end diff --git a/app/services/story_service.rb b/app/services/story_service.rb index b1c4b282..3a271e6d 100644 --- a/app/services/story_service.rb +++ b/app/services/story_service.rb @@ -13,15 +13,15 @@ def self.get_stories_data(user_id) def get_stories_data # Fetch ids of current stories from api - stories_ids = get_stories_ids_from_api - process_external_story_id_data(stories_ids).compact + external_stories_ids = get_stories_ids_from_api + process_external_story_id_data(external_stories_ids).compact end private - def process_external_story_id_data(stories_ids) - stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |id| - story = find_or_fetch_story(id) + def process_external_story_id_data(external_stories_ids) + external_stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |external_stories_id| + story = find_or_fetch_story(external_stories_id) next unless story generate_story_data_hash(story) diff --git a/spec/services/hacker_news_api_spec.rb b/spec/services/hacker_news_api_spec.rb new file mode 100644 index 00000000..c61e4f31 --- /dev/null +++ b/spec/services/hacker_news_api_spec.rb @@ -0,0 +1,131 @@ +require 'rails_helper' +require 'webmock/rspec' + +RSpec.describe HackerNewsApi do + let(:service) { described_class.new } + + describe '#new / initalize' do + it 'is initalized 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 From 61b120c02fe4849da44dcd61eca91a0915f64ebc Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 19:59:08 -0400 Subject: [PATCH 15/21] controller specs --- Gemfile | 3 +- Gemfile.lock | 5 + app/controllers/favorites_controller.rb | 3 + app/services/hacker_news_api.rb | 1 - .../controllers/favorites_controller_specs.rb | 93 +++++++++++++++++++ spec/controllers/pages_controller_specs.rb | 14 +++ spec/rails_helper.rb | 2 + 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 spec/controllers/favorites_controller_specs.rb create mode 100644 spec/controllers/pages_controller_specs.rb diff --git a/Gemfile b/Gemfile index 53efb2d9..3cdbf04c 100644 --- a/Gemfile +++ b/Gemfile @@ -21,4 +21,5 @@ 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 "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 9d9f6702..27ddba5c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,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) @@ -287,6 +291,7 @@ DEPENDENCIES pry-rails puma rails (~> 7.0.3) + rails-controller-testing rspec-rails sass-rails selenium-webdriver diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index c57720af..cdf35817 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -2,6 +2,7 @@ class FavoritesController < ApplicationController before_action :restrict_access def create + # binding.pry Favorite.find_or_create_by!(story_id: params[:story_id], user_id: current_user.id) redirect_to root_path end @@ -11,6 +12,8 @@ def destroy redirect_to root_path end + private + def restrict_access redirect_to new_user_session_path unless current_user end diff --git a/app/services/hacker_news_api.rb b/app/services/hacker_news_api.rb index a1b3eaca..183603a2 100644 --- a/app/services/hacker_news_api.rb +++ b/app/services/hacker_news_api.rb @@ -60,7 +60,6 @@ def response(uri) def handle_response(response) case response.code.to_i when 200..299 - binding.pry JSON.parse(response.body) when 400..499 Rails.logger.error("API request failed with code #{response.code}. Bad Request Error, please check request.") diff --git a/spec/controllers/favorites_controller_specs.rb b/spec/controllers/favorites_controller_specs.rb new file mode 100644 index 00000000..4ac4dcae --- /dev/null +++ b/spec/controllers/favorites_controller_specs.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 'DELTE #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_specs.rb b/spec/controllers/pages_controller_specs.rb new file mode 100644 index 00000000..f3c6b9c7 --- /dev/null +++ b/spec/controllers/pages_controller_specs.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe PagesController, type: :request do + describe 'GET #home' do + before do + get '/' + end + + it 'renders the home page' do + 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/rails_helper.rb b/spec/rails_helper.rb index cac08e28..7636d2ef 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -55,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 From ba46fc6ab983c42543fe43c1bdcf6acf41a49a5a Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 20:37:42 -0400 Subject: [PATCH 16/21] clean up specs --- app/controllers/favorites_controller.rb | 1 - ..._specs.rb => favorites_controller_spec.rb} | 0 spec/controllers/pages_controller_spec.rb | 23 +++++++++++++++++++ spec/controllers/pages_controller_specs.rb | 14 ----------- spec/services/hacker_news_api_spec.rb | 5 ++-- 5 files changed, 25 insertions(+), 18 deletions(-) rename spec/controllers/{favorites_controller_specs.rb => favorites_controller_spec.rb} (100%) create mode 100644 spec/controllers/pages_controller_spec.rb delete mode 100644 spec/controllers/pages_controller_specs.rb diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index cdf35817..25ad971d 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -2,7 +2,6 @@ class FavoritesController < ApplicationController before_action :restrict_access def create - # binding.pry Favorite.find_or_create_by!(story_id: params[:story_id], user_id: current_user.id) redirect_to root_path end diff --git a/spec/controllers/favorites_controller_specs.rb b/spec/controllers/favorites_controller_spec.rb similarity index 100% rename from spec/controllers/favorites_controller_specs.rb rename to spec/controllers/favorites_controller_spec.rb diff --git a/spec/controllers/pages_controller_spec.rb b/spec/controllers/pages_controller_spec.rb new file mode 100644 index 00000000..16f82001 --- /dev/null +++ b/spec/controllers/pages_controller_spec.rb @@ -0,0 +1,23 @@ +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 +end \ No newline at end of file diff --git a/spec/controllers/pages_controller_specs.rb b/spec/controllers/pages_controller_specs.rb deleted file mode 100644 index f3c6b9c7..00000000 --- a/spec/controllers/pages_controller_specs.rb +++ /dev/null @@ -1,14 +0,0 @@ -require 'rails_helper' - -RSpec.describe PagesController, type: :request do - describe 'GET #home' do - before do - get '/' - end - - it 'renders the home page' do - 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/services/hacker_news_api_spec.rb b/spec/services/hacker_news_api_spec.rb index c61e4f31..c1afab37 100644 --- a/spec/services/hacker_news_api_spec.rb +++ b/spec/services/hacker_news_api_spec.rb @@ -7,7 +7,6 @@ describe '#new / initalize' do it 'is initalized with a retry count' do expect(service.retry_count).to eq(3) - end end @@ -57,7 +56,7 @@ describe '#get_current_stories_ids' do subject { service.get_current_stories_ids } - let(:uri) { URI"#{HackerNewsApi::BASE_URL}/v0/topstories.json" } + 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 } @@ -88,7 +87,7 @@ 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(:uri) { URI("#{HackerNewsApi::BASE_URL}/v0/item/#{external_story_id}.json") } let(:response) { service.send(:response, uri) } let(:status) { 200 } let(:default_return) { {} } From e134fd862a696e8491d75d9b52c9d819976097af Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 20:45:22 -0400 Subject: [PATCH 17/21] typo clean up --- spec/controllers/favorites_controller_spec.rb | 2 +- spec/models/favorite_spec.rb | 2 +- spec/services/hacker_news_api_spec.rb | 4 ++-- spec/services/story_service_spec.rb | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/controllers/favorites_controller_spec.rb b/spec/controllers/favorites_controller_spec.rb index 4ac4dcae..a24147e2 100644 --- a/spec/controllers/favorites_controller_spec.rb +++ b/spec/controllers/favorites_controller_spec.rb @@ -51,7 +51,7 @@ end end - describe 'DELTE #destroy' do + describe 'DELETE #destroy' do subject { delete path, params: params } let(:path) { '/favorite' } let(:params) { {story_id: story.id} } diff --git a/spec/models/favorite_spec.rb b/spec/models/favorite_spec.rb index b02546df..732a8299 100644 --- a/spec/models/favorite_spec.rb +++ b/spec/models/favorite_spec.rb @@ -6,7 +6,7 @@ let(:story) { create(:story) } let(:attrs) { {user_id: user.id, story_id: story.id} } - it "should create a favorite assocation." do + it "should create a favorite association." do expect { described_class.create!(attrs) }.to change{ described_class.count }.by(1) end diff --git a/spec/services/hacker_news_api_spec.rb b/spec/services/hacker_news_api_spec.rb index c1afab37..a7d15ca3 100644 --- a/spec/services/hacker_news_api_spec.rb +++ b/spec/services/hacker_news_api_spec.rb @@ -4,8 +4,8 @@ RSpec.describe HackerNewsApi do let(:service) { described_class.new } - describe '#new / initalize' do - it 'is initalized with a retry count' do + describe '#new / initialize' do + it 'is initialize with a retry count' do expect(service.retry_count).to eq(3) end end diff --git a/spec/services/story_service_spec.rb b/spec/services/story_service_spec.rb index 9ec98597..d2051f81 100644 --- a/spec/services/story_service_spec.rb +++ b/spec/services/story_service_spec.rb @@ -18,7 +18,7 @@ ] end - describe '#new / initalize' do + describe '#new / initialize' do it 'can initialize with a user_id' do expect(service.user_id).to eq(user.id) end @@ -70,11 +70,11 @@ 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 exist' do + it 'query database and return story if it exists' do expect(subject).to eq(story_1) end - context 'if story does not exist' do + 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 @@ -146,7 +146,7 @@ usernames.unshift('Tony') end - it 'returns only the first 3 usernames and additonal message' do + 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 From b7b5754d32defc053ac52cccd197642d1aa0fbf6 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 20:48:39 -0400 Subject: [PATCH 18/21] reverting test.rb --- config/environments/test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/environments/test.rb b/config/environments/test.rb index 7b6dc4c0..8e5cbde5 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -5,7 +5,7 @@ # test suite. You never need to work with it otherwise. Remember that # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! - config.cache_classes = false + config.cache_classes = true # Do not eager load code on boot. This avoids loading your whole application # just for the purpose of running a single test. If you are using a tool that From 2bddd0cd5bed8758c172a32e615b4bd45031eb0c Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 22:37:47 -0400 Subject: [PATCH 19/21] add interesting page --- app/controllers/favorites_controller.rb | 4 +-- app/controllers/pages_controller.rb | 6 ++++ app/services/story_service.rb | 25 +++++++++----- app/views/pages/home.html.erb | 3 ++ config/routes.rb | 2 +- spec/controllers/pages_controller_spec.rb | 12 +++++++ spec/services/story_service_spec.rb | 42 +++++++++++++++++------ 7 files changed, 73 insertions(+), 21 deletions(-) diff --git a/app/controllers/favorites_controller.rb b/app/controllers/favorites_controller.rb index 25ad971d..32eaa711 100644 --- a/app/controllers/favorites_controller.rb +++ b/app/controllers/favorites_controller.rb @@ -3,12 +3,12 @@ class FavoritesController < ApplicationController def create Favorite.find_or_create_by!(story_id: params[:story_id], user_id: current_user.id) - redirect_to root_path + redirect_back(fallback_location: root_path) end def destroy Favorite.find_by(story_id: params[:story_id], user_id: current_user&.id)&.destroy - redirect_to root_path + redirect_back(fallback_location: root_path) end private diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index a23aa8f1..5a124a3f 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -3,5 +3,11 @@ 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/services/story_service.rb b/app/services/story_service.rb index 3a271e6d..93cb365e 100644 --- a/app/services/story_service.rb +++ b/app/services/story_service.rb @@ -1,14 +1,18 @@ class StoryService DEFAULT_NUMBER_OF_STORIES = 15 - attr_reader :user_id + attr_reader :user - def initialize(user_id = nil) - @user_id = user_id + def initialize(user = nil) + @user = user end - def self.get_stories_data(user_id) - new(user_id).get_stories_data + 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 @@ -17,11 +21,16 @@ def get_stories_data 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_stories_id| - story = find_or_fetch_story(external_stories_id) + 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) @@ -82,7 +91,7 @@ def story_favorite_by_users(story) end def favorite_by_user?(story) - story.users.find_by(id: @user_id).present? + story.users.find_by(id: @user).present? end def get_stories_ids_from_api diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 81b11bbe..baa40c98 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -11,6 +11,9 @@ Welcome to Top News + + <%= link_to 'All', root_path %> + <%= link_to 'Interesting', interesting_stories_path %>
diff --git a/config/routes.rb b/config/routes.rb index 3fc8af4f..e550a52a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,8 @@ Rails.application.routes.draw do devise_for :users root to: 'pages#home' - # resource :favorite, only: [:create, :destroy] + 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 diff --git a/spec/controllers/pages_controller_spec.rb b/spec/controllers/pages_controller_spec.rb index 16f82001..4ba9cbcd 100644 --- a/spec/controllers/pages_controller_spec.rb +++ b/spec/controllers/pages_controller_spec.rb @@ -20,4 +20,16 @@ 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/services/story_service_spec.rb b/spec/services/story_service_spec.rb index d2051f81..5397a4ed 100644 --- a/spec/services/story_service_spec.rb +++ b/spec/services/story_service_spec.rb @@ -1,9 +1,8 @@ require 'rails_helper' RSpec.describe StoryService do - let(:service) { described_class.new(user_id) } + let(:service) { described_class.new(user) } let(:user) { create(:user) } - let(:user_id) { user.id } let!(:story_1) { create(:story) } let!(:story_2) { create(:story, external_story_id: 2) } let!(:story_3) { create(:story, external_story_id: 3) } @@ -19,15 +18,15 @@ end describe '#new / initialize' do - it 'can initialize with a user_id' do - expect(service.user_id).to eq(user.id) + it 'can initialize with a user' do + expect(service.user).to eq(user) end - context 'when a user_id is not provided' do - let(:user_id) { nil } + context 'when a user is not provided' do + let(:user) { nil } - it 'can initialize without a user_id' do - expect(service.user_id).to be_nil + it 'can initialize without a user' do + expect(service.user).to be_nil end end end @@ -54,6 +53,29 @@ 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) } @@ -156,7 +178,7 @@ subject { service.send(:story_favorite_by_users, story_1) } before do - Favorite.create!(user_id: user_id, story_id: story_1.id) + Favorite.create!(user_id: user.id, story_id: story_1.id) end it 'returns a list of usernames that have favorited this story' do @@ -169,7 +191,7 @@ let(:story_to_check) { story_1 } before do - Favorite.create!(user_id: user_id, story_id: story_1.id) + Favorite.create!(user_id: user.id, story_id: story_1.id) end it 'returns a true boolean when user did favorite this story' do From a07ba7c6356c70f8869c757afabe53f0eb89e4c2 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 22:49:43 -0400 Subject: [PATCH 20/21] update home page --- app/services/story_service.rb | 2 +- app/views/pages/home.html.erb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/services/story_service.rb b/app/services/story_service.rb index 93cb365e..af6edf7a 100644 --- a/app/services/story_service.rb +++ b/app/services/story_service.rb @@ -53,7 +53,7 @@ def get_favorite_user_string(story) # Return a story object or nil if story is not found and api request fail def find_or_fetch_story(external_story_id) - Story.find_by(external_story_id: external_story_id) || + Story.includes(:users).find_by(external_story_id: external_story_id) || fetch_and_create_story(external_story_id) || nil end diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index baa40c98..c3fffba5 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -12,8 +12,9 @@ Welcome to Top News - <%= link_to 'All', root_path %> - <%= link_to 'Interesting', interesting_stories_path %> + <%= link_to 'All', root_path, class: 'title_line' %> + | + <%= link_to 'Interesting', interesting_stories_path, class: 'title_line' %>
From cab58e5fadf1360b30204230afde4774aa39ad97 Mon Sep 17 00:00:00 2001 From: Tony Lum Date: Wed, 24 Jul 2024 23:42:31 -0400 Subject: [PATCH 21/21] add safe nav --- app/services/story_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/story_service.rb b/app/services/story_service.rb index af6edf7a..1091ee48 100644 --- a/app/services/story_service.rb +++ b/app/services/story_service.rb @@ -91,7 +91,7 @@ def story_favorite_by_users(story) end def favorite_by_user?(story) - story.users.find_by(id: @user).present? + story.users.find_by(id: @user&.id).present? end def get_stories_ids_from_api