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

+ 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

+ 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