diff --git a/Gemfile b/Gemfile
index 5a8ffc43..bb55027f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -12,7 +12,6 @@ gem 'pg'
gem 'pry-rails'
gem 'puma'
gem 'rails', '~> 7.0.3'
-gem 'rspec-rails'
gem 'sass-rails'
gem 'selenium-webdriver', group: [:development, :test]
gem 'spring', group: :development
@@ -20,3 +19,18 @@ gem 'turbolinks'
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem 'uglifier'
gem 'web-console', group: :development
+gem 'httparty'
+gem 'pry-byebug'
+
+group :test do
+ gem 'rspec-rails'
+ gem 'factory_bot_rails'
+ gem 'faker'
+ gem 'database_cleaner'
+ gem 'rails-controller-testing'
+ gem 'shoulda-matchers', '~> 4.0'
+end
+
+group :test, :development do
+ gem 'database_cleaner-active_record'
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index 14ec6457..ce9f712a 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)
@@ -92,6 +93,13 @@ GEM
coffee-script-source (1.12.2)
concurrent-ruby (1.1.10)
crass (1.0.6)
+ csv (3.3.0)
+ database_cleaner (2.0.2)
+ database_cleaner-active_record (>= 2, < 3)
+ database_cleaner-active_record (2.1.0)
+ activerecord (>= 5.a)
+ database_cleaner-core (~> 2.0.0)
+ database_cleaner-core (2.0.1)
devise (4.8.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@@ -102,9 +110,20 @@ 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)
+ faker (3.4.1)
+ i18n (>= 1.8.11, < 2)
ffi (1.15.5)
globalid (1.0.0)
activesupport (>= 5.0)
+ httparty (0.22.0)
+ csv
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jbuilder (2.11.5)
@@ -124,6 +143,8 @@ GEM
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.16.3)
+ multi_xml (0.7.1)
+ bigdecimal (~> 3.1)
net-imap (0.2.3)
digest
net-protocol
@@ -147,6 +168,9 @@ GEM
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
+ pry-byebug (3.10.1)
+ byebug (~> 11.0)
+ pry (>= 0.13, < 0.15)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.0)
@@ -170,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)
@@ -224,6 +252,8 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
+ shoulda-matchers (4.5.1)
+ activesupport (>= 4.2.0)
spring (4.1.0)
sprockets (4.1.1)
concurrent-ruby (~> 1.0)
@@ -265,16 +295,24 @@ DEPENDENCIES
byebug
capybara
coffee-rails
+ database_cleaner
+ database_cleaner-active_record
devise
+ factory_bot_rails
+ faker
+ httparty
jbuilder
listen
pg
+ pry-byebug
pry-rails
puma
rails (~> 7.0.3)
+ rails-controller-testing
rspec-rails
sass-rails
selenium-webdriver
+ shoulda-matchers (~> 4.0)
spring
turbolinks
tzinfo-data
diff --git a/app/assets/images/star-empty-icon.png b/app/assets/images/star-empty-icon.png
new file mode 100644
index 00000000..1c7ad372
Binary files /dev/null and b/app/assets/images/star-empty-icon.png differ
diff --git a/app/assets/images/star-gold-icon.png b/app/assets/images/star-gold-icon.png
new file mode 100644
index 00000000..34020b1f
Binary files /dev/null and b/app/assets/images/star-gold-icon.png differ
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index d05ea0f5..941bb936 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -13,3 +13,19 @@
*= require_tree .
*= require_self
*/
+
+.story h2 {
+ display: flex;
+ align-items: center;
+}
+
+.inline-form {
+ margin-right: 10px;
+ display: inline;
+}
+
+.flag-icon {
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+}
\ No newline at end of file
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 1c07694e..dcdd77e6 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,3 +1,7 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
+
+ def after_sign_in_path_for(resource)
+ stories_path
+ end
end
diff --git a/app/controllers/flags_controller.rb b/app/controllers/flags_controller.rb
new file mode 100644
index 00000000..c4f8ac7e
--- /dev/null
+++ b/app/controllers/flags_controller.rb
@@ -0,0 +1,23 @@
+class FlagsController < ApplicationController
+ before_action :authenticate_user!
+
+ def create
+ story_data = HackerNewsService.new.fetch_story(params[:story_id])
+ if story_data
+ story = Story.find_or_create_by(id: story_data['id']) do |s|
+ s.title = story_data['title']
+ s.url = story_data['url']
+ end
+ current_user.flagged_stories << story unless current_user.flagged_stories.exists?(story.id)
+ redirect_to stories_path, notice: 'Story flagged successfully.'
+ else
+ redirect_to stories_path, alert: 'Story not found.'
+ end
+ end
+
+ def destroy
+ story = Story.find(params[:story_id])
+ current_user.flagged_stories.delete(story)
+ redirect_to stories_path, notice: 'Story unflagged successfully.'
+ end
+end
diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb
new file mode 100644
index 00000000..828d3f5d
--- /dev/null
+++ b/app/controllers/stories_controller.rb
@@ -0,0 +1,14 @@
+class StoriesController < ApplicationController
+ before_action :authenticate_user!, except: [:index, :show]
+ before_action :load_stories, :flagged_stories
+
+ private
+
+ def load_stories
+ @stories = HackerNewsService.new.fetch_top_stories
+ end
+
+ def flagged_stories
+ @flagged_stories = Flag.includes(:story, :user).all.group_by(&:story_id)
+ end
+end
diff --git a/app/models/flag.rb b/app/models/flag.rb
new file mode 100644
index 00000000..1570fb52
--- /dev/null
+++ b/app/models/flag.rb
@@ -0,0 +1,4 @@
+class Flag < ApplicationRecord
+ belongs_to :user
+ belongs_to :story
+end
diff --git a/app/models/story.rb b/app/models/story.rb
new file mode 100644
index 00000000..2417f557
--- /dev/null
+++ b/app/models/story.rb
@@ -0,0 +1,7 @@
+class Story < ApplicationRecord
+ has_many :flags, dependent: :destroy
+ has_many :flagged_by_users, through: :flags, source: :user
+
+ validates :title, presence: true, uniqueness: { scope: :url }
+ validates :url, presence: true
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index b2091f9a..45afb947 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,4 +3,10 @@ class User < ApplicationRecord
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
+ has_many :flags
+ has_many :flagged_stories, through: :flags, source: :story
+
+ def name
+ first_name + " " + last_name
+ end
end
diff --git a/app/services/hacker_news_service.rb b/app/services/hacker_news_service.rb
new file mode 100644
index 00000000..2b3c27d4
--- /dev/null
+++ b/app/services/hacker_news_service.rb
@@ -0,0 +1,25 @@
+require 'net/http'
+require 'json'
+
+class HackerNewsService
+ TOP_STORIES_URL = 'https://hacker-news.firebaseio.com/v0/topstories.json'
+ STORY_URL = 'https://hacker-news.firebaseio.com/v0/item/%{id}.json'
+
+ def fetch_top_stories(limit = 20)
+ story_ids = fetch_story_ids
+ story_ids.first(limit).map { |id| fetch_story(id) }
+ end
+
+ def fetch_story(id)
+ url = STORY_URL % { id: id }
+ response = HTTParty.get(url)
+ response.parsed_response
+ end
+
+ private
+
+ def fetch_story_ids
+ response = HTTParty.get(TOP_STORIES_URL)
+ response.parsed_response
+ end
+end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 331a7ed0..0f3ddeeb 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -8,6 +8,19 @@
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
+
+ <% if current_user %>
+ <%= link_to 'Sign out', destroy_user_session_path, method: :delete %>
+ <% else %>
+ <%= link_to 'Sign in', new_user_session_path %>
+ <% end %>
+
+
+ <%= link_to 'Latest News Stories', stories_path %>
+
+
+ <%= link_to 'Team Favorites', flagged_stories_path %>
+
<%= notice %>
diff --git a/app/views/stories/_story.html.erb b/app/views/stories/_story.html.erb
new file mode 100644
index 00000000..3f19858b
--- /dev/null
+++ b/app/views/stories/_story.html.erb
@@ -0,0 +1,14 @@
+
+
+ <% if current_user.flagged_stories.exists?(story['id']) %>
+ <%= form_with url: story_flag_path(story['id']), method: :delete, local: true, class: 'inline-form' do %>
+ <%= image_tag 'star-gold-icon.png', alt: 'Unflag', data: { confirm: 'Are you sure you want to unflag this story?' }, class: 'flag-icon', onclick: 'this.closest("form").submit()' %>
+ <% end %>
+ <% else %>
+ <%= form_with url: story_flag_path(story['id']), method: :post, local: true, class: 'inline-form' do %>
+ <%= image_tag 'star-empty-icon.png', alt: 'Flag', class: 'flag-icon', onclick: 'this.closest("form").submit()' %>
+ <% end %>
+ <% end %>
+ <%= story['title'] %>
+
+
diff --git a/app/views/stories/flagged_stories.html.erb b/app/views/stories/flagged_stories.html.erb
new file mode 100644
index 00000000..924428f1
--- /dev/null
+++ b/app/views/stories/flagged_stories.html.erb
@@ -0,0 +1,19 @@
+Flagged Stories
+
+ <% @flagged_stories.each do |story_id, flags| %>
+
+
+ <% if current_user.flagged_stories.exists?(story_id) %>
+ <%= form_with url: story_flag_path(Story.find(story_id)), method: :delete, local: true, class: 'inline-form' do |form| %>
+ <%= image_tag 'star-gold-icon.png', alt: 'Unflag', data: { confirm: 'Are you sure you want to unflag this story?' }, class: 'flag-icon', onclick: 'this.closest("form").submit()' %>
+ <% end %>
+ <% else %>
+ <%= form_with url: story_flag_path(Story.find(story_id)), method: :post, local: true, class: 'inline-form' do %>
+ <%= image_tag 'star-empty-icon.png', alt: 'Flag', class: 'flag-icon', onclick: 'this.closest("form").submit()' %>
+ <% end %>
+ <% end %>
+ <%= flags.first.story.title %> - flagged by <%= flags.map(&:user).map(&:name).to_sentence %>
+
+
+ <% end %>
+
diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb
new file mode 100644
index 00000000..8905a179
--- /dev/null
+++ b/app/views/stories/index.html.erb
@@ -0,0 +1,6 @@
+Top Hacker News Stories
+
+ <% @stories.each do |story| %>
+ <%= render partial: 'story', locals: { story: story } %>
+ <% end %>
+
diff --git a/config/application.rb b/config/application.rb
index dab4cec6..eb28291e 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -11,6 +11,9 @@ class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 5.1
+ config.active_record.legacy_connection_handling = false
+ config.action_controller.urlsafe_csrf_tokens = true
+
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.
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/routes.rb b/config/routes.rb
index c12ef082..929e59d4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,4 +1,10 @@
Rails.application.routes.draw do
devise_for :users
root to: 'pages#home'
+
+ get 'flagged_stories', to: 'stories#flagged_stories', as: 'flagged_stories'
+
+ resources :stories do
+ resource :flag, only: [:create, :destroy]
+ end
end
diff --git a/db/migrate/20240704180117_create_stories.rb b/db/migrate/20240704180117_create_stories.rb
new file mode 100644
index 00000000..725c5b75
--- /dev/null
+++ b/db/migrate/20240704180117_create_stories.rb
@@ -0,0 +1,12 @@
+class CreateStories < ActiveRecord::Migration[7.0]
+ def change
+ create_table :stories do |t|
+ t.string :title
+ t.string :url
+
+ t.timestamps
+ end
+
+ add_index :stories, [:title, :url], unique: true
+ end
+end
diff --git a/db/migrate/20240704182024_create_flags.rb b/db/migrate/20240704182024_create_flags.rb
new file mode 100644
index 00000000..3e72d59d
--- /dev/null
+++ b/db/migrate/20240704182024_create_flags.rb
@@ -0,0 +1,10 @@
+class CreateFlags < ActiveRecord::Migration[6.1]
+ def change
+ create_table :flags 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 acc34f3b..71ad1a14 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,10 +10,27 @@
#
# 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_04_182024) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
+ create_table "flags", 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_flags_on_story_id"
+ t.index ["user_id"], name: "index_flags_on_user_id"
+ end
+
+ create_table "stories", force: :cascade do |t|
+ t.string "title"
+ t.string "author"
+ t.string "url"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "users", force: :cascade do |t|
t.string "first_name"
t.string "last_name"
@@ -33,4 +50,6 @@
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
+ add_foreign_key "flags", "stories"
+ add_foreign_key "flags", "users"
end
diff --git a/spec/controllers/flags_controller_spec.rb b/spec/controllers/flags_controller_spec.rb
new file mode 100644
index 00000000..6b9a4173
--- /dev/null
+++ b/spec/controllers/flags_controller_spec.rb
@@ -0,0 +1,74 @@
+require 'rails_helper'
+
+RSpec.describe FlagsController, type: :controller do
+ let(:user) { create(:user) }
+ let(:story) { create(:story, title: 'New Story', url: 'www.example.com') }
+
+ before do
+ sign_in user
+ end
+
+ describe 'POST #create' do
+ context 'with a valid story' do
+ it 'does not create a new story if it already exists' do
+ expect(story).to be_persisted
+ expect {
+ post :create, params: { story_id: story.id }
+ }.to change(Story, :count).by(0)
+ end
+
+ it 'creates a new flag record for the story' do
+ expect {
+ post :create, params: { story_id: story.id }
+ }.to change(Flag, :count).by(1)
+ end
+
+ it 'redirects to the stories index page' do
+ post :create, params: { story_id: story.id }
+ expect(response).to redirect_to(stories_path)
+ end
+
+ it 'sets a flash notice on success' do
+ post :create, params: { story_id: story.id }
+ expect(flash[:notice]).to eq('Story flagged successfully.')
+ end
+ end
+
+ context 'with an invalid story' do
+ it 'does not create a new flag record' do
+ expect {
+ post :create, params: { story_id: 999000000000000 }
+ }.to_not change(Flag, :count)
+ end
+
+ it 'redirects to the stories index page with an alert message' do
+ post :create, params: { story_id: 999000000000000 }
+ expect(response).to redirect_to(stories_path)
+ expect(flash[:alert]).to eq('Story not found.')
+ end
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ before do
+ current_user = user
+ current_user.flagged_stories << story
+ end
+
+ it 'removes a flag for the story' do
+ expect {
+ delete :destroy, params: { story_id: story.id }
+ }.to change(Flag, :count).by(-1)
+ end
+
+ it 'redirects to the stories index page' do
+ delete :destroy, params: { story_id: story.id }
+ expect(response).to redirect_to(stories_path)
+ end
+
+ it 'sets a flash notice on success' do
+ delete :destroy, params: { story_id: story.id }
+ expect(flash[:notice]).to eq('Story unflagged successfully.')
+ end
+ end
+end
diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb
new file mode 100644
index 00000000..7b27671c
--- /dev/null
+++ b/spec/controllers/stories_controller_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+RSpec.describe StoriesController, type: :controller do
+ let(:user) { create(:user) }
+ let(:story) { create(:story) }
+
+ before do
+ sign_in user
+ allow(HackerNewsService).to receive_message_chain(:new, :fetch_top_stories).and_return([story])
+ end
+
+ describe 'before actions' do
+ it 'calls #load_stories' do
+ expect(controller).to receive(:load_stories)
+ get :index
+ end
+
+ it 'calls #flagged_stories' do
+ expect(controller).to receive(:flagged_stories)
+ get :index
+ end
+ end
+
+ describe 'private methods' do
+ it 'correctly sets @stories' do
+ get :index
+ expect(assigns(:stories)).to include(story)
+ end
+
+ it 'correctly sets @flagged_stories' do
+ Flag.create(user: user, story: story)
+ get :index
+ expect(assigns(:flagged_stories)).to eq({ story.id => [Flag.last] })
+ end
+ end
+end
diff --git a/spec/factories/flags.rb b/spec/factories/flags.rb
new file mode 100644
index 00000000..0ddfc123
--- /dev/null
+++ b/spec/factories/flags.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :flag do
+ association :user
+ association :story
+ end
+end
diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb
new file mode 100644
index 00000000..55a34d42
--- /dev/null
+++ b/spec/factories/stories.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :story do
+ sequence(:id) { |n| n }
+ title { 'Example Story' }
+ url { 'https://example.com' }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
new file mode 100644
index 00000000..a38fda71
--- /dev/null
+++ b/spec/factories/users.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :user do
+ email { 'testuser@example.com' }
+ password { 'password123' }
+ password_confirmation { 'password123' }
+ end
+end
diff --git a/spec/models/flag_spec.rb b/spec/models/flag_spec.rb
new file mode 100644
index 00000000..eb6106a8
--- /dev/null
+++ b/spec/models/flag_spec.rb
@@ -0,0 +1,8 @@
+require 'rails_helper'
+
+RSpec.describe Flag, type: :model do
+ describe 'associations' do
+ it { should belong_to(:user) }
+ it { should belong_to(:story) }
+ end
+end
diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb
new file mode 100644
index 00000000..045a6398
--- /dev/null
+++ b/spec/models/story_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+
+RSpec.describe Story, type: :model do
+ subject { build(:story) }
+
+ describe 'associations' do
+ it { should have_many(:flags).dependent(:destroy) }
+ it { should have_many(:flagged_by_users).through(:flags).source(:user) }
+ end
+
+ describe 'validations' do
+ it { should validate_presence_of(:title) }
+ it { should validate_uniqueness_of(:title).scoped_to(:url) }
+ it { should validate_presence_of(:url) }
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index bbe1ba57..a9c81561 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -1,10 +1,14 @@
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
-require File.expand_path('../../config/environment', __FILE__)
+require_relative '../config/environment'
# 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 'rails-controller-testing'
+
+Rails::Controller::Testing.install
# Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in
@@ -20,21 +24,29 @@
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
-# Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
+# Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }
# Checks for pending migrations and applies them before tests are run.
-# If you are not using ActiveRecord, you can remove this line.
-ActiveRecord::Migration.maintain_test_schema!
-
+# If you are not using ActiveRecord, you can remove these lines.
+begin
+ ActiveRecord::Migration.maintain_test_schema!
+rescue ActiveRecord::PendingMigrationError => e
+ abort e.to_s.strip
+end
RSpec.configure do |config|
+ config.include FactoryBot::Syntax::Methods
+ config.include Devise::Test::ControllerHelpers, type: :controller
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
- config.fixture_path = "#{::Rails.root}/spec/fixtures"
+ config.fixture_path = Rails.root.join('spec/fixtures')
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
+ # You can uncomment this line to turn off ActiveRecord support entirely.
+ # config.use_active_record = false
+
# RSpec Rails can automatically mix in different behaviours to your tests
# based on their file location, for example enabling you to call `get` and
# `post` in specs under `spec/controllers`.
@@ -42,12 +54,12 @@
# You can disable this behaviour by removing the line below, and instead
# explicitly tag your specs with their type, e.g.:
#
- # RSpec.describe UsersController, :type => :controller do
+ # RSpec.describe UsersController, type: :controller do
# # ...
# end
#
# The different available types are documented in the features, such as in
- # https://relishapp.com/rspec/rspec-rails/docs
+ # https://rspec.info/features/6-0/rspec-rails
config.infer_spec_type_from_file_location!
# Filter lines from Rails gems in backtraces.
@@ -55,3 +67,10 @@
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
end
+
+Shoulda::Matchers.configure do |config|
+ config.integrate do |with|
+ with.test_framework :rspec
+ with.library :rails
+ end
+end
diff --git a/spec/services/hacker_news_service_spec.rb b/spec/services/hacker_news_service_spec.rb
new file mode 100644
index 00000000..25b2913f
--- /dev/null
+++ b/spec/services/hacker_news_service_spec.rb
@@ -0,0 +1,47 @@
+require 'rails_helper'
+
+RSpec.describe HackerNewsService, type: :service do
+ let(:service) { described_class.new }
+
+ before do
+ allow(service).to receive(:fetch_story_ids).and_return([1, 2, 3])
+ allow(service).to receive(:fetch_story).with(1).and_return({ 'id' => 1, 'title' => 'Story 1', 'url' => 'https://example.com/1' })
+ allow(service).to receive(:fetch_story).with(2).and_return({ 'id' => 2, 'title' => 'Story 2', 'url' => 'https://example.com/2' })
+ allow(service).to receive(:fetch_story).with(3).and_return({ 'id' => 3, 'title' => 'Story 3', 'url' => 'https://example.com/3' })
+ end
+
+ describe '#fetch_top_stories' do
+ it 'fetches the top stories from the API' do
+ stories = service.fetch_top_stories
+
+ expect(stories.size).to eq(3)
+ expect(stories.first).to include('id' => 1, 'title' => 'Story 1', 'url' => 'https://example.com/1')
+ expect(stories.second).to include('id' => 2, 'title' => 'Story 2', 'url' => 'https://example.com/2')
+ expect(stories.third).to include('id' => 3, 'title' => 'Story 3', 'url' => 'https://example.com/3')
+ end
+
+ it 'fetches only the number of stories specified by the limit' do
+ stories = service.fetch_top_stories(2)
+
+ expect(stories.size).to eq(2)
+ expect(stories.first['id']).to eq(1)
+ expect(stories.second['id']).to eq(2)
+ end
+ end
+
+ describe '#fetch_story_ids' do
+ it 'fetches the story IDs' do
+ ids = service.send(:fetch_story_ids)
+
+ expect(ids).to eq([1, 2, 3])
+ end
+ end
+
+ describe '#fetch_story' do
+ it 'fetches a single story' do
+ story = service.send(:fetch_story, 1)
+
+ expect(story).to include('id' => 1, 'title' => 'Story 1', 'url' => 'https://example.com/1')
+ end
+ end
+end