diff --git a/app/controllers/api/v0/classified_listings_controller.rb b/app/controllers/api/v0/classified_listings_controller.rb new file mode 100644 index 000000000..ff8e23eea --- /dev/null +++ b/app/controllers/api/v0/classified_listings_controller.rb @@ -0,0 +1,76 @@ +module Api + module V0 + class ClassifiedListingsController < ApiController + include ClassifiedListingsToolkit + respond_to :json + + before_action :set_classified_listing, only: %i[show update] + before_action :authenticate!, only: %i[create update] + + # skip CSRF checks for create and update + skip_before_action :verify_authenticity_token, only: %i[create update] + + def index + @classified_listings = ClassifiedListing.published. + order("bumped_at DESC"). + includes(:user, :organization, :taggings) + + @classified_listings = @classified_listings.where(category: params[:category]) if params[:category].present? + + per_page = (params[:per_page] || 30).to_i + num = [per_page, 100].min + page = params[:page] || 1 + @classified_listings = @classified_listings.page(page).per(num) + + set_surrogate_key_header "classified-listings-#{params[:category]}-#{page}-#{num}" + end + + def show + # rendering with json builder + end + + def create + super + end + + def update + super + end + + private + + attr_accessor :user + + alias current_user user + + def process_no_credit_left + msg = "Not enough available credits" + render json: { error: msg, status: 402 }, status: :payment_required + end + + def process_successful_draft + render "show", status: :created + end + + def process_unsuccessful_draft + render json: { errors: @classified_listing.errors }, status: :unprocessable_entity + end + + def process_successful_creation + render "show", status: :created + end + + def process_unsuccessful_creation + render json: { errors: @classified_listing.errors }, status: :unprocessable_entity + end + + def process_after_update + render "show", status: :ok + end + + def process_after_unpublish + render "show", status: :ok + end + end + end +end diff --git a/app/controllers/classified_listings_controller.rb b/app/controllers/classified_listings_controller.rb index 64bfe3357..db6f7931c 100644 --- a/app/controllers/classified_listings_controller.rb +++ b/app/controllers/classified_listings_controller.rb @@ -31,75 +31,20 @@ class ClassifiedListingsController < ApplicationController @credits = current_user.credits.unspent end + def create + super + end + + def update + super + end + def edit authorize @classified_listing @organizations = current_user.organizations @credits = current_user.credits.unspent end - def create - @classified_listing = ClassifiedListing.new(listing_params) - - # this will 500 for now if they don't belong in the org - authorize @classified_listing, :authorized_organization_poster? if @classified_listing.organization_id.present? - - @classified_listing.user_id = current_user.id - cost = ClassifiedListing.cost_by_category(@classified_listing.category) - org = Organization.find_by(id: @classified_listing.organization_id) - - if listing_params[:action] == "draft" - @classified_listing.published = false - if @classified_listing.save - redirect_to "/listings/dashboard" - else - @credits = current_user.credits.unspent - @classified_listing.cached_tag_list = listing_params[:tag_list] - @organizations = current_user.organizations - render :new - end - return - end - - available_org_credits = org.credits.unspent if org - available_user_credits = current_user.credits.unspent - - # we use the org's credits if available, otherwise we default to the user's - if org && available_org_credits.size >= cost - create_listing(org, cost) - elsif available_user_credits.size >= cost - create_listing(current_user, cost) - else - redirect_to credits_path, notice: "Not enough available credits" - end - end - - def update - authorize @classified_listing - - cost = ClassifiedListing.cost_by_category(@classified_listing.category) - - # NOTE: this should probably be split in three different actions: bump, unpublish, publish - return bump_listing(cost) if listing_params[:action] == "bump" - - if listing_params[:action] == "unpublish" - unpublish_listing - redirect_to "/listings/dashboard" - return - elsif listing_params[:action] == "publish" - unless @classified_listing.bumped_at? - first_publish(cost) - return - end - - publish_listing - elsif listing_params[:body_markdown].present? && ((@classified_listing.bumped_at && @classified_listing.bumped_at > 24.hours.ago) || !@classified_listing.published) - update_listing_details - end - - clear_listings_cache - redirect_to "/listings" - end - def dashboard @classified_listings = current_user.classified_listings. includes(:organization, :taggings) @@ -125,90 +70,31 @@ class ClassifiedListingsController < ApplicationController private - def create_listing(purchaser, cost) - successful_transaction = false - ActiveRecord::Base.transaction do - # subtract credits - Credits::Buyer.call( - purchaser: purchaser, - purchase: @classified_listing, - cost: cost, - ) - - # save the listing - @classified_listing.bumped_at = Time.current - @classified_listing.published = true - - # since we can't raise active record errors in this transaction - # due to the fact that we need to display them in the :new view, - # we manually rollback the transaction if there are validation errors - raise ActiveRecord::Rollback unless @classified_listing.save - - successful_transaction = true - end - - if successful_transaction - clear_listings_cache - @classified_listing.index! - redirect_to classified_listings_path - else - @credits = current_user.credits.unspent - @classified_listing.cached_tag_list = listing_params[:tag_list] - @organizations = current_user.organizations - render :new - end + def process_no_credit_left + redirect_to credits_path, notice: "Not enough available credits" end - def first_publish(cost) - available_author_credits = @classified_listing.author.credits.unspent - available_user_credits = [] - if @classified_listing.author.is_a?(Organization) - available_user_credits = current_user.credits.unspent - end - - if available_author_credits.size >= cost - create_listing(@classified_listing.author, cost) - elsif available_user_credits.size >= cost - create_listing(current_user, cost) - else - redirect_to credits_path, notice: "Not enough available credits" - end + def process_successful_draft + redirect_to "/listings/dashboard" end - def bump_listing(cost) - org = Organization.find_by(id: @classified_listing.organization_id) - - available_org_credits = org.credits.unspent if org - available_user_credits = current_user.credits.unspent - - if org && available_org_credits.size >= cost - charge_credits_before_bump(org, cost) - elsif available_user_credits.size >= cost - charge_credits_before_bump(current_user, cost) - else - redirect_to(credits_path, notice: "Not enough available credits") && return - end + def process_unsuccessful_draft + render :new end - def charge_credits_before_bump(purchaser, cost) - ActiveRecord::Base.transaction do - Credits::Buyer.call( - purchaser: purchaser, - purchase: @classified_listing, - cost: cost, - ) - - raise ActiveRecord::Rollback unless bump_listing_success - end + def process_successful_creation + redirect_to classified_listings_path end - def set_classified_listing - @classified_listing = ClassifiedListing.find(params[:id]) + def process_unsuccessful_creation + render :new end - # Never trust parameters from the scary internet, only allow a specific list through. - def listing_params - accessible = %i[title body_markdown category tag_list expires_at contact_via_connect location organization_id action] - params.require(:classified_listing).permit(accessible) + def process_after_update + redirect_to "/listings" + end + + def process_after_unpublish + redirect_to "/listings/dashboard" end end diff --git a/app/controllers/concerns/classified_listings_toolkit.rb b/app/controllers/concerns/classified_listings_toolkit.rb index 387057ba9..fc9d5b650 100644 --- a/app/controllers/concerns/classified_listings_toolkit.rb +++ b/app/controllers/concerns/classified_listings_toolkit.rb @@ -1,6 +1,8 @@ module ClassifiedListingsToolkit extend ActiveSupport::Concern + MANDATORY_FIELDS_FOR_UPDATE = %i[body_markdown title tag_list].freeze + def unpublish_listing @classified_listing.published = false @classified_listing.save @@ -34,4 +36,174 @@ module ClassifiedListingsToolkit def clear_listings_cache ClassifiedListings::BustCacheJob.perform_now(@classified_listing.id) end + + def set_classified_listing + @classified_listing = ClassifiedListing.find(params[:id]) + end + + def create + @classified_listing = ClassifiedListing.new(listing_params) + + # this will 500 for now if they don't belong in the org + authorize @classified_listing, :authorized_organization_poster? if @classified_listing.organization_id.present? + + @classified_listing.user_id = current_user.id + cost = ClassifiedListing.cost_by_category(@classified_listing.category) + org = Organization.find_by(id: @classified_listing.organization_id) + + if listing_params[:action] == "draft" + create_draft + return + end + + available_org_credits = org.credits.unspent if org + available_user_credits = current_user.credits.unspent + + # we use the org's credits if available, otherwise we default to the user's + if org && available_org_credits.size >= cost + create_listing(org, cost) + elsif available_user_credits.size >= cost + create_listing(current_user, cost) + else + process_no_credit_left + end + end + + # Never trust parameters from the scary internet, only allow a specific list through. + def listing_params + if params["classified_listing"]["tags"].present? + params["classified_listing"]["tags"] = params["classified_listing"]["tags"].join(", ") + params["classified_listing"]["tag_list"] = params["classified_listing"].delete "tags" + end + accessible = %i[title body_markdown category tag_list expires_at contact_via_connect location organization_id action] + params.require(:classified_listing).permit(accessible) + end + + def create_draft + @classified_listing.published = false + if @classified_listing.save + process_successful_draft + else + @credits = current_user.credits.unspent + @classified_listing.cached_tag_list = listing_params[:tag_list] + @organizations = current_user.organizations + process_unsuccessful_draft + end + end + + def create_listing(purchaser, cost) + successful_transaction = false + ActiveRecord::Base.transaction do + # subtract credits + Credits::Buyer.call( + purchaser: purchaser, + purchase: @classified_listing, + cost: cost, + ) + + # save the listing + @classified_listing.bumped_at = Time.current + @classified_listing.published = true + + # since we can't raise active record errors in this transaction + # due to the fact that we need to display them in the :new view, + # we manually rollback the transaction if there are validation errors + raise ActiveRecord::Rollback unless @classified_listing.save + + successful_transaction = true + end + + if successful_transaction + clear_listings_cache + @classified_listing.index! + process_successful_creation + else + @credits = current_user.credits.unspent + @classified_listing.cached_tag_list = listing_params[:tag_list] + @organizations = current_user.organizations + process_unsuccessful_creation + end + end + + def update + authorize @classified_listing + + cost = ClassifiedListing.cost_by_category(@classified_listing.category) + + # NOTE: this should probably be split in three different actions: bump, unpublish, publish + return bump_listing(cost) if listing_params[:action] == "bump" + + if listing_params[:action] == "unpublish" + unpublish_listing + process_after_unpublish + return + elsif listing_params[:action] == "publish" + unless @classified_listing.bumped_at? + first_publish(cost) + return + end + + publish_listing + elsif listing_updatable? + update_listing_details + end + + clear_listings_cache + process_after_update + end + + def bump_listing(cost) + org = Organization.find_by(id: @classified_listing.organization_id) + + available_org_credits = org.credits.unspent if org + available_user_credits = current_user.credits.unspent + + if org && available_org_credits.size >= cost + charge_credits_before_bump(org, cost) + elsif available_user_credits.size >= cost + charge_credits_before_bump(current_user, cost) + else + process_no_credit_left && return + end + end + + def charge_credits_before_bump(purchaser, cost) + ActiveRecord::Base.transaction do + Credits::Buyer.call( + purchaser: purchaser, + purchase: @classified_listing, + cost: cost, + ) + + raise ActiveRecord::Rollback unless bump_listing_success + end + end + + def first_publish(cost) + available_author_credits = @classified_listing.author.credits.unspent + available_user_credits = [] + if @classified_listing.author.is_a?(Organization) + available_user_credits = current_user.credits.unspent + end + + if available_author_credits.size >= cost + create_listing(@classified_listing.author, cost) + elsif available_user_credits.size >= cost + create_listing(current_user, cost) + else + process_no_credit_left + end + end + + def listing_updatable? + at_least_one_param_present? && (bumped_in_last_24_hrs? || !@classified_listing.published) + end + + def at_least_one_param_present? + MANDATORY_FIELDS_FOR_UPDATE.any? { |i| listing_params.include? i } + end + + def bumped_in_last_24_hrs? + @classified_listing.bumped_at && @classified_listing.bumped_at > 24.hours.ago + end end diff --git a/app/models/classified_listing.rb b/app/models/classified_listing.rb index e334be398..c4856ac5a 100644 --- a/app/models/classified_listing.rb +++ b/app/models/classified_listing.rb @@ -40,6 +40,8 @@ class ClassifiedListing < ApplicationRecord searchableAttributes %w[title processed_html tag_list slug location] end + scope :published, -> { where(published: true) } + def self.cost_by_category(category = "education") categories_available[category][:cost] end diff --git a/app/views/api/v0/articles/index.json.jbuilder b/app/views/api/v0/articles/index.json.jbuilder index bf1a34223..2f55d24fa 100644 --- a/app/views/api/v0/articles/index.json.jbuilder +++ b/app/views/api/v0/articles/index.json.jbuilder @@ -14,10 +14,10 @@ json.array! @articles do |article| json.positive_reactions_count article.positive_reactions_count json.published_timestamp article.published_timestamp - json.partial! "user", user: article.user + json.partial! "api/v0/shared/user", user: article.user if article.organization - json.partial! "organization", organization: article.organization + json.partial! "api/v0/shared/organization", organization: article.organization end flare_tag = FlareTag.new(article).tag diff --git a/app/views/api/v0/articles/me.json.jbuilder b/app/views/api/v0/articles/me.json.jbuilder index 67fdccc6f..d4cdafc30 100644 --- a/app/views/api/v0/articles/me.json.jbuilder +++ b/app/views/api/v0/articles/me.json.jbuilder @@ -16,10 +16,10 @@ json.array! @articles do |article| json.published_timestamp article.published_timestamp json.body_markdown article.body_markdown - json.partial! "user", user: article.user + json.partial! "api/v0/shared/user", user: article.user if article.organization - json.partial! "organization", organization: article.organization + json.partial! "api/v0/shared/organization", organization: article.organization end flare_tag = FlareTag.new(article).tag diff --git a/app/views/api/v0/articles/show.json.jbuilder b/app/views/api/v0/articles/show.json.jbuilder index e29cd54f0..a28379b68 100644 --- a/app/views/api/v0/articles/show.json.jbuilder +++ b/app/views/api/v0/articles/show.json.jbuilder @@ -23,4 +23,4 @@ json.last_comment_at @article.last_comment_at&.utc&.iso8601 json.body_html @article.processed_html json.body_markdown @article.body_markdown -json.partial! "user", user: @article.user +json.partial! "api/v0/shared/user", user: @article.user diff --git a/app/views/api/v0/classified_listings/_listing.json.jbuilder b/app/views/api/v0/classified_listings/_listing.json.jbuilder new file mode 100644 index 000000000..9da42a95f --- /dev/null +++ b/app/views/api/v0/classified_listings/_listing.json.jbuilder @@ -0,0 +1,11 @@ +json.type_of "classified_listing" +json.id listing.id +json.title listing.title +json.slug listing.slug +json.body_markdown listing.body_markdown +json.tag_list listing.cached_tag_list +json.tags listing.tag_list +json.category listing.category +json.processed_html listing.processed_html +json.published listing.published +json.slug listing.slug diff --git a/app/views/api/v0/classified_listings/index.json.jbuilder b/app/views/api/v0/classified_listings/index.json.jbuilder new file mode 100644 index 000000000..3f6839cdb --- /dev/null +++ b/app/views/api/v0/classified_listings/index.json.jbuilder @@ -0,0 +1,8 @@ +json.array! @classified_listings do |listing| + json.partial! "listing", listing: listing + json.partial! "api/v0/shared/user", user: listing.user + + if listing.organization + json.partial! "api/v0/shared/organization", organization: listing.organization + end +end diff --git a/app/views/api/v0/classified_listings/show.json.jbuilder b/app/views/api/v0/classified_listings/show.json.jbuilder new file mode 100644 index 000000000..ce5353df9 --- /dev/null +++ b/app/views/api/v0/classified_listings/show.json.jbuilder @@ -0,0 +1,7 @@ +json.partial! "listing", listing: @classified_listing + +json.partial! "api/v0/shared/user", user: @classified_listing.user + +if @classified_listing.organization + json.partial! "api/v0/shared/organization", organization: @classified_listing.organization +end diff --git a/app/views/api/v0/articles/_organization.json.jbuilder b/app/views/api/v0/shared/_organization.json.jbuilder similarity index 100% rename from app/views/api/v0/articles/_organization.json.jbuilder rename to app/views/api/v0/shared/_organization.json.jbuilder diff --git a/app/views/api/v0/articles/_user.json.jbuilder b/app/views/api/v0/shared/_user.json.jbuilder similarity index 100% rename from app/views/api/v0/articles/_user.json.jbuilder rename to app/views/api/v0/shared/_user.json.jbuilder diff --git a/app/views/api/v0/webhooks/show.json.jbuilder b/app/views/api/v0/webhooks/show.json.jbuilder index 1b64da514..886e74650 100644 --- a/app/views/api/v0/webhooks/show.json.jbuilder +++ b/app/views/api/v0/webhooks/show.json.jbuilder @@ -4,4 +4,4 @@ json.call(@webhook, :id, :source, :target_url, :events) json.created_at @webhook.created_at.rfc3339 -json.partial! "api/v0/articles/user", user: @webhook.user +json.partial! "api/v0/shared/user", user: @webhook.user diff --git a/config/routes.rb b/config/routes.rb index f44350b28..437b1b53e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -110,6 +110,8 @@ Rails.application.routes.draw do end resources :webhooks, only: %i[index create show destroy] + resources :classified_listings, path: :listings, only: %i[index show create update] + get "/listings/category/:category", to: "classified_listings#index", as: :classified_listings_category get "/analytics/totals", to: "analytics#totals" get "/analytics/historical", to: "analytics#historical" get "/analytics/past_day", to: "analytics#past_day" diff --git a/spec/factories/api_secrets.rb b/spec/factories/api_secrets.rb index ec026cfb8..26f354328 100644 --- a/spec/factories/api_secrets.rb +++ b/spec/factories/api_secrets.rb @@ -4,4 +4,11 @@ FactoryBot.define do description { Faker::Lorem.sentence } secret { SecureRandom.base58(24) } end + + trait :org_admin do + after(:create) do |api_secret| + org = create(:organization) + create(:organization_membership, user_id: api_secret.user.id, organization_id: org.id, type_of_user: "admin") + end + end end diff --git a/spec/requests/api/v0/classified_listings_spec.rb b/spec/requests/api/v0/classified_listings_spec.rb new file mode 100644 index 000000000..bf044abf1 --- /dev/null +++ b/spec/requests/api/v0/classified_listings_spec.rb @@ -0,0 +1,481 @@ +require "rails_helper" + +RSpec.describe "Api::V0::Listings" do + shared_context "when user is authorized" do + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + let(:api_secret_org) { create(:api_secret, :org_admin) } + end + + shared_context "when param list is valid" do + let(:listing_params) do + { + title: "Title", + body_markdown: "Markdown text", + category: "cfp", + tags: [], + contact_via_connect: true + } + end + let(:draft_params) do + { + title: "Title draft", + body_markdown: "Markdown draft text", + category: "cfp", + tags: [], + contact_via_connect: true, + action: "draft" + } + end + end + + shared_context "when user has enough credit" do + before do + create_list(:credit, 25, user: user) + end + end + + shared_context "with 7 listings and 2 user" do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + + before do + create_list(:classified_listing, 3, user: user1, category: "cfp") + create_list(:classified_listing, 4, user: user2) + end + end + + def json_response + JSON.parse(response.body) + end + + describe "GET /api/listings" do + include_context "with 7 listings and 2 user" + + it "returns json response and ok status" do + get api_classified_listings_path + expect(response.content_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "returns listings created" do + get api_classified_listings_path + expect(json_response.size).to eq(7) + expect(json_response.first["type_of"]).to eq("classified_listing") + expect(json_response.first["slug"]).to eq(ClassifiedListing.last.slug) + expect(json_response.first["user"]).to include("username") + expect(json_response.first["user"]["username"]).not_to be_empty + end + + it "supports pagination" do + get api_classified_listings_path, params: { page: 2, per_page: 2 } + expect(json_response.length).to eq(2) + get api_classified_listings_path, params: { page: 4, per_page: 2 } + expect(json_response.length).to eq(1) + end + end + + describe "GET /api/listings/category/:category" do + include_context "with 7 listings and 2 user" + + it "displays only listings from the cfp category" do + get api_classified_listings_category_path("cfp") + expect(response).to have_http_status(:ok) + expect(json_response.size).to eq(3) + end + end + + describe "GET /api/listings/:id" do + include_context "with 7 listings and 2 user" + let(:listing) { ClassifiedListing.where(category: "cfp").last } + + it "displays a unique listing" do + get api_classified_listing_path(listing.id) + expect(response).to have_http_status(:ok) + expect(json_response["type_of"]).to eq("classified_listing") + expect(json_response["slug"]).to eq(listing.slug) + expect(json_response["user"]).to include("username") + expect(json_response["user"]["username"]).not_to be_empty + end + end + + describe "POST /api/listings" do + def post_classified_listing(key: api_secret.secret, **params) + headers = { "api-key" => key, "content-type" => "application/json" } + post api_classified_listings_path, params: { classified_listing: params }.to_json, headers: headers + end + + def user_admin_organization(user) + org = create(:organization) + create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "admin") + org + end + + describe "user cannot proceed if not properly unauthorizedœ" do + let(:api_secret) { create(:api_secret) } + + it "fails with no api key" do + post api_classified_listings_path, headers: { "content-type" => "application/json" } + expect(response). to have_http_status(:unauthorized) + end + + it "fails with the wrong api key" do + post api_classified_listings_path, headers: { "api-key" => "foobar", "content-type" => "application/json" } + expect(response). to have_http_status(:unauthorized) + end + end + + describe "user must have enough credit to create a classified listing" do + include_context "when user is authorized" + + it "fails to create a classified listing if user does not have enough credit" do + post_classified_listing(category: "cfp") + expect(response).to have_http_status(:payment_required) + end + + it "fails to create a classifiedlisting if the org does not have enough credit" do + org = user_admin_organization(user) + post_classified_listing(category: "cfp", organization_id: org.id) + expect(response).to have_http_status(:payment_required) + end + end + + describe "user cannot create a classified with a request lacking mandatory parameters" do + let(:invalid_params) { { title: "Title", category: "cfp" } } + + include_context "when user is authorized" + include_context "when user has enough credit" + + it "fails if no params are given" do + post_classified_listing + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if a mandatory param is missing" do + post_classified_listing(invalid_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "does not subtract credits or create a listing if the listing is not valid" do + expect do + post_classified_listing(invalid_params) + end.to change(ClassifiedListing, :count).by(0).and change(user.credits.spent, :size).by(0) + end + end + + describe "user creates classified listings" do + include_context "when user is authorized" + include_context "when param list is valid" + include_context "when user has enough credit" + + it "supports oauth's access_token" do + access_token = create(:doorkeeper_access_token, resource_owner_id: user.id) + headers = { "authorization" => "Bearer #{access_token.token}", "content-type" => "application/json" } + + post api_classified_listings_path, params: { classified_listing: listing_params }.to_json, headers: headers + expect(response).to have_http_status(:created) + end + + it "properly deducts the amount of credits" do + post_classified_listing(listing_params) + listing_cost = ClassifiedListing.categories_available[:cfp][:cost] + expect(user.credits.spent.size).to eq(listing_cost) + end + + it "creates a listing draft under the org" do + org_admin = api_secret_org.user + org_id = org_admin.organizations.first.id + Credit.create(organization_id: org_id) + post_classified_listing(key: api_secret_org.secret, **draft_params.merge(organization_id: org_id)) + expect(ClassifiedListing.first.organization_id).to eq org_id + end + + it "creates a listing under the org" do + org = user_admin_organization(user) + Credit.create(organization_id: org.id) + post_classified_listing(listing_params.merge(organization_id: org.id)) + expect(ClassifiedListing.first.organization_id).to eq org.id + end + + it "does not create a listing draft for an org not belonging to the user" do + org = create(:organization) + expect { post_classified_listing(draft_params.merge(organization_id: org.id)) }.to raise_error(Pundit::NotAuthorizedError) + end + + it "does not create a listing for an org not belonging to the user" do + org = create(:organization) + expect { post_classified_listing(listing_params.merge(organization_id: org.id)) }.to raise_error(Pundit::NotAuthorizedError) + end + + it "assigns the spent credits to the listing" do + post_classified_listing(listing_params) + spent_credit = user.credits.spent.last + expect(spent_credit.purchase_type).to eq("ClassifiedListing") + expect(spent_credit.spent_at).not_to be_nil + end + + it "cannot create a draft due to internal error" do + listing = create(:classified_listing, user_id: user.id) + allow(ClassifiedListing).to receive_messages(cost_by_category: nil, new: listing) + allow(Organization).to receive(:find_by) + allow(listing).to receive(:save) do + listing.errors.add(:base) + false + end + post_classified_listing(draft_params) + expect(json_response["errors"]["base"]).to eq(["is invalid"]) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "creates listing draft and does not subtract credits" do + allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback) + expect do + post_classified_listing(draft_params) + end.to change(ClassifiedListing, :count).by(1). + and change(user.credits.spent, :size).by(0) + end + + it "does not create a listing or subtract credits if the purchase does not go through" do + allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback) + expect do + post_classified_listing(listing_params) + end.to change(ClassifiedListing, :count).by(0). + and change(user.credits.spent, :size).by(0) + end + + it "creates a classified listing belonging to the user" do + expect do + post_classified_listing(listing_params) + expect(response).to have_http_status(:created) + end.to change(ClassifiedListing, :count).by(1) + expect(ClassifiedListing.find(json_response["id"]).user).to eq(user) + end + + it "creates a classified listing with a title, a body markdown, a category" do + expect do + post_classified_listing(listing_params) + expect(response).to have_http_status(:created) + end.to change(ClassifiedListing, :count).by(1) + expect(ClassifiedListing.find(json_response["id"]).title).to eq("Title") + expect(ClassifiedListing.find(json_response["id"]).body_markdown).to eq("Markdown text") + expect(ClassifiedListing.find(json_response["id"]).category).to eq("cfp") + end + + it "creates a classified listing with a location" do + params = listing_params.merge(location: "Frejus") + expect do + post_classified_listing(params) + expect(response).to have_http_status(:created) + end.to change(ClassifiedListing, :count).by(1) + expect(ClassifiedListing.find(json_response["id"]).location).to eq("Frejus") + end + + it "creates a classified listing with a list of tags and a contact" do + params = listing_params.merge(tags: %w[discuss javascript], contact_via_connect: true) + expect do + post_classified_listing(params) + expect(response).to have_http_status(:created) + end.to change(ClassifiedListing, :count).by(1) + expect(ClassifiedListing.find(json_response["id"]).cached_tag_list).to eq("discuss, javascript") + expect(ClassifiedListing.find(json_response["id"]).contact_via_connect).to eq(true) + end + end + end + + describe "PUT /api/listings/:id" do + def put_classified_listing(id, **params) + headers = { "api-key" => api_secret.secret, "content-type" => "application/json" } + put api_classified_listing_path(id), params: { classified_listing: params }.to_json, headers: headers + end + + let(:user) { create(:user) } + let(:another_user) { create(:user) } + let(:listing) { create(:classified_listing, user_id: user.id) } + let(:another_user_listing) { create(:classified_listing, user_id: another_user.id) } + let(:listing_draft) { create(:classified_listing, user: user) } + let(:organization) { create(:organization) } + let(:org_listing) { create(:classified_listing, user: user, organization: organization) } + let(:org_listing_draft) { create(:classified_listing, user: user, organization: organization) } + + before do + listing_draft.update_columns(bumped_at: nil, published: false) + org_listing_draft.update_columns(bumped_at: nil, published: false) + end + + describe "user cannot proceed if not properly unauthorizedœ" do + let(:api_secret) { create(:api_secret) } + + it "fails with no api key" do + put api_classified_listing_path(listing.id), headers: { "content-type" => "application/json" } + expect(response).to have_http_status(:unauthorized) + end + + it "fails with the wrong api key" do + put api_classified_listing_path(listing.id), headers: { "api-key" => "foobar", "content-type" => "application/json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized user has no credit" do + include_context "when user is authorized" + + it "fails to bump a classified listing" do + previous_bumped_at = listing.bumped_at + put_classified_listing(listing.id, action: "bump") + expect(response).to have_http_status(:payment_required) + expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i) + end + + it "does not subtract spent credits if the user has not enough credits" do + expect do + put_classified_listing(listing.id, action: "bump") + end.to change(user.credits.spent, :size).by(0) + end + end + + context "when the bump action is called" do + include_context "when user is authorized" + include_context "when user has enough credit" + + let(:params) { { classified_listing: { action: "bump" } } } + + it "does not bump the listing or subtract credits if the purchase does not go through" do + previous_bumped_at = listing.bumped_at + allow(Credits::Buyer).to receive(:call).and_raise(ActiveRecord::Rollback) + expect do + put_classified_listing(listing.id, action: "bump") + end.to change(user.credits.spent, :size).by(0) + expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i) + end + + it "bumps the listing and subtract credits" do + cost = ClassifiedListing.cost_by_category(listing.category) + create_list(:credit, cost, user: user) + previous_bumped_at = listing.bumped_at + expect do + put_classified_listing(listing.id, action: "bump") + end.to change(user.credits.spent, :size).by(cost) + expect(listing.reload.bumped_at >= previous_bumped_at).to eq(true) + end + + it "bumps the org listing using org credits before user credits" do + cost = ClassifiedListing.cost_by_category(org_listing.category) + create_list(:credit, cost, organization: organization) + create_list(:credit, cost, user: user) + previous_bumped_at = org_listing.bumped_at + expect do + put_classified_listing(org_listing.id, action: "bump") + end.to change(organization.credits.spent, :size).by(cost) + expect(org_listing.reload.bumped_at >= previous_bumped_at).to eq(true) + end + + it "bumps the org listing using user credits if org credits insufficient and user credits are" do + cost = ClassifiedListing.cost_by_category(org_listing.category) + create_list(:credit, cost, user: user) + previous_bumped_at = org_listing.bumped_at + expect do + put_classified_listing(org_listing.id, action: "bump") + end.to change(user.credits.spent, :size).by(cost) + expect(org_listing.reload.bumped_at >= previous_bumped_at).to eq(true) + end + end + + context "when the publish action is called" do + include_context "when user is authorized" + + it "publishes a draft and charges user credits if first publish" do + cost = ClassifiedListing.cost_by_category(listing_draft.category) + create_list(:credit, cost, user: user) + expect do + put_classified_listing(listing_draft.id, action: "publish") + end.to change(user.credits.spent, :size).by(cost) + end + + it "publishes a draft and ensures published column is true" do + cost = ClassifiedListing.cost_by_category(listing_draft.category) + create_list(:credit, cost, user: user) + put_classified_listing(listing_draft.id, action: "publish") + expect(listing_draft.reload.published).to eq(true) + end + + it "publishes an org draft and charges org credits if first publish" do + cost = ClassifiedListing.cost_by_category(org_listing_draft.category) + create_list(:credit, cost, organization: organization) + expect do + put_classified_listing(org_listing_draft.id, action: "publish") + end.to change(organization.credits.spent, :size).by(cost) + end + + it "publishes an org draft and ensures published column is true" do + cost = ClassifiedListing.cost_by_category(org_listing_draft.category) + create_list(:credit, cost, organization: organization) + put_classified_listing(org_listing_draft.id, action: "publish") + expect(org_listing_draft.reload.published).to eq(true) + end + + it "publishes a draft that was charged and is within 30 days of bump doesn't charge credits" do + listing.update_column(:published, false) + expect do + put_classified_listing(listing.id, action: "publish") + end.to change(user.credits.spent, :size).by(0) + end + + it "publishes a draft that was charged and is within 30 days of bump and successfully sets published as true" do + listing.update_column(:published, false) + put_classified_listing(listing.id, action: "publish") + expect(listing.reload.published).to eq(true) + end + end + + context "when the publish action is called without credit" do + include_context "when user is authorized" + + it "fails to publish draft and doesn't charge credits" do + expect do + put_classified_listing(listing_draft.id, action: "publish") + end.to change(user.credits.spent, :size).by(0) + end + + it "fails to publish draft and published remains false" do + put_classified_listing(listing_draft.id, action: "publish") + expect(listing_draft.reload.published).to eq(false) + end + end + + context "when user is authorized and has credit to update one of his listing" do + include_context "when user is authorized" + include_context "when user has enough credit" + + it "returns HTTP 422 if no params given" do + put_classified_listing(listing.id) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "updates the title of his listing" do + put_classified_listing(listing.id, title: "This is a new title") + expect(response).to have_http_status(:ok) + expect(listing.reload.title).to eq "This is a new title" + end + + it "updates the tags" do + put_classified_listing(listing.id, tags: %w[golang api]) + expect(response).to have_http_status(:ok) + expect(listing.reload.cached_tag_list).to eq "golang, api" + end + + it "unpublishes the listing" do + expect do + put_classified_listing(listing.id, action: "unpublish") + listing.reload + end.to change(listing, :published).from(true).to(false) + expect(response).to have_http_status(:ok) + end + + it "cannot update another user listing" do + expect do + put_classified_listing(another_user_listing.id, title: "Test for a new title") + end.to raise_error(Pundit::NotAuthorizedError) + end + end + end +end