From 5dd53121055d0ed4ceaafe03df91ff25ce9a7c93 Mon Sep 17 00:00:00 2001 From: Shaun Guckian Date: Fri, 6 Sep 2024 17:43:35 -0400 Subject: [PATCH 1/2] Finished working app --- .env.example | 2 ++ .gitignore | 3 ++ Gemfile | 4 +++ Gemfile.lock | 26 ++++++++++++++ app/controllers/stories_controller.rb | 40 +++++++++++++++++++++ app/helpers/stories_helper.rb | 9 +++++ app/models/star.rb | 14 ++++++++ app/models/story.rb | 11 ++++++ app/models/user.rb | 5 +++ app/services/hacker_news_service.rb | 26 ++++++++++++++ app/views/layouts/_header.html.erb | 16 +++++++++ app/views/layouts/application.html.erb | 15 ++++---- app/views/stories/_star_button.html.erb | 18 ++++++++++ app/views/stories/_story.html.erb | 14 ++++++++ app/views/stories/index.html.erb | 15 ++++++++ app/views/stories/starred.html.erb | 13 +++++++ config/application.rb | 3 ++ config/database.yml | 2 ++ config/routes.rb | 10 +++++- db/migrate/20240904220730_create_stories.rb | 19 ++++++++++ db/migrate/20240904221415_create_stars.rb | 13 +++++++ db/schema.rb | 23 +++++++++++- 22 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100644 app/controllers/stories_controller.rb create mode 100644 app/helpers/stories_helper.rb create mode 100644 app/models/star.rb create mode 100644 app/models/story.rb create mode 100644 app/services/hacker_news_service.rb create mode 100644 app/views/layouts/_header.html.erb create mode 100644 app/views/stories/_star_button.html.erb create mode 100644 app/views/stories/_story.html.erb create mode 100644 app/views/stories/index.html.erb create mode 100644 app/views/stories/starred.html.erb create mode 100644 db/migrate/20240904220730_create_stories.rb create mode 100644 db/migrate/20240904221415_create_stars.rb diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..0605a55a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +TOPNEWS_DATABASE_USER= +TOPNEWS_DATABASE_PASSWORD= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 82701fed..9fcde2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ /yarn-error.log .byebug_history + +# ignore env +.env \ No newline at end of file diff --git a/Gemfile b/Gemfile index fd2e2b45..33ab21cc 100644 --- a/Gemfile +++ b/Gemfile @@ -21,3 +21,7 @@ gem 'turbolinks' gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] gem 'uglifier' gem 'web-console', group: :development +gem 'dotenv', groups: [:development, :test] +gem 'faraday' +gem 'rails-controller-testing', group: :test +gem 'webmock', group: :test diff --git a/Gemfile.lock b/Gemfile.lock index 7d7a3577..6bebc4a6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -70,6 +70,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) base64 (0.2.0) bcrypt (3.1.20) + bigdecimal (3.1.8) bindex (0.8.1) builder (3.3.0) byebug (11.1.3) @@ -91,6 +92,9 @@ GEM execjs coffee-script-source (1.12.2) concurrent-ruby (1.3.4) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) date (3.3.4) devise (4.9.4) @@ -100,6 +104,7 @@ GEM responders warden (~> 1.2.3) diff-lcs (1.5.1) + dotenv (3.1.2) erubi (1.13.0) execjs (2.9.1) factory_bot (6.4.2) @@ -107,9 +112,15 @@ GEM factory_bot_rails (6.4.3) factory_bot (~> 6.4) railties (>= 5.0.0) + faraday (2.11.0) + faraday-net_http (>= 2.0, < 3.4) + logger + faraday-net_http (3.3.0) + net-http ffi (1.17.0) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.1) i18n (1.14.5) concurrent-ruby (~> 1.0) jbuilder (2.12.0) @@ -133,6 +144,8 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.7) minitest (5.25.1) + net-http (0.4.1) + uri net-imap (0.4.14) date net-protocol @@ -174,6 +187,10 @@ GEM activesupport (= 7.0.8.4) bundler (>= 1.15.0) railties (= 7.0.8.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.2.0) activesupport (>= 5.0.0) minitest @@ -251,6 +268,7 @@ GEM concurrent-ruby (~> 1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) + uri (0.13.1) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -258,6 +276,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.23.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -274,13 +296,16 @@ DEPENDENCIES capybara coffee-rails devise + dotenv factory_bot_rails + faraday jbuilder listen pg pry-rails puma rails (~> 7.0.8) + rails-controller-testing rspec-rails sass-rails selenium-webdriver @@ -289,6 +314,7 @@ DEPENDENCIES tzinfo-data uglifier web-console + webmock RUBY VERSION ruby 3.2.3p157 diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb new file mode 100644 index 00000000..ef2b4a9d --- /dev/null +++ b/app/controllers/stories_controller.rb @@ -0,0 +1,40 @@ +class StoriesController < ApplicationController + before_action :authenticate_user!, only: [:star, :unstar] + before_action :set_starred_stories, only: [:index, :starred] + def index + + @stories = HackerNewsService.new.fetch_top_stories + + # This was outside of the scope but thought this could be fun to add to the homepage :) + # Set @current_user_starred_stories if there is a current_user + if current_user + # Eager loading to prevent n1 + @current_user_starred_stories = current_user.starred_stories.includes(:stars).distinct + end + + end + + def starred + # set_starred_stories handles getting starred stories + end + + def star + story = Story.find_or_create_by(hacker_news_id: params[:hacker_news_id], title: params[:title], url: params[:url].presence || '/') + current_user.stars.create(story: story) + redirect_back_or_to root_path + end + + def unstar + story = Story.find_by(hacker_news_id: params[:hacker_news_id]) + star = current_user.stars.find_by(story: story) + star&.destroy + redirect_back_or_to root_path + end + + private + + def set_starred_stories + @starred_stories = Story.includes(:starred_by_users).joins(:stars).distinct + end + +end diff --git a/app/helpers/stories_helper.rb b/app/helpers/stories_helper.rb new file mode 100644 index 00000000..3f4bb5c4 --- /dev/null +++ b/app/helpers/stories_helper.rb @@ -0,0 +1,9 @@ +module StoriesHelper + def story_in_database?(story_id) + Story.exists?(hacker_news_id: story_id) + end + + def story_starred_by_users(story_id) + Story.find_by(hacker_news_id: story_id)&.starred_by_users + end +end \ No newline at end of file diff --git a/app/models/star.rb b/app/models/star.rb new file mode 100644 index 00000000..ce6719d8 --- /dev/null +++ b/app/models/star.rb @@ -0,0 +1,14 @@ +class Star < ApplicationRecord + belongs_to :user + belongs_to :story + + # Callback to clean up story if it's no longer starred by any users + after_destroy :remove_story_if_unstarred + + private + + def remove_story_if_unstarred + # Destroy the story if no stars remain + story.destroy if story.stars.empty? + end +end diff --git a/app/models/story.rb b/app/models/story.rb new file mode 100644 index 00000000..58dfe453 --- /dev/null +++ b/app/models/story.rb @@ -0,0 +1,11 @@ +class Story < ApplicationRecord + # We need to get all related stars and the users that starred them + # We could do a 1 to 1 relationship but since multiple people can star a we need to create a pivot table + has_many :stars, dependent: :destroy + has_many :starred_by_users, through: :stars, source: :user + + # Make sure none of the fields are null and that + validates :hacker_news_id, presence: true, uniqueness: true + validates :title, :url, presence: true + +end diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..85aa3c1d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,4 +3,9 @@ class User < ApplicationRecord # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + # Has many stars + has_many :stars, dependent: :destroy + has_many :starred_stories, through: :stars, source: :story + end diff --git a/app/services/hacker_news_service.rb b/app/services/hacker_news_service.rb new file mode 100644 index 00000000..cc25278a --- /dev/null +++ b/app/services/hacker_news_service.rb @@ -0,0 +1,26 @@ +class HackerNewsService + HN_TOP_STORIES_URL = 'https://hacker-news.firebaseio.com/v0/topstories.json' + HN_ITEM_URL = 'https://hacker-news.firebaseio.com/v0/item/%d.json' + + def fetch_top_stories(limit = 15) + # TODO: Add pagination or lazy load + top_ids = fetch_top_story_ids.first(limit) + top_ids.map { |id| fetch_story_details(id) }.compact + end + + private + + def fetch_top_story_ids + response = Faraday.get(HN_TOP_STORIES_URL) + JSON.parse(response.body) if response.success? + rescue + [] + end + + def fetch_story_details(story_id) + response = Faraday.get(format(HN_ITEM_URL, id: story_id)) + JSON.parse(response.body).slice('id', 'title', 'url').transform_keys(&:to_sym) if response.success? + rescue + nil + end +end \ No newline at end of file diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb new file mode 100644 index 00000000..62826a9e --- /dev/null +++ b/app/views/layouts/_header.html.erb @@ -0,0 +1,16 @@ +
+ +
\ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 331a7ed0..47e63323 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -6,14 +6,13 @@ <%= csrf_meta_tags %> <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> - - -

- <%= notice %> -

-

- <%= alert %> -

+ + + + + <%= render 'layouts/header' %> +
<%= yield %> +
diff --git a/app/views/stories/_star_button.html.erb b/app/views/stories/_star_button.html.erb new file mode 100644 index 00000000..486839ea --- /dev/null +++ b/app/views/stories/_star_button.html.erb @@ -0,0 +1,18 @@ +<% if user_signed_in? %> + <% if current_user.starred_stories.exists?(hacker_news_id: id) %> + <%= button_to unstar_stories_path(hacker_news_id: id), method: :delete, class: 'bg-green-500 text-white p-2 rounded-full hover:bg-red-600 transition flex items-center' do %> + + Unstar + <% end %> + <% else %> + <%= button_to star_stories_path(hacker_news_id: id, title: story[:title], url: story[:url]), method: :post, class: 'bg-blue-500 text-white p-2 rounded-full hover:bg-blue-600 transition flex items-center' do %> + + Star + <% end %> + <% end %> +<% else %> + <%= button_to new_user_session_path, class: 'bg-gray-300 text-white p-2 rounded-full hover:bg-gray-500 transition flex items-center' do %> + + Star + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/stories/_story.html.erb b/app/views/stories/_story.html.erb new file mode 100644 index 00000000..5ecde42b --- /dev/null +++ b/app/views/stories/_story.html.erb @@ -0,0 +1,14 @@ +
+
+ <%= link_to story[:title], story[:url], target: '_blank', class: 'text-lg font-semibold text-gray-800 mb-2' %> + <%= render 'star_button', story: story, id: id %> +
+ + <% if story_starred_by_users(id) %> +

Starred by: + <% story_starred_by_users(id).each do |user| %> + <%= user.first_name %> <%= user.last_name %> + <% end %> +

+ <% end %> +
\ No newline at end of file diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb new file mode 100644 index 00000000..b067f672 --- /dev/null +++ b/app/views/stories/index.html.erb @@ -0,0 +1,15 @@ +

Top Hacker News Stories

+
+ <% @stories.each do |story| %> + <%= render 'story', story: story, id: story[:id] %> + <% end %> +
+ +<% if @current_user_starred_stories.present? %> +

Your Starred Stories

+
+ <% @current_user_starred_stories.each do |story| %> + <%= render 'story', story: story, id: story[:hacker_news_id] %> + <% end %> +
+<% end %> \ No newline at end of file diff --git a/app/views/stories/starred.html.erb b/app/views/stories/starred.html.erb new file mode 100644 index 00000000..58c9cd56 --- /dev/null +++ b/app/views/stories/starred.html.erb @@ -0,0 +1,13 @@ + + + +<% if @starred_stories.any? %> +

Starred Stories

+
+ <% @starred_stories.each do |story| %> + <%= render 'story', story: story, id: story[:hacker_news_id] %> + <% end %> +
+<% else %> +

Sorry there's no starred stories!

+<% end %> \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 569fba1e..d01a177c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -16,5 +16,8 @@ class Application < Rails::Application # -- all .rb files in that directory are automatically loaded. config.active_record.legacy_connection_handling = false + + # Autoload Service + config.autoload_paths << Rails.root.join('app/services') end end diff --git a/config/database.yml b/config/database.yml index 16fc6d17..8dd5b321 100644 --- a/config/database.yml +++ b/config/database.yml @@ -20,6 +20,8 @@ default: &default # For details on connection pooling, see Rails configuration guide # http://guides.rubyonrails.org/configuring.html#database-pooling pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + username: <%= ENV['TOPNEWS_DATABASE_USER'] %> + password: <%= ENV['TOPNEWS_DATABASE_PASSWORD'] %> development: <<: *default diff --git a/config/routes.rb b/config/routes.rb index c12ef082..df01da7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,12 @@ Rails.application.routes.draw do devise_for :users - root to: 'pages#home' + + resources :stories, only: [:index] do + post :star, on: :collection + delete :unstar, on: :collection + get :starred, on: :collection + end + + root 'stories#index' + end diff --git a/db/migrate/20240904220730_create_stories.rb b/db/migrate/20240904220730_create_stories.rb new file mode 100644 index 00000000..861b408e --- /dev/null +++ b/db/migrate/20240904220730_create_stories.rb @@ -0,0 +1,19 @@ +class CreateStories < ActiveRecord::Migration[7.0] + def change + create_table :stories do |t| + # Make sure we don't have null on any fields and that the id is unique to prevent + # having multiple of the same stories. + t.integer :hacker_news_id, null: false, unique: true + # Title / URL will be an issue if the stories / url changes on + # hackernews and we'd need a way to update it. Potentially running a sidekick job to check. + # But under the assumption it doesn't change, I'll add it here so that we don't need to make additional + # requests to hackernews for each story when getting our list. + t.string :title, null: false + t.string :url, null: false + + t.timestamps + end + # I'm adding an index to stories to improve query time + add_index :stories, :hacker_news_id, unique: true + end +end diff --git a/db/migrate/20240904221415_create_stars.rb b/db/migrate/20240904221415_create_stars.rb new file mode 100644 index 00000000..dc717260 --- /dev/null +++ b/db/migrate/20240904221415_create_stars.rb @@ -0,0 +1,13 @@ +class CreateStars < ActiveRecord::Migration[7.0] + def change + create_table :stars do |t| + # This is just a pivot table to map stars to stories + t.references :user, null: false, foreign_key: true + t.references :story, null: false, foreign_key: true + + t.timestamps + end + # Add an index on stars because there could potentially be lots of stars on a story + add_index :stars, [:user_id, :story_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..96a4f66b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,29 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2018_02_28_212101) do +ActiveRecord::Schema[7.0].define(version: 2024_09_04_221415) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "stars", 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_stars_on_story_id" + t.index ["user_id", "story_id"], name: "index_stars_on_user_id_and_story_id", unique: true + t.index ["user_id"], name: "index_stars_on_user_id" + end + + create_table "stories", force: :cascade do |t| + t.integer "hacker_news_id", null: false + t.string "title", null: false + t.string "url", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["hacker_news_id"], name: "index_stories_on_hacker_news_id", unique: true + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" @@ -33,4 +52,6 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "stars", "stories" + add_foreign_key "stars", "users" end From e825230671c0c3a38d0c20407e3375b93631851b Mon Sep 17 00:00:00 2001 From: Shaun Guckian Date: Fri, 6 Sep 2024 19:48:44 -0400 Subject: [PATCH 2/2] Finished writing tests --- spec/controllers/stories_controller_spec.rb | 55 +++++++++++++++++++++ spec/factories/stars.rb | 6 +++ spec/factories/stories.rb | 7 +++ spec/factories/users.rb | 8 +++ spec/helpers/stories_helper_spec.rb | 39 +++++++++++++++ spec/models/star_spec.rb | 30 +++++++++++ spec/models/story_spec.rb | 51 +++++++++++++++++++ spec/rails_helper.rb | 10 ++++ spec/services/hacker_news_service_spec.rb | 37 ++++++++++++++ spec/views/stories/_star.html.erb_spec.rb | 40 +++++++++++++++ spec/views/stories/_story.html.erb_spec.rb | 26 ++++++++++ spec/views/stories/index.html.erb_spec.rb | 37 ++++++++++++++ 12 files changed, 346 insertions(+) create mode 100644 spec/controllers/stories_controller_spec.rb create mode 100644 spec/factories/stars.rb create mode 100644 spec/factories/stories.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/helpers/stories_helper_spec.rb create mode 100644 spec/models/star_spec.rb create mode 100644 spec/models/story_spec.rb create mode 100644 spec/services/hacker_news_service_spec.rb create mode 100644 spec/views/stories/_star.html.erb_spec.rb create mode 100644 spec/views/stories/_story.html.erb_spec.rb create mode 100644 spec/views/stories/index.html.erb_spec.rb diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb new file mode 100644 index 00000000..9a1010ba --- /dev/null +++ b/spec/controllers/stories_controller_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +RSpec.describe StoriesController, type: :controller do + let(:user) { User.create(email: 'test@example.com', password: 'password') } + + before { sign_in user } + + describe 'GET index' do + it 'fetches top stories and assigns current user starred stories' do + allow_any_instance_of(HackerNewsService).to receive(:fetch_top_stories).and_return([{ id: 1, title: 'Story 1', url: 'http://story1.com' }]) + story = Story.create(hacker_news_id: 1, title: 'Story 1', url: 'http://story1.com') + user.stars.create(story: story) + + get :index + + expect(response).to be_successful + expect(assigns(:stories)).to eq([{ id: 1, title: 'Story 1', url: 'http://story1.com' }]) + expect(assigns(:current_user_starred_stories)).to include(story) + end + end + + describe 'GET starred' do + it 'assigns @starred_stories' do + story = Story.create(hacker_news_id: 1, title: 'Story 1', url: 'http://story1.com') + user.stars.create(story: story) + + get :starred + + expect(assigns(:starred_stories)).to include(story) + end + end + + describe 'POST star' do + it 'creates a star for the current user' do + expect { + post :star, params: { hacker_news_id: 1, title: 'Story 1', url: 'http://story1.com' } + }.to change(user.stars, :count).by(1) + + expect(response).to redirect_to(root_path) + end + end + + describe 'DELETE star' do + it 'removes the star for the current user' do + story = Story.create(hacker_news_id: 1, title: 'Story 1', url: 'http://story1.com') + user.stars.create(story: story) + + expect { + delete :unstar, params: { hacker_news_id: story.hacker_news_id } + }.to change(user.stars, :count).by(-1) + + expect(response).to redirect_to(root_path) + end + end +end \ No newline at end of file diff --git a/spec/factories/stars.rb b/spec/factories/stars.rb new file mode 100644 index 00000000..269b881c --- /dev/null +++ b/spec/factories/stars.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :star do + user { nil } + story { nil } + end +end diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb new file mode 100644 index 00000000..2fa2a0c3 --- /dev/null +++ b/spec/factories/stories.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :story do + hacker_news_id { 1 } + title { "test" } + url { "test" } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 00000000..f3de1195 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :user do + first_name { "John" } + last_name { "Doe" } + email { "john.doe@example.com" } + password { "password" } + end +end \ No newline at end of file diff --git a/spec/helpers/stories_helper_spec.rb b/spec/helpers/stories_helper_spec.rb new file mode 100644 index 00000000..6c4be050 --- /dev/null +++ b/spec/helpers/stories_helper_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe StoriesHelper, type: :helper do + let(:story) { create(:story, hacker_news_id: 1) } + let(:user) { create(:user) } + + before do + # Setting up the association between story and users + story.starred_by_users << user + end + + describe 'Story is in database' do + context 'when the story exists in the database' do + it 'returns true' do + expect(helper.story_in_database?(story.hacker_news_id)).to be(true) + end + end + + context 'when the story does not exist in the database' do + it 'returns false' do + expect(helper.story_in_database?(999)).to be(false) + end + end + end + + describe 'Story starred by users' do + context 'when the story exists and has users who starred it' do + it 'returns the users who starred the story' do + expect(helper.story_starred_by_users(story.hacker_news_id)).to include(user) + end + end + + context 'when the story does not exist' do + it 'returns nil' do + expect(helper.story_starred_by_users(999)).to be_nil + end + end + end +end \ No newline at end of file diff --git a/spec/models/star_spec.rb b/spec/models/star_spec.rb new file mode 100644 index 00000000..c462bbbc --- /dev/null +++ b/spec/models/star_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe Star, type: :model do + let(:user) { User.create(email: 'user@example.com', password: 'password') } + let(:story) { Story.create(hacker_news_id: 1, title: 'Sample Story', url: 'http://example.com') } + + it 'creates a valid star' do + star = Star.create(user: user, story: story) + expect(star).to be_valid + end + + describe 'callbacks' do + it 'removes the story when the last star is destroyed' do + star = Star.create(user: user, story: story) + expect { star.destroy }.to change { Story.count }.by(-1) + end + + it 'does not remove the story if other stars remain' do + user1 = User.create(email: 'testuser1@example.com', password: 'password') + user2 = User.create(email: 'testuser2@example.com', password: 'password') + story = Story.create(hacker_news_id: 13, title: 'Story to Keep', url: 'http://example.com') + star1 = Star.create(user: user1, story: story) + star2 = Star.create(user: user2, story: story) + + # Destroy one star and ensure the story still exists + star1.destroy + expect(Story.exists?(story.id)).to be true + end + end +end \ No newline at end of file diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb new file mode 100644 index 00000000..f709ba6a --- /dev/null +++ b/spec/models/story_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe Story, type: :model do + describe 'associations' do + it 'has many stars with dependent destroy' do + story = Story.create(hacker_news_id: 1, title: 'Sample Title', url: 'http://example.com') + star = Star.create(story: story, user: User.create(email: 'test@example.com', password: 'password')) + + expect(story.stars).to include(star) + + # Test dependent destroy + expect { story.destroy }.to change { Star.count }.by(-1) + end + + it 'has many starred_by_users through stars' do + user = User.create(email: 'test2@example.com', password: 'password') + story = Story.create(hacker_news_id: 2, title: 'Another Title', url: 'http://example.com') + Star.create(story: story, user: user) + + expect(story.starred_by_users).to include(user) + end + end + + describe 'validations' do + it 'is invalid without a hacker_news_id' do + story = Story.new(title: 'No ID', url: 'http://example.com') + expect(story.valid?).to be false + expect(story.errors[:hacker_news_id]).to include("can't be blank") + end + + it 'is invalid without a title' do + story = Story.new(hacker_news_id: 3, url: 'http://example.com') + expect(story.valid?).to be false + expect(story.errors[:title]).to include("can't be blank") + end + + it 'is invalid without a url' do + story = Story.new(hacker_news_id: 4, title: 'No URL') + expect(story.valid?).to be false + expect(story.errors[:url]).to include("can't be blank") + end + + it 'is invalid if hacker_news_id is not unique' do + Story.create(hacker_news_id: 5, title: 'Unique ID', url: 'http://example.com') + duplicate_story = Story.new(hacker_news_id: 5, title: 'Duplicate ID', url: 'http://example.com') + + expect(duplicate_story.valid?).to be false + expect(duplicate_story.errors[:hacker_news_id]).to include('has already been taken') + end + end +end \ No newline at end of file diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bbe1ba57..ba77f774 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,5 +1,6 @@ # This file is copied to spec/ when you run 'rails generate rspec:install' require 'spec_helper' +require 'webmock/rspec' ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../../config/environment', __FILE__) # Prevent database truncation if the environment is production @@ -54,4 +55,13 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + + # test controllers + config.include Devise::Test::ControllerHelpers, type: :controller + + # Allow mock calls in testing + WebMock.disable_net_connect!(allow_localhost: true) + + # Include FactoryBot methods + config.include FactoryBot::Syntax::Methods 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..5bced12d --- /dev/null +++ b/spec/services/hacker_news_service_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe HackerNewsService do + let(:service) { described_class.new } + + describe '#fetch_top_story_ids' do + it 'fetches top story ids from Hacker News API' do + stub_request(:get, HackerNewsService::HN_TOP_STORIES_URL) + .to_return(status: 200, body: '[1, 2, 3]') + + expect(service.send(:fetch_top_story_ids)).to eq([1, 2, 3]) + end + + it 'returns nil on failure' do + stub_request(:get, HackerNewsService::HN_TOP_STORIES_URL) + .to_return(status: 500) + + expect(service.send(:fetch_top_story_ids)).to eq(nil) + end + end + + describe '#fetch_story_details' do + it 'fetches story details given a story id' do + stub_request(:get, format(HackerNewsService::HN_ITEM_URL, id: 1)) + .to_return(status: 200, body: '{"id": 1, "title": "Story 1", "url": "http://example.com"}') + + expect(service.send(:fetch_story_details, 1)).to eq({ id: 1, title: 'Story 1', url: 'http://example.com' }) + end + + it 'returns nil on failure' do + stub_request(:get, format(HackerNewsService::HN_ITEM_URL, id: 1)) + .to_return(status: 404) + + expect(service.send(:fetch_story_details, 1)).to be_nil + end + end +end \ No newline at end of file diff --git a/spec/views/stories/_star.html.erb_spec.rb b/spec/views/stories/_star.html.erb_spec.rb new file mode 100644 index 00000000..e4a1745e --- /dev/null +++ b/spec/views/stories/_star.html.erb_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe 'stories/_star_button', type: :view do + let(:story) { { title: 'Test Story', url: 'https://example.com', id: 1 } } + let(:user) { create(:user) } + + context 'when the user is signed in' do + before do + allow(view).to receive(:user_signed_in?).and_return(true) + allow(view).to receive(:current_user).and_return(user) + end + + it 'renders the unstar button if the story is already starred' do + allow(user).to receive_message_chain(:starred_stories, :exists?).with(hacker_news_id: 1).and_return(true) + + render partial: 'stories/star_button', locals: { story: story, id: story[:id] } + + expect(rendered).to have_selector('.unstar-btn') + end + + it 'renders the star button if the story is not starred' do + allow(user).to receive_message_chain(:starred_stories, :exists?).with(hacker_news_id: 1).and_return(false) + + render partial: 'stories/star_button', locals: { story: story, id: story[:id] } + expect(rendered).to have_selector('.star-btn') + end + end + + context 'when the user is not signed in' do + before do + allow(view).to receive(:user_signed_in?).and_return(false) + end + + it 'renders a button to sign in' do + render partial: 'stories/star_button', locals: { story: story, id: story[:id] } + + expect(rendered).to have_selector('.logged-out-star-btn') + end + end +end \ No newline at end of file diff --git a/spec/views/stories/_story.html.erb_spec.rb b/spec/views/stories/_story.html.erb_spec.rb new file mode 100644 index 00000000..4f70f819 --- /dev/null +++ b/spec/views/stories/_story.html.erb_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe 'stories/_story', type: :view do + let(:story) { { title: 'Test Story', url: 'https://example.com', id: 1 } } + let(:user) { create(:user) } + + before do + assign(:story, story) + allow(view).to receive(:user_signed_in?).and_return(true) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:story_starred_by_users).and_return([user]) + end + + it 'displays the story title and link' do + render partial: 'stories/story', locals: { story: story, id: story[:id] } + + expect(rendered).to have_link('Test Story', href: 'https://example.com') + end + + it 'displays users who starred the story if any' do + render partial: 'stories/story', locals: { story: story, id: story[:id] } + + expect(rendered).to have_content('Starred by:') + expect(rendered).to have_content('John Doe') + end +end \ No newline at end of file diff --git a/spec/views/stories/index.html.erb_spec.rb b/spec/views/stories/index.html.erb_spec.rb new file mode 100644 index 00000000..9fbd8131 --- /dev/null +++ b/spec/views/stories/index.html.erb_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe 'stories/index', type: :view do + let(:stories) { [{ title: 'Story 1', url: 'https://example.com/1', id: 1 }, { title: 'Story 2', url: 'https://example.com/2', id: 2 }] } + let(:starred_stories) { [{ title: 'Starred Story', url: 'https://example.com/starred', hacker_news_id: 3 }] } + let(:user) { create(:user) } + + before do + assign(:stories, stories) + assign(:current_user_starred_stories, starred_stories) + allow(view).to receive(:user_signed_in?).and_return(true) + allow(view).to receive(:current_user).and_return(user) + end + + it 'displays the top stories section' do + render + + expect(rendered).to have_content('Top Hacker News Stories') + expect(rendered).to have_link('Story 1', href: 'https://example.com/1') + expect(rendered).to have_link('Story 2', href: 'https://example.com/2') + end + + it 'displays the starred stories section if the user has starred stories' do + render + + expect(rendered).to have_content('Your Starred Stories') + expect(rendered).to have_link('Starred Story', href: 'https://example.com/starred') + end + + it 'does not display the starred stories section if there are no starred stories' do + assign(:current_user_starred_stories, []) + + render + + expect(rendered).not_to have_content('Your Starred Stories') + end +end \ No newline at end of file