diff --git a/Gemfile b/Gemfile index 8d4bd1d..45991d6 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,11 @@ gem "doorkeeper" # JSON serialization for API gem "active_model_serializers", "~> 0.10.0" +# Background jobs with Redis +gem "sidekiq" +gem "redis" +gem "sidekiq-cron" + # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/Gemfile.lock b/Gemfile.lock index 9b45241..84d98cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -111,6 +111,9 @@ GEM concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) database_cleaner-active_record (2.2.2) activerecord (>= 5.a) database_cleaner-core (~> 2.0) @@ -333,6 +336,10 @@ GEM rdoc (6.14.2) erb psych (>= 4.0.0) + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.25.2) + connection_pool regexp_parser (2.10.0) reline (0.6.2) io-console (~> 0.5) @@ -396,6 +403,17 @@ GEM websocket (~> 1.0) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) + sidekiq (8.0.7) + connection_pool (>= 2.5.0) + json (>= 2.9.0) + logger (>= 1.6.2) + rack (>= 3.1.0) + redis-client (>= 0.23.2) + sidekiq-cron (2.3.1) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) + globalid (>= 1.0.1) + sidekiq (>= 6.5.0) snaky_hash (2.0.3) hashie (>= 0.1.0, < 6) version_gem (>= 1.1.8, < 3) @@ -449,6 +467,7 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unicode (0.4.4.5) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) @@ -507,10 +526,13 @@ DEPENDENCIES pundit rails (~> 8.0.2) rails-controller-testing + redis rspec-rails (~> 6.1.0) rubocop-rails-omakase selenium-webdriver shoulda-matchers + sidekiq + sidekiq-cron solid_cable solid_cache solid_queue diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb new file mode 100644 index 0000000..fc83cea --- /dev/null +++ b/app/controllers/subscriptions_controller.rb @@ -0,0 +1,36 @@ +class SubscriptionsController < ApplicationController + before_action :authenticate_user! + before_action :set_question, only: [ :create ] + before_action :set_subscription, only: [ :destroy ] + + def create + subscription = @question.subscriptions.find_or_initialize_by(user: current_user) + + if subscription.persisted? || subscription.save + redirect_to @question, notice: "You are subscribed to question updates" + else + redirect_to @question, alert: "Failed to subscribe to updates" + end + end + + def destroy + question = @subscription.question + + if @subscription.user_id == current_user.id + @subscription.destroy + redirect_to question, notice: "You have unsubscribed from question updates" + else + redirect_to question, alert: "You can only unsubscribe from your own subscriptions" + end + end + + private + + def set_question + @question = Question.find(params[:question_id]) + end + + def set_subscription + @subscription = Subscription.find(params[:id]) + end +end diff --git a/app/jobs/answer_notification_job.rb b/app/jobs/answer_notification_job.rb new file mode 100644 index 0000000..0080dd8 --- /dev/null +++ b/app/jobs/answer_notification_job.rb @@ -0,0 +1,16 @@ +class AnswerNotificationJob < ApplicationJob + queue_as :default + + def perform(answer_id) + answer = Answer.find_by(id: answer_id) + return unless answer + + question = answer.question + + question.subscribers + .where.not(id: answer.user_id) + .find_each(batch_size: 500) do |user| + AnswerMailer.new_answer(user, answer).deliver_now + end + end +end diff --git a/app/jobs/daily_digest_job.rb b/app/jobs/daily_digest_job.rb new file mode 100644 index 0000000..2f95a35 --- /dev/null +++ b/app/jobs/daily_digest_job.rb @@ -0,0 +1,12 @@ +class DailyDigestJob < ApplicationJob + queue_as :default + + def perform + questions = Question.where("created_at >= ?", 24.hours.ago).order(created_at: :desc) + return if questions.empty? + + User.where.not(confirmed_at: nil).find_each(batch_size: 500) do |user| + DigestMailer.daily_digest(user, questions).deliver_now + end + end +end diff --git a/app/mailers/answer_mailer.rb b/app/mailers/answer_mailer.rb new file mode 100644 index 0000000..edb3147 --- /dev/null +++ b/app/mailers/answer_mailer.rb @@ -0,0 +1,10 @@ +class AnswerMailer < ApplicationMailer + default from: "noreply@stackoverflow-clone.com" + + def new_answer(user, answer) + @user = user + @answer = answer + @question = answer.question + mail(to: @user.email, subject: "New answer to your question: #{@question.title}") + end +end diff --git a/app/mailers/digest_mailer.rb b/app/mailers/digest_mailer.rb new file mode 100644 index 0000000..dc4339b --- /dev/null +++ b/app/mailers/digest_mailer.rb @@ -0,0 +1,9 @@ +class DigestMailer < ApplicationMailer + default from: "noreply@stackoverflow-clone.com" + + def daily_digest(user, questions) + @user = user + @questions = questions + mail(to: @user.email, subject: "Daily questions digest") + end +end diff --git a/app/models/answer.rb b/app/models/answer.rb index c80219b..1c67d5d 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -19,6 +19,7 @@ class Answer < ApplicationRecord scope :best_first, -> { order(best: :desc, created_at: :desc) } after_create_commit -> { broadcast_append_to [ question, :answers ], target: "answers" } + after_create_commit :notify_subscribers def preview body.truncate(50) if body @@ -48,4 +49,8 @@ def no_dangerous_content errors.add(:body, "contains potentially dangerous code") end end + + def notify_subscribers + AnswerNotificationJob.perform_later(id) + end end diff --git a/app/models/question.rb b/app/models/question.rb index a7277d5..f0865cb 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -8,6 +8,8 @@ class Question < ApplicationRecord has_many :links, as: :linkable, dependent: :destroy has_one :reward, dependent: :destroy has_many :comments, as: :commentable, dependent: :destroy + has_many :subscriptions, dependent: :destroy + has_many :subscribers, through: :subscriptions, source: :user accepts_nested_attributes_for :links, allow_destroy: true, reject_if: :all_blank accepts_nested_attributes_for :reward, allow_destroy: true, reject_if: :all_blank @@ -22,6 +24,7 @@ class Question < ApplicationRecord scope :recent, -> { order(created_at: :desc) } after_create_commit -> { broadcast_prepend_to :questions, target: "questions" } + after_create_commit :auto_subscribe_author def preview body.truncate(150) if body @@ -47,4 +50,8 @@ def no_dangerous_content errors.add(:body, "contains potentially dangerous code") end end + + def auto_subscribe_author + subscriptions.find_or_create_by(user: user) + end end diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 0000000..ad6c0f2 --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,6 @@ +class Subscription < ApplicationRecord + belongs_to :user + belongs_to :question + + validates :user_id, uniqueness: { scope: :question_id } +end diff --git a/app/models/user.rb b/app/models/user.rb index 1adb013..53013b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,14 +7,16 @@ class User < ApplicationRecord has_many :answers, dependent: :destroy has_many :created_rewards, class_name: "Reward", dependent: :destroy has_many :received_rewards, class_name: "Reward", foreign_key: "recipient_id", dependent: :nullify + has_many :subscriptions, dependent: :destroy + has_many :subscribed_questions, through: :subscriptions, source: :question validates :email, presence: true, uniqueness: true, allow_blank: false validates :unconfirmed_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true validates :provider, :uid, presence: true, if: :oauth_user? - # Отключаем автоматическую отправку confirmation instructions для OAuth пользователей с временным email def send_on_create_confirmation_instructions return if oauth_user? && email.include?("@temp.local") + return if confirmed? super end diff --git a/app/views/answer_mailer/new_answer.html.erb b/app/views/answer_mailer/new_answer.html.erb new file mode 100644 index 0000000..e56d961 --- /dev/null +++ b/app/views/answer_mailer/new_answer.html.erb @@ -0,0 +1,9 @@ +

New answer to your question

+

Hello, <%= @user.name %>!

+

A new answer was posted to the question “<%= @question.title %>”:

+
+ <%= simple_format @answer.body %> +
+

+ Go to the question: <%= link_to 'Open', question_url(@question) %> +

diff --git a/app/views/digest_mailer/daily_digest.html.erb b/app/views/digest_mailer/daily_digest.html.erb new file mode 100644 index 0000000..6886f2a --- /dev/null +++ b/app/views/digest_mailer/daily_digest.html.erb @@ -0,0 +1,13 @@ +

Daily questions digest

+

Hello, <%= @user.name %>!

+

Here is the list of questions created in the last 24 hours:

+ +

Have a great day!

diff --git a/config/application.rb b/config/application.rb index 49ec4eb..f93b9de 100644 --- a/config/application.rb +++ b/config/application.rb @@ -27,6 +27,9 @@ class Application < Rails::Application # Set time zone config.time_zone = "UTC" + + # Use Sidekiq for background jobs + config.active_job.queue_adapter = :sidekiq # config.eager_load_paths << Rails.root.join("extras") end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..c16cf20 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,17 @@ +require "sidekiq" +require "sidekiq-cron" + +Sidekiq.configure_server do |config| + config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } + + schedule_file = Rails.root.join("config", "sidekiq.yml") + if File.exist?(schedule_file) + yaml = YAML.load_file(schedule_file) + schedule = yaml["schedule"] || yaml[:schedule] + Sidekiq::Cron::Job.load_from_hash(schedule) if schedule.present? + end +end + +Sidekiq.configure_client do |config| + config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") } +end diff --git a/config/routes.rb b/config/routes.rb index 83a6c1a..68be6c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,12 @@ Rails.application.routes.draw do + require "sidekiq/web" use_doorkeeper devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" } + mount Sidekiq::Web => "/sidekiq" + resources :user_email_confirmations, path: "users/email_confirmations", controller: "users/email_confirmations", only: [ :new, :create ] do member do get :confirm @@ -19,6 +22,7 @@ resources :rewards, only: :index resources :questions, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do + resources :subscriptions, only: [ :create ], shallow: true resources :comments, only: [ :create, :destroy ], shallow: true resources :votes, only: [ :destroy ], defaults: { votable: "question" } do collection do @@ -42,6 +46,8 @@ end end + resources :subscriptions, only: [ :destroy ] + namespace :api do namespace :v1 do get "profiles/me", to: "profiles#me" diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 0000000..b757ac2 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,9 @@ +:concurrency: 5 +:queues: + - default + +:schedule: + daily_digest: + cron: "0 8 * * *" # каждый день в 8 часов по времени сервера + class: "DailyDigestJob" + queue: default diff --git a/db/migrate/20250908175000_create_subscriptions.rb b/db/migrate/20250908175000_create_subscriptions.rb new file mode 100644 index 0000000..87f18f3 --- /dev/null +++ b/db/migrate/20250908175000_create_subscriptions.rb @@ -0,0 +1,12 @@ +class CreateSubscriptions < ActiveRecord::Migration[8.0] + def change + create_table :subscriptions do |t| + t.references :user, null: false, foreign_key: true + t.references :question, null: false, foreign_key: true + + t.timestamps + end + + add_index :subscriptions, [:user_id, :question_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f338fec..b26f4e0 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[8.0].define(version: 2025_09_04_084055) do +ActiveRecord::Schema[8.0].define(version: 2025_09_08_175000) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -136,6 +136,16 @@ t.index ["user_id"], name: "index_rewards_on_user_id" end + create_table "subscriptions", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "question_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["question_id"], name: "index_subscriptions_on_question_id" + t.index ["user_id", "question_id"], name: "index_subscriptions_on_user_id_and_question_id", unique: true + t.index ["user_id"], name: "index_subscriptions_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -179,5 +189,7 @@ add_foreign_key "rewards", "questions" add_foreign_key "rewards", "users" add_foreign_key "rewards", "users", column: "recipient_id" + add_foreign_key "subscriptions", "questions" + add_foreign_key "subscriptions", "users" add_foreign_key "votes", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 7a0b1aa..0a510a5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,6 +1,3 @@ -# This file creates sample data for the Stack Overflow clone application -# Run with: rails db:seed - puts "Cleaning database..." Vote.destroy_all if defined?(Vote) Comment.destroy_all if defined?(Comment) @@ -10,7 +7,6 @@ Question.destroy_all if defined?(Question) User.destroy_all -# Create test users puts "Creating test users..." test_user = User.create!( email: "test@example.com", @@ -28,7 +24,6 @@ ) puts "Created second user: #{second_user.email} with password: password123" -# Create sample questions puts "Creating questions..." questions = [ { diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..470ca8a Binary files /dev/null and b/dump.rdb differ diff --git a/spec/controllers/subscriptions_controller_spec.rb b/spec/controllers/subscriptions_controller_spec.rb new file mode 100644 index 0000000..de6a87a --- /dev/null +++ b/spec/controllers/subscriptions_controller_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe SubscriptionsController, type: :controller do + let(:user) { create(:user, confirmed_at: Time.current) } + let(:other_user) { create(:user, confirmed_at: Time.current) } + let(:question) { create(:question, user: user) } + + describe 'POST #create' do + context 'when unauthenticated' do + it 'redirects to sign in' do + post :create, params: { question_id: question.id } + expect(response).to have_http_status(302) + end + end + + context 'when authenticated' do + before do + question.subscriptions.find_or_create_by!(user: user) + end + it 'creates subscription for current user (who is not the author)' do + sign_in other_user + expect { + post :create, params: { question_id: question.id } + }.to change(Subscription, :count).by(1) + expect(response).to redirect_to(question) + expect(flash[:notice]).to eq('You are subscribed to question updates') + end + + it 'does not duplicate subscription if already exists' do + question.subscriptions.find_or_create_by!(user: user) + sign_in user + expect { + post :create, params: { question_id: question.id } + }.not_to change(Subscription, :count) + expect(response).to redirect_to(question) + end + end + end + + describe 'DELETE #destroy' do + let!(:subscription) do + question.subscriptions.find_by(user: user) || create(:subscription, user: user, question: question) + end + + context 'when unauthenticated' do + it 'redirects to sign in' do + delete :destroy, params: { id: subscription.id } + expect(response).to have_http_status(302) + end + end + + context 'when authenticated' do + it 'allows owner to unsubscribe' do + sign_in user + expect { + delete :destroy, params: { id: subscription.id } + }.to change(Subscription, :count).by(-1) + expect(response).to redirect_to(question) + expect(flash[:notice]).to eq('You have unsubscribed from question updates') + end + + it 'prevents other users from deleting someone else subscription' do + sign_in other_user + expect { + delete :destroy, params: { id: subscription.id } + }.not_to change(Subscription, :count) + expect(response).to redirect_to(question) + expect(flash[:alert]).to eq('You can only unsubscribe from your own subscriptions') + end + end + end +end diff --git a/spec/controllers/users/email_confirmations_controller_spec.rb b/spec/controllers/users/email_confirmations_controller_spec.rb index 4a21cfc..39a34e8 100644 --- a/spec/controllers/users/email_confirmations_controller_spec.rb +++ b/spec/controllers/users/email_confirmations_controller_spec.rb @@ -85,7 +85,7 @@ expect(response).to redirect_to(root_path) expect(flash[:notice]).to eq("Email successfully confirmed! Welcome!") - expect(controller.current_user).to eq(user) + expect(warden).to be_authenticated(:user) end end diff --git a/spec/controllers/users/omniauth_callbacks_controller_spec.rb b/spec/controllers/users/omniauth_callbacks_controller_spec.rb index 87121a1..cf29e9c 100644 --- a/spec/controllers/users/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/users/omniauth_callbacks_controller_spec.rb @@ -36,7 +36,7 @@ expect(user).to be_email_verified expect(response).to redirect_to(root_path) - expect(controller.current_user).to eq(user) + expect(warden).to be_authenticated(:user) end end diff --git a/spec/factories/subscriptions.rb b/spec/factories/subscriptions.rb new file mode 100644 index 0000000..ebb4e57 --- /dev/null +++ b/spec/factories/subscriptions.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :subscription do + association :user + association :question + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index df13777..e6c640c 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -5,6 +5,10 @@ password_confirmation { 'password123' } confirmed_at { Time.current } + before(:create) do |user| + user.skip_confirmation_notification! + end + trait :oauth_user do provider { 'google_oauth2' } sequence(:uid) { |n| "oauth_uid_#{n}" } diff --git a/spec/jobs/answer_notification_job_spec.rb b/spec/jobs/answer_notification_job_spec.rb new file mode 100644 index 0000000..87357ce --- /dev/null +++ b/spec/jobs/answer_notification_job_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +RSpec.describe AnswerNotificationJob, type: :job do + before do + ActionMailer::Base.deliveries.clear + end + + let!(:author) { create(:user, confirmed_at: Time.current) } + let!(:other_subscriber) { create(:user, confirmed_at: Time.current) } + let!(:answer_author) { create(:user, confirmed_at: Time.current) } + + let!(:question) { create(:question, user: author) } + + it 'notifies all subscribers except the answer author' do + create(:subscription, user: other_subscriber, question: question) + + answer = create(:answer, question: question, user: answer_author) + + expect { + described_class.perform_now(answer.id) + }.to change { ActionMailer::Base.deliveries.size }.by(2) + + recipients = ActionMailer::Base.deliveries.map { |m| m.to }.flatten + expect(recipients).to include(author.email, other_subscriber.email) + expect(recipients).not_to include(answer_author.email) + + subjects = ActionMailer::Base.deliveries.map(&:subject) + expect(subjects).to all(include('New answer to your question')) + end +end diff --git a/spec/jobs/daily_digest_job_spec.rb b/spec/jobs/daily_digest_job_spec.rb new file mode 100644 index 0000000..9b1ea87 --- /dev/null +++ b/spec/jobs/daily_digest_job_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe DailyDigestJob, type: :job do + include ActiveSupport::Testing::TimeHelpers + + let!(:confirmed_user1) { create(:user, confirmed_at: Time.current) } + let!(:confirmed_user2) { create(:user, confirmed_at: Time.current) } + let!(:unconfirmed_user) { create(:user, confirmed_at: nil) } + + before do + ActionMailer::Base.deliveries.clear + end + + it 'sends digest only to confirmed users with questions from last 24 hours' do + travel_to Time.zone.parse('2025-09-08 09:00:00 UTC') do + recent_q1 = create(:question, user: confirmed_user1, created_at: 2.hours.ago, title: 'Recent Question 1') + recent_q2 = create(:question, user: confirmed_user2, created_at: 3.hours.ago, title: 'Recent Question 2') + _old_q = create(:question, user: confirmed_user1, created_at: 2.days.ago, title: 'Old Question') + + expect { + described_class.perform_now + }.to change { ActionMailer::Base.deliveries.size }.by(2) + + subjects = ActionMailer::Base.deliveries.map(&:subject) + expect(subjects).to all(include('Daily questions digest')) + + bodies = ActionMailer::Base.deliveries.map { |m| m.body.encoded } + expect(bodies.join).to include(recent_q1.title) + expect(bodies.join).to include(recent_q2.title) + expect(bodies.join).not_to include('Old Question') + end + end +end diff --git a/spec/mailers/answer_mailer_spec.rb b/spec/mailers/answer_mailer_spec.rb new file mode 100644 index 0000000..4d5851b --- /dev/null +++ b/spec/mailers/answer_mailer_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe AnswerMailer, type: :mailer do + before { ActionMailer::Base.deliveries.clear } + + it 'composes new answer email with proper subject and content' do + user = create(:user, confirmed_at: Time.current) + author = create(:user, confirmed_at: Time.current) + question = create(:question, user: author, title: 'How to test mailers?') + answer = create(:answer, question: question, user: author, body: 'Use RSpec mailer specs') + + mail = described_class.new_answer(user, answer) + + expect(mail.to).to eq([ user.email ]) + expect(mail.subject).to include('New answer to your question') + expect(mail.body.encoded).to include('How to test mailers?') + expect(mail.body.encoded).to include('Use RSpec mailer specs') + end +end diff --git a/spec/requests/votes_spec.rb b/spec/requests/votes_spec.rb index 2484128..34d0185 100644 --- a/spec/requests/votes_spec.rb +++ b/spec/requests/votes_spec.rb @@ -7,18 +7,152 @@ let(:answer) { create(:answer, question: question, user: author) } describe 'Question votes' do - include_examples 'votable endpoints', - up_path: ->(votable) { up_question_votes_path(votable) }, - down_path: ->(votable) { down_question_votes_path(votable) }, - delete_path: ->(votable, vote) { question_vote_path(votable, vote) }, - build_votable: -> { create(:question, user: author) } + let(:votable) { create(:question, user: author) } + let(:vote) { create(:vote, votable: votable, user: user) } + + describe 'POST /questions/:question_id/votes/up' do + context 'when user is authenticated' do + before { sign_in(user) } + + it 'creates an upvote' do + expect { + post up_question_votes_path(votable) + }.to change(Vote, :count).by(1) + + expect(response).to have_http_status(:success) + expect(votable.votes.last.value).to eq(1) + end + end + + context 'when user is not authenticated' do + it 'redirects to login page' do + post up_question_votes_path(votable) + expect(response).to have_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'POST /questions/:question_id/votes/down' do + context 'when user is authenticated' do + before { sign_in(user) } + + it 'creates a downvote' do + expect { + post down_question_votes_path(votable) + }.to change(Vote, :count).by(1) + + expect(response).to have_http_status(:success) + expect(votable.votes.last.value).to eq(-1) + end + end + + context 'when user is not authenticated' do + it 'redirects to login page' do + post down_question_votes_path(votable) + expect(response).to have_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'DELETE /questions/:question_id/votes/:id' do + context 'when user is authenticated' do + before { sign_in(user) } + + it 'deletes the vote' do + vote # создаем голос + + expect { + delete question_vote_path(votable, vote) + }.to change(Vote, :count).by(-1) + + expect(response).to have_http_status(:success) + end + end + + context 'when user is not authenticated' do + it 'redirects to login page' do + delete question_vote_path(votable, vote) + expect(response).to have_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end end describe 'Answer votes' do - include_examples 'votable endpoints', - up_path: ->(votable) { up_answer_votes_path(votable) }, - down_path: ->(votable) { down_answer_votes_path(votable) }, - delete_path: ->(votable, vote) { vote_path(vote, votable: 'answer', answer_id: votable.id) }, - build_votable: -> { create(:answer, question: question, user: author) } + let(:votable) { create(:answer, question: question, user: author) } + let(:vote) { create(:vote, votable: votable, user: user) } + + describe 'POST /answers/:answer_id/votes/up' do + context 'when user is authenticated' do + before { sign_in(user) } + + it 'creates an upvote' do + expect { + post up_answer_votes_path(votable) + }.to change(Vote, :count).by(1) + + expect(response).to have_http_status(:success) + expect(votable.votes.last.value).to eq(1) + end + end + + context 'when user is not authenticated' do + it 'redirects to login page' do + post up_answer_votes_path(votable) + expect(response).to have_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'POST /answers/:answer_id/votes/down' do + context 'when user is authenticated' do + before { sign_in(user) } + + it 'creates a downvote' do + expect { + post down_answer_votes_path(votable) + }.to change(Vote, :count).by(1) + + expect(response).to have_http_status(:success) + expect(votable.votes.last.value).to eq(-1) + end + end + + context 'when user is not authenticated' do + it 'redirects to login page' do + post down_answer_votes_path(votable) + expect(response).to have_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'DELETE /votes/:id' do + context 'when user is authenticated' do + before { sign_in(user) } + + it 'deletes the vote' do + vote + + expect { + delete vote_path(vote, votable: 'answer', answer_id: votable.id) + }.to change(Vote, :count).by(-1) + + expect(response).to have_http_status(:success) + end + end + + context 'when user is not authenticated' do + it 'redirects to login page' do + delete vote_path(vote, votable: 'answer', answer_id: votable.id) + expect(response).to have_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end end end