diff --git a/Gemfile b/Gemfile index ec14f1a..259c4f2 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,8 @@ gem 'uglifier', '>= 1.3.0' # gem 'mini_racer', platforms: :ruby # Devise gem 'devise' +# JS +gem 'jquery-rails' # Use CoffeeScript for .coffee assets and views gem 'coffee-rails', '~> 4.2' @@ -61,7 +63,9 @@ end group :test do # Adds support for Capybara system testing and selenium driver gem 'capybara', '>= 2.15' + gem 'selenium-webdriver' # Easy installation and use of chromedriver to run system tests with Chrome + gem 'webdrivers', '~> 4.0' gem 'shoulda-matchers' gem 'rails-controller-testing' gem 'launchy' diff --git a/Gemfile.lock b/Gemfile.lock index cddc5b8..b398e95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,6 +59,8 @@ GEM rack-test (>= 0.6.3) regexp_parser (~> 1.5) xpath (~> 3.2) + childprocess (1.0.1) + rake (< 13.0) coffee-rails (4.2.2) coffee-script (>= 2.2.0) railties (>= 4.0.0) @@ -89,6 +91,10 @@ GEM concurrent-ruby (~> 1.0) jbuilder (2.9.1) activesupport (>= 4.2.0) + jquery-rails (4.3.5) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) launchy (2.4.3) addressable (~> 2.3) listen (3.1.5) @@ -107,7 +113,7 @@ GEM mini_mime (1.0.1) mini_portile2 (2.4.0) minitest (5.11.3) - msgpack (1.2.10) + msgpack (1.3.0) nio4r (2.3.1) nokogiri (1.10.3) mini_portile2 (~> 2.4.0) @@ -172,6 +178,7 @@ GEM rspec-support (~> 3.8.0) rspec-support (3.8.2) ruby_dep (1.5.0) + rubyzip (1.2.3) sass (3.7.4) sass-listen (~> 4.0.0) sass-listen (4.0.0) @@ -183,6 +190,9 @@ GEM sprockets (>= 2.8, < 4.0) sprockets-rails (>= 2.0, < 4.0) tilt (>= 1.1, < 3) + selenium-webdriver (3.142.3) + childprocess (>= 0.5, < 2.0) + rubyzip (~> 1.2, >= 1.2.2) shoulda-matchers (4.1.0) activesupport (>= 4.2.0) slim (4.0.1) @@ -221,6 +231,10 @@ GEM activemodel (>= 5.0) bindex (>= 0.4.0) railties (>= 5.0) + webdrivers (4.0.1) + nokogiri (~> 1.6) + rubyzip (~> 1.0) + selenium-webdriver (>= 3.0, < 4.0) websocket-driver (0.7.1) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.4) @@ -238,6 +252,7 @@ DEPENDENCIES devise factory_bot_rails jbuilder (~> 2.5) + jquery-rails launchy listen (>= 3.0.5, < 3.2) pg (>= 0.18, < 2.0) @@ -246,6 +261,7 @@ DEPENDENCIES rails-controller-testing rspec-rails (~> 3.8) sass-rails (~> 5.0) + selenium-webdriver shoulda-matchers slim-rails spring @@ -254,6 +270,7 @@ DEPENDENCIES tzinfo-data uglifier (>= 1.3.0) web-console (>= 3.3.0) + webdrivers (~> 4.0) RUBY VERSION ruby 2.6.3p62 diff --git a/app/assets/javascripts/answers.coffee b/app/assets/javascripts/answers.coffee deleted file mode 100644 index 24f83d1..0000000 --- a/app/assets/javascripts/answers.coffee +++ /dev/null @@ -1,3 +0,0 @@ -# Place all the behaviors and hooks related to the matching controller here. -# All this logic will automatically be available in application.js. -# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/answers.js b/app/assets/javascripts/answers.js new file mode 100644 index 0000000..936f8ac --- /dev/null +++ b/app/assets/javascripts/answers.js @@ -0,0 +1,8 @@ +$(document).on('turbolinks:load', function() { + $('.answers').on('click', '.edit-answer-links', function(e) { + e.preventDefault() + $(this).hide() + var answerId = $(this).data('answerId') + $('form#edit-answer-' + answerId).show() + }) +}) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 82e6f0f..a55d271 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -13,4 +13,5 @@ //= require rails-ujs //= require activestorage //= require turbolinks +//= require jquery3 //= require_tree . diff --git a/app/assets/javascripts/questions.coffee b/app/assets/javascripts/questions.coffee deleted file mode 100644 index 24f83d1..0000000 --- a/app/assets/javascripts/questions.coffee +++ /dev/null @@ -1,3 +0,0 @@ -# Place all the behaviors and hooks related to the matching controller here. -# All this logic will automatically be available in application.js. -# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/questions.js b/app/assets/javascripts/questions.js new file mode 100644 index 0000000..44b3cdb --- /dev/null +++ b/app/assets/javascripts/questions.js @@ -0,0 +1,8 @@ +$(document).on('turbolinks:load', function() { + $('.edit-question-link').on('click', function(e) { + e.preventDefault() + $(this).hide() + var questionId = $(this).data('questionId') + $('form#edit-question-' + questionId).show() + }) +}) diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss new file mode 100644 index 0000000..cb57e72 --- /dev/null +++ b/app/assets/stylesheets/common.scss @@ -0,0 +1,3 @@ +.hidden { + display: none +} diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index 73afc65..31aeb26 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -1,5 +1,6 @@ class AnswersController < ApplicationController before_action :authenticate_user! + before_action :set_answer, only: %i[update destroy mark] def new @answer = Answer.new @@ -9,20 +10,24 @@ def create @question = Question.find(params[:question_id]) @answer = @question.answers.new(answer_params) @answer.user = current_user + @answer.save + end - if @answer.save - redirect_to @question, notice: "Your answer was saved." - else - flash[:alert] = "Your answer was not saved." - render "questions/show" - end + def update + return head :forbidden unless current_user.owner?(@answer) + @answer.update(answer_params) + @question = @answer.question end def destroy - answer = Answer.find(params[:id]) - return head :forbidden unless current_user.owned?(answer) - answer.destroy - redirect_to answer.question, notice: "Your answer was successfully deleted!" + return head :forbidden unless current_user.owner?(@answer) + @answer.destroy + end + + def mark + return head :forbidden unless current_user.owner?(@answer.question) + @answer.mark_as_best + @question = @answer.question end private @@ -30,4 +35,8 @@ def destroy def answer_params params.require(:answer).permit(:body) end + + def set_answer + @answer = Answer.find(params[:id]) + end end diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index c9b1a94..f23fa9d 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -29,15 +29,12 @@ def create end def update - if @question.update(question_params) - redirect_to @question - else - render :edit - end + return head :forbidden unless current_user.owner?(@question) + @question.update(question_params) end def destroy - return head :forbidden unless current_user.owned?(@question) + return head :forbidden unless current_user.owner?(@question) @question.destroy redirect_to questions_path, notice: "Your question was successfully deleted!" end diff --git a/app/models/answer.rb b/app/models/answer.rb index 3fb76d6..871d79c 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -2,6 +2,32 @@ class Answer < ApplicationRecord belongs_to :user belongs_to :question + default_scope { order(best: :desc, created_at: :asc) } + scope :best, -> { where(best: true) } + validates :body, presence: true, length: { minimum: 50 } + validate :only_one_best_per_question + + def mark_as_best + transaction do + question.answers.best.update_all(best: false) + self.update!(best: true) + end + end + + private + + def only_one_best_per_question + return unless best? + return unless question&.answers.best.any? + + if persisted? + # update + errors.add(:best, :there_is_the_best_answer_in_question_already) if changes['best'] == [false, true] + else + # create + errors.add(:best, :there_is_the_best_answer_in_question_already) + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 58afc9d..dffc397 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,7 +8,7 @@ class User < ApplicationRecord :rememberable, :validatable - def owned?(content) + def owner?(content) content.user_id == id end end diff --git a/app/views/answers/_answer.html.slim b/app/views/answers/_answer.html.slim index 7455e6f..d1062ba 100644 --- a/app/views/answers/_answer.html.slim +++ b/app/views/answers/_answer.html.slim @@ -1,2 +1,12 @@ -p= answer.body -p= render(partial: 'shared/manage_answer_links', locals: { answer: answer }) if current_user&.owned?(answer) +div id="answer_#{answer.id}" + - if answer.persisted? + - if answer.best? + div id="best-answer" + h4 The best answer + p= answer.body + p= render(partial: 'shared/answer_links', locals: { answer: answer }) + + = form_with model: answer, class: 'hidden', html: { id: "edit-answer-#{answer.id}" } do |f| + = f.label :body, 'Your answer' + = f.text_area :body + = f.submit 'Update' diff --git a/app/views/answers/create.js.erb b/app/views/answers/create.js.erb new file mode 100644 index 0000000..f075720 --- /dev/null +++ b/app/views/answers/create.js.erb @@ -0,0 +1,5 @@ +$('.answer-errors').html('<%= render 'shared/errors', resource: @answer %>') +<% if @answer.persisted? %> + $('.answers').append('<%= j render @answer %>') + $('.new-answer #answer_body').val('') +<% end %> diff --git a/app/views/answers/destroy.js.erb b/app/views/answers/destroy.js.erb new file mode 100644 index 0000000..9290d66 --- /dev/null +++ b/app/views/answers/destroy.js.erb @@ -0,0 +1 @@ +$('#answer_' + <%= @answer.id %>).remove() diff --git a/app/views/answers/mark.js.erb b/app/views/answers/mark.js.erb new file mode 100644 index 0000000..2c524c3 --- /dev/null +++ b/app/views/answers/mark.js.erb @@ -0,0 +1 @@ +$('.answers').html('<%= j render @question.answers %>') diff --git a/app/views/answers/update.js.erb b/app/views/answers/update.js.erb new file mode 100644 index 0000000..76bc285 --- /dev/null +++ b/app/views/answers/update.js.erb @@ -0,0 +1,4 @@ +$('.answer-errors').html('<%= render 'shared/errors', resource: @answer %>') +<% if @answer.errors.empty? %> + $("#answer_" + <%= @answer.id %>).replaceWith('<%= j render @answer %>') +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e536b96..d6f50f6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -10,9 +10,7 @@ -

- <%= render 'shared/user_nav' %> -

+ <%= render 'shared/user_nav' %>

<%= notice %>

<%= alert %>

<%= yield %> diff --git a/app/views/questions/_question_for_show.html.slim b/app/views/questions/_question_for_show.html.slim new file mode 100644 index 0000000..fe7272f --- /dev/null +++ b/app/views/questions/_question_for_show.html.slim @@ -0,0 +1,11 @@ +h4= @question.title +p= @question.body +=render(partial: 'shared/question_links', locals: { question: @question }) if current_user&.owner?(@question) +.question-errors + =render 'shared/errors', resource: @question += form_with model: @question, class: 'hidden', html: { id: "edit-question-#{@question.id}" } do |f| + = f.label :title + = f.text_field :title + = f.label :body, 'Body' + = f.text_area :body + = f.submit 'Update' diff --git a/app/views/questions/show.html.slim b/app/views/questions/show.html.slim index 4bc1b39..05a8929 100644 --- a/app/views/questions/show.html.slim +++ b/app/views/questions/show.html.slim @@ -1,12 +1,12 @@ -= render 'shared/errors', resource: @answer - -=render(partial: 'shared/manage_question_links', locals: { question: @question }) if current_user&.owned?(@question) -h4= @question.title -p= @question.body -p Answers -p= render @question.reload.answers +div id="question_#{@question.id}" + = render 'question_for_show', resource: @question +h4 Answers +div.answers + = render @question.answers p Your answer -= form_with model: @answer, url: question_answers_path(@question), local: true do |f| +.answer-errors + = render 'shared/errors', resource: @answer += form_with model: [@question, @answer], class: 'new-answer' do |f| = f.label :body = f.text_area :body = f.submit 'Leave' diff --git a/app/views/questions/update.js.erb b/app/views/questions/update.js.erb new file mode 100644 index 0000000..a085def --- /dev/null +++ b/app/views/questions/update.js.erb @@ -0,0 +1,4 @@ +$('.question-errors').html('<%= render 'shared/errors', resource: @question %>') +<% if !@question.errors.present? %> + $("#question_" + <%= @question.id %>).html('<%= j render 'question_for_show', resource: @question %>') +<% end %> diff --git a/app/views/shared/_answer_links.html.slim b/app/views/shared/_answer_links.html.slim new file mode 100644 index 0000000..6d20456 --- /dev/null +++ b/app/views/shared/_answer_links.html.slim @@ -0,0 +1,7 @@ +p + => link_to('Mark as the best', mark_answer_path(answer), method: :post, remote: true) \ + if current_user&.owner?(answer.question) && !answer.best? + => link_to('Edit', '#', class: 'edit-answer-links', data: { answer_id: answer.id }) \ + if current_user&.owner?(answer) + = link_to('Delete', answer_path(answer), method: :delete, remote: true) \ + if current_user&.owner?(answer) diff --git a/app/views/shared/_manage_answer_links.html.slim b/app/views/shared/_manage_answer_links.html.slim deleted file mode 100644 index d5bef88..0000000 --- a/app/views/shared/_manage_answer_links.html.slim +++ /dev/null @@ -1 +0,0 @@ -p= link_to 'Delete the answer', answer_path(answer), method: :delete diff --git a/app/views/shared/_manage_question_links.html.slim b/app/views/shared/_manage_question_links.html.slim deleted file mode 100644 index 45d5e31..0000000 --- a/app/views/shared/_manage_question_links.html.slim +++ /dev/null @@ -1 +0,0 @@ -p= link_to 'Delete the question', question_path(question), method: :delete diff --git a/app/views/shared/_question_links.html.slim b/app/views/shared/_question_links.html.slim new file mode 100644 index 0000000..881b8bf --- /dev/null +++ b/app/views/shared/_question_links.html.slim @@ -0,0 +1,3 @@ +p + => link_to 'Edit', '#', class: 'edit-question-link', data: { question_id: question.id } + = link_to 'Delete', question_path(question), method: :delete diff --git a/app/views/shared/_user_nav.html.slim b/app/views/shared/_user_nav.html.slim index 0ff5995..074341a 100644 --- a/app/views/shared/_user_nav.html.slim +++ b/app/views/shared/_user_nav.html.slim @@ -1 +1,7 @@ -p= link_to 'Log out', destroy_user_session_path, method: :delete +- if current_user + p= link_to 'Log out', destroy_user_session_path, method: :delete +- else + p + = link_to 'Log in', new_user_session_path + | |  + = link_to 'Sign up', new_user_registration_path diff --git a/config/routes.rb b/config/routes.rb index 0c9d95c..4175820 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,10 @@ root to: "questions#index" resources :questions do - resources :answers, shallow: true, except: %i[index show] + resources :answers, shallow: true, except: %i[index show] do + member do + post :mark + end + end end end diff --git a/db/migrate/20190624142356_add_best_to_answers.rb b/db/migrate/20190624142356_add_best_to_answers.rb new file mode 100644 index 0000000..a9007d8 --- /dev/null +++ b/db/migrate/20190624142356_add_best_to_answers.rb @@ -0,0 +1,5 @@ +class AddBestToAnswers < ActiveRecord::Migration[5.2] + def change + add_column :answers, :best, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 6107500..199c4e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_06_18_204807) do +ActiveRecord::Schema.define(version: 2019_06_24_142356) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -21,6 +21,7 @@ t.datetime "updated_at", null: false t.bigint "question_id" t.bigint "user_id" + t.boolean "best", default: false t.index ["question_id"], name: "index_answers_on_question_id" t.index ["user_id"], name: "index_answers_on_user_id" end diff --git a/spec/controllers/answers_controller_spec.rb b/spec/controllers/answers_controller_spec.rb index 46934e8..3bf1d07 100644 --- a/spec/controllers/answers_controller_spec.rb +++ b/spec/controllers/answers_controller_spec.rb @@ -2,12 +2,12 @@ require 'byebug' RSpec.describe AnswersController, type: :controller do - let(:user) { create(:user) } - let(:question) { create(:question, user: user) } + let(:user1) { create(:user) } + let(:question1) { create(:question, user: user1) } describe 'GET #new' do - before { login(user) } - before { get :new, params: { question_id: question.id } } + before { login(user1) } + before { get :new, params: { question_id: question1.id } } it 'assigns a new answer to @answer' do expect(assigns(:answer)).to be_a_new(Answer) @@ -19,63 +19,220 @@ end describe 'POST #create' do - before { login(user) } + context 'authenticated user' do + before { login(user1) } + + context 'with valid attributes' do + it 'saves a new answer in the database' do + expect { + post :create, + params: { + question_id: question1.id, + answer: attributes_for(:answer) + }, + format: :js + }.to change(question1.answers, :count).by(1) + end + + it 'creates answer by the name of logged user' do + new_answer_params = attributes_for(:answer) + post :create, + params: { + question_id: question1.id, + answer: new_answer_params + }, + format: :js + created_answer = question1.answers.find_by! new_answer_params + + expect(user1).to be_owner(created_answer) + end + + it 'renders create template', js: true do + post :create, + params: { + question_id: question1.id, + answer: attributes_for(:answer) + }, + format: :js + + expect(response).to render_template 'create' + end + end + + context 'with invalid attributes' do + it 'does not save a new answer with short body in the database' do + expect { + post :create, + params: { + question_id: question1.id, + answer: attributes_for(:answer, :invalid) + }, + format: :js + }.to_not change(Answer, :count) + end + + it 'renders create template' do + post :create, + params: { + question_id: question1.id, + answer: attributes_for(:answer, :invalid) + }, + format: :js + expect(response).to render_template 'create' + end + end + end - context 'with valid attributes' do - it 'saves a new answer in the database' do - expect { post :create, params: { question_id: question.id, answer: attributes_for(:answer) } }.to change(question.answers, :count).by(1) + context 'unauthenticated user' do + it 'tries to create an answer' do + expect { + post :create, + params: { + question_id: question1.id, + answer: attributes_for(:answer) + }, + format: :js + }.to_not change(question1.answers, :count) end + end + end + + describe 'DELETE #destroy' do + let!(:answer1) { create(:answer, question: question1, user: user1) } + let(:user2) { create(:user) } + let!(:answer2) { create(:answer, question: question1, user: user2) } - it 'redirects to question show view' do - post :create, params: { question_id: question.id, answer: attributes_for(:answer) } + context 'authenticated user' do + before { login(user1) } - expect(response).to redirect_to question + it "deletes user's answer" do + expect { + delete :destroy, params: { id: answer1 }, format: :js + }.to change(question1.answers, :count).by(-1) + expect { answer1.reload }.to raise_error ActiveRecord::RecordNotFound end - it 'creates answer by the name of logged user' do - new_answer_params = attributes_for(:answer) - post :create, params: { question_id: question.id, answer: new_answer_params } - created_answer = question.answers.find_by! new_answer_params + it 'renders destroy template' do + delete :destroy, params: { id: answer1 }, format: :js + + expect(response).to render_template 'destroy' + end - expect(created_answer.user).to eq user + it "tries to delete another user's answer" do + expect { + delete :destroy, params: { id: answer2 }, format: :js + }.to_not change(Answer, :count) end end - context 'with invalid attributes' do - it 'does not save a new answer with short body in the database' do - expect { post :create, params: { question_id: question.id, answer: attributes_for(:answer, :invalid) } }.to_not change(Answer, :count) + it "authenticated user tries to delete an answer" do + expect { + delete :destroy, params: { id: answer2 }, format: :js + }.to_not change(Answer, :count) + end + end + + describe 'PATCH #update' do + let(:user2) { create(:user) } + let!(:answer1) { create(:answer, question: question1, user: user1) } + let!(:answer2) { create(:answer, question: question1, user: user2) } + let!(:new_body) { "New answer's body #{"a" * 32}" } + + context 'authenticated user' do + before { login(user1) } + + context 'with valid attributes' do + it "updates user's answer" do + patch :update, + params: { id: answer1, answer: { body: new_body } }, + format: :js + answer1.reload + + expect(answer1.body).to eq new_body + end + + it "does not update another user's answer" do + expect { + patch :update, + params: { id: answer2, answer: { body: new_body } }, + format: :js + }.to_not change { answer2.reload.body } + end + + it 'renders update view' do + patch :update, + params: { id: answer1, answer: { body: new_body} }, + format: :js + + expect(response).to render_template :update + end end - it 'renders question show view' do - post :create, params: { question_id: question.id, answer: attributes_for(:answer, :invalid) } - expect(response).to render_template 'questions/show' + context 'with invalid attributes' do + it 'does not update an answer' do + expect { + patch :update, + params: { id: answer1, answer: attributes_for(:answer, :invalid) }, + format: :js + }.to_not change { answer1.reload.body } + end + + it 'renders update view' do + patch :update, + params: { id: answer1, answer: attributes_for(:answer, :invalid) }, + format: :js + + expect(response).to render_template :update + end + end + end + + context 'unauthenticated user' do + it 'does not update an answer' do + expect { + patch :update, + params: { id: answer1, answer: attributes_for(:answer) }, + format: :js + }.to_not change { answer1.reload.body } end end end - describe 'DELETE #destroy' do - let(:question) { create(:question, user: user) } - let!(:answer) { create(:answer, question: question, user: user) } + describe 'POST #mark' do let(:user2) { create(:user) } - let!(:answer2) { create(:answer, question: question, user: user2) } + let!(:question2) { create(:question, user: user2) } + let!(:answer1) { create(:answer, question: question2, user: user1) } + let!(:answer2) { create(:answer, question: question1, user: user2) } + let!(:answer3) { create(:answer, question: question1, user: user2) } + + context 'authenticated user' do + before do + login(user1) + answer2.best = true + end - before { login(user) } + it 'marks answers to his question' do + post :mark, params: { id: answer3 }, format: :js + answer2.reload + answer3.reload - it 'deletes the answer' do - expect { - delete :destroy, params: { id: answer } - }.to change(Answer, :count).by(-1) - expect { answer.reload }.to raise_error ActiveRecord::RecordNotFound - end + expect(answer2).to_not be_best + expect(answer3).to be_best + end - it 'redirects to Question show view' do - delete :destroy, params: { id: answer } + it "tries to mark an answer to another user's question" do + post :mark, params: { id: answer1 }, format: :js - expect(response).to redirect_to question + expect(answer1).to_not be_best + end end - it "tries to delete another user's answer" do - expect { delete :destroy, params: { id: answer2 } }.to_not change(Answer, :count) + context 'unauthenticated user' do + it "tries to mark an answer to another user's question" do + post :mark, params: { id: answer1 }, format: :js + + expect(answer2).to_not be_best + end end end end diff --git a/spec/controllers/questions_controller_spec.rb b/spec/controllers/questions_controller_spec.rb index a353cef..26ae93c 100644 --- a/spec/controllers/questions_controller_spec.rb +++ b/spec/controllers/questions_controller_spec.rb @@ -97,35 +97,87 @@ end describe 'PATCH #update' do - before { login(user) } - - context 'with valid attributes' do - it 'changes question attributes' do - patch :update, params: { id: question, question: { title: "New title for the first question", body: "#{"b" * 50}" } } - question.reload - - expect(question.title).to eq("New title for the first question") - expect(question.body).to eq("#{"b" * 50}") + context 'authenticated user' do + let(:user2) { create(:user) } + let(:question2) { create(:question, user: user2) } + let(:new_title) { "New title for the first question" } + let(:new_body) { "#{"b" * 50}" } + + before { login(user) } + + context 'with valid attributes' do + it 'changes question attributes' do + patch :update, + params: { + id: question, + question: { title: new_title, body: new_body } + }, + format: :js + question.reload + + expect(question.title).to eq(new_title) + expect(question.body).to eq(new_body) + end + + it "tries to update another user's question" do + expect { + patch :update, + params: { + id: question2, + question: attributes_for(:question) + }, + format: :js + }.to_not change { question.reload.updated_at } + end + + it 'renders update view' do + patch :update, + params: { + id: question, + question: attributes_for(:question) + }, + format: :js + + expect(response).to render_template :update + end end - it 'redirects to updated question' do - patch :update, params: { id: question, question: attributes_for(:question) } - - expect(response).to redirect_to question + context 'with invalid attributes' do + it 'does not change the question' do + expect { + patch :update, + params: { + id: question, + question: attributes_for(:question, :invalid) + }, + format: :js + }.to_not change { question.reload.updated_at } + end + + it 'renders update view' do + patch :update, + params: { + id: question, + question: attributes_for(:question, :invalid) + }, + format: :js + + expect(response).to render_template :update + end end end - context 'with invalid attributes' do - it 'does not change the question' do + + context 'unauthenticated user' do + it 'tries to change question attributes' do expect { - patch :update, params: { id: question, question: attributes_for(:question, :invalid) } + patch :update, + params: { + id: question, + question: attributes_for(:question) + }, + format: :js }.to_not change { question.reload.updated_at } end - - it 'renders edit view' do - patch :update, params: { id: question, question: attributes_for(:question, :invalid) } - - expect(response).to render_template :edit - end end end @@ -148,7 +200,9 @@ end it "tries to delete another user's question" do - expect { delete :destroy, params: { id: question2 } }.to_not change(Question, :count) + expect { + delete :destroy, params: { id: question2 } + }.to_not change(Question, :count) end end end diff --git a/spec/features/answer/create_spec.rb b/spec/features/answer/create_spec.rb index edb3d74..e1d76a0 100644 --- a/spec/features/answer/create_spec.rb +++ b/spec/features/answer/create_spec.rb @@ -8,23 +8,21 @@ given(:user) { create(:user) } given(:question) { create(:question, user: user) } - before do + background do login(user) visit question_path(question) end - scenario 'answers to a question' do + scenario 'answers to a question', js: true do fill_in 'Body', with: "#{"body" * 25}" click_on 'Leave' - expect(page).to have_content 'Your answer was saved.' expect(page).to have_content "#{"body" * 25}" end - scenario 'answers to a question with errors' do + scenario 'answers to a question with errors', js: true do click_on 'Leave' - expect(page).to have_content 'Your answer was not saved.' expect(page).to have_content "Body can't be blank" expect(page).to have_content 'Body is too short (minimum is 50 characters)' end @@ -38,10 +36,12 @@ given(:user) { create(:user) } given(:question) { create(:question, user: user) } - scenario 'unauthenticated user tries to ask a question' do + scenario 'unauthenticated user tries to get an answer' do visit question_path(question) - fill_in 'Body', with: "#{"body" * 25}" - click_on 'Leave' + within ".new-answer" do + fill_in 'Body', with: "#{"body" * 25}" + click_on 'Leave' + end expect(page).to have_content 'You need to sign in or sign up before continuing.' end diff --git a/spec/features/answer/destroy_spec.rb b/spec/features/answer/destroy_spec.rb index dcf28c0..73fb2c5 100644 --- a/spec/features/answer/destroy_spec.rb +++ b/spec/features/answer/destroy_spec.rb @@ -8,26 +8,39 @@ } do given(:user1) { create(:user) } given(:user2) { create(:user) } - given(:user3) { create(:user) } - given(:question) { create(:question, user: user3) } - - before { login(user1) } - - scenario 'user deletes his answer' do - answer = create(:answer, question: question, user: user1) - - visit question_path(question) - click_on 'Delete the answer' - - expect(page).to have_content "Your answer was successfully deleted!" - expect { answer.reload }.to raise_error ActiveRecord::RecordNotFound + given(:question) { create(:question, user: user1) } + + describe 'authenticated user', js: true do + background do + login(user1) + end + + scenario 'deletes his answer' do + answer = create(:answer, question: question, user: user1) + visit question_path(question) + within "#answer_#{answer.id}" do + click_on 'Delete' + end + + expect(page).to_not have_content answer.body + end + + scenario "tries to delete another's answer" do + answer = create(:answer, question: question, user: user2) + visit question_path(question) + + within "#answer_#{answer.id}" do + expect(page).to_not have_link 'Delete' + end + end end - scenario "user tries to delete another's answer" do - create(:answer, question: question, user: user2) - + scenario 'unauthenticated user tries to delete an answer' do + answer = create(:answer, question: question, user: user1) visit question_path(question) - expect(page).to_not have_link 'Delete the answer' + within "#answer_#{answer.id}" do + expect(page).to_not have_link 'Delete' + end end end diff --git a/spec/features/answer/edit_spec.rb b/spec/features/answer/edit_spec.rb new file mode 100644 index 0000000..0e5c5a8 --- /dev/null +++ b/spec/features/answer/edit_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +feature 'user can edit his answer', %q{ + in order to correct mistakes + as an author of an answer + i'd like to be able to edit my answer +} do + given(:user) { create(:user) } + given(:user2) { create(:user) } + given(:question) { create(:question, user: user) } + given!(:answer) { create(:answer, question: question, user: user) } + given!(:answer2) { create(:answer, question: question, user: user2) } + + scenario 'unauthenticated user can not edit answers' do + visit question_path(question) + + expect(page).to_not have_link "Edit" + end + + describe 'authenticated user' do + background do + login(user) + visit question_path(question) + end + + given(:new_valid_body) { "New answer's body #{"a" * 32}" } + given(:new_invalid_body) { "New answer's invalid body" } + + scenario 'edits his answer', js: true do + within "#answer_#{answer.id}" do + click_on 'Edit' + fill_in 'answer_body', with: new_valid_body + click_on 'Update' + + expect(page).to_not have_content answer.body + expect(page).to have_content new_valid_body + expect(page).to_not have_selector 'textarea' + end + end + + scenario 'tries to edit his answer with errors', js: true do + within "#answer_#{answer.id}" do + click_on 'Edit' + fill_in 'answer_body', with: new_invalid_body + click_on 'Update' + + expect(page).to have_content answer.body + end + expect(page).to have_content 'Body is too short (minimum is 50 characters)' + end + + scenario "tries to edit another user's answer" do + within "#answer_#{answer2.id}" do + expect(page).to_not have_link 'Edit' + end + end + end +end diff --git a/spec/features/answer/mark_spec.rb b/spec/features/answer/mark_spec.rb new file mode 100644 index 0000000..e0175d1 --- /dev/null +++ b/spec/features/answer/mark_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +feature 'user can mark one answer as the best', %q{ + in order to emphasize the most proper answer + as an author of a question + i'd like to be able to mark + one of answers to my question as the best +} do + given(:user1) { create(:user) } + given(:question1) { create(:question, user: user1) } + + scenario 'unauthenticated user tries to choose the best answer' do + visit question_path(question1) + + expect(page).to_not have_link 'Mark as the best' + end + + describe 'authenticated user', js: true do + given(:user2) { create(:user) } + given(:question2) { create(:question, user: user2) } + given!(:answer1) { create(:answer, question: question2, user: user1) } + given!(:answer2) { create(:answer, question: question1, user: user2) } + given!(:answer3) { create(:answer, question: question1, user: user2) } + + background do + login(user1) + end + + scenario 'marks an answer to his question as the best' do + visit question_path(question1) + + within "#answer_#{answer2.id}" do + click_on 'Mark as the best' + + expect(page).to have_content 'The best answer' + expect(page).to_not have_link 'Mark as the best' + end + + within "#answer_#{answer3.id}" do + click_on 'Mark as the best' + + expect(page).to have_content 'The best answer' + expect(page).to_not have_link 'Mark as the best' + end + + within "#answer_#{answer2.id}" do + expect(page).to_not have_content 'The best answer' + expect(page).to have_link 'Mark as the best' + end + end + + scenario "tries to mark an answer to another's user question as the best" do + visit question_path(question2) + + within "#answer_#{answer1.id}" do + expect(page).to_not have_link 'Mark as the best' + end + end + + scenario %q{ + marks an answer to his question as the best + thereby moving this answer to the top + } do + visit question_path(question1) + + answer2_el = find("#answer_#{answer2.id}") + answer3_el = find("#answer_#{answer3.id}") + + if answer2_el.path < answer3_el.path + upper_answer, lower_answer = answer2, answer3 + else + upper_answer, lower_answer = answer3, answer2 + end + + within "#answer_#{lower_answer.id}" do + click_on 'Mark as the best' + end + + best_answer_el = find("#answer_#{lower_answer.id}") + regular_answer_el = find("#answer_#{upper_answer.id}") + + expect(best_answer_el.path).to be < regular_answer_el.path + end + end +end diff --git a/spec/features/question/destroy_spec.rb b/spec/features/question/destroy_spec.rb index 7b43b9c..dc6b365 100644 --- a/spec/features/question/destroy_spec.rb +++ b/spec/features/question/destroy_spec.rb @@ -10,19 +10,35 @@ given(:question1) { create(:question, user: user1) } given(:question2) { create(:question, user: user2) } - before { login(user1) } + context 'authenticated user' do + before { login(user1) } - scenario 'user deletes his question' do - visit question_path(question1) - click_on 'Delete the question' + scenario 'user deletes his question' do + visit question_path(question1) + within "#question_#{question1.id}" do + click_on 'Delete' + end - expect(page).to have_content "Your question was successfully deleted!" - expect(page).to_not have_content question1.title + expect(page).to have_content "Your question was successfully deleted!" + expect(page).to_not have_content question1.title + end + + scenario "user tries to delete another's question" do + visit question_path(question2) + + within "#question_#{question2.id}" do + expect(page).to_not have_link 'Delete' + end + end end - scenario "user tries to delete another's question" do - visit question_path(question2) + context 'unauthenticated user' do + scenario "user tries to delete another's question" do + visit question_path(question1) - expect(page).to_not have_link 'Delete the question' + within "#question_#{question1.id}" do + expect(page).to_not have_link 'Delete' + end + end end end diff --git a/spec/features/question/edit_spec.rb b/spec/features/question/edit_spec.rb new file mode 100644 index 0000000..6537234 --- /dev/null +++ b/spec/features/question/edit_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +feature 'user can edit his question', %q{ + in order to correct mistakes + as an author of an question + i'd like to be able to edit my question +} do + given(:user1) { create(:user) } + given(:user2) { create(:user) } + given(:question1) { create(:question, user: user1) } + given(:question2) { create(:question, user: user2) } + + scenario 'unauthenticated user can not edit questions' do + visit question_path(question1) + + within "#question_#{question1.id}" do + expect(page).to_not have_link "Edit" + end + end + + describe 'authenticated user' do + given(:new_valid_body) { "New question's body #{"c" * 32}" } + given(:new_invalid_body) { "New question's invalid body" } + + background do + login(user1) + end + + scenario 'edits his question', js: true do + visit question_path(question1) + + within "#question_#{question1.id}" do + click_on 'Edit' + fill_in 'question_body', with: new_valid_body + click_on 'Update' + + expect(page).to_not have_content(question1.body) + expect(page).to have_content new_valid_body + expect(page).to_not have_selector 'textarea' + end + end + + scenario 'tries to edit his question with errors', js: true do + visit question_path(question1) + + within "#question_#{question1.id}" do + click_on 'Edit' + fill_in 'question_body', with: new_invalid_body + click_on 'Update' + + expect(page).to have_content(question1.body) + end + expect(page).to have_content 'Body is too short (minimum is 50 characters)' + end + + scenario "tries to edit another user's question", js: true do + visit question_path(question2) + + within "#question_#{question2.id}" do + expect(page).to_not have_link 'Edit' + end + end + end +end diff --git a/spec/features/question/show_spec.rb b/spec/features/question/show_spec.rb index aaeb67c..04d6df3 100644 --- a/spec/features/question/show_spec.rb +++ b/spec/features/question/show_spec.rb @@ -12,7 +12,7 @@ given(:question) { create(:question, user: user) } given!(:answers) { create_list(:answer, n, question: question) } - scenario 'unauthenticated user sees a question and answers' do + scenario 'unauthenticated user sees a question and answers', js: true do visit question_path(question) expect(page).to have_content question.title diff --git a/spec/features/sign_in_spec.rb b/spec/features/sign_in_spec.rb index 4c9a6e7..f2d4a42 100644 --- a/spec/features/sign_in_spec.rb +++ b/spec/features/sign_in_spec.rb @@ -12,7 +12,7 @@ scenario 'registered user tries to sign in' do fill_in 'Email', with: user.email fill_in 'Password', with: user.password - click_on 'Log in' + click_button 'Log in' expect(page).to have_content 'Signed in successfully.' end @@ -20,7 +20,7 @@ scenario 'unregistered user tries to sign in' do fill_in 'Email', with: 'wrong@test.com' fill_in 'Password', with: '12345678' - click_on 'Log in' + click_button 'Log in' expect(page).to have_content 'Invalid Email or password.' end diff --git a/spec/features/sign_up_spec.rb b/spec/features/sign_up_spec.rb index fac997d..4470f2a 100644 --- a/spec/features/sign_up_spec.rb +++ b/spec/features/sign_up_spec.rb @@ -11,7 +11,7 @@ fill_in 'Email', with: 'user@test.com' fill_in 'Password', with: '12345678' fill_in 'Password confirmation', with: '12345678' - click_on 'Sign up' + click_button 'Sign up' expect(page).to have_content 'Welcome! You have signed up successfully.' end @@ -20,7 +20,7 @@ fill_in 'Email', with: 'user@test.com' fill_in 'Password', with: '12345678' fill_in 'Password confirmation', with: '87654321' - click_on 'Sign up' + click_button 'Sign up' expect(page).to have_content "Password confirmation doesn't match" end @@ -29,7 +29,7 @@ fill_in 'Email', with: 'user_at_test.com' fill_in 'Password', with: '12345678' fill_in 'Password confirmation', with: '12345678' - click_on 'Sign up' + click_button 'Sign up' expect(page).to have_content "Email is invalid" end diff --git a/spec/models/answer_spec.rb b/spec/models/answer_spec.rb index 1352b36..348157e 100644 --- a/spec/models/answer_spec.rb +++ b/spec/models/answer_spec.rb @@ -3,8 +3,28 @@ RSpec.describe Answer, type: :model do it { should belong_to :question } it { should belong_to :user } - it { should validate_presence_of :body } - it { should validate_length_of(:body).is_at_least(50) } + + let(:user) { create(:user) } + let(:question) { create(:question, user: user) } + let(:answer1) { create(:answer, question: question, user: user) } + let(:answer2) { create(:answer, question: question, user: user) } + + describe '#mark_best' do + it 'marks one answer as the best' do + answer1.mark_as_best + + expect(answer1).to be_best + expect(answer2).to_not be_best + end + end + + describe 'default_scope order' do + it 'sorting answers with the best as the first' do + answer1.mark_as_best + + expect(answer1.question.answers).to eq [answer1, answer2] + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ac58464..0f247f3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -7,18 +7,18 @@ it { should validate_presence_of :email } it { should validate_presence_of :password } - describe '#owned?' do + describe '#owner?' do let(:user1) { create(:user) } let(:user2) { create(:user) } let(:question1) { create(:question, user: user1) } let(:question2) { create(:question, user: user2) } it 'check if user is owner of resource created by himself' do - expect(user1).to be_owned(question1) + expect(user1).to be_owner(question1) end - it 'check if user is owner of resource created by himself' do - expect(user1).to_not be_owned(question2) + it 'check if user is not owner of resource created by another user' do + expect(user1).to_not be_owner(question2) end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3555d8d..a56e492 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -36,6 +36,8 @@ config.include ControllerHelpers, type: :controller config.include FeatureHelpers, type: :feature + Capybara.javascript_driver = :selenium_chrome_headless + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_path = "#{::Rails.root}/spec/fixtures" diff --git a/spec/support/feature_helpers.rb b/spec/support/feature_helpers.rb index d5d93ec..7319b74 100644 --- a/spec/support/feature_helpers.rb +++ b/spec/support/feature_helpers.rb @@ -3,6 +3,6 @@ def login(user) visit new_user_session_path fill_in 'Email', with: user.email fill_in 'Password', with: user.password - click_on 'Log in' + click_button 'Log in' end end