diff --git a/app/assets/javascripts/initializePage.js.erb b/app/assets/javascripts/initializePage.js.erb index ddfcdb3ee..317839055 100644 --- a/app/assets/javascripts/initializePage.js.erb +++ b/app/assets/javascripts/initializePage.js.erb @@ -41,6 +41,7 @@ function callInitalizers(){ initializePWAFunctionality(); initializeEllipsisMenu(); initializeArchivedPostFilter(); + initializeCreditsPage(); initializeDrawerSliders(); diff --git a/app/assets/javascripts/initializers/initializeCreditsPage.js b/app/assets/javascripts/initializers/initializeCreditsPage.js new file mode 100644 index 000000000..9d7fb6723 --- /dev/null +++ b/app/assets/javascripts/initializers/initializeCreditsPage.js @@ -0,0 +1,13 @@ +/* global localizeTimeElements */ +function initializeCreditsPage() { + const datetimes = document.querySelectorAll('.ledger time'); + + localizeTimeElements(datetimes, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} diff --git a/app/assets/javascripts/utilities/localDateTime.js b/app/assets/javascripts/utilities/localDateTime.js index 07ab763d2..ad3bc5606 100644 --- a/app/assets/javascripts/utilities/localDateTime.js +++ b/app/assets/javascripts/utilities/localDateTime.js @@ -55,3 +55,20 @@ function addLocalizedDateTimeToElementsTitles(elements, timestampAttribute) { } } } + +function localizeTimeElements(elements, timeOptions) { + for (let i = 0; i < elements.length; i += 1) { + const element = elements[i]; + + const timestamp = element.getAttribute('datetime'); + if (timestamp) { + const localDateTime = timestampToLocalDateTime( + timestamp, + navigator.language, + timeOptions, + ); + + element.textContent = localDateTime; + } + } +} diff --git a/app/assets/stylesheets/credits.scss b/app/assets/stylesheets/credits.scss index e19615fc2..260512c7c 100644 --- a/app/assets/stylesheets/credits.scss +++ b/app/assets/stylesheets/credits.scss @@ -3,12 +3,14 @@ .credits-container { margin: 50px auto; + .notice { background: $green; padding: 30px 0px; margin-top: 68px; text-align: center; } + .credits-banner { background-color: $dark-purple; background-image: linear-gradient(141deg, $dark-purple 0%, #1f89db 51%, #2981e5 75%); @@ -33,7 +35,7 @@ font-size: 1.8em; } } - + form#new_credit { width: 500px; @@ -44,13 +46,13 @@ .InputElement { @include themeable( - background-color, - theme-container-background, + background-color, + theme-container-background, white ); @include themeable( - color, - theme-color, + color, + theme-color, $black ); } @@ -62,13 +64,13 @@ border: 1px solid transparent; border-radius: 4px; @include themeable( - background-color, - theme-container-background, + background-color, + theme-container-background, white ); @include themeable( - color, - theme-color, + color, + theme-color, $black ); @@ -196,13 +198,13 @@ } button#add-new-card { @include themeable( - background-color, - theme-container-background, + background-color, + theme-container-background, $light-gray ); @include themeable( - color, - theme-color, + color, + theme-color, $black ); font-size: 15px; @@ -221,8 +223,8 @@ } .credit-card-component { @include themeable( - background, - theme-container-accent-background, + background, + theme-container-accent-background, $lightest-gray ); border-radius: 3px; @@ -235,8 +237,8 @@ label.main-form-label { margin-bottom: 4px; @include themeable( - background, - theme-container-accent-background, + background, + theme-container-accent-background, $light-gray ); border-radius: 3px; @@ -254,6 +256,7 @@ padding: 30px; border-radius: 3px; text-align: center; + .existing-credits-status { font-size: 1.8em; font-weight: bold; @@ -263,7 +266,8 @@ font-size: initial; } } - a { + + > a { color: white; border: 3px solid white; padding: 10px 30px; @@ -271,4 +275,28 @@ font-weight: bold; } } -} \ No newline at end of file + + .ledger { + margin: 2rem auto; + text-align: right; + width: 600px; + + caption { + font-size: 1.2rem; + margin-bottom: 1.5rem; + } + + td { + padding-top: 1.1rem; + } + + .misc { + td { + padding-top: 1.5rem; + &:first-child { font-style: italic; } + } + } + + a { color: #fff; } + } +} diff --git a/app/controllers/classified_listings_controller.rb b/app/controllers/classified_listings_controller.rb index 2b9e534c3..3f04220a7 100644 --- a/app/controllers/classified_listings_controller.rb +++ b/app/controllers/classified_listings_controller.rb @@ -7,11 +7,18 @@ class ClassifiedListingsController < ApplicationController def index @displayed_classified_listing = ClassifiedListing.find_by!(slug: params[:slug]) if params[:slug] - mod_page if params[:view] == "moderate" + + if params[:view] == "moderate" + return redirect_to "/internal/listings/#{@displayed_classified_listing.id}/edit" + end + @classified_listings = if params[:category].blank? - ClassifiedListing.where(published: true).order("bumped_at DESC").limit(12) + ClassifiedListing.where(published: true). + order("bumped_at DESC"). + includes(:user, :organization, :taggings). + limit(12) else - [] + ClassifiedListing.none end set_surrogate_key_header "classified-listings-#{params[:category]}" end @@ -19,59 +26,55 @@ class ClassifiedListingsController < ApplicationController def new @classified_listing = ClassifiedListing.new @organizations = current_user.organizations - @credits = current_user.credits.where(spent: false) + @credits = current_user.credits.unspent end def edit authorize @classified_listing @organizations = current_user.organizations - @credits = current_user.credits.where(spent: false) + @credits = current_user.credits.unspent end def create @classified_listing = ClassifiedListing.new(listing_params) - @classified_listing.user_id = current_user.id - @number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) - @org = Organization.find_by(id: @classified_listing.organization_id) - available_org_credits = @org.credits.where(spent: false) if @org - available_individual_credits = current_user.credits.where(spent: false) - if @org && available_org_credits.size >= @number_of_credits_needed - create_listing(available_org_credits) - elsif available_individual_credits.size >= @number_of_credits_needed - create_listing(available_individual_credits) - else - redirect_to "/credits" - end - end - - def create_listing(credits) - @classified_listing.bumped_at = Time.current - @classified_listing.published = true # 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? - if @classified_listing.save - clear_listings_cache - credits.limit(@number_of_credits_needed).update_all(spent: true) - @classified_listing.index! - redirect_to "/listings" + + @classified_listing.user_id = current_user.id + cost = ClassifiedListing.cost_by_category(@classified_listing.category) + + org = Organization.find_by(id: @classified_listing.organization_id) + + 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 - @credits = current_user.credits.where(spent: false) - @classified_listing.cached_tag_list = listing_params[:tag_list] - @organizations = current_user.organizations - render :new + redirect_to credits_path, notice: "Not enough available credits" end end def update authorize @classified_listing - available_credits = current_user.credits.where(spent: false) - number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) # Bumping + + # NOTE: this should probably be split in three different actions: bump, unpublish, publish if listing_params[:action] == "bump" - bump_listing - if available_credits.size >= number_of_credits_needed - @classified_listing.save - available_credits.limit(number_of_credits_needed).update_all(spent: true) + cost = ClassifiedListing.cost_by_category(@classified_listing.category) + if current_user.credits.unspent.size >= cost + ActiveRecord::Base.transaction do + Credits::Buyer.call( + purchaser: current_user, + purchase: @classified_listing, + cost: cost, + ) + + raise ActiveRecord::Rollback unless bump_listing + end end elsif listing_params[:action] == "unpublish" unpublish_listing @@ -80,12 +83,15 @@ class ClassifiedListingsController < ApplicationController elsif listing_params[:body_markdown].present? && @classified_listing.bumped_at > 24.hours.ago update_listing_details end + clear_listings_cache redirect_to "/listings" end def dashboard - @classified_listings = current_user.classified_listings + @classified_listings = current_user.classified_listings. + includes(:organization, :taggings) + organizations_ids = current_user.organization_memberships. where(type_of_user: "admin"). pluck(:organization_id) @@ -96,8 +102,38 @@ class ClassifiedListingsController < ApplicationController private - def mod_page - redirect_to "/internal/listings/#{@displayed_classified_listing.id}/edit" + def create_listing(purchaser, cost) + successful_transaction = false + ActiveRecord::Base.transaction do + # substract 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 end def set_classified_listing diff --git a/app/controllers/concerns/classified_listings_toolkit.rb b/app/controllers/concerns/classified_listings_toolkit.rb index 130068377..f9a7c319e 100644 --- a/app/controllers/concerns/classified_listings_toolkit.rb +++ b/app/controllers/concerns/classified_listings_toolkit.rb @@ -24,7 +24,9 @@ module ClassifiedListingsToolkit def bump_listing @classified_listing.bumped_at = Time.current - @classified_listing.save + saved = @classified_listing.save + @classified_listing.index! if saved + saved end def clear_listings_cache diff --git a/app/controllers/credits_controller.rb b/app/controllers/credits_controller.rb index 717c5b337..148a4bd55 100644 --- a/app/controllers/credits_controller.rb +++ b/app/controllers/credits_controller.rb @@ -2,7 +2,9 @@ class CreditsController < ApplicationController before_action :authenticate_user! def index - @credits = current_user.credits.where(spent: false) + @user_unspent_credits_count = current_user.credits.unspent.size + @ledger = Credits::Ledger.call(current_user) + @organizations = current_user.admin_organizations end @@ -35,10 +37,10 @@ class CreditsController < ApplicationController end Credit.import credit_objects @purchaser.credits_count = @purchaser.credits.size - @purchaser.spent_credits_count = @purchaser.credits.where(spent: true).size - @purchaser.unspent_credits_count = @purchaser.credits.where(spent: false).size + @purchaser.spent_credits_count = @purchaser.credits.spent.size + @purchaser.unspent_credits_count = @purchaser.credits.unspent.size @purchaser.save - redirect_to "/credits", notice: "#{@number_to_purchase} new credits purchased!" + redirect_to credits_path, notice: "#{@number_to_purchase} new credits purchased!" end def make_payment @@ -49,7 +51,7 @@ class CreditsController < ApplicationController true rescue Stripe::CardError => e flash[:error] = e.message - redirect_to "/credits/purchase" + redirect_to purchase_credits_path false end diff --git a/app/models/classified_listing.rb b/app/models/classified_listing.rb index 519ad890e..8af8bf8bc 100644 --- a/app/models/classified_listing.rb +++ b/app/models/classified_listing.rb @@ -9,6 +9,7 @@ class ClassifiedListing < ApplicationRecord before_create :create_slug before_validation :modify_inputs acts_as_taggable_on :tags + has_many :credits, as: :purchase, inverse_of: :purchase, dependent: :nullify validates :user_id, presence: true validates :organization_id, presence: true, unless: :user_id? @@ -71,7 +72,7 @@ class ClassifiedListing < ApplicationRecord "forsale" => { cost: 1, name: "Stuff for Sale", rules: "Personally owned physical items for sale." }, "events" => { cost: 1, name: "Upcoming Events", rules: "In-person or online events with date included." }, "misc" => { cost: 1, name: "Miscellaneous", rules: "Must not fit in any other category." } - } + }.with_indifferent_access end def path diff --git a/app/models/credit.rb b/app/models/credit.rb index d25959951..731145200 100644 --- a/app/models/credit.rb +++ b/app/models/credit.rb @@ -1,8 +1,12 @@ class Credit < ApplicationRecord attr_accessor :number_to_purchase - belongs_to :user, optional: true - belongs_to :organization, optional: true + belongs_to :user, optional: true + belongs_to :organization, optional: true + belongs_to :purchase, polymorphic: true, optional: true + + scope :spent, -> { where(spent: true) } + scope :unspent, -> { where(spent: false) } counter_culture :user, column_name: proc { |model| "#{model.spent ? 'spent' : 'unspent'}_credits_count" }, diff --git a/app/services/credits/buyer.rb b/app/services/credits/buyer.rb new file mode 100644 index 000000000..6cf4dbe9a --- /dev/null +++ b/app/services/credits/buyer.rb @@ -0,0 +1,33 @@ +module Credits + class Buyer + def initialize(purchaser:, purchase:, cost:) + @purchaser = purchaser + @purchase = purchase + @cost = cost + end + + def self.call(*args) + new(*args).call + end + + def call + return false unless has_available_credits? + + purchaser.credits.unspent.limit(cost).update_all( + spent: true, + spent_at: Time.current, + purchase_type: purchase.class.name, + purchase_id: purchase.id, + ) + true + end + + private + + attr_reader :purchaser, :purchase, :cost + + def has_available_credits? + purchaser.credits.unspent.size >= cost + end + end +end diff --git a/app/services/credits/ledger.rb b/app/services/credits/ledger.rb new file mode 100644 index 000000000..2eed5b5fb --- /dev/null +++ b/app/services/credits/ledger.rb @@ -0,0 +1,69 @@ +module Credits + class Ledger + Item = Struct.new(:purchase, :cost, :purchased_at, keyword_init: true) + + def initialize(user) + @user = user + end + + def self.call(*args) + new(*args).call + end + + def call + # build the ledger for the user + ledger = { + [User.name, user.id] => build_ledger_for(user.credits) + } + + # build the ledger for the organizations the user is an admin at + user.admin_organizations.find_each do |org| + ledger[[Organization.name, org.id]] = build_ledger_for(org.credits) + end + + ledger + end + + private + + attr_reader :user + + def purchaseable + %w[ClassifiedListing] + end + + def load_purchases(credits) + credits.spent.select( + :purchase_id, + :purchase_type, + Arel.sql("COUNT(*)").as("cost"), + Arel.sql("MAX(spent_at)").as("purchased_at"), + ).group(:purchase_id, :purchase_type).order(purchased_at: :desc) + end + + def build_ledger_for(credits) + purchases = load_purchases(credits) + items = [] + + # to avoid N+1 on purchases, we load them by type separately + listings_purchases = purchases.select { |row| row.purchase_type == "ClassifiedListing" } + listings = ClassifiedListing.where(id: listings_purchases.map(&:purchase_id)) + listings_purchases.each do |purchase| + listing = listings.detect { |l| l.id == purchase.purchase_id } + items << Item.new( + purchase: listing, + cost: purchase.cost.to_i, + purchased_at: purchase.purchased_at, + ) + end + + # add items without a purchase association at the bottom + unassociated_purchase = purchases.reject(&:purchase_type).first + if unassociated_purchase + items << Item.new(cost: unassociated_purchase.cost.to_i) + end + + items + end + end +end diff --git a/app/views/credits/_ledger.html.erb b/app/views/credits/_ledger.html.erb new file mode 100644 index 000000000..922ab1c2a --- /dev/null +++ b/app/views/credits/_ledger.html.erb @@ -0,0 +1,39 @@ + + + + + + + + + + + + + <% for item in items %> + <% if item.purchase %> + + <% else %> + + <% end %> + + + + + <% end %> + +
Purchase history
ItemCost (credits)Date
+ <% if item.purchase %> + + <%= truncate(item.purchase.title, length: 20) %> + + <% else %> + Miscellaneous items + <% end %> + <%= item.cost %> + <% if item.purchased_at %> + + <% end %> +
diff --git a/app/views/credits/index.html.erb b/app/views/credits/index.html.erb index a61af860f..2620b249a 100644 --- a/app/views/credits/index.html.erb +++ b/app/views/credits/index.html.erb @@ -1,33 +1,47 @@ <% title "Credits" %>
-<% if flash[:notice].present? %> -
- <%= flash[:notice] %> -
-<% end %> + <% if flash[:notice].present? %> +
+ <%= flash[:notice] %> +
+ <% end %> +
-
You have <%= @credits.size %> credits to spend
- Purchase additional credits +
You have <%= @user_unspent_credits_count %> credits to spend
+ Purchase additional credits + + <% if @user_unspent_credits_count.positive? %> + <%= render "ledger", items: @ledger[["User", current_user.id]] %> + <% end %>
+ <% if @organizations.present? %> - <% @organizations.each do |org| %> + <% @organizations.find_each do |org| %>
<%= org.name %> has <%= org.unspent_credits_count %> credits to spend
- Purchase additional credits + + Purchase additional credits + + + <% if org.unspent_credits_count.positive? %> + <%= render "ledger", items: @ledger[["Organization", org.id]] %> + <% end %>
<% end %> <% end %> - <% if @credits.size > 0 %> + + <% if @user_unspent_credits_count.positive? %>

- Create a Listing + Create a Listing

+ <% if @organizations.present? %>

- View Partnership Opportunities + View Partnership Opportunities

<% end %>
diff --git a/config/routes.rb b/config/routes.rb index 2759f613d..2df2fc50c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -144,7 +144,9 @@ Rails.application.routes.draw do resources :rating_votes, only: [:create] resources :page_views, only: %i[create update] resources :classified_listings, path: :listings, only: %i[index new create edit update delete dashboard] - resources :credits, only: %i[index new create] + resources :credits, only: %i[index new create] do + get "purchase", on: :collection, to: "credits#new" + end resources :buffer_updates, only: [:create] resources :reading_list_items, only: [:update] resources :poll_votes, only: %i[show create] @@ -153,10 +155,9 @@ Rails.application.routes.draw do resources :partnerships, only: %i[index create] get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id" - get "/credits/purchase" => "credits#new" get "/listings/dashboard" => "classified_listings#dashboard" get "/listings/:category" => "classified_listings#index" - get "/listings/:category/:slug" => "classified_listings#index" + get "/listings/:category/:slug" => "classified_listings#index", as: :classified_listing_slug get "/listings/:category/:slug/:view" => "classified_listings#index", constraints: { view: /moderate/ } get "/notifications/:filter" => "notifications#index" diff --git a/db/migrate/20190704082551_add_purchase_to_credits.rb b/db/migrate/20190704082551_add_purchase_to_credits.rb new file mode 100644 index 000000000..bd5132a39 --- /dev/null +++ b/db/migrate/20190704082551_add_purchase_to_credits.rb @@ -0,0 +1,7 @@ +class AddPurchaseToCredits < ActiveRecord::Migration[5.2] + def change + add_column :credits, :purchase_id, :bigint + add_column :credits, :purchase_type, :string + add_index :credits, [:purchase_id, :purchase_type] + end +end diff --git a/db/migrate/20190704091636_add_index_to_credits_spent.rb b/db/migrate/20190704091636_add_index_to_credits_spent.rb new file mode 100644 index 000000000..a21112bb8 --- /dev/null +++ b/db/migrate/20190704091636_add_index_to_credits_spent.rb @@ -0,0 +1,7 @@ +class AddIndexToCreditsSpent < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def change + add_index :credits, :spent, algorithm: :concurrently + end +end diff --git a/db/migrate/20190704105143_add_spent_at_and_remove_spent_on_for_credits.rb b/db/migrate/20190704105143_add_spent_at_and_remove_spent_on_for_credits.rb new file mode 100644 index 000000000..cdcb7cde6 --- /dev/null +++ b/db/migrate/20190704105143_add_spent_at_and_remove_spent_on_for_credits.rb @@ -0,0 +1,6 @@ +class AddSpentAtAndRemoveSpentOnForCredits < ActiveRecord::Migration[5.2] + def change + add_column :credits, :spent_at, :datetime + remove_column :credits, :spent_on, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index bea0f6c75..1de83b0a2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_07_03_003817) do +ActiveRecord::Schema.define(version: 2019_07_04_105143) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -311,10 +311,14 @@ ActiveRecord::Schema.define(version: 2019_07_03_003817) do t.float "cost", default: 0.0 t.datetime "created_at", null: false t.bigint "organization_id" + t.bigint "purchase_id" + t.string "purchase_type" t.boolean "spent", default: false - t.string "spent_on" + t.datetime "spent_at" t.datetime "updated_at", null: false t.bigint "user_id" + t.index ["purchase_id", "purchase_type"], name: "index_credits_on_purchase_id_and_purchase_type" + t.index ["spent"], name: "index_credits_on_spent" end create_table "delayed_jobs", id: :serial, force: :cascade do |t| diff --git a/spec/factories/classified_listings.rb b/spec/factories/classified_listings.rb index 761ba87fe..562ae1f98 100644 --- a/spec/factories/classified_listings.rb +++ b/spec/factories/classified_listings.rb @@ -1,5 +1,6 @@ FactoryBot.define do factory :classified_listing do + user title { Faker::Book.title + rand(100).to_s } body_markdown { Faker::Hipster.paragraph(2) } category { "education" } diff --git a/spec/models/classified_listing_spec.rb b/spec/models/classified_listing_spec.rb index 83377660a..51f3991e1 100644 --- a/spec/models/classified_listing_spec.rb +++ b/spec/models/classified_listing_spec.rb @@ -7,6 +7,7 @@ RSpec.describe ClassifiedListing, type: :model do it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:body_markdown) } + it { is_expected.to have_many(:credits) } describe "valid associations" do it "is not valid w/o user and org" do @@ -55,4 +56,15 @@ RSpec.describe ClassifiedListing, type: :model do expect(classified_listing.tag_list).to eq(%w[thebest taglist]) end end + + describe "credits" do + it "does not destroy associated credits if destroyed" do + credit = create(:credit) + classified_listing.credits << credit + classified_listing.save! + + expect { classified_listing.destroy }.not_to change(Credit, :count) + expect(credit.reload.purchase).to be_nil + end + end end diff --git a/spec/models/credit_spec.rb b/spec/models/credit_spec.rb index db3f2faab..2c1c93f57 100644 --- a/spec/models/credit_spec.rb +++ b/spec/models/credit_spec.rb @@ -5,7 +5,13 @@ RSpec.describe Credit, type: :model do let(:organization) { create(:organization) } let(:random_number) { rand(100) } - it "counts credits for user" do + it { is_expected.to belong_to(:user).optional } + it { is_expected.to belong_to(:organization).optional } + it { is_expected.to belong_to(:purchase).optional } + + xit "counts credits for user" do + # See https://github.com/magnusvk/counter_culture/issues/259 + create_list(:credit, random_number, user: user) Credit.counter_culture_fix_counts expect(user.reload.credits_count).to eq(random_number) @@ -36,4 +42,18 @@ RSpec.describe Credit, type: :model do create_list(:credit, random_number, organization: organization, spent: true) expect(organization.reload.spent_credits_count).to eq(random_number) end + + describe "#purchase" do + let(:listing) { create(:classified_listing) } + + it "associates to a purchase" do + credit = create(:credit, purchase: listing) + expect(credit.purchase).to eq(listing) + end + + it "is valid without a purchase" do + credit = create(:credit, purchase: nil) + expect(credit).to be_valid + end + end end diff --git a/spec/requests/classified_listings_spec.rb b/spec/requests/classified_listings_spec.rb index 7eedadbc5..bb1ec1ae3 100644 --- a/spec/requests/classified_listings_spec.rb +++ b/spec/requests/classified_listings_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" RSpec.describe "ClassifiedListings", type: :request do let(:user) { create(:user) } - let(:valid_listing_params) do + let(:listing_params) do { classified_listing: { title: "something", @@ -75,8 +75,8 @@ RSpec.describe "ClassifiedListings", type: :request do end context "when the listing is invalid" do - it "renders errors with the listing" do - post "/listings", params: { + let(:invalid_params) do + { classified_listing: { title: "nothing", body_markdown: "", @@ -84,44 +84,120 @@ RSpec.describe "ClassifiedListings", type: :request do tag_list: "" } } + end + + it "renders errors with the listing" do + post "/listings", params: invalid_params expect(response.body).to include("prohibited this listing from being saved") end + it "does not subtract credits or create a listing if the listing is not valid" do + expect do + post "/listings", params: invalid_params + end.to change(ClassifiedListing, :count).by(0). + and change(user.credits.spent, :size).by(0) + end + end + + context "when the listing is valid" do it "redirects if the user does not have enough credits" do Credit.delete_all - post "/listings", params: valid_listing_params + post "/listings", params: listing_params expect(response.body).to redirect_to("/credits") end it "redirects if the org does not have enough credits" do org_admin = create(:user, :org_admin) - valid_listing_params[:classified_listing][:post_as_organization] = "1" + listing_params[:classified_listing][:post_as_organization] = "1" sign_in org_admin - post "/listings", params: valid_listing_params + post "/listings", params: listing_params expect(response.body).to redirect_to("/credits") end - end - context "when the listing is valid" do it "redirects to /listings" do - post "/listings", params: valid_listing_params + post "/listings", params: listing_params expect(response).to redirect_to "/listings" end it "properly deducts the amount of credits" do - post "/listings", params: valid_listing_params - expect(user.credits.where(spent: false).size).to eq 24 + post "/listings", params: listing_params + listing_cost = ClassifiedListing.categories_available[:cfp][:cost] + expect(user.credits.spent.size).to eq(listing_cost) end it "creates a listing under the org" do org_admin = create(:user, :org_admin) org_id = org_admin.organizations.first.id Credit.create(organization_id: org_id) - valid_listing_params[:classified_listing][:organization_id] = org_id + listing_params[:classified_listing][:organization_id] = org_id sign_in org_admin - post "/listings", params: valid_listing_params + post "/listings", params: listing_params expect(ClassifiedListing.first.organization_id).to eq org_id end + + it "does not create a listing for an org not belonging to the user" do + org = create(:organization) + listing_params[:classified_listing][:organization_id] = org.id + expect { post "/listings", params: listing_params }.to raise_error(Pundit::NotAuthorizedError) + end + + it "assigns the spent credits to the listing" do + post "/listings", params: 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 "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 "/listings", params: listing_params + end.to change(ClassifiedListing, :count).by(0). + and change(user.credits.spent, :size).by(0) + end + end + end + + describe "PUT /listings/:id" do + let(:listing) { create(:classified_listing, user: user) } + + before do + sign_in user + end + + context "when the bump action is called" do + let(:params) { { classified_listing: { action: "bump" } } } + + it "does not bump the listing if the use has not enough credits" do + previous_bumped_at = listing.bumped_at + put "/listings/#{listing.id}", params: params + 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 "/listings/#{listing.id}", params: params + end.to change(user.credits.spent, :size).by(0) + end + + 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 "/listings/#{listing.id}", params: params + 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 "/listings/#{listing.id}", params: params + end.to change(user.credits.spent, :size).by(cost) + expect(listing.reload.bumped_at >= previous_bumped_at).to eq(true) + end end end end diff --git a/spec/requests/comments_spec.rb b/spec/requests/comments_spec.rb index a1ff9d08d..96e9a20ec 100644 --- a/spec/requests/comments_spec.rb +++ b/spec/requests/comments_spec.rb @@ -49,7 +49,9 @@ RSpec.describe "Comments", type: :request do commentable_type: "Article", user_id: user.id) get child.path - expect(CGI.unescapeHTML(response.body)).to include("TOP OF THREAD", child.processed_html, comment.title(150)) + expect(response.body).to include("TOP OF THREAD") + expect(response.body).to include(CGI.escapeHTML(comment.title(150))) + expect(response.body).to include(child.processed_html) end end diff --git a/spec/services/credits/buyer_spec.rb b/spec/services/credits/buyer_spec.rb new file mode 100644 index 000000000..2e1bdaf5d --- /dev/null +++ b/spec/services/credits/buyer_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe Credits::Buyer do + let(:user) { create(:user) } + let(:org) { create(:organization) } + let(:listing) { create(:classified_listing, user: user) } + + context "when not enough credits are available" do + it "does not spend credits for the user" do + create(:credit, user: user) + expect do + res = described_class.call(purchaser: user, purchase: listing, cost: 2) + expect(res).to be(false) + end.not_to change(user.credits.spent, :count) + end + + it "does not spend credits for the organization" do + create(:credit, organization: org) + expect do + res = described_class.call(purchaser: org, purchase: listing, cost: 2) + expect(res).to be(false) + end.not_to change(org.credits.spent, :count) + end + end + + context "when enough credits are available" do + it "spends credits for the user" do + create_list(:credit, 2, user: user) + expect do + res = described_class.call(purchaser: user, purchase: listing, cost: 2) + expect(res).to be(true) + end.to change(user.credits.spent, :count) + end + + it "spends credits for the organization" do + create_list(:credit, 2, organization: org) + expect do + res = described_class.call(purchaser: org, purchase: listing, cost: 2) + expect(res).to be(true) + end.to change(org.credits.spent, :count) + end + end +end diff --git a/spec/services/credits/ledger_spec.rb b/spec/services/credits/ledger_spec.rb new file mode 100644 index 000000000..b99b55a64 --- /dev/null +++ b/spec/services/credits/ledger_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe Credits::Ledger do + let(:user) { create(:user) } + let(:org) { create(:organization) } + let(:user_listing) { create(:classified_listing, user: user) } + let(:org_listing) { create(:classified_listing, organization: org) } + + def buy(purchaser, purchase, cost) + params = { + spent: true, + spent_at: Time.current, + purchase_type: purchase.class.name, + purchase_id: purchase.id + } + params.merge!(( + purchaser.is_a?(User) ? { user: purchaser } : { organization: purchaser })) + create_list(:credit, cost, params) + end + + it "returns a list of user purchases with their costs" do + start = Time.current + buy(user, user_listing, 3) + + items = described_class.call(user)[[User.name, user.id]] + expect(items.length).to be(1) + + item = items.first + expect(item.purchase.is_a?(ClassifiedListing)).to be(true) + expect(item.cost).to eq(3) + expect(item.purchased_at >= start).to eq(true) + end + + it "returns a list of purchases for the org the user is an admin for" do + create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "admin") + buy(org, org_listing, 3) + items = described_class.call(user)[[Organization.name, org.id]] + expect(items.length).to be(1) + end + + it "does not return purchases for other orgs the user belongs to" do + create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "member") + buy(org, org_listing, 3) + items = described_class.call(user)[[Organization.name, org.id]] + expect(items).to be(nil) + end +end