docbrown/spec/requests/api/v1/listings_spec.rb
Fernando Valverde 8c836430ca
API V1 transition (#17835)
* API Articles v0-v1 restructure

* Remove unused helper

* Bulk move API controllers into concerns + add V1 controllers

* Extract API routes + some fixes

* Fix v1 api_controller authenticate! + add more article_controller specs

* Completed spec/requests/api/v1/articles_spec.rb

* specs up to listings

* All v1 specs except for 9 skips

* mime_types cleanup + authenticate! relocation

Co-authored-by: Fernando Valverde <fernando@visualcosita.com>
2022-06-23 14:26:00 -06:00

693 lines
25 KiB
Ruby

require "rails_helper"
RSpec.describe "Api::V1::Listings", type: :request do
let(:cfp_category) do
create(:listing_category, :cfp)
end
let(:edu_category) do
create(:listing_category)
end
let!(:v1_headers) { { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } }
before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) }
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) }
let(:headers) { v1_headers.merge({ "api-key" => api_secret }) }
end
shared_context "when param list is valid" do
let(:listing_params) do
{
title: "Title",
body_markdown: "Markdown text",
category: cfp_category.slug
}
end
let(:draft_params) do
{
title: "Title draft",
body_markdown: "Markdown draft text",
category: cfp_category.slug,
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(:listing, 3, user: user1, listing_category: cfp_category)
create_list(:listing, 4, user: user2, listing_category: edu_category)
end
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 "GET /api/listings" do
include_context "with 7 listings and 2 user"
xcontext "when unauthenticated" do
it "returns unauthorized" do
get api_listings_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" }
expect(response).to have_http_status(:unauthorized)
end
end
xcontext "when unauthorized" do
it "returns unauthorized" do
get api_listings_path, headers: v1_headers.merge({ "api-key" => "invalid api key" })
expect(response).to have_http_status(:unauthorized)
end
end
context "when authorized" do
include_context "when user is authorized"
it "returns json response and ok status" do
get api_listings_path, headers: headers
expect(response.media_type).to eq("application/json")
expect(response).to have_http_status(:ok)
end
it "returns listings created" do
get api_listings_path, headers: headers
expect(response.parsed_body.size).to eq(7)
expect(response.parsed_body.first["type_of"]).to eq("listing")
expect(response.parsed_body.first["slug"]).to eq(Listing.last.slug)
expect(response.parsed_body.first["user"]).to include("username")
expect(response.parsed_body.first["user"]["username"]).not_to be_empty
end
it "supports pagination" do
get api_listings_path, params: { page: 2, per_page: 2 }, headers: headers
expect(response.parsed_body.length).to eq(2)
get api_listings_path, params: { page: 4, per_page: 2 }, headers: headers
expect(response.parsed_body.length).to eq(1)
end
it "sets the correct caching headers" do
get api_listings_path, headers: headers
expect(response.headers["cache-control"]).to be_present
expect(response.headers["x-accel-expires"]).to be_present
expect(response.headers["surrogate-control"]).to match(/max-age/).and(match(/stale-if-error/))
end
it "sets the correct edge caching surrogate key" do
get api_listings_path, headers: headers
expected_key = (
["classified_listings"] +
user1.listings.map(&:record_key) +
user2.listings.map(&:record_key)
).to_set
expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key)
end
it "does not return unpublished listings" do
listing = user1.listings.last
listing.update(published: false)
get api_listings_path, headers: headers
expect(response.parsed_body.detect { |l| l["published"] == false }).to be_nil
end
# Regression test for https://github.com/forem/forem/issues/14436
it "includes the created at timestamp for listings" do
get api_listings_path, headers: headers
listing = response.parsed_body.first
expect(listing["created_at"]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/)
end
end
end
describe "GET /api/listings/category/:category" do
include_context "with 7 listings and 2 user"
xcontext "when unauthenticated" do
it "returns unauthorized" do
get api_listings_category_path("cfp"), headers: v1_headers
expect(response).to have_http_status(:unauthorized)
end
end
xcontext "when unauthorized" do
it "returns unauthorized" do
get api_listings_category_path("cfp"), headers: v1_headers.merge({ "api-key" => "invalid api key" })
expect(response).to have_http_status(:unauthorized)
end
end
context "when authorized" do
include_context "when user is authorized"
it "displays only listings from the cfp category" do
get api_listings_category_path("cfp"), headers: headers
expect(response).to have_http_status(:ok)
expect(response.parsed_body.size).to eq(3)
end
it "does not return unpublished listings" do
category = "cfp"
listing = user1.listings.in_category(category).last
listing.update(published: false)
get api_listings_category_path(category), headers: headers
expect(response.parsed_body.detect { |l| l["published"] == false }).to be_nil
end
end
end
describe "GET /api/listings/:id" do
include_context "with 7 listings and 2 user"
let(:listing) { Listing.in_category("cfp").last }
xcontext "when unauthenticated" do
it "returns unauthorized" do
listing.update(published: true)
get api_listing_path(listing.id), headers: v1_headers
expect(response).to have_http_status(:unauthorized)
end
end
xcontext "when unauthorized" do
it "returns unauthorized" do
listing.update(published: true)
get api_listing_path(listing.id), headers: v1_headers.merge({ "api-key" => "invalid api key" })
expect(response).to have_http_status(:unauthorized)
end
end
context "when authorized" do
include_context "when user is authorized"
it "returns a published listing" do
listing.update(published: true)
get api_listing_path(listing.id), headers: headers
expect(response).to have_http_status(:ok)
end
it "does not return an unpublished listing belonging to another user" do
listing.update(published: false, user: user1)
get api_listing_path(listing.id), headers: headers
expect(response).to have_http_status(:not_found)
end
xit "returns an unpublished listing belonging to the authenticated user" do
listing.update(published: false, user: api_secret.user)
get api_listing_path(listing.id), headers: headers
expect(response).to have_http_status(:ok)
end
it "returns the correct listing format" do
get api_listing_path(listing.id), headers: headers
expect(response).to have_http_status(:ok)
expect(response.parsed_body["type_of"]).to eq("listing")
expect(response.parsed_body["slug"]).to eq(listing.slug)
expect(response.parsed_body["user"]).to include("username")
expect(response.parsed_body["user"]["username"]).not_to be_empty
end
it "sets the correct caching headers" do
get api_listing_path(listing.id), headers: headers
expect(response.headers["cache-control"]).to be_present
expect(response.headers["x-accel-expires"]).to be_present
expect(response.headers["surrogate-control"]).to match(/max-age/).and(match(/stale-if-error/))
end
it "sets the correct edge caching surrogate key" do
get api_listing_path(listing.id), headers: headers
expected_key = [listing.record_key].to_set
expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key)
end
end
end
describe "POST /api/listings" do
def post_listing(key: api_secret.secret, **params)
headers = v1_headers.merge({ "api-key" => key })
post api_listings_path, params: { listing: params }.to_json, headers: headers
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_listings_path, headers: v1_headers
expect(response).to have_http_status(:unauthorized)
end
it "fails with the wrong api key" do
post api_listings_path, headers: v1_headers.merge({ "api-key" => "foobar" })
expect(response).to have_http_status(:unauthorized)
end
end
describe "user must have enough credit to create a listing" do
include_context "when user is authorized"
include_context "when param list is valid"
it "fails to create a listing if user does not have enough credit" do
post_listing(**listing_params)
expect(response).to have_http_status(:payment_required)
end
it "fails to create a listing if the org does not have enough credit" do
org = user_admin_organization(user)
post_listing(
**listing_params,
organization_id: org.id,
)
expect(response).to have_http_status(:payment_required)
end
end
describe "user cannot create a with a request lacking mandatory parameters" do
let(:invalid_params) do
{
title: "Title",
category: "cfp",
listing_category: cfp_category
}
end
include_context "when user is authorized"
include_context "when user has enough credit"
it "fails if no params are given" do
post_listing
expect(response).to have_http_status(:unprocessable_entity)
end
it "fails if body_markdown is missing" do
post_listing(**invalid_params)
expect(response).to have_http_status(:unprocessable_entity)
end
it "fails if category is missing" do
post_listing(title: "Title", body_markdown: "body")
expect(response).to have_http_status(:unprocessable_entity)
end
it "fails if category is invalid" do
post_listing(title: "Title", body_markdown: "body", category: "unknown")
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body.dig("errors", "listing_category").first)
.to match(/must exist/)
end
it "does not subtract credits or create a listing if the listing is not valid" do
expect do
post_listing(**invalid_params)
end.to not_change(Listing, :count).and not_change(user.credits.spent, :size)
end
end
describe "user creates listings" do
include_context "when user is authorized"
include_context "when param list is valid"
include_context "when user has enough credit"
it "properly deducts the amount of credits" do
post_listing(**listing_params)
expect(response).to have_http_status(:created)
expect(user.credits.spent.size).to eq(cfp_category.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_listing(key: api_secret_org.secret, **draft_params.merge(organization_id: org_id))
expect(Listing.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_listing(**listing_params.merge(organization_id: org.id))
expect(Listing.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 do
post_listing(**draft_params.merge(organization_id: org.id))
expect(response).to have_http_status(:unauthorized)
end.not_to change(Listing, :count)
end
it "does not create a listing for an org not belonging to the user" do
org = create(:organization)
expect do
post_listing(**listing_params.merge(organization_id: org.id))
expect(response).to have_http_status(:unauthorized)
end.not_to change(Listing, :count)
end
it "assigns the spent credits to the listing" do
post_listing(**listing_params)
spent_credit = user.credits.spent.last
expect(spent_credit.purchase_type).to eq("Listing")
expect(spent_credit.spent_at).not_to be_nil
end
it "cannot create a draft due to internal error" do
allow(Organization).to receive(:find_by)
post_listing(**draft_params.except(:category))
expect(response.parsed_body.dig("errors", "listing_category").first)
.to match(/must exist/)
expect(response).to have_http_status(:unprocessable_entity)
end
it "creates listing draft and does not subtract credits" do
allow(Credits::Buy).to receive(:call).and_raise(ActiveRecord::Rollback)
expect do
post_listing(**draft_params)
end.to change(Listing, :count).by(1)
.and not_change(user.credits.spent, :size)
end
it "does not create a listing or subtract credits if the purchase does not go through" do
allow(Credits::Buy).to receive(:call).and_raise(ActiveRecord::Rollback)
expect do
post_listing(**listing_params)
end.to not_change(Listing, :count)
.and not_change(user.credits.spent, :size)
end
it "creates a listing belonging to the user" do
expect do
post_listing(**listing_params)
expect(response).to have_http_status(:created)
end.to change(Listing, :count).by(1)
expect(Listing.find(response.parsed_body["id"]).user).to eq(user)
end
it "creates a listing with a title, a body markdown, a category" do
expect do
post_listing(**listing_params)
expect(response).to have_http_status(:created)
end.to change(Listing, :count).by(1)
listing = Listing.find(response.parsed_body["id"])
expect(listing.title).to eq(listing_params[:title])
expect(listing.body_markdown).to eq(listing_params[:body_markdown])
expect(listing.category).to eq(cfp_category.slug)
end
it "creates a listing with a location" do
params = listing_params.merge(location: "Frejus")
expect do
post_listing(**params)
expect(response).to have_http_status(:created)
end.to change(Listing, :count).by(1)
expect(Listing.find(response.parsed_body["id"]).location).to eq("Frejus")
end
it "creates a listing with a list of tags" do
params = listing_params.merge(tags: %w[discuss javascript])
expect do
post_listing(**params)
expect(response).to have_http_status(:created)
end.to change(Listing, :count).by(1)
listing = Listing.find(response.parsed_body["id"])
expect(listing.cached_tag_list).to eq("discuss, javascript")
end
it "reads the 'classified_listing' key when listing not present" do
# this is like post_listing but uses the "classified_listing" key
key = api_secret.secret
headers = { "api-key" => key, "content-type" => "application/json" }
expect do
post api_listings_path, params: { classified_listing: listing_params }.to_json, headers: headers
expect(response).to have_http_status(:created)
end.to change(Listing, :count).by(1)
end
end
end
describe "PUT /api/listings/:id" do
def put_listing(id, **params)
headers = v1_headers.merge({ "api-key" => api_secret.secret })
put api_listing_path(id), params: { classified_listing: params }.to_json, headers: headers
end
let(:user) { create(:user) }
let(:another_user) { create(:user) }
let!(:listing) { create(:listing, user: user) }
let(:another_user_listing) { create(:listing, user_id: another_user.id) }
let(:listing_draft) { create(:listing, user: user) }
let(:organization) { create(:organization) }
let(:org_listing) { create(:listing, user: user, organization: organization) }
let(:org_listing_draft) { create(: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_listing_path(listing.id), headers: v1_headers
expect(response).to have_http_status(:unauthorized)
end
it "fails with the wrong api key" do
put api_listing_path(listing.id), headers: v1_headers.merge({ "api-key" => "foobar" })
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 listing" do
previous_bumped_at = listing.bumped_at
put_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_listing(listing.id, action: "bump")
end.not_to change(user.credits.spent, :size)
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) { { 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::Buy).to receive(:call).and_raise(ActiveRecord::Rollback)
expect do
put_listing(listing.id, action: "bump")
end.not_to change(user.credits.spent, :size)
expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i)
end
it "bumps the listing and subtract credits" do
cost = listing.cost
create_list(:credit, cost, user: user)
previous_bumped_at = listing.bumped_at
expect do
put_listing(listing.id, action: "bump")
end.to change(user.credits.spent, :size).by(cost)
expect(listing.reload.bumped_at >= previous_bumped_at).to be(true)
end
it "bumps the org listing using org credits before user credits" do
cost = org_listing.cost
create_list(:credit, cost, organization: organization)
create_list(:credit, cost, user: user)
previous_bumped_at = org_listing.bumped_at
expect do
put_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 be(true)
end
it "bumps the org listing using user credits if org credits insufficient and user credits are" do
cost = org_listing.cost
create_list(:credit, cost, user: user)
previous_bumped_at = org_listing.bumped_at
expect do
put_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 be(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 = listing_draft.cost
create_list(:credit, cost, user: user)
expect do
put_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 = listing_draft.cost
create_list(:credit, cost, user: user)
put_listing(listing_draft.id, action: "publish")
expect(listing_draft.reload.published).to be(true)
end
it "publishes an org draft and charges org credits if first publish" do
cost = org_listing_draft.cost
create_list(:credit, cost, organization: organization)
expect do
put_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 = org_listing_draft.cost
create_list(:credit, cost, organization: organization)
put_listing(org_listing_draft.id, action: "publish")
expect(org_listing_draft.reload.published).to be(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_listing(listing.id, action: "publish")
end.not_to change(user.credits.spent, :size)
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_listing(listing.id, action: "publish")
expect(listing.reload.published).to be(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_listing(listing_draft.id, action: "publish")
end.not_to change(user.credits.spent, :size)
end
it "fails to publish draft and published remains false" do
put_listing(listing_draft.id, action: "publish")
expect(listing_draft.reload.published).to be(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 "fails if no params have been given" do
put_listing(listing.id)
expect(response).to have_http_status(:unprocessable_entity)
end
it "fails if category is invalid" do
max_id = ListingCategory.maximum(:id)
put_listing(listing.id, title: "New title", listing_category_id: max_id + 1)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body.dig("errors", "listing_category").first)
.to match(/must exist/)
end
it "updates the title of his listing" do
put_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_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_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
put_listing(another_user_listing.id, title: "Test for a new title")
expect(response).to have_http_status(:unauthorized)
end
it "updates details if the listing has been bumped in the last 24 hours" do
listing.update!(bumped_at: 3.minutes.ago)
new_title = Faker::Book.title
put_listing(listing.id, title: new_title)
expect(listing.reload.title).to eq(new_title)
end
it "does not update details if the listing hasn't been bumped in the last 24 hours" do
listing.update!(bumped_at: 24.hours.ago)
new_title = Faker::Book.title
put_listing(listing.id, title: new_title)
expect(listing.reload.title).to eq(listing.title)
end
it "does not update a published listing" do
listing.update!(bumped_at: nil, published: true)
old_title = listing.title
new_title = Faker::Book.title
put_listing(listing.id, title: new_title)
expect(listing.reload.title).to eq(old_title)
end
end
end
end