Credits ledger (#3395)

* Add #purchase to Credit

* Assigns spent credits to the listing

* Add Credits::Ledger and use it

* Localize ledger datetimes

* Add organization to the ledger

* Add tests for ledger

* Remove unused class

* Fix scope error

* Compare UTC times

* Fix broken specs

* Wrap listing create in a transaction

* Wrap listings bump into a transaction

* Avoid microseconds issue with datetimes in tests

* Use .detect instead of .select.first

* Fix spec description
This commit is contained in:
rhymes 2019-07-09 16:11:19 +02:00 committed by Ben Halpern
parent a9acb8642e
commit 1f97669e66
25 changed files with 585 additions and 100 deletions

View file

@ -41,6 +41,7 @@ function callInitalizers(){
initializePWAFunctionality();
initializeEllipsisMenu();
initializeArchivedPostFilter();
initializeCreditsPage();
initializeDrawerSliders();

View file

@ -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',
});
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}
.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; }
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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" },

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,39 @@
<table class="ledger">
<caption>Purchase history</caption>
<thead>
<tr>
<th>Item</th>
<th>Cost (credits)</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<% for item in items %>
<% if item.purchase %>
<tr>
<% else %>
<tr class="misc">
<% end %>
<td>
<% if item.purchase %>
<a
href="<%= classified_listing_slug_path(item.purchase.category, item.purchase.slug) %>"
title="<%= item.purchase.title %>">
<%= truncate(item.purchase.title, length: 20) %>
</a>
<% else %>
<span>Miscellaneous items</span>
<% end %>
</td>
<td class="cost"><%= item.cost %></td>
<td>
<% if item.purchased_at %>
<time datetime="<%= item.purchased_at.iso8601 %>"><%= item.purchased_at.iso8601 %></time>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

View file

@ -1,33 +1,47 @@
<% title "Credits" %>
<div class="credits-container">
<% if flash[:notice].present? %>
<div class="notice" id="notice">
<%= flash[:notice] %>
</div>
<% end %>
<% if flash[:notice].present? %>
<div class="notice" id="notice">
<%= flash[:notice] %>
</div>
<% end %>
<div class="existing-credits">
<div class="existing-credits-status">You have <%= @credits.size %> credits to spend</div>
<a href="/credits/purchase" data-no-instant>Purchase additional credits</a>
<div class="existing-credits-status">You have <%= @user_unspent_credits_count %> credits to spend</div>
<a href="<%= purchase_credits_path %>" data-no-instant>Purchase additional credits</a>
<% if @user_unspent_credits_count.positive? %>
<%= render "ledger", items: @ledger[["User", current_user.id]] %>
<% end %>
</div>
<% if @organizations.present? %>
<% @organizations.each do |org| %>
<% @organizations.find_each do |org| %>
<div class="existing-credits">
<div class="existing-credits-status">
<%= org.name %> has <span id="org-credits-number"><%= org.unspent_credits_count %></span> credits to spend
</div>
<a id="org-credits-purchase-link" href="/credits/purchase?organization_id=<%= org.id %>" data-no-instant>Purchase additional credits</a>
<a id="org-credits-purchase-link" href="<%= purchase_credits_path(organization_id: org.id) %>" data-no-instant>
Purchase additional credits
</a>
<% if org.unspent_credits_count.positive? %>
<%= render "ledger", items: @ledger[["Organization", org.id]] %>
<% end %>
</div>
<% end %>
<% end %>
<% if @credits.size > 0 %>
<% if @user_unspent_credits_count.positive? %>
<center>
<p>
<a href="/listings/new">Create a Listing</a>
<a href="<%= new_classified_listing_path %>">Create a Listing</a>
</p>
<% if @organizations.present? %>
<p>
<a href="/partnerships">View Partnership Opportunities</a>
<a href="<%= partnerships_path %>">View Partnership Opportunities</a>
</p>
<% end %>
</center>

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,7 @@
class AddIndexToCreditsSpent < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def change
add_index :credits, :spent, algorithm: :concurrently
end
end

View file

@ -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

View file

@ -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|

View file

@ -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" }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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