diff --git a/.gitignore b/.gitignore index 82701fed..f01e281d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ /yarn-error.log .byebug_history + +# Ignore vim swap files +*.swp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..84114e0c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM ruby:3.1.2 + +# RUN apt-get update +# RUN apt-get install nodejs -y +RUN apt-get update -qq && apt-get install -y build-essential apt-utils libpq-dev nodejs + +RUN gem install bundler -v 2.3.22 + +WORKDIR /topnews + +COPY .ruby-version /topnews/ +COPY Gemfile* /topnews/ + +# RUN bundle _2.3.22_ install +RUN bundle install + +EXPOSE 3000 + +COPY . /topnews + +CMD ["rails", "server", "-b", "0.0.0.0"] diff --git a/Gemfile b/Gemfile index 5a8ffc43..f66cd194 100644 --- a/Gemfile +++ b/Gemfile @@ -12,7 +12,8 @@ gem 'pg' gem 'pry-rails' gem 'puma' gem 'rails', '~> 7.0.3' -gem 'rspec-rails' +gem 'rspec-rails', group: [:development, :test] +gem 'factory_bot_rails', group: [:development, :test] gem 'sass-rails' gem 'selenium-webdriver', group: [:development, :test] gem 'spring', group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 14ec6457..42da1112 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,67 +1,67 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.0.4) - actionpack (= 7.0.4) - activesupport (= 7.0.4) + actioncable (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.4) - actionpack (= 7.0.4) - activejob (= 7.0.4) - activerecord (= 7.0.4) - activestorage (= 7.0.4) - activesupport (= 7.0.4) + 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) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.4) - actionpack (= 7.0.4) - actionview (= 7.0.4) - activejob (= 7.0.4) - activesupport (= 7.0.4) + 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) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.4) - actionview (= 7.0.4) - activesupport (= 7.0.4) - rack (~> 2.0, >= 2.2.0) + actionpack (7.0.8.4) + actionview (= 7.0.8.4) + activesupport (= 7.0.8.4) + 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.4) - actionpack (= 7.0.4) - activerecord (= 7.0.4) - activestorage (= 7.0.4) - activesupport (= 7.0.4) + 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) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.4) - activesupport (= 7.0.4) + actionview (7.0.8.4) + activesupport (= 7.0.8.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.4) - activesupport (= 7.0.4) + activejob (7.0.8.4) + activesupport (= 7.0.8.4) globalid (>= 0.3.6) - activemodel (7.0.4) - activesupport (= 7.0.4) - activerecord (7.0.4) - activemodel (= 7.0.4) - activesupport (= 7.0.4) - activestorage (7.0.4) - actionpack (= 7.0.4) - activejob (= 7.0.4) - activerecord (= 7.0.4) - activesupport (= 7.0.4) + 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) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.4) + activesupport (7.0.8.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -70,7 +70,7 @@ GEM public_suffix (>= 2.0.2, < 6.0) bcrypt (3.1.18) bindex (0.8.1) - builder (3.2.4) + builder (3.3.0) byebug (11.1.3) capybara (3.37.1) addressable @@ -90,8 +90,9 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - concurrent-ruby (1.1.10) + concurrent-ruby (1.3.4) crass (1.0.6) + date (3.3.4) devise (4.8.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -99,13 +100,17 @@ GEM responders warden (~> 1.2.3) diff-lcs (1.5.0) - digest (3.1.0) - erubi (1.11.0) + erubi (1.13.0) execjs (2.8.1) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) ffi (1.15.5) - globalid (1.0.0) - activesupport (>= 5.0) - i18n (1.12.0) + globalid (1.2.1) + activesupport (>= 6.1) + i18n (1.14.5) concurrent-ruby (~> 1.0) jbuilder (2.11.5) actionview (>= 5.0.0) @@ -113,34 +118,32 @@ GEM listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.19.0) + loofah (2.22.0) crass (~> 1.0.2) - nokogiri (>= 1.5.9) - mail (2.7.1) + nokogiri (>= 1.12.0) + mail (2.8.1) mini_mime (>= 0.1.1) - marcel (1.0.2) + net-imap + net-pop + net-smtp + marcel (1.0.4) matrix (0.4.2) - method_source (1.0.0) - mini_mime (1.1.2) - mini_portile2 (2.8.0) - minitest (5.16.3) - net-imap (0.2.3) - digest + method_source (1.1.0) + mini_mime (1.1.5) + mini_portile2 (2.8.7) + minitest (5.25.0) + net-imap (0.4.14) + date net-protocol - strscan - net-pop (0.1.1) - digest + net-pop (0.1.2) net-protocol + net-protocol (0.2.2) timeout - net-protocol (0.1.3) - timeout - net-smtp (0.3.1) - digest + net-smtp (0.5.0) net-protocol - timeout - nio4r (2.5.8) - nokogiri (1.13.8) - mini_portile2 (~> 2.8.0) + nio4r (2.7.3) + nokogiri (1.16.7) + mini_portile2 (~> 2.8.2) racc (~> 1.4) orm_adapter (0.5.0) pg (1.4.3) @@ -152,37 +155,39 @@ GEM public_suffix (5.0.0) puma (5.6.5) nio4r (~> 2.0) - racc (1.6.0) - rack (2.2.4) - rack-test (2.0.2) + racc (1.8.1) + rack (2.2.9) + rack-test (2.1.0) rack (>= 1.3) - rails (7.0.4) - actioncable (= 7.0.4) - actionmailbox (= 7.0.4) - actionmailer (= 7.0.4) - actionpack (= 7.0.4) - actiontext (= 7.0.4) - actionview (= 7.0.4) - activejob (= 7.0.4) - activemodel (= 7.0.4) - activerecord (= 7.0.4) - activestorage (= 7.0.4) - activesupport (= 7.0.4) + 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) bundler (>= 1.15.0) - railties (= 7.0.4) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + railties (= 7.0.8.4) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.4.3) - loofah (~> 2.3) - railties (7.0.4) - actionpack (= 7.0.4) - activesupport (= 7.0.4) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.0.8.4) + actionpack (= 7.0.8.4) + activesupport (= 7.0.8.4) method_source rake (>= 12.2) thor (~> 1.0) zeitwerk (~> 2.5) - rake (13.0.6) + rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -232,14 +237,13 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - strscan (3.0.4) - thor (1.2.1) + thor (1.3.1) tilt (2.0.11) - timeout (0.3.0) + timeout (0.4.1) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) - tzinfo (2.0.5) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) @@ -251,12 +255,12 @@ GEM bindex (>= 0.4.0) railties (>= 6.0.0) websocket (1.2.9) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.0) + zeitwerk (2.6.17) PLATFORMS ruby @@ -266,6 +270,7 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot_rails jbuilder listen pg diff --git a/README.md b/README.md index 500f71a1..1d7a9e0a 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,126 @@ When a team member signs in, they will see recent news stories and be able to st * As an internal tool for a small team, performance optimization is not a requirement. * Be prepared to discuss known performance shortcomings of your solution and potential improvements. * UX design here is of little importance. The design can be minimal or it can have zero design at all. + + +# Running the environment (local) + +## Requirements + +``` +ruby 3.1.2 +postgres 14.4 +``` + +Manage requirements using `asdf`: + +- [`asdf` - Getting Started](https://asdf-vm.com/guide/getting-started.html) + +`asdf` requirements versions are specified in the file `.tool-versions` for `asdf`: + +```bash +cat .tool-versions +``` + +## Setup + +```bash +bundle install +bundle exec rake db:create +bundle exec rake db:migrate +bundle exec rake db:seed +``` + +## Running the server + +```bash +bundle exec rails server +``` + +# Running tests + +## Setup + +```bash +bundle exec rails db:migrate RAILS_ENV=test +``` + +## Running tests + +```bash +bundle exec rspec +# or +bundle exec rspec --format documentation +``` + +# Running the environment (Docker) + +Build image using multi-platform images + +```bash +docker build --platform linux/amd64 . +``` + +## Development + +Run the environment + +```bash +docker compose up +``` + +Stoping the environment + +```bash +docker compose down -v +``` + +## Helpful commands + +Get services `CONTAINER_ID` or `NAMES`: + +```bash +docker ps +``` + +Using the `CONTAINER_ID` or `NAMES` from the previous command, +we can execute commands against our docker services + +First time commands: +```bash +docker exec -it bundle exec rails db:create +docker exec -it bundle exec rails db:migrate +docker exec -it bundle exec rails db:seed +``` + +Rails console in container: +```bash +# This will be similar run against running server +docker exec -it bundle exec rails console +``` + +Rails console from service: +```bash +# This will be similar to running `bundle exec rails console` +docker compose run rails bundle exec rails console +``` + +## Running tests + +First steps for setup +```bash +docker compose -f docker-compose.test.yml run test bundle exec rails db:test:prepare +``` + +### Running tests suite + +Running tests suite +```bash +docker compose -f docker-compose.test.yml run test bundle exec rspec --format documentation +``` + +Running tests in container +```bash +docker compose -f docker-compose.test.yml up +``` + diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1c07694e..23077a9b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,5 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception + + before_action :authenticate_user! end diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb new file mode 100644 index 00000000..fbbec29d --- /dev/null +++ b/app/controllers/articles_controller.rb @@ -0,0 +1,58 @@ +require_relative '../../lib/services/hacker_news/hacker_news.rb' + +class ArticlesController < ApplicationController + TOP_ARTICLES = 10 + def index + get_top_stories_ids + get_top_stories_details + @articles + end + + private + + # TODO: Refactor this articles or story to articles conversion + # into its hown service class + def get_top_stories_ids + stories = hacker_news.topstories + if stories + @top_stories_ids = stories.take(TOP_ARTICLES) + return @top_stories_ids + end + end + + def hacker_news + @hn ||= HackerNews.new + end + + def get_top_stories_details + @top_stories = [] + return nil unless @top_stories_ids + + find_articles_by_stories_ids + + @articles.each do |article| + if @top_stories_ids.include?(article.external_id) + @top_stories_ids.delete(article.external_id) + end + end + + @top_stories_ids.each do |story_id| + story = hacker_news.item(story_id) + if story + article = convert_story_to_article(story) + @articles << article + end + end + end + + def find_articles_by_stories_ids + @articles = Article.where(external_id: @top_stories_ids).to_a + end + + def convert_story_to_article(story) + story["document"] = story.clone + story["external_id"] = story["id"] + story.delete("id") + return Article.new(story.slice(*Article.attribute_names)) + end +end diff --git a/app/controllers/user_articles_controller.rb b/app/controllers/user_articles_controller.rb new file mode 100644 index 00000000..b9ee743c --- /dev/null +++ b/app/controllers/user_articles_controller.rb @@ -0,0 +1,47 @@ +class UserArticlesController < ApplicationController + def index + @user_articles = UserArticle.includes(:user, :article) + end + + def create + @article_created = false + @user_article_created = false + + params.required(:article).permit! + article_attributes = params[:article] + find_article_from_attributes(article_attributes) + create_article_if_doesnt_exists(article_attributes) + find_or_create_user_article + + head :created + end + + private + + # TODO: Move this to before_action + def find_article_from_attributes(article_attributes) + @article = nil + if article_attributes.key?("id") && article_attributes["id"].present? + @article = Article.where(id: article_attributes["id"]).first + elsif article_attributes.key?("external_id") && article_attributes["external_id"].present? + @article = Article.where(external_id: article_attributes["external_id"]).first + end + end + + # TODO: Move this to before_action + def create_article_if_doesnt_exists(article_attributes) + return if @article + @article ||= Article.create(article_attributes) + @article_created = true + end + + # TODO: Move this to before_action + def find_or_create_user_article + return unless @article + @user_article = UserArticle.where(user: current_user, article: @article).first + unless @user_article + @user_article = UserArticle.create(user: current_user, article: @article) + @user_article_created = true + end + end +end diff --git a/app/helpers/articles_helper.rb b/app/helpers/articles_helper.rb new file mode 100644 index 00000000..29682775 --- /dev/null +++ b/app/helpers/articles_helper.rb @@ -0,0 +1,2 @@ +module ArticlesHelper +end diff --git a/app/helpers/user_articles_helper.rb b/app/helpers/user_articles_helper.rb new file mode 100644 index 00000000..7bb97d99 --- /dev/null +++ b/app/helpers/user_articles_helper.rb @@ -0,0 +1,2 @@ +module UserArticlesHelper +end diff --git a/app/models/article.rb b/app/models/article.rb new file mode 100644 index 00000000..5a073f17 --- /dev/null +++ b/app/models/article.rb @@ -0,0 +1,6 @@ +class Article < ApplicationRecord + self.inheritance_column = :_type + + has_many :user_articles + has_many :users, through: :user_articles +end diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..0455901f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,4 +3,7 @@ class User < ApplicationRecord # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + has_many :user_articles + has_many :articles, through: :user_articles end diff --git a/app/models/user_article.rb b/app/models/user_article.rb new file mode 100644 index 00000000..b79cc55c --- /dev/null +++ b/app/models/user_article.rb @@ -0,0 +1,4 @@ +class UserArticle < ApplicationRecord + belongs_to :user + belongs_to :article +end diff --git a/app/views/articles/index.html.erb b/app/views/articles/index.html.erb new file mode 100644 index 00000000..8e0e19d5 --- /dev/null +++ b/app/views/articles/index.html.erb @@ -0,0 +1,10 @@ +

Articles TOP Feed

+ +
    + <% @articles.each do |article| %> +
  • + <%= article.score %> | <%= article.title %> | <%= link_to "link", article.url, target: "_blank", rel: "alternate" %> + <%= link_to "pick", user_articles_path(article: article.as_json), { method: :post, remote: true } %> +
  • + <% end %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 331a7ed0..691e463c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -14,6 +14,16 @@

<%= alert %>

+ + <% if user_signed_in? %> + Hi <%= "#{current_user.first_name} #{current_user.last_name}" %>. + You're logged in! + <%= link_to "Log out", destroy_user_session_path, method: :delete %> + <% else %> + Not logged in. + <%= link_to "Sign in", new_user_session_path, class: 'nav-link' %> + <% end %> + <%= yield %> diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 8bfd8294..19b9d35b 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1 +1,10 @@

Welcome to Top News

+ +
    +
  • + <%= link_to "TOP Articles", articles_path, method: :get %> +
  • +
  • + <%= link_to "Team Articles", user_articles_path, method: :get %> +
  • +
diff --git a/app/views/user_articles/index.html.erb b/app/views/user_articles/index.html.erb new file mode 100644 index 00000000..9c08983a --- /dev/null +++ b/app/views/user_articles/index.html.erb @@ -0,0 +1,14 @@ +

Team Articles

+ +
    + <% @user_articles.each do |ua| %> + <% article = ua.article %> + <% user = ua.user %> + <% picked_by = user.id == current_user.id ? "(You)" : "#{user.first_name} #{user.last_name}" %> + +
  • + <%= article.score %> | <%= article.title %> | <%= link_to "link", article.url, target: "_blank", rel: "alternate" %> + | Picked By: <%= picked_by %> +
  • + <% end %> +
diff --git a/config/database.yml b/config/database.yml index 16fc6d17..0c6bd49c 100644 --- a/config/database.yml +++ b/config/database.yml @@ -17,6 +17,9 @@ default: &default adapter: postgresql encoding: unicode + host: postgres + username: <%= ENV.fetch("POSTGRES_USER") { 'postgres' } %> + password: <%= ENV.fetch("POSTGRES_PASSWORD") { 'postgres' } %> # For details on connection pooling, see Rails configuration guide # http://guides.rubyonrails.org/configuring.html#database-pooling pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> @@ -57,6 +60,7 @@ development: # Do not set this db to the same as development or production. test: <<: *default + host: test-postgres database: topnews_test # As with config/secrets.yml, you never want to store sensitive information, diff --git a/config/environments/test.rb b/config/environments/test.rb index 8e5cbde5..7a4d824f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -5,7 +5,10 @@ # 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 + + # Spring reloads, and therefore needs the application to have reloading enabled. + # Please, set config.cache_classes to false in config/environments/test.rb. + 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 @@ -39,4 +42,10 @@ # Raises error for missing translations # config.action_view.raise_on_missing_translations = true + + # DEPRECATION WARNING: Using legacy connection handling is deprecated. Please set + # `legacy_connection_handling` to `false` in your application. + # See: https://guides.rubyonrails.org/v7.0/active_record_multiple_databases.html#migrate-to-the-new-connection-handling + # - https://guides.rubyonrails.org/active_record_multiple_databases.html#migrate-to-the-new-connection-handling + config.active_record.legacy_connection_handling = false end diff --git a/config/routes.rb b/config/routes.rb index c12ef082..7a31bf47 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,8 @@ Rails.application.routes.draw do devise_for :users root to: 'pages#home' + + get "/articles", to: "articles#index" + get "/user_articles", to: "user_articles#index" + post "/user_articles", to: "user_articles#create" end diff --git a/db/migrate/20240815220406_create_articles.rb b/db/migrate/20240815220406_create_articles.rb new file mode 100644 index 00000000..d81677a1 --- /dev/null +++ b/db/migrate/20240815220406_create_articles.rb @@ -0,0 +1,17 @@ +class CreateArticles < ActiveRecord::Migration[7.0] + def change + create_table :articles do |t| + t.string :title, null: false + t.string :url, null: false + t.integer :score + t.string :by + t.string :type + t.string :_type + t.string :external_id, null: false, index: { unique: true, name: 'unique_external_id' } + t.timestamp :time + t.jsonb :document + + t.timestamps + end + end +end diff --git a/db/migrate/20240815221329_create_join_table_users_articles.rb b/db/migrate/20240815221329_create_join_table_users_articles.rb new file mode 100644 index 00000000..5ed405f7 --- /dev/null +++ b/db/migrate/20240815221329_create_join_table_users_articles.rb @@ -0,0 +1,10 @@ +class CreateJoinTableUsersArticles < ActiveRecord::Migration[7.0] + def change + create_join_table :users, :articles, table_name: 'user_articles', column_options: { null: false, foreign_key: true } do |t| + t.index [:user_id, :article_id] + t.index [:article_id, :user_id] + t.index :user_id + t.index :article_id + end + end +end diff --git a/db/migrate/20240816231156_change_external_id_to_be_integer_in_articles.rb b/db/migrate/20240816231156_change_external_id_to_be_integer_in_articles.rb new file mode 100644 index 00000000..4262b65b --- /dev/null +++ b/db/migrate/20240816231156_change_external_id_to_be_integer_in_articles.rb @@ -0,0 +1,5 @@ +class ChangeExternalIdToBeIntegerInArticles < ActiveRecord::Migration[7.0] + def change + change_column :articles, :external_id, 'integer USING CAST(external_id AS integer)' + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..69462ced 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,34 @@ # # 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_08_16_231156) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "articles", force: :cascade do |t| + t.string "title", null: false + t.string "url", null: false + t.integer "score" + t.string "by" + t.string "type" + t.string "_type" + t.integer "external_id", null: false + t.datetime "time", precision: nil + t.jsonb "document" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["external_id"], name: "unique_external_id", unique: true + end + + create_table "user_articles", id: false, force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "article_id", null: false + t.index ["article_id", "user_id"], name: "index_user_articles_on_article_id_and_user_id" + t.index ["article_id"], name: "index_user_articles_on_article_id" + t.index ["user_id", "article_id"], name: "index_user_articles_on_user_id_and_article_id" + t.index ["user_id"], name: "index_user_articles_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" @@ -33,4 +57,6 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "user_articles", "articles" + add_foreign_key "user_articles", "users" end diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..913dc269 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,28 @@ +version: '3' +services: + test: + platform: linux/x86_64 + build: . + command: bundle exec rspec --format documentation + volumes: + - ".:/topnews" + environment: + - "RAILS_ENV=test" + depends_on: + - test-postgres + + test-postgres: + platform: linux/x86_64 + image: postgres:14.4 + volumes: + - "test-postgres:/var/lib/postgresql/data" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: topnews_test + healthcheck: + test: pg_isready -U $$POSTGRES_USER + +volumes: + test-postgres: + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..08e32bf9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3' +services: + rails: + platform: linux/x86_64 + build: . + ports: + - "3000:3000" + volumes: + - ".:/topnews" + environment: + - "RAILS_ENV=development" + depends_on: + - postgres + + postgres: + platform: linux/x86_64 + image: postgres:14.4 + volumes: + - "postgres:/var/lib/postgresql/data" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: topnews_development + healthcheck: + test: pg_isready -U $$POSTGRES_USER + +volumes: + postgres: + diff --git a/lib/services/hacker_news/hacker_news.rb b/lib/services/hacker_news/hacker_news.rb new file mode 100644 index 00000000..68665c79 --- /dev/null +++ b/lib/services/hacker_news/hacker_news.rb @@ -0,0 +1,23 @@ +# require 'uri' +# require 'net/http' + +class HackerNews + API_BASE = 'https://hacker-news.firebaseio.com/v0/' + TOP_STORIES = 'topstories.json' + ITEM = 'item/%s.json' + + def topstories() + url = API_BASE + TOP_STORIES + uri = URI(url) + response = Net::HTTP.get_response(uri) + return JSON.parse(response.body) if response.code == '200' + end + + def item(id) + item_url = ITEM % id + url = API_BASE + item_url + uri = URI(url) + response = Net::HTTP.get_response(uri) + return HashWithIndifferentAccess.new(JSON.parse(response.body)) if response.code == '200' + end +end diff --git a/spec/factories/article.rb b/spec/factories/article.rb new file mode 100644 index 00000000..ddd8122d --- /dev/null +++ b/spec/factories/article.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :article do + sequence(:external_id) { |n| n } + sequence(:title) { |n| "Show HN: #{n}# Story" } + sequence(:url) { |n| "https://ytch#{n}.xyz" } + type { "story" } + score { rand(1..5000) } + time { Time.zone.now.to_i } + sequence(:by) { |n| "hadisafa#{n}" } + end +end diff --git a/spec/factories/user.rb b/spec/factories/user.rb new file mode 100644 index 00000000..23492c3a --- /dev/null +++ b/spec/factories/user.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :user do + sequence(:email) { |n| "person#{n}@example.com" } + password { 'Test@Pass#123' } + sequence(:first_name) { |n| "John#{n}" } + sequence(:last_name) { |n| "Doe#{n}" } + end +end diff --git a/spec/factories/user_article.rb b/spec/factories/user_article.rb new file mode 100644 index 00000000..0ede07a9 --- /dev/null +++ b/spec/factories/user_article.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :user_article do + association :user + association :article + end +end diff --git a/spec/helpers/articles_helper_spec.rb b/spec/helpers/articles_helper_spec.rb new file mode 100644 index 00000000..eac039d7 --- /dev/null +++ b/spec/helpers/articles_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the ArticlesHelper. For example: +# +# describe ArticlesHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe ArticlesHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/user_articles_helper_spec.rb b/spec/helpers/user_articles_helper_spec.rb new file mode 100644 index 00000000..f371fd97 --- /dev/null +++ b/spec/helpers/user_articles_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the UserArticlesHelper. For example: +# +# describe UserArticlesHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe UserArticlesHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/lib/hacker_news/hacker_news_spec.rb b/spec/lib/hacker_news/hacker_news_spec.rb new file mode 100644 index 00000000..47d4bbe7 --- /dev/null +++ b/spec/lib/hacker_news/hacker_news_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' +require_relative '../../../lib/services/hacker_news/hacker_news.rb' + +describe HackerNews do + let(:instance) { HackerNews.new } + it 'exists' do + instance + end + + describe '#topstories' do + it 'responds to #topstories' do + expect(instance).to respond_to(:topstories) + end + + it 'returns list of stories ids on success' do + mock_topstories = [41247023,41248104,41247982,41245159,41247841,41241942,41230267,41250470,41247193,41247238,41247153,41236718,41246181,41246050,41248208,41248460,41246211,41246686,41244468,41235662,41247211,41240556,41247132,41244423,41224128,41244847,41222528,41248542,41245901,41250577,41250723,41248438,41247083,41227011,41248984,41250302,41222655,41247881,41249044,41249543,41226548,41227172,41249527,41249429,41220549,41219501,41231123,41238102,41248448,41228513,41248069,41249178,41236430,41248417,41235259,41246204,41232446,41222577,41244172,41240755,41200881,41234713,41222101,41237275,41234636,41249340,41239800,41242979,41212297,41239913,41202134,41231141,41227987,41248315,41233811,41199092,41212072,41210745,41243931,41222632,41232621,41206168,41207987,41249504,41215649,41243901,41249445,41241825,41248360,41235677,41243703,41228278,41236273,41249354,41235789,41244648,41234964,41235462,41207838,41227179,41235038,41227772,41249147,41247085,41236275,41234877,41240680,41245688,41224225,41224689,41246961,41226538,41226039,41230546,41212566,41248283,41238632,41230344,41239670,41245836,41228630,41245815,41224286,41220188,41213902,41235733,41248316,41230994,41230033,41241124,41241637,41198931,41231490,41234174,41237149,41227792,41215727,41232259,41221718,41209900,41218722,41237204,41247926,41248896,41248260,41236745,41223902,41249491,41244939,41227369,41243333,41247727,41237000,41221252,41243992,41211507,41228113,41247275,41226802,41221501,41242202,41248707,41244919,41223934,41239096,41215626,41233206,41244798,41229049,41224853,41240640,41221218,41229328,41239031,41241647,41227350,41199320,41222759,41248015,41224557,41249080,41202841,41218314,41247992,41208343,41214762,41227061,41246177,41220059,41241431,41245032,41245262,41243147,41240641,41239635,41248847,41234219,41228935,41243551,41224253,41233924,41215679,41212271,41208506,41212103,41242943,41215593,41216560,41246439,41241657,41247754,41211091,41219080,41245681,41217319,41244336,41215201,41224316,41237259,41245452,41219962,41245423,41245504,41211889,41217058,41214693,41213053,41214259,41229236,41219562,41217136,41243697,41241532,41238732,41228022,41204368,41213561,41209688,41218206,41237542,41242400,41240300,41208988,41224780,41234289,41208704,41236439,41213064,41217903,41239287,41243877,41204228,41211540,41207569,41232354,41213711,41227142,41229029,41237363,41248241,41230169,41218737,41237446,41232827,41213442,41214307,41199567,41217162,41239749,41246922,41241090,41206908,41207417,41215724,41212193,41211039,41239741,41217758,41241373,41207048,41235721,41203306,41209452,41245053,41240344,41246413,41207182,41214180,41242174,41218928,41223774,41224623,41239739,41241938,41207793,41234490,41206465,41237018,41240510,41248453,41246015,41235940,41209994,41202694,41242259,41242198,41231145,41230794,41238836,41215631,41204881,41219440,41209181,41203509,41215489,41203475,41239642,41220079,41239496,41203269,41220775,41221829,41233321,41233309,41240869,41211519,41218811,41220532,41240450,41245823,41214900,41213151,41239596,41205176,41218696,41225357,41235614,41239968,41211741,41212773,41229597,41238020,41213387,41237864,41209966,41219122,41223907,41225816,41220284,41228325,41232046,41200605,41221399,41213082,41219788,41233675,41240636,41226958,41229306,41238557,41241328,41240421,41212364,41215166,41213347,41242091,41212899,41218916,41226035,41238037,41219723,41217037,41231964,41198491,41213618,41239940,41207355,41239859,41232067,41223835,41207415,41226754,41241915,41219005,41238843,41238820,41246288,41224070,41238592,41218600,41205141,41241056,41229109,41229600,41203928,41203368,41224741,41227165,41227149,41226982,41240037,41240562,41248074,41198776,41212976,41201555,41226200,41220097,41236946,41225295,41225796,41234863,41201922,41239828,41231735,41202064,41239718,41206025,41239417,41229196,41205554,41223288,41228191,41238756,41235424,41203909,41235326,41205439,41223101,41235152,41217517,41235318,41204622,41242361,41228579,41234633,41207221,41221603,41205834,41214229,41212616,41198151,41219304,41230512,41231028,41240716,41204159,41240099,41236847,41248240,41246917,41220806,41217855,41225856,41218733,41206443,41214966,41208934,41220143,41230039,41212555,41229595,41237583,41237057,41203307,41219779,41242280,41228535,41232799,41210700,41215126,41245702,41247648,41227072,41217857,41203075,41245779,41207608,41237111,41219284,41206746,41219981,41213580,41206024,41235262,41231465,41228568,41228534,41228499,41204552,41231358,41218828,41218794,41208148,41240240,41203274,41224260,41203024,41233722,41215147] + mock_reponse = double('response', code: '200', body: JSON.generate(mock_topstories)) + allow(Net::HTTP).to receive(:get_response).and_return(mock_reponse) + + topstories = instance.topstories + expect(topstories).to be_a(Array) + expect(topstories).to eq(mock_topstories) + expect(topstories.count).to eq(mock_topstories.count) + end + + it 'returns nil on error' do + mock_reponse = double('response', code: '403', body: nil) + allow(Net::HTTP).to receive(:get_response).and_return(mock_reponse) + + expect(instance.topstories).to be_nil + end + end + + describe '#item' do + it 'responds to #item' do + expect(instance).to respond_to(:item) + end + + it 'returns item s details' do + item_id = 41247023 + mock_item = { + "id"=>41247023, + "kids"=>[41247704, 41247268], + "descendants"=>496, + "title"=>"Show HN: If YouTube had actual channels", + "url"=>"https://ytch.xyz", + "type"=>"story", + "score"=>2554, + "time"=>1723648206, + "by"=>"hadisafa" + } + mock_reponse = double('response', code: '200', body: JSON.generate(mock_item)) + allow(Net::HTTP).to receive(:get_response).and_return(mock_reponse) + + item = instance.item(item_id) + expect(item).to be_a(Hash) + expect(item).to have_key(:id) + expect(item).to have_key("id") + expect(item).to have_key(:title) + expect(item).to have_key("title") + expect(item).to have_key(:url) + expect(item).to have_key("url") + end + + it 'returns nil on error' do + item_id = 41247023 + mock_reponse = double('response', code: '403', body: nil) + allow(Net::HTTP).to receive(:get_response).and_return(mock_reponse) + + item = instance.item(item_id) + expect(item).to be_nil + end + end +end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb new file mode 100644 index 00000000..1cdc122f --- /dev/null +++ b/spec/models/article_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +describe Article do + context 'creating a new article' do + let(:document) do + { + external_id: 41247023, + kids: [41247704, 41247268], + descendants: 496, + title: "Show HN: If YouTube had actual channels", + url: "https://ytch.xyz", + type: "story", + score: 2554, + time: 1723648206, + by: "hadisafa" + } + end + let(:attrs) do + { + external_id: 41247023, + document: document, + title: "Show HN: If YouTube had actual channels", + url: "https://ytch.xyz", + type: "story", + score: 2554, + time: 1723648206, + by: "hadisafa" + } + end + + it "should have title, url, score, external_id" do + expect { Article.create(attrs) }.to change{ Article.count }.by(1) + end + + it "should be valid object" do + expect(Article.new(attrs)).to be_valid + end + + it "should stores the a jsonb object" do + expect(Article.new(attrs).document.stringify_keys) =~ document + end + end +end diff --git a/spec/models/user_article_spec.rb b/spec/models/user_article_spec.rb new file mode 100644 index 00000000..e77d09af --- /dev/null +++ b/spec/models/user_article_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +describe UserArticle do + context 'creating a new user_articles' do + let(:user) { create(:user) } + let(:article) { create(:article) } + let(:attrs) do + { + user: user, + article: article + } + end + + it "should create the user article based on those two models" do + expect { UserArticle.create(attrs) }.to change{ UserArticle.count }.by(1) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bbe1ba57..ec2e1497 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -7,6 +7,13 @@ require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! +# Support add helper methods into rspec +require 'support/factory_bot' +require 'support/devise' +# Require all spec/support/shared directory instead of individual files +# require 'support/shared' +Dir[Rails.root.join('spec/support/shared/*.rb')].each { |f| require f } + # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are # run as spec files by default. This means that files in spec/support that end diff --git a/spec/requests/application_spec.rb b/spec/requests/application_spec.rb new file mode 100644 index 00000000..f18855bc --- /dev/null +++ b/spec/requests/application_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe "Applications", type: :request do + describe "visits any route" do + context 'without sign in user' do + it 'redirects to sign in page' do + get "/" + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(:new_user_session) + + follow_redirect! + + expect(response.body).to include("You need to sign in or sign up before continuing") + expect(response.body).to include("Log in") + expect(response.body).to include("Email") + expect(response.body).to include("Password") + expect(response.body).to include("Remember me") + end + end + + context 'with sign in user' do + include_context "signed in user" + + it 'renders the requested page' do + get "/" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("You're logged in!") + expect(response.body).to include(user.first_name) + expect(response.body).to include(user.last_name) + expect(response.body).to include("Welcome to Top News") + expect(response.body).to include("TOP Articles") + expect(response.body).to include("Team Articles") + expect(response.body).to include("Log out") + end + end + end +end diff --git a/spec/requests/articles_spec.rb b/spec/requests/articles_spec.rb new file mode 100644 index 00000000..8ad864af --- /dev/null +++ b/spec/requests/articles_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +RSpec.describe "Articles", type: :request do + describe "GET /index" do + context 'without sign in user' do + it "returns http redirects" do + get "/articles" + + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(:new_user_session) + + follow_redirect! + + expect(response.body).to include("You need to sign in or sign up before continuing") + expect(response.body).to include("Log in") + expect(response.body).to include("Email") + expect(response.body).to include("Password") + expect(response.body).to include("Remember me") + end + end + + context 'with sign in user' do + include_context "signed in user" + + let(:mock_story) do + { + "id"=>41247023, + "kids"=>[41247704, 41247268], + "descendants"=>496, + "title"=>"Show HN: If YouTube had actual channels", + "url"=>"https://ytch.xyz", + "type"=>"story", + "score"=>2554, + "time"=>1723648206, + "by"=>"hadisafa" + } + end + + it "returns http success" do + get "/articles" + expect(response).to have_http_status(:success) + end + + it "renders articles and stories" do + mock_topstories_ids = [41247023,41248104,41247982] + mock_hacker_news = double('HackerNews', topstories: mock_topstories_ids, item: mock_story) + allow(HackerNews).to receive(:new).and_return(mock_hacker_news) + article = create(:article, external_id: mock_topstories_ids.last) + + get "/articles" + expect(response).to have_http_status(:success) + expect(response.body).to include("Articles TOP Feed") + expect(response.body).to include(article.score.to_s) + expect(response.body).to include(article.title) + expect(response.body).to include(article.url) + expect(response.body).to include(mock_story["score"].to_s) + expect(response.body).to include(mock_story["title"]) + expect(response.body).to include(mock_story["url"]) + end + end + end +end diff --git a/spec/requests/user_articles_spec.rb b/spec/requests/user_articles_spec.rb new file mode 100644 index 00000000..e0e5b326 --- /dev/null +++ b/spec/requests/user_articles_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe "UserArticles", type: :request do + describe "GET /index" do + context 'without sign in user' do + it "returns http redirects" do + get "/user_articles" + + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(:new_user_session) + + follow_redirect! + + expect(response.body).to include("You need to sign in or sign up before continuing") + expect(response.body).to include("Log in") + expect(response.body).to include("Email") + expect(response.body).to include("Password") + expect(response.body).to include("Remember me") + end + end + + context 'with sign in user' do + include_context "signed in user" + let(:user1) { create(:user) } + let(:articles) { create_list(:article, 3) } + let(:article) { articles.last } + let(:article1) { articles.first} + let(:article2) { articles.second} + let!(:no_user_article) { create(:article) } + + before do + create(:user_article, user: user1, article: article1) + create(:user_article, user: user1, article: article2) + create(:user_article, user: current_user, article: article) + end + + it "returns http success" do + get "/user_articles" + expect(response).to have_http_status(:success) + end + + it "renders user_articles" do + get "/user_articles" + + expect(response).to have_http_status(:success) + expect(response.body).to include("Team Articles") + expect(response.body).to include(article.score.to_s) + expect(response.body).to include(article.title) + expect(response.body).to include(article.url) + + expect(response.body).to include(current_user.first_name) + expect(response.body).to include(current_user.last_name) + + expect(response.body).to include(article1.score.to_s) + expect(response.body).to include(article1.title) + expect(response.body).to include(article1.url) + + expect(response.body).to include(article2.score.to_s) + expect(response.body).to include(article2.title) + expect(response.body).to include(article2.url) + + expect(response.body).to include(user1.first_name) + expect(response.body).to include(user1.last_name) + + expect(response.body).not_to include(no_user_article.score.to_s) + expect(response.body).not_to include(no_user_article.title) + expect(response.body).not_to include(no_user_article.url) + end + end + end + + describe "POST /create" do + context 'with sign in user' do + include_context "signed in user" + let(:article_attr) { attributes_for(:article) } + + it "returns http created" do + post "/user_articles", params: { article: article_attr.as_json } + expect(response).to have_http_status(:created) + end + + it "creates article and user_article if they doesnt exists from params" do + expect do + post "/user_articles", params: { article: article_attr.as_json } + end.to change { Article.count }.by(1) + .and change { UserArticle.count }.by(1) + expect(response).to have_http_status(:created) + end + + context "with existing models" do + it "creates only the user_article if article already exists" do + article = create(:article, article_attr) + + expect do + post "/user_articles", params: { article: article_attr.as_json } + end.to change { Article.count }.by(0) + .and change { UserArticle.count }.by(1) + expect(response).to have_http_status(:created) + end + + it "does not creates anything if article and user_article already exists" do + article = create(:article, article_attr) + user_article = create(:user_article, user: current_user, article: article) + expect do + post "/user_articles", params: { article: article_attr.as_json } + end.to change { Article.count }.by(0) + .and change { UserArticle.count }.by(0) + expect(response).to have_http_status(:created) + end + end + end + end +end diff --git a/spec/support/devise.rb b/spec/support/devise.rb new file mode 100644 index 00000000..a09485ae --- /dev/null +++ b/spec/support/devise.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include Devise::Test::IntegrationHelpers, type: :request +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 00000000..c7890e49 --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/spec/support/shared/shared_context.rb b/spec/support/shared/shared_context.rb new file mode 100644 index 00000000..6f495eb8 --- /dev/null +++ b/spec/support/shared/shared_context.rb @@ -0,0 +1,8 @@ +RSpec.shared_context "signed in user" do + let(:user) { create(:user) } + let(:current_user) { user } + + before do + sign_in user + end +end diff --git a/spec/views/articles/index.html.erb_spec.rb b/spec/views/articles/index.html.erb_spec.rb new file mode 100644 index 00000000..eacda558 --- /dev/null +++ b/spec/views/articles/index.html.erb_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "articles/index.html.erb", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end