diff --git a/app/views/articles/_sticky_nav.html.erb b/app/views/articles/_sticky_nav.html.erb
index 382c73912..95c6d50ed 100644
--- a/app/views/articles/_sticky_nav.html.erb
+++ b/app/views/articles/_sticky_nav.html.erb
@@ -41,6 +41,7 @@
@@ -119,7 +120,7 @@
<% end %>
-
+
<%= article.title %>
<% article.decorate.cached_tag_list_array.each do |tag| %>
diff --git a/app/views/articles/index.html.erb b/app/views/articles/index.html.erb
index 71b921a31..c6a5c1e5e 100644
--- a/app/views/articles/index.html.erb
+++ b/app/views/articles/index.html.erb
@@ -117,7 +117,7 @@
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb
index 26fba1d99..c01d27a46 100644
--- a/app/views/articles/show.html.erb
+++ b/app/views/articles/show.html.erb
@@ -213,7 +213,8 @@
<% end %>
-<% cache("sticky-sidebar-#{@article.id}-#{(@organization || @user).profile_updated_at}-#{(@organization || @user).last_article_at}-#{@variant_number}", expires_in: 50.hours) do %>
+<% stick_nav_cache_key = "sticky-sidebar-#{@article.id}-#{(@organization || @user).profile_updated_at}-#{(@organization || @user).last_article_at}-#{@variant_number}-#{(@organization || @user).pro?}" %>
+<% cache(stick_nav_cache_key, expires_in: 50.hours) do %>
<%= render "articles/sticky_nav" %>
<% end %>
diff --git a/app/views/credits/_ledger.html.erb b/app/views/credits/_ledger.html.erb
index 431114f65..4150f62b0 100644
--- a/app/views/credits/_ledger.html.erb
+++ b/app/views/credits/_ledger.html.erb
@@ -20,7 +20,7 @@
<% end %>
<% if item.purchase %>
- <%= item.purchase.class.name == "ClassifiedListing" ? "Listing" : item.purchase.class.name %>
+ <%= item.purchase.class.name == "ClassifiedListing" ? "Listing" : item.purchase.class.name.titleize %>
<% end %>
|
diff --git a/app/views/credits/_ledger_pro_membership.html.erb b/app/views/credits/_ledger_pro_membership.html.erb
new file mode 100644
index 000000000..a1569a383
--- /dev/null
+++ b/app/views/credits/_ledger_pro_membership.html.erb
@@ -0,0 +1,3 @@
+
+ Pro Membership
+
diff --git a/app/views/mailers/pro_membership_mailer/expiring_membership.html.erb b/app/views/mailers/pro_membership_mailer/expiring_membership.html.erb
new file mode 100644
index 000000000..32b9104a4
--- /dev/null
+++ b/app/views/mailers/pro_membership_mailer/expiring_membership.html.erb
@@ -0,0 +1,9 @@
+<% host = "#{ApplicationConfig['APP_PROTOCOL']}#{ApplicationConfig['APP_DOMAIN']}" %>
+<% credits_url = url_for(host: host, controller: :credits, action: :index) %>
+<% pro_membership_url = url_for(host: host, controller: :users, action: :edit, tab: "pro-membership") %>
+
+ Hi <%= @user.name %>, your Pro Membership will expire in <%= @days %> days on <%= @date.to_s(:long) %>!
+
+Make sure you have enough credits for its renewal: <%= credits_url %>.
+
+You can also review your Pro Membership status.
diff --git a/app/views/mailers/pro_membership_mailer/expiring_membership.text.erb b/app/views/mailers/pro_membership_mailer/expiring_membership.text.erb
new file mode 100644
index 000000000..2642f1e19
--- /dev/null
+++ b/app/views/mailers/pro_membership_mailer/expiring_membership.text.erb
@@ -0,0 +1,9 @@
+<% host = "#{ApplicationConfig['APP_PROTOCOL']}#{ApplicationConfig['APP_DOMAIN']}" %>
+<% credits_url = url_for(host: host, controller: :credits, action: :index) %>
+<% pro_membership_url = url_for(host: host, controller: :users, action: :edit, tab: "pro-membership") %>
+
+Hi <%= @user.name %>, your Pro Membership will expire in <%= @days %> days on <%= @date.to_s(:long) %>!
+
+Make sure you have enough credits for its renewal: <%= credits_url %>
+
+You can also review your Pro Membership status: <%= pro_membership_url %>
diff --git a/app/views/partnerships/_form.html.erb b/app/views/partnerships/_form.html.erb
index b0d5bd2f9..9504d586b 100644
--- a/app/views/partnerships/_form.html.erb
+++ b/app/views/partnerships/_form.html.erb
@@ -12,7 +12,7 @@
<% elsif %w[silver bronze tag].include?(level) %>
<% organizations.find_each do |org| %>
- <% if org.credits.unspent.size < Sponsorship::CREDITS[level] %>
+ <% if !org.has_enough_credits?(Sponsorship::CREDITS[level]) %>
What next?
Purchase Credits for @<%= org.slug %>
diff --git a/app/views/pro_accounts/index.html.erb b/app/views/pro_memberships/show.html.erb
similarity index 77%
rename from app/views/pro_accounts/index.html.erb
rename to app/views/pro_memberships/show.html.erb
index 2ba4f9c8d..4bc3eb8ab 100644
--- a/app/views/pro_accounts/index.html.erb
+++ b/app/views/pro_memberships/show.html.erb
@@ -1,4 +1,4 @@
-<% title "Pro Account" %>
+<% title "Pro Membership" %>
+ <% if flash[:notice].present? %>
+
+ <%= flash[:notice] %>
+
+ <% elsif flash[:error].present? %>
+
+ ERROR
+ <%= flash[:error] %>
+
+ <% end %>
+
" /> Like a Pro
Pro is coming soon. This page is a work in progress. Since we're open source and transparently working on this, no sense in keeping it private eh?
@@ -28,8 +39,13 @@
🙌 Always Improving
Because this will be the landing destination for experimental and low scale features, you will always have the best and most interesting DEV features. We also plan to connect for exclusive deals on new and upcoming features on other platforms.
-
- $25/mo
- (or 5 credits/mo)
-
+ <% if @user %>
+
+ <% if @user.pro? %>
+ <%= link_to "View your Pro Membership", user_settings_path("pro-membership") %>
+ <% else %>
+ <%= link_to "Become a Pro member", user_settings_path("pro-membership") %>
+ <% end %>
+
+ <% end %>
diff --git a/app/views/shared/_pro_checkmark.html.erb b/app/views/shared/_pro_checkmark.html.erb
new file mode 100644
index 000000000..e12643e26
--- /dev/null
+++ b/app/views/shared/_pro_checkmark.html.erb
@@ -0,0 +1 @@
+<% # placeholder for future "pro indicator" %>
diff --git a/app/views/users/_pro_membership.html.erb b/app/views/users/_pro_membership.html.erb
new file mode 100644
index 000000000..a9dd5ea83
--- /dev/null
+++ b/app/views/users/_pro_membership.html.erb
@@ -0,0 +1,66 @@
+ Pro Membership
+
+<% if @user.pro? %>
+ <% if @pro_membership %>
+ <%= form_for(@pro_membership) do |f| %>
+
+ - Status
+ -
+ <% if @pro_membership.active? %>
+ <%= image_tag("checkmark-green.svg", class: "icon-img", alt: "check mark", title: "active", width: 25) %>
+ <% else %>
+ expired
+ <% end %>
+
+ - Expiration date
+ -
+
+
+ - Top up from credit card?
+ -
+
+ <%= f.check_box :auto_recharge %>
+
+
+
+ <%= f.submit "SUBMIT", class: "cta" %>
+ <% end %>
+ <% else %>
+
+ - Status
+ -
+ <%= image_tag("checkmark-green.svg", class: "icon-img", alt: "check mark", title: "active", width: 25) %>
+
+ - Expiration date
+ -
+ Never
+
+
+ <% end %>
+<% else %>
+
+ <%= link_to "Pro Membership", pro_membership_path %>
+ is <%= ProMembership::MONTHLY_COST %> credits per month.
+
+
+ <% available_credits = @user.credits.unspent.size %>
+ <% if available_credits >= ProMembership::MONTHLY_COST %>
+ You currently have <%= available_credits %> available credits.
+
+ <%= form_for(ProMembership.new(user: @user)) do |f| %>
+ <%= f.submit("Become a Pro member", class: "cta") %>
+ <% end %>
+ <% elsif available_credits.positive? %>
+ You currently have 4 credits, you need <%= ProMembership::MONTHLY_COST %> to become a Pro member.
+ Purchase credits
+ <% else %>
+ You currently have no credits, you need <%= ProMembership::MONTHLY_COST %> to become a Pro member.
+ Purchase credits
+ <% end %>
+<% end %>
+
+
+ You can find additional info on the <%= link_to "Pro Membership page", pro_membership_path %>.
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index aed26044c..bf8c6d9ed 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -32,7 +32,7 @@
- <%= @user.name %>
+ <%= @user.name %><%= render "shared/pro_checkmark", style: nil, width: nil %>
diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb
index 687c3a088..e2f5ed49e 100644
--- a/config/initializers/stripe.rb
+++ b/config/initializers/stripe.rb
@@ -5,3 +5,7 @@ Rails.configuration.stripe = {
}
Stripe.api_key = Rails.configuration.stripe[:secret_key]
+
+if Rails.env.development? && Stripe.api_key.present?
+ Stripe.log_level = Stripe::LEVEL_INFO
+end
diff --git a/config/routes.rb b/config/routes.rb
index 437b1b53e..bdf950813 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -173,6 +173,8 @@ Rails.application.routes.draw do
resources :profile_pins, only: %i[create update]
resources :partnerships, only: %i[index create show], param: :option
resources :display_ad_events, only: [:create]
+ resource :pro_membership, path: :pro, only: %i[show create update]
+ resolve("ProMembership") { [:pro_membership] } # see https://guides.rubyonrails.org/routing.html#using-resolve
get "/chat_channel_memberships/find_by_chat_channel_id" => "chat_channel_memberships#find_by_chat_channel_id"
get "/listings/dashboard" => "classified_listings#dashboard"
@@ -196,7 +198,6 @@ Rails.application.routes.draw do
post "/chat_channels/create_chat" => "chat_channels#create_chat"
post "/chat_channels/block_chat" => "chat_channels#block_chat"
get "/live/:username" => "twitch_live_streams#show"
- get "/pro" => "pro_accounts#index"
post "/pusher/auth" => "pusher#auth"
@@ -301,7 +302,7 @@ Rails.application.routes.draw do
end
end
- get "/settings/(:tab)" => "users#edit"
+ get "/settings/(:tab)" => "users#edit", as: :user_settings
get "/settings/:tab/:org_id" => "users#edit"
get "/signout_confirm" => "users#signout_confirm"
get "/dashboard" => "dashboards#show"
diff --git a/db/migrate/20190711093610_create_pro_memberships.rb b/db/migrate/20190711093610_create_pro_memberships.rb
new file mode 100644
index 000000000..f6ca1cf7f
--- /dev/null
+++ b/db/migrate/20190711093610_create_pro_memberships.rb
@@ -0,0 +1,17 @@
+class CreateProMemberships < ActiveRecord::Migration[5.2]
+ def change
+ create_table :pro_memberships do |t|
+ t.references :user, foreign_key: true
+ t.string :status, default: "active"
+ t.datetime :expires_at, null: false
+ t.datetime :expiration_notification_at
+ t.integer :expiration_notifications_count, null: false, default: 0
+ t.boolean :auto_recharge, null: false, default: false
+
+ t.timestamps
+ end
+ add_index :pro_memberships, :status
+ add_index :pro_memberships, :expires_at
+ add_index :pro_memberships, :auto_recharge
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4ca5a1a8d..fef46ea16 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -792,6 +792,21 @@ ActiveRecord::Schema.define(version: 2019_09_18_104106) do
t.datetime "updated_at", null: false
end
+ create_table "pro_memberships", force: :cascade do |t|
+ t.boolean "auto_recharge", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "expiration_notification_at"
+ t.integer "expiration_notifications_count", default: 0, null: false
+ t.datetime "expires_at", null: false
+ t.string "status", default: "active"
+ t.datetime "updated_at", null: false
+ t.bigint "user_id"
+ t.index ["auto_recharge"], name: "index_pro_memberships_on_auto_recharge"
+ t.index ["expires_at"], name: "index_pro_memberships_on_expires_at"
+ t.index ["status"], name: "index_pro_memberships_on_status"
+ t.index ["user_id"], name: "index_pro_memberships_on_user_id"
+ end
+
create_table "profile_pins", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "pinnable_id"
diff --git a/lib/tasks/pro_memberships.rake b/lib/tasks/pro_memberships.rake
new file mode 100644
index 000000000..e3c800f1e
--- /dev/null
+++ b/lib/tasks/pro_memberships.rake
@@ -0,0 +1,19 @@
+# All of these tasks need to be scheduled daily
+namespace :pro_memberships do
+ desc "Notify pro users with insufficient credits that their membership is about to expire in a week"
+ task notify_expirations_one_week_before: :environment do
+ num_notified = ProMemberships::ExpirationNotifier.call(1.week.from_now)
+ Rails.logger.info("Notified #{num_notified} Pro users...")
+ end
+
+ desc "Notify pro users with insufficient credits that their membership is about to expire in a day"
+ task notify_expirations_one_day_before: :environment do
+ num_notified = ProMemberships::ExpirationNotifier.call(1.day.from_now)
+ Rails.logger.info("Notified #{num_notified} Pro users...")
+ end
+
+ desc "Bill pro users and optionally charge their cards"
+ task bill_users: :environment do
+ ProMemberships::Biller.call
+ end
+end
diff --git a/spec/factories/pro_memberships.rb b/spec/factories/pro_memberships.rb
new file mode 100644
index 000000000..0f0115f72
--- /dev/null
+++ b/spec/factories/pro_memberships.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :pro_membership do
+ user
+ status { "active" }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index f1438982d..829604af9 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -101,5 +101,11 @@ FactoryBot.define do
user.update(articles_count: 1, comments_count: 1)
end
end
+
+ trait :with_pro_membership do
+ after(:create) do |user|
+ create(:pro_membership, user: user)
+ end
+ end
end
end
diff --git a/spec/jobs/pro_memberships/populate_history_job_spec.rb b/spec/jobs/pro_memberships/populate_history_job_spec.rb
new file mode 100644
index 000000000..da500c980
--- /dev/null
+++ b/spec/jobs/pro_memberships/populate_history_job_spec.rb
@@ -0,0 +1,19 @@
+require "rails_helper"
+
+RSpec.describe ProMemberships::PopulateHistoryJob, type: :job do
+ include_examples "#enqueues_job", "pro_memberships_populate_history", 1
+
+ describe "#perform_now" do
+ let(:user) { create(:user) }
+
+ before do
+ allow(user.page_views).to receive(:reindex!)
+ end
+
+ it "indexes user page views" do
+ described_class.perform_now(user.id) do
+ expect(user.page_views).to have_received(:reindex!).once
+ end
+ end
+ end
+end
diff --git a/spec/jobs/users/resave_articles_job_spec.rb b/spec/jobs/users/resave_articles_job_spec.rb
new file mode 100644
index 000000000..fd7ca3dd3
--- /dev/null
+++ b/spec/jobs/users/resave_articles_job_spec.rb
@@ -0,0 +1,19 @@
+require "rails_helper"
+
+RSpec.describe Users::ResaveArticlesJob, type: :job do
+ include_examples "#enqueues_job", "users_resave_articles", [1, 2]
+
+ describe "#perform_now" do
+ let(:user) { create(:user) }
+ let(:article) { create(:article, user: user) }
+
+ it "resaves articles" do
+ old_updated_at = article.updated_at
+ Timecop.freeze(Time.current) do
+ described_class.perform_now(user.id) do
+ expect(article.reload.updated_at > old_updated_at).to be(true)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/mailers/previews/digest_mailer_preview.rb b/spec/mailers/previews/digest_mailer_preview.rb
index f48215f73..1287dc485 100644
--- a/spec/mailers/previews/digest_mailer_preview.rb
+++ b/spec/mailers/previews/digest_mailer_preview.rb
@@ -1,4 +1,4 @@
-# Preview all emails at http://localhost:3000/rails/mailers/notify_mailer
+# Preview all emails at http://localhost:3000/rails/mailers/digest_mailer
class DigestMailerPreview < ActionMailer::Preview
def digest_email
DigestMailer.digest_email(User.last, Article.all)
diff --git a/spec/mailers/previews/pro_membership_mailer_preview.rb b/spec/mailers/previews/pro_membership_mailer_preview.rb
new file mode 100644
index 000000000..b0ddb8519
--- /dev/null
+++ b/spec/mailers/previews/pro_membership_mailer_preview.rb
@@ -0,0 +1,7 @@
+# Preview all emails at http://localhost:3000/rails/mailers/pro_membership_mailer
+class ProMembershipMailerPreview < ActionMailer::Preview
+ def expiring_membership
+ pro_membership = ProMembership.find_or_create_by(user: User.last)
+ ProMembershipMailer.expiring_membership(pro_membership, 1.week.from_now)
+ end
+end
diff --git a/spec/mailers/previews/scholarship_mailer_preview.rb b/spec/mailers/previews/scholarship_mailer_preview.rb
index 1b1486570..9d44fabc5 100644
--- a/spec/mailers/previews/scholarship_mailer_preview.rb
+++ b/spec/mailers/previews/scholarship_mailer_preview.rb
@@ -1,3 +1,4 @@
+# Preview all emails at http://localhost:3000/rails/mailers/scholarship_mailer
class ScholarshipMailerPreview < ActionMailer::Preview
def scholarship_awarded_email
ScholarshipMailer.scholarship_awarded_email(User.last)
diff --git a/spec/mailers/pro_membership_mailer_spec.rb b/spec/mailers/pro_membership_mailer_spec.rb
new file mode 100644
index 000000000..2a0032d81
--- /dev/null
+++ b/spec/mailers/pro_membership_mailer_spec.rb
@@ -0,0 +1,68 @@
+require "rails_helper"
+
+RSpec.describe ProMembershipMailer, type: :mailer do
+ let(:pro_membership) { build_stubbed(:pro_membership) }
+ let(:user) { pro_membership.user }
+
+ describe "#expiring_membership" do
+ it "works correctly" do
+ Timecop.freeze(Time.current) do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+
+ expect(email.subject).to eq("Your Pro Membership will expire in 7 days!")
+ expect(email.to).to eq([user.email])
+ expect(email.from).to eq(["yo@dev.to"])
+ end
+ end
+
+ context "when generating the plain text email" do
+ it "includes the user's name" do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+ expect(email.text_part.body).to include(user.name)
+ end
+
+ it "includes the expiration date" do
+ Timecop.freeze(Time.current) do
+ expiration_date = 1.week.from_now
+ email = described_class.expiring_membership(pro_membership, expiration_date)
+ expect(email.text_part.body).to include(expiration_date.to_date.to_s(:long))
+ end
+ end
+
+ it "includes credits path" do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+ expect(email.text_part.body).to include(credits_path)
+ end
+
+ it "includes pro membership path" do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+ expect(email.text_part.body).to include(user_settings_path("pro-membership"))
+ end
+ end
+
+ context "when generating the html email" do
+ it "includes the user's name" do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+ expect(email.html_part.body).to include(user.name)
+ end
+
+ it "includes the expiration date" do
+ Timecop.freeze(Time.current) do
+ expiration_date = 1.week.from_now
+ email = described_class.expiring_membership(pro_membership, expiration_date)
+ expect(email.html_part.body).to include(expiration_date.to_date.to_s(:long))
+ end
+ end
+
+ it "includes credits path" do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+ expect(email.html_part.body).to include(CGI.escape(credits_path))
+ end
+
+ it "includes pro membership path" do
+ email = described_class.expiring_membership(pro_membership, 1.week.from_now)
+ expect(email.html_part.body).to include(CGI.escape(user_settings_path("pro-membership")))
+ end
+ end
+ end
+end
diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb
index e3cc7bac6..8a5d69154 100644
--- a/spec/models/article_spec.rb
+++ b/spec/models/article_spec.rb
@@ -393,21 +393,26 @@ RSpec.describe Article, type: :model do
article = create(:article, user_id: user.id)
expect(article.path).to eq("/#{article.username}/#{article.slug}")
end
+
it "assigns cached_user_name on save" do
article = create(:article, user_id: user.id)
expect(article.cached_user_name).to eq(article.cached_user_name)
end
+
it "assigns cached_user_username on save" do
article = create(:article, user_id: user.id)
expect(article.cached_user_username).to eq(article.user_username)
end
+
it "assigns cached_user on save" do
article = create(:article, user_id: user.id)
expect(article.cached_user.username).to eq(article.user.username)
expect(article.cached_user.name).to eq(article.user.name)
expect(article.cached_user.profile_image_url).to eq(article.user.profile_image_url)
expect(article.cached_user.profile_image_90).to eq(article.user.profile_image_90)
+ expect(article.cached_user.pro).to eq(article.user.pro?)
end
+
it "assigns cached_organization on save" do
organization = create(:organization)
article = create(:article, user_id: user.id, organization_id: organization.id)
@@ -416,6 +421,7 @@ RSpec.describe Article, type: :model do
expect(article.cached_organization.slug).to eq(article.organization.slug)
expect(article.cached_organization.profile_image_90).to eq(article.organization.profile_image_90)
expect(article.cached_organization.profile_image_url).to eq(article.organization.profile_image_url)
+ expect(article.cached_organization.pro).to be(false)
end
end
diff --git a/spec/models/chat_channel_spec.rb b/spec/models/chat_channel_spec.rb
index 12e9fd0a4..e9e9226e8 100644
--- a/spec/models/chat_channel_spec.rb
+++ b/spec/models/chat_channel_spec.rb
@@ -31,4 +31,15 @@ RSpec.describe ChatChannel, type: :model do
expect(chat_channel.active_users.size).to eq(1)
expect(chat_channel.channel_users.size).to eq(1)
end
+
+ describe "#remove_user" do
+ let(:user) { create(:user) }
+
+ it "removes a user from a channel" do
+ chat_channel.add_users(user)
+ expect(chat_channel.chat_channel_memberships.exists?(user_id: user.id)).to be(true)
+ chat_channel.remove_user(user)
+ expect(chat_channel.chat_channel_memberships.exists?(user_id: user.id)).to be(false)
+ end
+ end
end
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb
index 60a25f281..934bd0b89 100644
--- a/spec/models/organization_spec.rb
+++ b/spec/models/organization_spec.rb
@@ -168,4 +168,26 @@ RSpec.describe Organization, type: :model do
expect(article.cached_organization.slug).to eq new_slug
end
end
+
+ describe "#has_enough_credits?" do
+ it "returns false if the user has less unspent credits than neeed" do
+ expect(organization.has_enough_credits?(1)).to be(false)
+ end
+
+ it "returns true if the user has the exact amount of unspent credits" do
+ create(:credit, organization: organization, spent: false)
+ expect(organization.has_enough_credits?(1)).to be(true)
+ end
+
+ it "returns true if the user has more unspent credits than needed" do
+ create_list(:credit, 2, organization: organization, spent: false)
+ expect(organization.has_enough_credits?(1)).to be(true)
+ end
+ end
+
+ describe "#pro?" do
+ it "always returns false" do
+ expect(organization.pro?).to be(false)
+ end
+ end
end
diff --git a/spec/models/pro_membership_spec.rb b/spec/models/pro_membership_spec.rb
new file mode 100644
index 000000000..068480d94
--- /dev/null
+++ b/spec/models/pro_membership_spec.rb
@@ -0,0 +1,138 @@
+require "rails_helper"
+
+RSpec.describe ProMembership, type: :model do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to validate_presence_of(:user) }
+ it { is_expected.to validate_presence_of(:status) }
+ it { is_expected.to validate_presence_of(:expires_at).on(:save) }
+ it { is_expected.to validate_presence_of(:expiration_notifications_count) }
+ it { is_expected.to validate_inclusion_of(:status).in_array(ProMembership::STATUSES) }
+ it { is_expected.to have_db_index(:status) }
+ it { is_expected.to have_db_index(:expires_at) }
+
+ describe "constants" do
+ it "has the correct values for constants" do
+ expect(ProMembership::STATUSES).to eq(%w[active expired])
+ expect(ProMembership::MONTHLY_COST).to eq(5)
+ expect(ProMembership::MONTHLY_COST_USD).to eq(25)
+ end
+ end
+
+ describe "defaults" do
+ it "has the correct defaults" do
+ pm = ProMembership.new
+ expect(pm.status).to eq("active")
+ expect(pm.expires_at).to be(nil)
+ expect(pm.expiration_notification_at).to be(nil)
+ expect(pm.expiration_notifications_count).to eq(0)
+ expect(pm.auto_recharge).to be(false)
+ end
+ end
+
+ describe "creation" do
+ it "sets expires_at to a month from now" do
+ Timecop.freeze(Time.current) do
+ pm = ProMembership.create!(user: create(:user))
+ expect(pm.expires_at.to_i).to eq(1.month.from_now.to_i)
+ end
+ end
+ end
+
+ describe "#expired?" do
+ it "returns false if expires_at is in the future" do
+ Timecop.freeze(Time.current) do
+ pm = build(:pro_membership, expires_at: 5.seconds.from_now)
+ expect(pm.expired?).to be(false)
+ end
+ end
+
+ it "returns true if expires_at is in the past" do
+ Timecop.freeze(Time.current) do
+ pm = build(:pro_membership, expires_at: 5.seconds.ago)
+ expect(pm.expired?).to be(true)
+ end
+ end
+ end
+
+ describe "#active?" do
+ it "returns true if expires_at is in the future" do
+ Timecop.freeze(Time.current) do
+ pm = build(:pro_membership, expires_at: 5.seconds.from_now)
+ expect(pm.active?).to be(true)
+ end
+ end
+
+ it "returns false if expires_at is in the past" do
+ Timecop.freeze(Time.current) do
+ pm = build(:pro_membership, expires_at: 5.seconds.ago)
+ expect(pm.active?).to be(false)
+ end
+ end
+ end
+
+ describe "#expire!" do
+ let(:pro_membership) { create(:pro_membership) }
+
+ it "sets expires_at to the current time" do
+ Timecop.freeze(Time.current) do
+ pro_membership.expire!
+ expect(pro_membership.reload.expires_at.to_i).to eq(Time.current.to_i)
+ end
+ end
+
+ it "sets status to expired" do
+ Timecop.freeze(Time.current) do
+ pro_membership.expire!
+ expect(pro_membership.reload.status).to eq("expired")
+ end
+ end
+
+ it "makes the membership expired" do
+ Timecop.freeze(Time.current) do
+ pro_membership.expire!
+ expect(pro_membership.reload.expired?).to be(true)
+ end
+ end
+ end
+
+ describe "#renew!" do
+ let(:pro_membership) { create(:pro_membership) }
+
+ it "sets expires_at to a month from now" do
+ Timecop.freeze(Time.current) do
+ pro_membership.renew!
+ expect(pro_membership.reload.expires_at.to_i).to eq(1.month.from_now.to_i)
+ end
+ end
+
+ it "sets status to active" do
+ Timecop.freeze(Time.current) do
+ pro_membership.renew!
+ expect(pro_membership.reload.status).to eq("active")
+ end
+ end
+
+ it "sets expiration_notification_at to nil" do
+ Timecop.freeze(Time.current) do
+ pro_membership.update_column(:expiration_notification_at, Time.current)
+ pro_membership.renew!
+ expect(pro_membership.reload.expiration_notification_at).to be(nil)
+ end
+ end
+
+ it "sets expiration_notifications_count to 0" do
+ Timecop.freeze(Time.current) do
+ pro_membership.update_column(:expiration_notifications_count, 1)
+ pro_membership.renew!
+ expect(pro_membership.reload.expiration_notifications_count).to eq(0)
+ end
+ end
+
+ it "makes the membership active" do
+ Timecop.freeze(Time.current) do
+ pro_membership.renew!
+ expect(pro_membership.reload.active?).to be(true)
+ end
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index f8c524902..740646427 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -30,6 +30,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:chat_channels).through(:chat_channel_memberships) }
it { is_expected.to have_many(:push_notification_subscriptions).dependent(:destroy) }
it { is_expected.to have_many(:notification_subscriptions).dependent(:destroy) }
+ it { is_expected.to have_one(:pro_membership).dependent(:destroy) }
it { is_expected.to validate_uniqueness_of(:username).case_insensitive }
it { is_expected.to validate_uniqueness_of(:github_username).allow_nil }
it { is_expected.to validate_uniqueness_of(:twitter_username).allow_nil }
@@ -674,14 +675,29 @@ RSpec.describe User, type: :model do
end
describe "#pro?" do
+ let(:user) { create(:user) }
+
it "returns false if the user is not a pro" do
expect(user.pro?).to be(false)
end
- it "returns true if the user is a pro" do
+ it "returns true if the user has the pro role" do
user.add_role(:pro)
expect(user.pro?).to be(true)
end
+
+ it "returns true if the user has an active pro membership" do
+ create(:pro_membership, user: user)
+ expect(user.pro?).to be(true)
+ end
+
+ it "returns false if the user has an expired pro membership" do
+ Timecop.freeze(Time.current) do
+ membership = create(:pro_membership, user: user)
+ membership.expire!
+ expect(user.pro?).to be(false)
+ end
+ end
end
describe "when agolia auto-indexing/removal is triggered" do
@@ -693,4 +709,20 @@ RSpec.describe User, type: :model do
expect { user.destroy }.not_to have_enqueued_job.on_queue("algoliasearch")
end
end
+
+ describe "#has_enough_credits?" do
+ it "returns false if the user has less unspent credits than neeed" do
+ expect(user.has_enough_credits?(1)).to be(false)
+ end
+
+ it "returns true if the user has the exact amount of unspent credits" do
+ create(:credit, user: user, spent: false)
+ expect(user.has_enough_credits?(1)).to be(true)
+ end
+
+ it "returns true if the user has more unspent credits than needed" do
+ create_list(:credit, 2, user: user, spent: false)
+ expect(user.has_enough_credits?(1)).to be(true)
+ end
+ end
end
diff --git a/spec/requests/credits_spec.rb b/spec/requests/credits_spec.rb
index 88ea3a2e0..935812699 100644
--- a/spec/requests/credits_spec.rb
+++ b/spec/requests/credits_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe "Credits", type: :request do
expect(response.body).to include(CGI.escapeHTML(org.name))
end
- context "when the user has made purchases" do
+ context "when the user has made purchases that will appear in the ledger" do
let(:params) { { spent: true, spent_at: Time.current } }
it "shows listing purchases" do
@@ -96,6 +96,19 @@ RSpec.describe "Credits", type: :request do
expect(response.body).to include("Purchase history")
expect(response.body).to include("Miscellaneous items")
end
+
+ it "shows a pro membership purchase" do
+ pro_membership = create(:pro_membership, user: user)
+ purchase_params = { user: user, purchase_type: pro_membership.class.name, purchase_id: pro_membership.id }
+ create(:credit, params.merge(purchase_params))
+
+ sign_in user
+ get credits_path
+
+ expect(response.body).to include("Purchase history")
+ expect(response.body).to include("Pro Membership")
+ expect(response.body).to include(pro_membership_path)
+ end
end
end
diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb
index 7059aa371..e72f18544 100644
--- a/spec/requests/dashboard_spec.rb
+++ b/spec/requests/dashboard_spec.rb
@@ -197,6 +197,15 @@ RSpec.describe "Dashboards", type: :request do
end
end
+ context "when user has a pro membership" do
+ it "shows page properly" do
+ create(:pro_membership, user: user)
+ sign_in user
+ get "/dashboard/pro"
+ expect(response.body).to include("pro")
+ end
+ end
+
context "when user has pro permission and is an org admin" do
it "shows page properly" do
org = create :organization
diff --git a/spec/requests/history_spec.rb b/spec/requests/history_spec.rb
index 1c0c5ea6f..f848b061c 100644
--- a/spec/requests/history_spec.rb
+++ b/spec/requests/history_spec.rb
@@ -3,6 +3,7 @@ require "rails_helper"
RSpec.describe "History", type: :request do
let(:user) { create(:user) }
let(:pro_user) { create(:user, :pro) }
+ let(:pro_membership_user) { create(:user, :with_pro_membership) }
describe "GET /history" do
it "does not allow access to a regular user" do
@@ -16,5 +17,12 @@ RSpec.describe "History", type: :request do
expect(response).to have_http_status(:ok)
expect(response.body).to include("History")
end
+
+ it "allows access to a user with a pro membership" do
+ sign_in pro_membership_user
+ get history_path
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("History")
+ end
end
end
diff --git a/spec/requests/partnerships_spec.rb b/spec/requests/partnerships_spec.rb
index 1bb13fac7..d7964e5ca 100644
--- a/spec/requests/partnerships_spec.rb
+++ b/spec/requests/partnerships_spec.rb
@@ -1,7 +1,7 @@
# rubocop:disable RSpec/NestedGroups
require "rails_helper"
-RSpec.describe "Pages", type: :request do
+RSpec.describe "Partnerships", type: :request do
describe "GET /partnerships" do
context "when user is logged in" do
before do
diff --git a/spec/requests/pro_accounts_spec.rb b/spec/requests/pro_accounts_spec.rb
deleted file mode 100644
index 69add8ce3..000000000
--- a/spec/requests/pro_accounts_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require "rails_helper"
-
-RSpec.describe "PollVotes", type: :request do
- describe "GET /pro" do
- it "returns pro lander" do
- get "/pro"
- expect(response.body).to include("Like a Pro")
- end
- end
-end
diff --git a/spec/requests/pro_memberships_spec.rb b/spec/requests/pro_memberships_spec.rb
new file mode 100644
index 000000000..d210a4f16
--- /dev/null
+++ b/spec/requests/pro_memberships_spec.rb
@@ -0,0 +1,187 @@
+require "rails_helper"
+
+RSpec.describe "Pro Memberships", type: :request do
+ describe "GET /pro" do
+ it "returns Pro landing page" do
+ get pro_membership_path
+ expect(response.body).to include("Like a Pro")
+ end
+ end
+
+ describe "POST /pro" do
+ let(:user) { create(:user) }
+
+ context "when the user is not logged in" do
+ it "redirects to the sign up page" do
+ post pro_membership_path
+ expect(response).to redirect_to(sign_up_path)
+ end
+ end
+
+ context "when the user is logged in and already has a pro memberships" do
+ before do
+ sign_in user
+ end
+
+ it "does not authorize creation if it has an active membership" do
+ create(:pro_membership, user: user)
+ expect do
+ post pro_membership_path
+ end.to raise_error(Pundit::NotAuthorizedError)
+ end
+
+ it "does not authorize creation if it has an expired membership" do
+ Timecop.freeze(Time.current) do
+ membership = create(:pro_membership, user: user)
+ membership.expire!
+
+ expect do
+ post pro_membership_path
+ end.to raise_error(Pundit::NotAuthorizedError)
+ end
+ end
+
+ it "does not authorize creation if the user as a pro role" do
+ user.add_role(:pro)
+ expect do
+ post pro_membership_path
+ end.to raise_error(Pundit::NotAuthorizedError)
+ end
+ end
+
+ context "when the user is logged in without a pro membership and enough credits" do
+ before do
+ sign_in user
+ create_list(:credit, ProMembership::MONTHLY_COST, user: user)
+ end
+
+ it "creates an active pro membership" do
+ expect do
+ post pro_membership_path
+ end.to change(ProMembership, :count).by(1)
+ expect(user.reload.pro_membership.active?).to be(true)
+ end
+
+ it "buys the pro membership with the correct amount of credits" do
+ expect do
+ post pro_membership_path
+ end.to change(user.credits.spent, :count).by(ProMembership::MONTHLY_COST)
+ end
+
+ it "enqueues a job to populate the history" do
+ assert_enqueued_with(
+ job: ProMemberships::PopulateHistoryJob,
+ args: [user.id],
+ queue: "pro_memberships_populate_history",
+ ) do
+ post pro_membership_path
+ end
+ end
+
+ it "enqueues a job to bust the user's cache" do
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued
+ assert_enqueued_with(
+ job: Users::BustCacheJob,
+ args: [user.id],
+ queue: "users_bust_cache",
+ ) do
+ post pro_membership_path
+ end
+ end
+
+ it "enqueues a job to bust the user's articles caches" do
+ assert_enqueued_with(
+ job: Users::ResaveArticlesJob,
+ args: [user.id],
+ queue: "users_resave_articles",
+ ) do
+ post pro_membership_path
+ end
+ end
+
+ it "adds the user to the pro members channel" do
+ create(:chat_channel, channel_type: "invite_only", slug: "pro-members")
+ post pro_membership_path
+ expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(true)
+ end
+
+ it "redirects to the pro membership settings page with a notice" do
+ post pro_membership_path
+ expect(response).to redirect_to(user_settings_path("pro-membership"))
+ expect(flash[:settings_notice]).to eq("You are now a Pro!")
+ end
+ end
+
+ context "when the user is logged in without a pro membership and not enough credits" do
+ before do
+ sign_in user
+ end
+
+ it "does not create an active pro membership" do
+ expect do
+ post pro_membership_path
+ end.to change(ProMembership, :count).by(0)
+ end
+
+ it "does not subtract credits" do
+ expect do
+ post pro_membership_path
+ end.to change(user.credits.spent, :count).by(0)
+ end
+
+ it "redirects to the pro membership settings page with an error message" do
+ post pro_membership_path
+ expect(response).to redirect_to(user_settings_path("pro-membership"))
+ expect(flash[:error]).to eq("You don't have enough credits!")
+ end
+ end
+ end
+
+ describe "PUT /pro" do
+ let(:user) { create(:user) }
+
+ context "when the user is not logged in" do
+ it "redirects to the sign up page" do
+ put pro_membership_path
+ expect(response).to redirect_to(sign_up_path)
+ end
+ end
+
+ context "when the user is logged in without a pro membership" do
+ before do
+ sign_in user
+ end
+
+ it "does not authorize the operation" do
+ expect do
+ put pro_membership_path
+ end.to raise_error(Pundit::NotAuthorizedError)
+ end
+ end
+
+ context "when the user is logged in with a pro membership" do
+ before do
+ sign_in user
+ end
+
+ it "works correctly" do
+ create(:pro_membership, user: user)
+ put pro_membership_path, params: { pro_membership: { auto_recharge: true } }
+ expect(flash[:settings_notice]).to eq("Your membership has been updated!")
+ expect(response).to redirect_to(user_settings_path("pro-membership"))
+ end
+
+ it "activates auto recharge" do
+ pro_membership = create(:pro_membership, user: user)
+ put pro_membership_path, params: { pro_membership: { auto_recharge: true } }
+ expect(pro_membership.reload.auto_recharge).to be(true)
+ end
+
+ it "deactivates auto recharge" do
+ pro_membership = create(:pro_membership, user: user, auto_recharge: true)
+ put pro_membership_path, params: { pro_membership: { auto_recharge: false } }
+ expect(pro_membership.reload.auto_recharge).to be(false)
+ end
+ end
+ end
+end
diff --git a/spec/services/credits/buyer_spec.rb b/spec/services/credits/buyer_spec.rb
index 2e1bdaf5d..c42e1dd1a 100644
--- a/spec/services/credits/buyer_spec.rb
+++ b/spec/services/credits/buyer_spec.rb
@@ -39,5 +39,21 @@ RSpec.describe Credits::Buyer do
expect(res).to be(true)
end.to change(org.credits.spent, :count)
end
+
+ it "updates the updated_at of the user" do
+ create_list(:credit, 2, user: user)
+ Timecop.freeze(Time.current) do
+ described_class.call(purchaser: user, purchase: listing, cost: 2)
+ expect(user.reload.updated_at.to_i >= Time.current.to_i).to be(true)
+ end
+ end
+
+ it "updates the updated_at of the organization" do
+ create_list(:credit, 2, organization: org)
+ Timecop.freeze(Time.current) do
+ described_class.call(purchaser: org, purchase: listing, cost: 2)
+ expect(org.reload.updated_at.to_i >= Time.current.to_i).to be(true)
+ end
+ end
end
end
diff --git a/spec/services/payments/customer_spec.rb b/spec/services/payments/customer_spec.rb
new file mode 100644
index 000000000..fc7a31f3a
--- /dev/null
+++ b/spec/services/payments/customer_spec.rb
@@ -0,0 +1,97 @@
+require "rails_helper"
+
+RSpec.describe Payments::Customer do
+ before do
+ StripeMock.start
+ end
+
+ after do
+ StripeMock.stop
+ end
+
+ describe ".get" do
+ it "retrieves an existing customer" do
+ customer = Stripe::Customer.create
+ expect(described_class.get(customer.id)).to be_present
+ end
+
+ it "raises Payments::CustomerNotFoundError if the customer does not exist" do
+ expect { described_class.get("foobar") }.to raise_error(Payments::InvalidRequestError)
+ end
+
+ it "raises Payments::PaymentsError for any other known error" do
+ allow(Stripe::Customer).to receive(:retrieve).with("foobar").and_raise(Stripe::StripeError)
+ expect { described_class.get("foobar") }.to raise_error(Payments::PaymentsError)
+ end
+ end
+
+ describe ".create" do
+ it "creates a new customer" do
+ expect(described_class.create).to be_present
+ end
+
+ it "raises an error if anything in the params is invalid" do
+ error = Stripe::InvalidRequestError.new("message", :email)
+ allow(Stripe::Customer).to receive(:create).and_raise(error)
+ expect { described_class.create(email: "foobar") }.to raise_error(Payments::InvalidRequestError)
+ end
+
+ it "raises Payments::PaymentsError for any other known error" do
+ allow(Stripe::Customer).to receive(:create).with(email: "foobar").
+ and_raise(Stripe::StripeError)
+ expect { described_class.create(email: "foobar") }.to raise_error(Payments::PaymentsError)
+ end
+ end
+
+ describe ".create_source" do
+ it "creates a new source" do
+ customer = Stripe::Customer.create
+ expect(described_class.create_source(customer.id, "token")).to be_present
+ end
+
+ it "raises an error if anything in the params is invalid" do
+ error = Stripe::InvalidRequestError.new("message", :token)
+ allow(Stripe::Customer).to receive(:create_source).and_raise(error)
+ expect { described_class.create_source("customer_id", "token") }.to raise_error(Payments::InvalidRequestError)
+ end
+
+ it "raises Payments::PaymentsError for any other known error" do
+ allow(Stripe::Customer).to receive(:create_source).and_raise(Stripe::StripeError)
+ expect { described_class.create_source("customer_id", "token") }.to raise_error(Payments::PaymentsError)
+ end
+ end
+
+ describe ".charge" do
+ let(:stripe_helper) { StripeMock.create_test_helper }
+
+ it "charges a customer" do
+ customer = Stripe::Customer.create
+ charge = described_class.charge(customer: customer, amount: 1, description: "Test charge")
+ expect(charge).to be_present
+ end
+
+ it "charges a customer with an explicit card id" do
+ customer = Stripe::Customer.create
+ token = stripe_helper.generate_card_token
+ card = Stripe::Customer.create_source(customer.id, source: token)
+ charge = described_class.charge(
+ customer: customer, amount: 1, description: "Test charge", card_id: card.id,
+ )
+ expect(charge).to be_present
+ end
+
+ it "raises a card error if the card has any troubles" do
+ StripeMock.prepare_card_error(:expired_card)
+
+ customer = Stripe::Customer.create
+ token = stripe_helper.generate_card_token
+ card = Stripe::Customer.create_source(customer.id, source: token)
+
+ expect do
+ described_class.charge(
+ customer: customer, amount: 1, description: "Test charge", card_id: card.id,
+ )
+ end.to raise_error(Payments::CardError)
+ end
+ end
+end
diff --git a/spec/services/pro_memberships/biller_spec.rb b/spec/services/pro_memberships/biller_spec.rb
new file mode 100644
index 000000000..20ad0329e
--- /dev/null
+++ b/spec/services/pro_memberships/biller_spec.rb
@@ -0,0 +1,264 @@
+require "rails_helper"
+
+RSpec.describe ProMemberships::Biller, type: :service do
+ context "when there are expiring memberships with enough credits" do
+ let(:pro_membership) { create(:pro_membership) }
+ let(:user) { pro_membership.user }
+
+ before do
+ create_list(:credit, ProMembership::MONTHLY_COST, user: user)
+ end
+
+ it "renews the membership" do
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ pro_membership.reload
+ expect(pro_membership.expires_at.to_i).to eq(1.month.from_now.to_i)
+ expect(pro_membership.status).to eq("active")
+ end
+ end
+
+ it "subtracts the correct amount of credits" do
+ Timecop.travel(pro_membership.expires_at) do
+ expect do
+ described_class.call
+ end.to change(user.credits.spent, :size).by(ProMembership::MONTHLY_COST)
+ end
+ end
+
+ it "adds the user back to the pro members chat channel" do
+ create(:chat_channel, slug: "pro-members", channel_type: "invite_only")
+
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(true)
+ end
+ end
+
+ it "does not fail if the user is already in the pro members chat channel" do
+ cc = create(:chat_channel, slug: "pro-members", channel_type: "invite_only")
+ cc.add_users(user)
+
+ allow(Rails.logger).to receive(:error)
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ expect(Rails.logger).not_to have_received(:error)
+ expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(true)
+ end
+ end
+
+ it "enqueues a job to bust the users caches" do
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(
+ job: Users::BustCacheJob,
+ args: [user.id],
+ queue: "users_bust_cache",
+ ) do
+ described_class.call
+ end
+ end
+ end
+
+ it "enqueues a job to bust the users articles caches" do
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(
+ job: Users::ResaveArticlesJob,
+ args: [user.id],
+ queue: "users_resave_articles",
+ ) do
+ described_class.call
+ end
+ end
+ end
+
+ context "when an error occurs" do
+ it "does not renew the membership" do
+ Timecop.travel(pro_membership.expires_at) do
+ pro_membership.expire!
+ allow(Credits::Buyer).to receive(:call).and_raise(StandardError)
+ described_class.call
+ expect(pro_membership.expires_at.to_i).to be(Time.current.to_i)
+ expect(pro_membership.status).to eq("expired")
+ end
+ end
+
+ it "does not subtract credits" do
+ Timecop.travel(pro_membership.expires_at) do
+ allow(Credits::Buyer).to receive(:call).and_raise(StandardError)
+ expect do
+ described_class.call
+ end.to change(user.credits.spent, :size).by(0)
+ end
+ end
+
+ it "notifies the admins about the error" do
+ Timecop.travel(pro_membership.expires_at) do
+ allow(Credits::Buyer).to receive(:call).and_raise(StandardError)
+ assert_enqueued_with(job: SlackBotPingJob) do
+ described_class.call
+ end
+ end
+ end
+ end
+ end
+
+ context "when there are expiring memberships with insufficient credits" do
+ let(:pro_membership) { create(:pro_membership) }
+ let(:user) { pro_membership.user }
+
+ it "expires the membership" do
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ pro_membership.reload
+ expect(pro_membership.expired?).to be(true)
+ expect(pro_membership.status).to eq("expired")
+ end
+ end
+
+ it "removes the user from the pro members chat channel" do
+ cc = create(:chat_channel, slug: "pro-members", channel_type: "invite_only")
+ cc.add_users(user)
+
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ expect(user.reload.chat_channels.exists?(slug: "pro-members")).to be(false)
+ end
+ end
+
+ it "notifies the admins about the expiration" do
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(job: SlackBotPingJob) do
+ described_class.call
+ end
+ end
+ end
+
+ it "enqueues a job to bust the users caches" do
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(
+ job: Users::BustCacheJob,
+ args: [user.id],
+ queue: "users_bust_cache",
+ ) do
+ described_class.call
+ end
+ end
+ end
+
+ it "enqueues a job to bust the users articles caches" do
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(
+ job: Users::ResaveArticlesJob,
+ args: [user.id],
+ queue: "users_resave_articles",
+ ) do
+ described_class.call
+ end
+ end
+ end
+ end
+
+ context "when there are expiring memberships with insufficient credits and auto recharge" do
+ let(:pro_membership) { create(:pro_membership, auto_recharge: true) }
+ let(:user) { pro_membership.user }
+
+ context "when the user has an associated customer" do
+ before do
+ StripeMock.start
+ customer = Payments::Customer.create
+ user.update_columns(stripe_id_code: customer.id)
+ end
+
+ after do
+ StripeMock.stop
+ end
+
+ it "charges the customer" do
+ customer = Payments::Customer.get(user.stripe_id_code)
+ allow(Payments::Customer).to receive(:charge)
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ end
+
+ expect(Payments::Customer).to have_received(:charge).with(
+ customer: customer,
+ amount: ProMembership::MONTHLY_COST_USD,
+ description: "Purchase of 5 credits.",
+ )
+ end
+
+ it "adds the correct amount of credits" do
+ Timecop.travel(pro_membership.expires_at) do
+ # we cannot use "expect.to change" because of how activerecord-import works
+ old_num_credits = user.credits.size
+ described_class.call
+ expect(user.reload.credits.size).to be(old_num_credits + ProMembership::MONTHLY_COST)
+ end
+ end
+
+ it "renews the membership" do
+ Timecop.travel(pro_membership.expires_at) do
+ described_class.call
+ pro_membership.reload
+ expect(pro_membership.expires_at.to_i).to eq(1.month.from_now.to_i)
+ expect(pro_membership.status).to eq("active")
+ end
+ end
+
+ it "spends the correct amount of credits" do
+ Timecop.travel(pro_membership.expires_at) do
+ # we cannot use "expect.to change" because of how activerecord-import works
+ old_num_credits = user.reload.credits.spent.size
+ described_class.call
+ expect(user.reload.credits.spent.size).to eq(old_num_credits + ProMembership::MONTHLY_COST)
+ end
+ end
+
+ it "enqueues a job to bust the users caches" do
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear # make sure it hasn't been previously queued
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(
+ job: Users::BustCacheJob,
+ args: [user.id],
+ queue: "users_bust_cache",
+ ) do
+ described_class.call
+ end
+ end
+ end
+
+ it "enqueues a job to bust the users articles caches" do
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(
+ job: Users::ResaveArticlesJob,
+ args: [user.id],
+ queue: "users_resave_articles",
+ ) do
+ described_class.call
+ end
+ end
+ end
+ end
+
+ context "when the user has no associated customer" do
+ it "notifies the admins about the problem" do
+ allow(user).to receive(:stripe_id_code).and_return(nil)
+ Timecop.travel(pro_membership.expires_at) do
+ assert_enqueued_with(job: SlackBotPingJob) do
+ described_class.call
+ end
+ end
+ end
+
+ it "does not change the number of credits" do
+ Timecop.travel(pro_membership.expires_at) do
+ expect do
+ described_class.call
+ end.to change(user.credits, :size).by(0)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/pro_memberships/expiration_notifier_spec.rb b/spec/services/pro_memberships/expiration_notifier_spec.rb
new file mode 100644
index 000000000..4fdb7a9d9
--- /dev/null
+++ b/spec/services/pro_memberships/expiration_notifier_spec.rb
@@ -0,0 +1,66 @@
+require "rails_helper"
+
+RSpec.describe ProMemberships::ExpirationNotifier, type: :service do
+ context "when there are expiring memberships with enough credits" do
+ let(:pro_membership) { create(:pro_membership) }
+ let(:user) { pro_membership.user }
+
+ it "does not deliver an email to the user" do
+ create_list(:credit, ProMembership::MONTHLY_COST, user: user)
+ Timecop.travel(pro_membership.expires_at - 1.week) do
+ assert_emails 0 do
+ described_class.call(1.week.from_now)
+ end
+ end
+ end
+ end
+
+ context "when there are expiring memberships with insufficient credits" do
+ let(:pro_membership) { create(:pro_membership) }
+
+ it "delivers an email to the user" do
+ Timecop.travel(pro_membership.expires_at - 1.week) do
+ assert_emails 1 do
+ described_class.call(1.week.from_now)
+ end
+ end
+ end
+
+ it "sets the expiration notification datetime" do
+ Timecop.travel(pro_membership.expires_at - 1.week) do
+ described_class.call(1.week.from_now)
+ expect(pro_membership.reload.expiration_notification_at.to_i).to eq(Time.current.to_i)
+ end
+ end
+
+ it "increments the notifications count" do
+ Timecop.travel(pro_membership.expires_at - 1.week) do
+ expect(pro_membership.expiration_notifications_count).to eq(0)
+ described_class.call(1.week.from_now)
+ expect(pro_membership.reload.expiration_notifications_count).to eq(1)
+ end
+ end
+
+ it "enqueus a slack bot ping job" do
+ Timecop.travel(pro_membership.expires_at - 1.week) do
+ assert_enqueued_with(job: SlackBotPingJob) do
+ described_class.call(1.week.from_now)
+ end
+ end
+ end
+ end
+
+ context "when there are expiring memberships with insufficient credits and auto recharge" do
+ let(:pro_membership) { create(:pro_membership) }
+ let(:user) { pro_membership.user }
+
+ it "does not deliver an email to the user" do
+ pro_membership.update_columns(auto_recharge: true)
+ Timecop.travel(pro_membership.expires_at - 1.week) do
+ assert_emails 0 do
+ described_class.call(1.week.from_now)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/system/pro_memberships/user_creates_a_pro_membership_spec.rb b/spec/system/pro_memberships/user_creates_a_pro_membership_spec.rb
new file mode 100644
index 000000000..8921e31cd
--- /dev/null
+++ b/spec/system/pro_memberships/user_creates_a_pro_membership_spec.rb
@@ -0,0 +1,19 @@
+require "rails_helper"
+
+RSpec.describe "Creates a Pro Membership", type: :system do
+ let(:user) { create(:user) }
+
+ context "when signed in as a regular user with enough credits" do
+ before do
+ create_list(:credit, 5, user: user)
+ sign_in user
+ end
+
+ it "makes the user become a pro" do
+ visit "/settings/pro-membership"
+ label = "Become a Pro member"
+ click_on label
+ expect(page).to have_content("You are now a Pro!")
+ end
+ end
+end
diff --git a/spec/system/pro_memberships/user_visits_pro_page_spec.rb b/spec/system/pro_memberships/user_visits_pro_page_spec.rb
new file mode 100644
index 000000000..010c403e1
--- /dev/null
+++ b/spec/system/pro_memberships/user_visits_pro_page_spec.rb
@@ -0,0 +1,67 @@
+require "rails_helper"
+
+RSpec.describe "Visits Pro Memberships page", type: :system do
+ let(:user) { create(:user) }
+
+ context "when not signed in" do
+ it "does not say you are a member" do
+ visit "/pro"
+ expect(page).not_to have_content("View your Pro Membership")
+ end
+
+ it "does not ask to become a pro member" do
+ visit "/pro"
+ expect(page).not_to have_content("Become a Pro member")
+ end
+ end
+
+ context "when signed in as a regular user" do
+ before do
+ sign_in user
+ end
+
+ it "does not say you are a member" do
+ visit "/pro"
+ expect(page).not_to have_content("View your Pro Membership")
+ end
+
+ it "asks to become a pro member" do
+ visit "/pro"
+ expect(page).to have_content("Become a Pro member")
+ end
+ end
+
+ context "when signed in as as user with a pro role" do
+ before do
+ user.add_role(:pro)
+ sign_in user
+ end
+
+ it "says you are a member" do
+ visit "/pro"
+ expect(page).to have_content("View your Pro Membership")
+ end
+
+ it "does not ask to become a pro member" do
+ visit "/pro"
+ expect(page).not_to have_content("Become a Pro member")
+ end
+ end
+
+ context "when signed in as as user with a pro membership" do
+ before do
+ create(:pro_membership, user: user, expires_at: 1.month.from_now)
+ sign_in user
+ end
+
+ it "says you are a member" do
+ visit "/pro"
+ expect(page).to have_content("View your Pro Membership")
+ end
+
+ it "does not ask to become a pro member" do
+ visit "/pro"
+ expect(page).not_to have_content("Become a Pro member")
+ end
+ end
+end
diff --git a/spec/system/pro_memberships/user_visits_settings_pro_membership_page_spec.rb b/spec/system/pro_memberships/user_visits_settings_pro_membership_page_spec.rb
new file mode 100644
index 000000000..97073856e
--- /dev/null
+++ b/spec/system/pro_memberships/user_visits_settings_pro_membership_page_spec.rb
@@ -0,0 +1,86 @@
+require "rails_helper"
+
+RSpec.describe "Visits Pro Membership settings page", type: :system do
+ let(:user) { create(:user) }
+ let(:cost) { ProMembership::MONTHLY_COST }
+
+ context "when signed in as a regular user" do
+ before do
+ sign_in user
+ end
+
+ it "contains the correct info if you have no credits" do
+ visit "/settings/pro-membership"
+
+ expect(page).to have_content("Pro Membership is #{cost} credits per month")
+ expect(page).to have_content("You currently have no credits, you need #{cost} to become a Pro member")
+ expect(page).to have_link("Purchase credits", href: "/credits/purchase")
+ expect(page).to have_link("Pro Membership page", href: "/pro")
+ end
+
+ it "contains the correct info if you have some credits but not enough" do
+ create(:credit, user: user)
+
+ visit "/settings/pro-membership"
+ expect(page).to have_content("Pro Membership is #{cost} credits per month")
+ expect(page).to have_content(
+ "You currently have #{cost - 1} credits, you need #{cost} to become a Pro member",
+ )
+ expect(page).to have_link("Purchase credits", href: "/credits/purchase")
+ expect(page).to have_link("Pro Membership page", href: "/pro")
+ end
+
+ it "contains the correct info if you have enough credits" do
+ create_list(:credit, cost, user: user)
+
+ visit "/settings/pro-membership"
+ expect(page).to have_content("Pro Membership is #{cost} credits per month")
+ expect(page).to have_content("You currently have #{user.credits.unspent.size} available credits")
+ expect(page).not_to have_link("Purchase credits", href: "/credits/purchase")
+ expect(page).to have_selector("input[type=submit][value='Become a Pro member']")
+ expect(page).to have_link("Pro Membership page", href: "/pro")
+ end
+ end
+
+ context "when signed in as as user with a pro role" do
+ before do
+ user.add_role(:pro)
+ sign_in user
+ end
+
+ it "shows the status of the membership" do
+ visit "/settings/pro-membership"
+
+ expect(page).to have_content("Status")
+ expect(page).to have_content("Expiration date")
+ expect(page).to have_content("Never")
+ expect(page).to have_link("Pro Membership page", href: "/pro")
+ end
+ end
+
+ context "when signed in as as user with a pro membership" do
+ before do
+ create(:pro_membership, user: user, expires_at: 1.month.from_now)
+ sign_in user
+ end
+
+ it "shows the status of the membership" do
+ visit "/settings/pro-membership"
+
+ expect(page).to have_content("Status")
+ expect(page).to have_content("Expiration date")
+ expect(page).to have_content(user.pro_membership.expires_at.to_date.to_s(:long))
+ expect(page).to have_content("Top up from credit card?")
+ expect(page).to have_selector("input[type=submit]")
+ expect(page).to have_link("Pro Membership page", href: "/pro")
+ end
+
+ it "updates the auto recharge" do
+ visit "/settings/pro-membership"
+
+ check "pro_membership[auto_recharge]"
+ click_on "SUBMIT"
+ expect(page).to have_content("Your membership has been updated!")
+ end
+ end
+end
|