From 7440b2a4884d371d55d099dea254c4b92f2c4b79 Mon Sep 17 00:00:00 2001 From: Alex Shugaley Date: Thu, 27 Apr 2023 08:41:15 -0500 Subject: [PATCH 1/8] job --- Gemfile | 6 ++ Gemfile.lock | 17 ++++++ app/jobs/get_stories_job.rb | 59 +++++++++++++++++++ app/models/story.rb | 7 +++ config/environments/test.rb | 2 +- config/schedule.rb | 3 + db/migrate/20230427130136_create_stories.rb | 15 +++++ db/schema.rb | 13 ++++- spec/jobs/get_stories_job_spec.rb | 63 +++++++++++++++++++++ spec/models/story_spec.rb | 5 ++ spec/rails_helper.rb | 14 +++++ 11 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 app/jobs/get_stories_job.rb create mode 100644 app/models/story.rb create mode 100644 config/schedule.rb create mode 100644 db/migrate/20230427130136_create_stories.rb create mode 100644 spec/jobs/get_stories_job_spec.rb create mode 100644 spec/models/story_spec.rb diff --git a/Gemfile b/Gemfile index 5a8ffc43..ccfbf010 100644 --- a/Gemfile +++ b/Gemfile @@ -20,3 +20,9 @@ gem 'turbolinks' gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] gem 'uglifier' gem 'web-console', group: :development +gem 'whenever' +gem 'httparty' + +group :test do + gem 'webmock' +end diff --git a/Gemfile.lock b/Gemfile.lock index 14ec6457..3e905fe7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,7 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) childprocess (4.1.0) + chronic (0.10.2) coderay (1.1.3) coffee-rails (5.0.0) coffee-script (>= 2.2.0) @@ -91,6 +92,8 @@ GEM execjs coffee-script-source (1.12.2) concurrent-ruby (1.1.10) + crack (0.4.5) + rexml crass (1.0.6) devise (4.8.1) bcrypt (~> 3.0) @@ -105,6 +108,10 @@ GEM ffi (1.15.5) globalid (1.0.0) activesupport (>= 5.0) + hashdiff (1.0.1) + httparty (0.21.0) + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) i18n (1.12.0) concurrent-ruby (~> 1.0) jbuilder (2.11.5) @@ -124,6 +131,7 @@ GEM mini_mime (1.1.2) mini_portile2 (2.8.0) minitest (5.16.3) + multi_xml (0.6.0) net-imap (0.2.3) digest net-protocol @@ -250,10 +258,16 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.18.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) websocket-extensions (0.1.5) + whenever (1.0.0) + chronic (>= 0.6.3) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.6.0) @@ -266,6 +280,7 @@ DEPENDENCIES capybara coffee-rails devise + httparty jbuilder listen pg @@ -280,6 +295,8 @@ DEPENDENCIES tzinfo-data uglifier web-console + webmock + whenever RUBY VERSION ruby 3.1.2p20 diff --git a/app/jobs/get_stories_job.rb b/app/jobs/get_stories_job.rb new file mode 100644 index 00000000..249909f8 --- /dev/null +++ b/app/jobs/get_stories_job.rb @@ -0,0 +1,59 @@ +class GetStoriesJob < ApplicationJob + queue_as :default + + module URL + HN_BEST_STORIES = 'https://hacker-news.firebaseio.com/v0/beststories.json' + + module HH_SINGLE_STORY + PREFIX = 'https://hacker-news.firebaseio.com/v0/item/' + SUFFIX = '.json?print=pretty' + end + end + + def perform(*args) + fetch_and_save_stories + end + + private + + def fetch_and_save_stories + story_ids = fetch_stories + enrich_and_save_stories(story_ids) + end + + def fetch_stories + response = HTTParty.get(URL::HN_BEST_STORIES) + + if response.success? + JSON.parse(response.body) + else + Rails.logger.error("Failed to fetch stories: #{response.code} - #{response.message}") + raise "Failed to fetch stories - failing job" + end + end + + def enrich_and_save_stories(story_ids) + story_ids.each do |story_id| + fetch_and_store_story(story_id) + end + end + + def save_story(story_data) + Story.find_or_create_by(external_id: story_data[:id]) do |story| + story.title = story_data[:title] + story.url = story_data[:url] + end + end + def single_story_url(id) + URL::HH_SINGLE_STORY::PREFIX + id.to_s + URL::HH_SINGLE_STORY::SUFFIX + end + def fetch_and_store_story(story_id) + response = HTTParty.get(single_story_url(story_id)) + + if response.success? + save_story(JSON.parse(response.body).deep_symbolize_keys) + else + Rails.logger.error("Failed to fetch story with ID #{story_id}: #{response.code} - #{response.message}") + end + end +end diff --git a/app/models/story.rb b/app/models/story.rb new file mode 100644 index 00000000..2f081003 --- /dev/null +++ b/app/models/story.rb @@ -0,0 +1,7 @@ +class Story < ApplicationRecord + + validates :title, presence: true + validates :url, presence: true + validates :external_id, presence: true, uniqueness: true + +end 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/config/schedule.rb b/config/schedule.rb new file mode 100644 index 00000000..17bf4612 --- /dev/null +++ b/config/schedule.rb @@ -0,0 +1,3 @@ +every 15.minute do + runner "GetStoriesJob.perform_later" +end diff --git a/db/migrate/20230427130136_create_stories.rb b/db/migrate/20230427130136_create_stories.rb new file mode 100644 index 00000000..a08d0a76 --- /dev/null +++ b/db/migrate/20230427130136_create_stories.rb @@ -0,0 +1,15 @@ +class CreateStories < ActiveRecord::Migration[7.0] + def change + create_table :stories do |t| + t.string :title + t.string :url + t.integer :external_id + + t.timestamps + end + + add_index :stories, :id + add_index :stories, :created_at + add_index :stories, :external_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..ac9f97e0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,21 @@ # # 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: 2023_04_27_130136) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "stories", force: :cascade do |t| + t.string "title" + t.string "url" + t.integer "external_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_stories_on_created_at" + t.index ["external_id"], name: "index_stories_on_external_id", unique: true + t.index ["id"], name: "index_stories_on_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" diff --git a/spec/jobs/get_stories_job_spec.rb b/spec/jobs/get_stories_job_spec.rb new file mode 100644 index 00000000..5599c9a5 --- /dev/null +++ b/spec/jobs/get_stories_job_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe GetStoriesJob, type: :job do + include ActiveJob::TestHelper + + subject { described_class.perform_later } + + let(:story_ids_response) do + [123] + end + + let(:single_story_response) do + { + 'id': 123, + 'title': 'Some Story', + 'url': 'www.some_url.com', + }.to_json + end + + let(:return_code) { 200 } + + before do + stub_request(:get, GetStoriesJob::URL::HN_BEST_STORIES) + .to_return(status: return_code, body: story_ids_response.to_s) + + stub_request(:get, GetStoriesJob::URL::HH_SINGLE_STORY::PREFIX + story_ids_response.first.to_s + GetStoriesJob::URL::HH_SINGLE_STORY::SUFFIX) + .to_return(status: return_code, body: single_story_response) + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end + + it 'queues the job' do + expect { subject }.to have_enqueued_job(GetStoriesJob) + end + + context 'when success' do + it 'executes the job and creates stories' do + perform_enqueued_jobs { subject } + + expect(Story.count).to eq(1) + + story = Story.first + expect(story.title).to eq('Some Story') + expect(story.url).to eq('www.some_url.com') + expect(story.external_id).to eq(123) + end + end + + context 'when failed' do + let(:return_code) { 500 } + + it 'handles API errors' do + expect { + perform_enqueued_jobs { described_class.perform_later } + }.to raise_error + + expect(Story.count).to eq(0) + end + end +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb new file mode 100644 index 00000000..dd70e5ba --- /dev/null +++ b/spec/models/story_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Story, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bbe1ba57..8d03f881 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,6 +5,7 @@ # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' +require 'webmock/rspec' # Add additional requires below this line. Rails is not loaded until this point! # Requires supporting ruby files with custom matchers and macros, etc, in @@ -55,3 +56,16 @@ # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end + +# stupid hack to fix this issue - https://github.com/rspec/rspec-rails/issues/2545 +if Rails::VERSION::MAJOR >= 7 + require 'rspec/rails/version' + + RSpec::Core::ExampleGroup.module_eval do + include ActiveSupport::Testing::TaggedLogging + + def name + 'foobar' + end + end +end From aadf12dd04ae956bdd0ff6ad19108bc25ce5afce Mon Sep 17 00:00:00 2001 From: Alex Shugaley Date: Thu, 27 Apr 2023 10:01:15 -0500 Subject: [PATCH 2/8] rest-of-the-code --- Gemfile | 4 ++ Gemfile.lock | 24 +++++++ app/controllers/stories_controller.rb | 22 ++++++ app/controllers/users_controller.rb | 8 +++ app/models/story.rb | 3 +- app/models/upvote.rb | 4 ++ app/models/user.rb | 10 ++- app/views/layouts/application.html.erb | 18 +++-- app/views/stories/index.html.erb | 35 ++++++++++ app/views/stories/upvoted.html.erb | 29 ++++++++ app/views/users/show.html.erb | 19 ++++++ config/routes.rb | 11 ++- db/migrate/20230427135306_create_upvotes.rb | 10 +++ db/schema.rb | 13 +++- spec/factories/stories.rb | 7 ++ spec/factories/upvotes.rb | 6 ++ spec/factories/users.rb | 7 ++ spec/models/story_spec.rb | 35 +++++++++- spec/rails_helper.rb | 5 +- spec/requests/stories_spec.rb | 76 +++++++++++++++++++++ spec/requests/users_spec.rb | 20 ++++++ 21 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 app/controllers/stories_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/models/upvote.rb create mode 100644 app/views/stories/index.html.erb create mode 100644 app/views/stories/upvoted.html.erb create mode 100644 app/views/users/show.html.erb create mode 100644 db/migrate/20230427135306_create_upvotes.rb create mode 100644 spec/factories/stories.rb create mode 100644 spec/factories/upvotes.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/requests/stories_spec.rb create mode 100644 spec/requests/users_spec.rb diff --git a/Gemfile b/Gemfile index ccfbf010..f50459a3 100644 --- a/Gemfile +++ b/Gemfile @@ -22,7 +22,11 @@ gem 'uglifier' gem 'web-console', group: :development gem 'whenever' gem 'httparty' +gem 'kaminari' group :test do gem 'webmock' + gem 'factory_bot' + gem 'faker' + gem 'rails-controller-testing' end diff --git a/Gemfile.lock b/Gemfile.lock index 3e905fe7..bd821523 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,6 +105,10 @@ GEM digest (3.1.0) erubi (1.11.0) execjs (2.8.1) + factory_bot (6.2.1) + activesupport (>= 5.0.0) + faker (3.2.0) + i18n (>= 1.8.11, < 2) ffi (1.15.5) globalid (1.0.0) activesupport (>= 5.0) @@ -117,6 +121,18 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -178,6 +194,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) @@ -280,13 +300,17 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot + faker httparty jbuilder + kaminari listen pg pry-rails puma rails (~> 7.0.3) + rails-controller-testing rspec-rails sass-rails selenium-webdriver diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb new file mode 100644 index 00000000..09d0926c --- /dev/null +++ b/app/controllers/stories_controller.rb @@ -0,0 +1,22 @@ +class StoriesController < ApplicationController + before_action :authenticate_user! + + def index + @stories = Story.page(params[:page]).per(20) + end + + def upvote + story = Story.find(params[:id]) + current_user.upvote_story(story) + + redirect_to stories_path + end + + def upvoted + @upvoted_stories = Story.select('stories.*, COUNT(upvotes.id) AS upvotes_count') + .joins(:upvotes) + .group('stories.id') + .order('upvotes_count DESC') + .page(params[:page]).per(20) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000..78b06890 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,8 @@ +class UsersController < ApplicationController + before_action :authenticate_user! + + def show + @user = User.find(params[:id]) + @upvoted_stories = @user.upvoted_stories + end +end diff --git a/app/models/story.rb b/app/models/story.rb index 2f081003..f0662adf 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -1,7 +1,8 @@ class Story < ApplicationRecord - validates :title, presence: true validates :url, presence: true validates :external_id, presence: true, uniqueness: true + has_many :upvotes + has_many :upvoted_by_users, through: :upvotes, source: :user end diff --git a/app/models/upvote.rb b/app/models/upvote.rb new file mode 100644 index 00000000..925fea22 --- /dev/null +++ b/app/models/upvote.rb @@ -0,0 +1,4 @@ +class Upvote < ApplicationRecord + belongs_to :user + belongs_to :story +end diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..07debe7e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,12 @@ class User < ApplicationRecord - # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable and :omniauthable + devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + has_many :upvotes + has_many :upvoted_stories, through: :upvotes, source: :story + + def upvote_story(story) + upvotes.create(story: story) + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 331a7ed0..9ff3af01 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,12 +8,18 @@ <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> -

- <%= notice %> -

-

- <%= alert %> -

+ + <%= yield %> diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb new file mode 100644 index 00000000..3d3ebc05 --- /dev/null +++ b/app/views/stories/index.html.erb @@ -0,0 +1,35 @@ + +

Top Hacker News Stories

+ + + + + + + + + + + + + <% @stories.each do |story| %> + + + + + + + + <% end %> + + + <%= paginate @stories %> +
TitleURLUpvotesActionUpvoted by
<%= story.title %><%= link_to story.url, story.url, target: "_blank" %><%= story.upvotes.count %> + <% if story.upvoted_by_users.include?(current_user) %> + Upvoted + <% else %> + <%= link_to "Upvote", upvote_story_path(story), method: :post %> + <% end %> + + <%= story.upvoted_by_users.map(&:email).join(', ') %> +
diff --git a/app/views/stories/upvoted.html.erb b/app/views/stories/upvoted.html.erb new file mode 100644 index 00000000..18f06964 --- /dev/null +++ b/app/views/stories/upvoted.html.erb @@ -0,0 +1,29 @@ + +

Top Upvoted Stories

+ + + + + + + + + + + + <% @upvoted_stories.each do |story| %> + + + + + + + <% end %> + +
TitleURLUpvotesUpvoted by
<%= story.title %><%= link_to story.url, story.url, target: "_blank" %><%= story.upvotes.size %> + <% story.upvoted_by_users.each do |user| %> + <%= user.email %>
+ <% end %> +
+ +<%= paginate @upvoted_stories %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 00000000..24621cd4 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,19 @@ + +

<%= @user.email %>'s Upvoted Stories

+ + + + + + + + + + <% @upvoted_stories.each do |story| %> + + + + + <% end %> + +
TitleURL
<%= story.title %><%= link_to story.url, story.url, target: "_blank" %>
diff --git a/config/routes.rb b/config/routes.rb index c12ef082..7327491e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,13 @@ Rails.application.routes.draw do devise_for :users - root to: 'pages#home' + root to: 'stories#index' + + resources :stories, only: [:index] do + member do + post :upvote + end + end + + get 'upvoted_stories', to: 'stories#upvoted', as: :upvoted_stories + resources :users, only: [:show] end diff --git a/db/migrate/20230427135306_create_upvotes.rb b/db/migrate/20230427135306_create_upvotes.rb new file mode 100644 index 00000000..296f0f8f --- /dev/null +++ b/db/migrate/20230427135306_create_upvotes.rb @@ -0,0 +1,10 @@ +class CreateUpvotes < ActiveRecord::Migration[7.0] + def change + create_table :upvotes do |t| + t.references :user, null: false, foreign_key: true + t.references :story, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ac9f97e0..54aa53eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_04_27_130136) do +ActiveRecord::Schema[7.0].define(version: 2023_04_27_135306) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -25,6 +25,15 @@ t.index ["id"], name: "index_stories_on_id" end + create_table "upvotes", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "story_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["story_id"], name: "index_upvotes_on_story_id" + t.index ["user_id"], name: "index_upvotes_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" @@ -44,4 +53,6 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "upvotes", "stories" + add_foreign_key "upvotes", "users" end diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb new file mode 100644 index 00000000..4f56df26 --- /dev/null +++ b/spec/factories/stories.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :story do + title { ::Faker::Lorem.word } + url { "https://example.com" } + external_id { ::Faker::Number.binary(digits: 10) } + end +end diff --git a/spec/factories/upvotes.rb b/spec/factories/upvotes.rb new file mode 100644 index 00000000..876fbe31 --- /dev/null +++ b/spec/factories/upvotes.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :upvote do + association :story + association :user + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 00000000..4a152ad8 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :user do + email { ::Faker::Internet.email } + password { "password" } + password_confirmation { "password" } + end +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb index dd70e5ba..070b38bc 100644 --- a/spec/models/story_spec.rb +++ b/spec/models/story_spec.rb @@ -1,5 +1,38 @@ require 'rails_helper' RSpec.describe Story, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe "validations" do + it "is valid with a title and url" do + story = FactoryBot.build(:story) + expect(story).to be_valid + end + + it "is invalid without a title" do + story = FactoryBot.build(:story, title: nil) + expect(story).not_to be_valid + end + + it "is invalid without a url" do + story = FactoryBot.build(:story, url: nil) + expect(story).not_to be_valid + end + end + + describe "associations" do + it "has many upvotes" do + story = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story) + upvote2 = FactoryBot.create(:upvote, story: story) + expect(story.upvotes).to match_array([upvote1, upvote2]) + end + + it "has many upvoters" do + user1 = FactoryBot.create(:user) + user2 = FactoryBot.create(:user) + story = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story, user: user1) + upvote2 = FactoryBot.create(:upvote, story: story, user: user2) + expect(story.upvotes.first.user.id).to match(user1.id) + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8d03f881..bbcb24c0 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,8 +5,9 @@ # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' -require 'webmock/rspec' # Add additional requires below this line. Rails is not loaded until this point! +require 'webmock/rspec' +Dir[Rails.root.join('spec', 'factories', '**', '*.rb')].each { |file| require file } # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -53,6 +54,8 @@ # Filter lines from Rails gems in backtraces. config.filter_rails_from_backtrace! + config.include FactoryBot::Syntax::Methods + config.include Devise::Test::ControllerHelpers, type: :controller # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb new file mode 100644 index 00000000..4221b403 --- /dev/null +++ b/spec/requests/stories_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +RSpec.describe StoriesController, type: :controller do + let(:user) { create(:user) } + # let!(:story) { create(:story) } + + before do + sign_in user + end + + describe 'GET #index' do + it "returns a successful response" do + get :index + expect(response).to be_successful + end + + it "assigns the stories" do + story1 = FactoryBot.create(:story) + story2 = FactoryBot.create(:story) + get :index + expect(assigns(:stories)).to match_array([story1, story2]) + end + end + + describe 'POST #upvote' do + context "with valid params" do + let!(:story) { FactoryBot.create(:story) } + + before do + post :upvote, params: { id: story.id } + end + + it "upvotes the story for the current user" do + expect(story.upvotes.find_by(user_id: user.id)).not_to be_nil + end + + it "redirects to the stories index page" do + expect(response).to redirect_to(stories_path) + end + end + + context "with invalid params" do + it "raises a RecordNotFound error" do + expect { + post :upvote, params: { id: -1 } + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe "GET #upvoted" do + it "returns a successful response" do + get :upvoted + expect(response).to be_successful + end + + it "assigns the upvoted stories" do + story1 = FactoryBot.create(:story) + story2 = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story1) + upvote2 = FactoryBot.create(:upvote, story: story2) + get :upvoted + expect(assigns(:upvoted_stories)).to match_array([story1, story2]) + end + + it "orders the upvoted stories by number of upvotes" do + story1 = FactoryBot.create(:story) + story2 = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story1) + upvote2 = FactoryBot.create(:upvote, story: story1) + upvote3 = FactoryBot.create(:upvote, story: story2) + get :upvoted + expect(assigns(:upvoted_stories)).to eq([story1, story2]) + end + end +end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb new file mode 100644 index 00000000..28f00aae --- /dev/null +++ b/spec/requests/users_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe UsersController, type: :controller do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:upvoted_story) { create(:story) } + + before do + user.upvote_story(upvoted_story) + sign_in user + end + + describe 'GET #show' do + it 'assigns the requested user as @user and shows their upvoted stories' do + get :show, params: { id: user.id } + expect(assigns(:user)).to eq(user) + expect(assigns(:upvoted_stories)).to eq([upvoted_story]) + end + end +end From 43089ca9bd688f5a32638b94bb1f7e85b1bcecb6 Mon Sep 17 00:00:00 2001 From: Alexander Shugaley Date: Thu, 27 Apr 2023 10:06:08 -0500 Subject: [PATCH 3/8] cosmetic --- app/jobs/get_stories_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/get_stories_job.rb b/app/jobs/get_stories_job.rb index 249909f8..bf031daa 100644 --- a/app/jobs/get_stories_job.rb +++ b/app/jobs/get_stories_job.rb @@ -44,9 +44,11 @@ def save_story(story_data) story.url = story_data[:url] end end + def single_story_url(id) URL::HH_SINGLE_STORY::PREFIX + id.to_s + URL::HH_SINGLE_STORY::SUFFIX end + def fetch_and_store_story(story_id) response = HTTParty.get(single_story_url(story_id)) From b333c0802c1d347378445e60a0e5dc0f83b3e6c6 Mon Sep 17 00:00:00 2001 From: Alex Shugaley Date: Thu, 16 Oct 2025 10:17:37 -0400 Subject: [PATCH 4/8] test --- app/controllers/stories_controller.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 09d0926c..2751971d 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -20,3 +20,7 @@ def upvoted .page(params[:page]).per(20) end end + + + +#test \ No newline at end of file From 4e87d8e9b0957784bd19b97d4275118a575a2a7f Mon Sep 17 00:00:00 2001 From: Alex Shugaley Date: Thu, 16 Oct 2025 11:07:23 -0400 Subject: [PATCH 5/8] merge --- Gemfile | 6 + Gemfile.lock | 264 +++++++++++--------- app/jobs/get_stories_job.rb | 59 +++++ app/models/story.rb | 7 + config/schedule.rb | 3 + db/migrate/20230427130136_create_stories.rb | 15 ++ db/schema.rb | 13 +- spec/jobs/get_stories_job_spec.rb | 63 +++++ spec/models/story_spec.rb | 5 + spec/rails_helper.rb | 14 ++ 10 files changed, 328 insertions(+), 121 deletions(-) create mode 100644 app/jobs/get_stories_job.rb create mode 100644 app/models/story.rb create mode 100644 config/schedule.rb create mode 100644 db/migrate/20230427130136_create_stories.rb create mode 100644 spec/jobs/get_stories_job_spec.rb create mode 100644 spec/models/story_spec.rb diff --git a/Gemfile b/Gemfile index fd2e2b45..e7625a4f 100644 --- a/Gemfile +++ b/Gemfile @@ -21,3 +21,9 @@ gem 'turbolinks' gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] gem 'uglifier' gem 'web-console', group: :development +gem 'whenever' +gem 'httparty' + +group :test do + gem 'webmock' +end diff --git a/Gemfile.lock b/Gemfile.lock index 7d7a3577..acda5e0a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,78 +1,79 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8.4) - actionpack (= 7.0.8.4) - activesupport (= 7.0.8.4) + actioncable (7.0.8.7) + actionpack (= 7.0.8.7) + activesupport (= 7.0.8.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.4) - actionpack (= 7.0.8.4) - activejob (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionmailbox (7.0.8.7) + actionpack (= 7.0.8.7) + activejob (= 7.0.8.7) + activerecord (= 7.0.8.7) + activestorage (= 7.0.8.7) + activesupport (= 7.0.8.7) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.4) - actionpack (= 7.0.8.4) - actionview (= 7.0.8.4) - activejob (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionmailer (7.0.8.7) + actionpack (= 7.0.8.7) + actionview (= 7.0.8.7) + activejob (= 7.0.8.7) + activesupport (= 7.0.8.7) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.4) - actionview (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionpack (7.0.8.7) + actionview (= 7.0.8.7) + activesupport (= 7.0.8.7) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.4) - actionpack (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + actiontext (7.0.8.7) + actionpack (= 7.0.8.7) + activerecord (= 7.0.8.7) + activestorage (= 7.0.8.7) + activesupport (= 7.0.8.7) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.4) - activesupport (= 7.0.8.4) + actionview (7.0.8.7) + activesupport (= 7.0.8.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.8.4) - activesupport (= 7.0.8.4) + activejob (7.0.8.7) + activesupport (= 7.0.8.7) globalid (>= 0.3.6) - activemodel (7.0.8.4) - activesupport (= 7.0.8.4) - activerecord (7.0.8.4) - activemodel (= 7.0.8.4) - activesupport (= 7.0.8.4) - activestorage (7.0.8.4) - actionpack (= 7.0.8.4) - activejob (= 7.0.8.4) - activerecord (= 7.0.8.4) - activesupport (= 7.0.8.4) + activemodel (7.0.8.7) + activesupport (= 7.0.8.7) + activerecord (7.0.8.7) + activemodel (= 7.0.8.7) + activesupport (= 7.0.8.7) + activestorage (7.0.8.7) + actionpack (= 7.0.8.7) + activejob (= 7.0.8.7) + activerecord (= 7.0.8.7) + activesupport (= 7.0.8.7) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.8.4) + activesupport (7.0.8.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) + bigdecimal (3.3.1) bindex (0.8.1) builder (3.3.0) - byebug (11.1.3) + byebug (12.0.0) capybara (3.40.0) addressable matrix @@ -82,6 +83,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + chronic (0.10.2) coderay (1.1.3) coffee-rails (5.0.0) coffee-script (>= 2.2.0) @@ -90,36 +92,46 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) - date (3.3.4) + csv (3.3.5) + date (3.4.1) devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - diff-lcs (1.5.1) - erubi (1.13.0) - execjs (2.9.1) - factory_bot (6.4.2) - activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) - railties (>= 5.0.0) - ffi (1.17.0) - globalid (1.2.1) + diff-lcs (1.6.2) + erubi (1.13.1) + execjs (2.10.0) + factory_bot (6.5.5) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + ffi (1.17.2-arm64-darwin) + globalid (1.3.0) activesupport (>= 6.1) - i18n (1.14.5) + hashdiff (1.2.1) + httparty (0.23.2) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.7) concurrent-ruby (~> 1.0) - jbuilder (2.12.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.13.2) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.0) - loofah (2.22.0) + logger (1.7.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -127,95 +139,95 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) - matrix (0.4.2) + marcel (1.1.0) + matrix (0.4.3) method_source (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.25.1) - net-imap (0.4.14) + minitest (5.26.0) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol - nio4r (2.7.3) - nokogiri (1.16.7) - mini_portile2 (~> 2.8.2) + nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) orm_adapter (0.5.0) - pg (1.5.7) - pry (0.14.2) + pg (1.6.2-arm64-darwin) + prism (1.4.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) pry-rails (0.3.11) pry (>= 0.13.0) - public_suffix (6.0.1) - puma (6.4.2) + public_suffix (6.0.2) + puma (7.0.4) nio4r (~> 2.0) racc (1.8.1) - rack (2.2.9) - rack-test (2.1.0) + rack (2.2.20) + rack-test (2.2.0) rack (>= 1.3) - rails (7.0.8.4) - actioncable (= 7.0.8.4) - actionmailbox (= 7.0.8.4) - actionmailer (= 7.0.8.4) - actionpack (= 7.0.8.4) - actiontext (= 7.0.8.4) - actionview (= 7.0.8.4) - activejob (= 7.0.8.4) - activemodel (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + rails (7.0.8.7) + actioncable (= 7.0.8.7) + actionmailbox (= 7.0.8.7) + actionmailer (= 7.0.8.7) + actionpack (= 7.0.8.7) + actiontext (= 7.0.8.7) + actionview (= 7.0.8.7) + activejob (= 7.0.8.7) + activemodel (= 7.0.8.7) + activerecord (= 7.0.8.7) + activestorage (= 7.0.8.7) + activesupport (= 7.0.8.7) bundler (>= 1.15.0) - railties (= 7.0.8.4) - rails-dom-testing (2.2.0) + railties (= 7.0.8.7) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.0.8.4) - actionpack (= 7.0.8.4) - activesupport (= 7.0.8.4) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.0.8.7) + actionpack (= 7.0.8.7) + activesupport (= 7.0.8.7) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) - rake (13.2.1) + rake (13.3.0) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - regexp_parser (2.9.2) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rexml (3.3.5) - strscan - rspec-core (3.13.0) + regexp_parser (2.11.3) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + rexml (3.4.4) + rspec-core (3.13.5) rspec-support (~> 3.13.0) - rspec-expectations (3.13.2) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.4) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) + rspec-rails (7.1.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) rspec-core (~> 3.13) rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.1) - rubyzip (2.3.2) + rspec-support (3.13.6) + rubyzip (3.2.0) sass-rails (6.0.0) sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) @@ -226,30 +238,32 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (4.23.0) + selenium-webdriver (4.36.0) base64 (~> 0.2) + json (<= 2.13.2) logger (~> 1.4) + prism (~> 1.0, < 1.5) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - spring (4.2.1) - sprockets (4.2.1) + spring (4.4.0) + sprockets (4.2.2) concurrent-ruby (~> 1.0) + logger rack (>= 2.2.4, < 4) sprockets-rails (3.5.2) actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - strscan (3.1.0) - thor (1.3.1) - tilt (2.4.0) - timeout (0.4.1) + thor (1.4.0) + tilt (2.6.1) + timeout (0.4.3) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - uglifier (4.2.0) + uglifier (4.2.1) execjs (>= 0.3.0, < 3) warden (1.2.9) rack (>= 2.0.9) @@ -258,16 +272,23 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) - websocket-driver (0.7.6) + websocket-driver (0.8.0) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + whenever (1.0.0) + chronic (>= 0.6.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.17) + zeitwerk (2.7.3) PLATFORMS - ruby + arm64-darwin-20 DEPENDENCIES byebug @@ -275,6 +296,7 @@ DEPENDENCIES coffee-rails devise factory_bot_rails + httparty jbuilder listen pg @@ -289,9 +311,11 @@ DEPENDENCIES tzinfo-data uglifier web-console + webmock + whenever RUBY VERSION ruby 3.2.3p157 BUNDLED WITH - 2.3.22 + 2.4.19 diff --git a/app/jobs/get_stories_job.rb b/app/jobs/get_stories_job.rb new file mode 100644 index 00000000..249909f8 --- /dev/null +++ b/app/jobs/get_stories_job.rb @@ -0,0 +1,59 @@ +class GetStoriesJob < ApplicationJob + queue_as :default + + module URL + HN_BEST_STORIES = 'https://hacker-news.firebaseio.com/v0/beststories.json' + + module HH_SINGLE_STORY + PREFIX = 'https://hacker-news.firebaseio.com/v0/item/' + SUFFIX = '.json?print=pretty' + end + end + + def perform(*args) + fetch_and_save_stories + end + + private + + def fetch_and_save_stories + story_ids = fetch_stories + enrich_and_save_stories(story_ids) + end + + def fetch_stories + response = HTTParty.get(URL::HN_BEST_STORIES) + + if response.success? + JSON.parse(response.body) + else + Rails.logger.error("Failed to fetch stories: #{response.code} - #{response.message}") + raise "Failed to fetch stories - failing job" + end + end + + def enrich_and_save_stories(story_ids) + story_ids.each do |story_id| + fetch_and_store_story(story_id) + end + end + + def save_story(story_data) + Story.find_or_create_by(external_id: story_data[:id]) do |story| + story.title = story_data[:title] + story.url = story_data[:url] + end + end + def single_story_url(id) + URL::HH_SINGLE_STORY::PREFIX + id.to_s + URL::HH_SINGLE_STORY::SUFFIX + end + def fetch_and_store_story(story_id) + response = HTTParty.get(single_story_url(story_id)) + + if response.success? + save_story(JSON.parse(response.body).deep_symbolize_keys) + else + Rails.logger.error("Failed to fetch story with ID #{story_id}: #{response.code} - #{response.message}") + end + end +end diff --git a/app/models/story.rb b/app/models/story.rb new file mode 100644 index 00000000..e4784b51 --- /dev/null +++ b/app/models/story.rb @@ -0,0 +1,7 @@ +class Story < ApplicationRecord + + validates :title, presence: true + validates :url, presence: true + validates :external_id, presence: true, uniqueness: true + +end \ No newline at end of file diff --git a/config/schedule.rb b/config/schedule.rb new file mode 100644 index 00000000..17bf4612 --- /dev/null +++ b/config/schedule.rb @@ -0,0 +1,3 @@ +every 15.minute do + runner "GetStoriesJob.perform_later" +end diff --git a/db/migrate/20230427130136_create_stories.rb b/db/migrate/20230427130136_create_stories.rb new file mode 100644 index 00000000..a08d0a76 --- /dev/null +++ b/db/migrate/20230427130136_create_stories.rb @@ -0,0 +1,15 @@ +class CreateStories < ActiveRecord::Migration[7.0] + def change + create_table :stories do |t| + t.string :title + t.string :url + t.integer :external_id + + t.timestamps + end + + add_index :stories, :id + add_index :stories, :created_at + add_index :stories, :external_id, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..ac9f97e0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,21 @@ # # 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: 2023_04_27_130136) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "stories", force: :cascade do |t| + t.string "title" + t.string "url" + t.integer "external_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_stories_on_created_at" + t.index ["external_id"], name: "index_stories_on_external_id", unique: true + t.index ["id"], name: "index_stories_on_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" diff --git a/spec/jobs/get_stories_job_spec.rb b/spec/jobs/get_stories_job_spec.rb new file mode 100644 index 00000000..5599c9a5 --- /dev/null +++ b/spec/jobs/get_stories_job_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe GetStoriesJob, type: :job do + include ActiveJob::TestHelper + + subject { described_class.perform_later } + + let(:story_ids_response) do + [123] + end + + let(:single_story_response) do + { + 'id': 123, + 'title': 'Some Story', + 'url': 'www.some_url.com', + }.to_json + end + + let(:return_code) { 200 } + + before do + stub_request(:get, GetStoriesJob::URL::HN_BEST_STORIES) + .to_return(status: return_code, body: story_ids_response.to_s) + + stub_request(:get, GetStoriesJob::URL::HH_SINGLE_STORY::PREFIX + story_ids_response.first.to_s + GetStoriesJob::URL::HH_SINGLE_STORY::SUFFIX) + .to_return(status: return_code, body: single_story_response) + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end + + it 'queues the job' do + expect { subject }.to have_enqueued_job(GetStoriesJob) + end + + context 'when success' do + it 'executes the job and creates stories' do + perform_enqueued_jobs { subject } + + expect(Story.count).to eq(1) + + story = Story.first + expect(story.title).to eq('Some Story') + expect(story.url).to eq('www.some_url.com') + expect(story.external_id).to eq(123) + end + end + + context 'when failed' do + let(:return_code) { 500 } + + it 'handles API errors' do + expect { + perform_enqueued_jobs { described_class.perform_later } + }.to raise_error + + expect(Story.count).to eq(0) + end + end +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb new file mode 100644 index 00000000..dd70e5ba --- /dev/null +++ b/spec/models/story_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Story, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bbe1ba57..8d03f881 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,6 +5,7 @@ # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' +require 'webmock/rspec' # Add additional requires below this line. Rails is not loaded until this point! # Requires supporting ruby files with custom matchers and macros, etc, in @@ -55,3 +56,16 @@ # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end + +# stupid hack to fix this issue - https://github.com/rspec/rspec-rails/issues/2545 +if Rails::VERSION::MAJOR >= 7 + require 'rspec/rails/version' + + RSpec::Core::ExampleGroup.module_eval do + include ActiveSupport::Testing::TaggedLogging + + def name + 'foobar' + end + end +end From 224d7918c0e6c71ee4063dc9d5ac42d7afa26549 Mon Sep 17 00:00:00 2001 From: Alex Shugaley Date: Thu, 27 Apr 2023 10:01:15 -0500 Subject: [PATCH 6/8] rest-of-the-code dada ! Q! q1! >?>sdfjsd QW!1:WQ :wq ZZ Please enter the commit message for your changes. Lines starting --- .idea/.gitignore | 8 ++ .idea/misc.xml | 4 + .idea/modules.xml | 8 ++ .idea/topnews.iml | 112 ++++++++++++++++++++ .idea/vcs.xml | 6 ++ Gemfile | 4 + Gemfile.lock | 22 ++++ app/controllers/stories_controller.rb | 22 ++++ app/controllers/users_controller.rb | 8 ++ app/models/story.rb | 5 +- app/models/upvote.rb | 4 + app/models/user.rb | 10 +- app/views/layouts/application.html.erb | 18 ++-- app/views/stories/index.html.erb | 35 ++++++ app/views/stories/upvoted.html.erb | 29 +++++ app/views/users/show.html.erb | 19 ++++ config/routes.rb | 11 +- db/migrate/20230427135306_create_upvotes.rb | 10 ++ db/schema.rb | 13 ++- spec/factories/stories.rb | 7 ++ spec/factories/upvotes.rb | 6 ++ spec/factories/users.rb | 7 ++ spec/models/story_spec.rb | 35 +++++- spec/rails_helper.rb | 5 +- spec/requests/stories_spec.rb | 76 +++++++++++++ spec/requests/users_spec.rb | 20 ++++ 26 files changed, 490 insertions(+), 14 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/topnews.iml create mode 100644 .idea/vcs.xml create mode 100644 app/controllers/stories_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/models/upvote.rb create mode 100644 app/views/stories/index.html.erb create mode 100644 app/views/stories/upvoted.html.erb create mode 100644 app/views/users/show.html.erb create mode 100644 db/migrate/20230427135306_create_upvotes.rb create mode 100644 spec/factories/stories.rb create mode 100644 spec/factories/upvotes.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/requests/stories_spec.rb create mode 100644 spec/requests/users_spec.rb diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..4de3b792 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..6af377ba --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/topnews.iml b/.idea/topnews.iml new file mode 100644 index 00000000..29ab8820 --- /dev/null +++ b/.idea/topnews.iml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Gemfile b/Gemfile index e7625a4f..ef7e34e2 100644 --- a/Gemfile +++ b/Gemfile @@ -23,7 +23,11 @@ gem 'uglifier' gem 'web-console', group: :development gem 'whenever' gem 'httparty' +gem 'kaminari' group :test do gem 'webmock' + gem 'factory_bot' + gem 'faker' + gem 'rails-controller-testing' end diff --git a/Gemfile.lock b/Gemfile.lock index acda5e0a..63ae5be7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,6 +113,8 @@ GEM factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) + faker (3.5.2) + i18n (>= 1.8.11, < 2) ffi (1.17.2-arm64-darwin) globalid (1.3.0) activesupport (>= 6.1) @@ -127,6 +129,18 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) json (2.13.2) + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -187,6 +201,10 @@ GEM activesupport (= 7.0.8.7) bundler (>= 1.15.0) railties (= 7.0.8.7) + 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.3.0) activesupport (>= 5.0.0) minitest @@ -295,14 +313,18 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot factory_bot_rails + faker httparty jbuilder + kaminari listen pg pry-rails puma rails (~> 7.0.8) + rails-controller-testing rspec-rails sass-rails selenium-webdriver diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb new file mode 100644 index 00000000..09d0926c --- /dev/null +++ b/app/controllers/stories_controller.rb @@ -0,0 +1,22 @@ +class StoriesController < ApplicationController + before_action :authenticate_user! + + def index + @stories = Story.page(params[:page]).per(20) + end + + def upvote + story = Story.find(params[:id]) + current_user.upvote_story(story) + + redirect_to stories_path + end + + def upvoted + @upvoted_stories = Story.select('stories.*, COUNT(upvotes.id) AS upvotes_count') + .joins(:upvotes) + .group('stories.id') + .order('upvotes_count DESC') + .page(params[:page]).per(20) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000..78b06890 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,8 @@ +class UsersController < ApplicationController + before_action :authenticate_user! + + def show + @user = User.find(params[:id]) + @upvoted_stories = @user.upvoted_stories + end +end diff --git a/app/models/story.rb b/app/models/story.rb index e4784b51..f0662adf 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -1,7 +1,8 @@ class Story < ApplicationRecord - validates :title, presence: true validates :url, presence: true validates :external_id, presence: true, uniqueness: true -end \ No newline at end of file + has_many :upvotes + has_many :upvoted_by_users, through: :upvotes, source: :user +end diff --git a/app/models/upvote.rb b/app/models/upvote.rb new file mode 100644 index 00000000..925fea22 --- /dev/null +++ b/app/models/upvote.rb @@ -0,0 +1,4 @@ +class Upvote < ApplicationRecord + belongs_to :user + belongs_to :story +end diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..07debe7e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,12 @@ class User < ApplicationRecord - # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable and :omniauthable + devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + has_many :upvotes + has_many :upvoted_stories, through: :upvotes, source: :story + + def upvote_story(story) + upvotes.create(story: story) + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 331a7ed0..9ff3af01 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,12 +8,18 @@ <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> -

- <%= notice %> -

-

- <%= alert %> -

+ + <%= yield %> diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb new file mode 100644 index 00000000..3d3ebc05 --- /dev/null +++ b/app/views/stories/index.html.erb @@ -0,0 +1,35 @@ + +

Top Hacker News Stories

+ + + + + + + + + + + + + <% @stories.each do |story| %> + + + + + + + + <% end %> + + + <%= paginate @stories %> +
TitleURLUpvotesActionUpvoted by
<%= story.title %><%= link_to story.url, story.url, target: "_blank" %><%= story.upvotes.count %> + <% if story.upvoted_by_users.include?(current_user) %> + Upvoted + <% else %> + <%= link_to "Upvote", upvote_story_path(story), method: :post %> + <% end %> + + <%= story.upvoted_by_users.map(&:email).join(', ') %> +
diff --git a/app/views/stories/upvoted.html.erb b/app/views/stories/upvoted.html.erb new file mode 100644 index 00000000..18f06964 --- /dev/null +++ b/app/views/stories/upvoted.html.erb @@ -0,0 +1,29 @@ + +

Top Upvoted Stories

+ + + + + + + + + + + + <% @upvoted_stories.each do |story| %> + + + + + + + <% end %> + +
TitleURLUpvotesUpvoted by
<%= story.title %><%= link_to story.url, story.url, target: "_blank" %><%= story.upvotes.size %> + <% story.upvoted_by_users.each do |user| %> + <%= user.email %>
+ <% end %> +
+ +<%= paginate @upvoted_stories %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb new file mode 100644 index 00000000..24621cd4 --- /dev/null +++ b/app/views/users/show.html.erb @@ -0,0 +1,19 @@ + +

<%= @user.email %>'s Upvoted Stories

+ + + + + + + + + + <% @upvoted_stories.each do |story| %> + + + + + <% end %> + +
TitleURL
<%= story.title %><%= link_to story.url, story.url, target: "_blank" %>
diff --git a/config/routes.rb b/config/routes.rb index c12ef082..7327491e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,13 @@ Rails.application.routes.draw do devise_for :users - root to: 'pages#home' + root to: 'stories#index' + + resources :stories, only: [:index] do + member do + post :upvote + end + end + + get 'upvoted_stories', to: 'stories#upvoted', as: :upvoted_stories + resources :users, only: [:show] end diff --git a/db/migrate/20230427135306_create_upvotes.rb b/db/migrate/20230427135306_create_upvotes.rb new file mode 100644 index 00000000..296f0f8f --- /dev/null +++ b/db/migrate/20230427135306_create_upvotes.rb @@ -0,0 +1,10 @@ +class CreateUpvotes < ActiveRecord::Migration[7.0] + def change + create_table :upvotes do |t| + t.references :user, null: false, foreign_key: true + t.references :story, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ac9f97e0..54aa53eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_04_27_130136) do +ActiveRecord::Schema[7.0].define(version: 2023_04_27_135306) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -25,6 +25,15 @@ t.index ["id"], name: "index_stories_on_id" end + create_table "upvotes", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "story_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["story_id"], name: "index_upvotes_on_story_id" + t.index ["user_id"], name: "index_upvotes_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" @@ -44,4 +53,6 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "upvotes", "stories" + add_foreign_key "upvotes", "users" end diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb new file mode 100644 index 00000000..4f56df26 --- /dev/null +++ b/spec/factories/stories.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :story do + title { ::Faker::Lorem.word } + url { "https://example.com" } + external_id { ::Faker::Number.binary(digits: 10) } + end +end diff --git a/spec/factories/upvotes.rb b/spec/factories/upvotes.rb new file mode 100644 index 00000000..876fbe31 --- /dev/null +++ b/spec/factories/upvotes.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :upvote do + association :story + association :user + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 00000000..4a152ad8 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :user do + email { ::Faker::Internet.email } + password { "password" } + password_confirmation { "password" } + end +end diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb index dd70e5ba..070b38bc 100644 --- a/spec/models/story_spec.rb +++ b/spec/models/story_spec.rb @@ -1,5 +1,38 @@ require 'rails_helper' RSpec.describe Story, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe "validations" do + it "is valid with a title and url" do + story = FactoryBot.build(:story) + expect(story).to be_valid + end + + it "is invalid without a title" do + story = FactoryBot.build(:story, title: nil) + expect(story).not_to be_valid + end + + it "is invalid without a url" do + story = FactoryBot.build(:story, url: nil) + expect(story).not_to be_valid + end + end + + describe "associations" do + it "has many upvotes" do + story = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story) + upvote2 = FactoryBot.create(:upvote, story: story) + expect(story.upvotes).to match_array([upvote1, upvote2]) + end + + it "has many upvoters" do + user1 = FactoryBot.create(:user) + user2 = FactoryBot.create(:user) + story = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story, user: user1) + upvote2 = FactoryBot.create(:upvote, story: story, user: user2) + expect(story.upvotes.first.user.id).to match(user1.id) + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 8d03f881..bbcb24c0 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,8 +5,9 @@ # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require 'rspec/rails' -require 'webmock/rspec' # Add additional requires below this line. Rails is not loaded until this point! +require 'webmock/rspec' +Dir[Rails.root.join('spec', 'factories', '**', '*.rb')].each { |file| require file } # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are @@ -53,6 +54,8 @@ # Filter lines from Rails gems in backtraces. config.filter_rails_from_backtrace! + config.include FactoryBot::Syntax::Methods + config.include Devise::Test::ControllerHelpers, type: :controller # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end diff --git a/spec/requests/stories_spec.rb b/spec/requests/stories_spec.rb new file mode 100644 index 00000000..4221b403 --- /dev/null +++ b/spec/requests/stories_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +RSpec.describe StoriesController, type: :controller do + let(:user) { create(:user) } + # let!(:story) { create(:story) } + + before do + sign_in user + end + + describe 'GET #index' do + it "returns a successful response" do + get :index + expect(response).to be_successful + end + + it "assigns the stories" do + story1 = FactoryBot.create(:story) + story2 = FactoryBot.create(:story) + get :index + expect(assigns(:stories)).to match_array([story1, story2]) + end + end + + describe 'POST #upvote' do + context "with valid params" do + let!(:story) { FactoryBot.create(:story) } + + before do + post :upvote, params: { id: story.id } + end + + it "upvotes the story for the current user" do + expect(story.upvotes.find_by(user_id: user.id)).not_to be_nil + end + + it "redirects to the stories index page" do + expect(response).to redirect_to(stories_path) + end + end + + context "with invalid params" do + it "raises a RecordNotFound error" do + expect { + post :upvote, params: { id: -1 } + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe "GET #upvoted" do + it "returns a successful response" do + get :upvoted + expect(response).to be_successful + end + + it "assigns the upvoted stories" do + story1 = FactoryBot.create(:story) + story2 = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story1) + upvote2 = FactoryBot.create(:upvote, story: story2) + get :upvoted + expect(assigns(:upvoted_stories)).to match_array([story1, story2]) + end + + it "orders the upvoted stories by number of upvotes" do + story1 = FactoryBot.create(:story) + story2 = FactoryBot.create(:story) + upvote1 = FactoryBot.create(:upvote, story: story1) + upvote2 = FactoryBot.create(:upvote, story: story1) + upvote3 = FactoryBot.create(:upvote, story: story2) + get :upvoted + expect(assigns(:upvoted_stories)).to eq([story1, story2]) + end + end +end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb new file mode 100644 index 00000000..28f00aae --- /dev/null +++ b/spec/requests/users_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe UsersController, type: :controller do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let!(:upvoted_story) { create(:story) } + + before do + user.upvote_story(upvoted_story) + sign_in user + end + + describe 'GET #show' do + it 'assigns the requested user as @user and shows their upvoted stories' do + get :show, params: { id: user.id } + expect(assigns(:user)).to eq(user) + expect(assigns(:upvoted_stories)).to eq([upvoted_story]) + end + end +end From 488cb52889d4bee0311037c49e4c288abda6c5c6 Mon Sep 17 00:00:00 2001 From: Alexander Shugaley Date: Thu, 27 Apr 2023 10:06:08 -0500 Subject: [PATCH 7/8] cosmetic --- app/jobs/get_stories_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/get_stories_job.rb b/app/jobs/get_stories_job.rb index 249909f8..bf031daa 100644 --- a/app/jobs/get_stories_job.rb +++ b/app/jobs/get_stories_job.rb @@ -44,9 +44,11 @@ def save_story(story_data) story.url = story_data[:url] end end + def single_story_url(id) URL::HH_SINGLE_STORY::PREFIX + id.to_s + URL::HH_SINGLE_STORY::SUFFIX end + def fetch_and_store_story(story_id) response = HTTParty.get(single_story_url(story_id)) From 50d95aa984316a46ad58c0f4b83be4386088fbee Mon Sep 17 00:00:00 2001 From: Alex Shugaley Date: Thu, 16 Oct 2025 10:17:37 -0400 Subject: [PATCH 8/8] test --- app/controllers/stories_controller.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 09d0926c..2751971d 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -20,3 +20,7 @@ def upvoted .page(params[:page]).per(20) end end + + + +#test \ No newline at end of file