Listings creation and update API (#4014)
* Listings creation and update API - new listings controller - refactored code to share core functionnality between api and web controllers - code in common in classified listing concerns - added tests for index/create/update - json output as views - updated a factory * Listings creation and update API: code update - refactored json calls - added test cases - added pagination for list of listings - refactored jbuilder files - typos * Listings creation and update API: code update - tags and tag_list in controller - changed view to display both - refactored and added test * Listings creation and update API: code update - small change to resolve conflit * Listings creation and update API: code update - added test case - deleted unneeded test case
This commit is contained in:
parent
f172856ec1
commit
70631ad2f7
16 changed files with 796 additions and 144 deletions
76
app/controllers/api/v0/classified_listings_controller.rb
Normal file
76
app/controllers/api/v0/classified_listings_controller.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
app/views/api/v0/classified_listings/_listing.json.jbuilder
Normal file
11
app/views/api/v0/classified_listings/_listing.json.jbuilder
Normal file
|
|
@ -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
|
||||
8
app/views/api/v0/classified_listings/index.json.jbuilder
Normal file
8
app/views/api/v0/classified_listings/index.json.jbuilder
Normal file
|
|
@ -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
|
||||
7
app/views/api/v0/classified_listings/show.json.jbuilder
Normal file
7
app/views/api/v0/classified_listings/show.json.jbuilder
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
481
spec/requests/api/v0/classified_listings_spec.rb
Normal file
481
spec/requests/api/v0/classified_listings_spec.rb
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue