Add self-service account deletion (#2246)

* Add self-service account deletion

* Disable access to cancelled accounts & implement Stripe interactions

* Add tests

* Fix failing tests

* Remove cancelled accounts from lists

* Fix incorrect redirect

* Use _path instead of _url for consistency

* Don't track how far the inicieration job got

We still want the step tracking so that the job can be interrupted. But, since the scope is idempotent, we don't need to track how far it got.

* Specify the exact time when the data will be deleted

* Fix crash due to unadvancable cursor

* Rename up_for_incineration to due_for_incineration

* Regenrate the schema

* Fix incorrect path check

* Migrate the SQLite schema

* Only show the cancel button on cancellable accounts

* Check that a subscirption method exists before calling it

* Ignore job failures due to missing records when an account gets deleted

* Skip sending notifications on cancelled accounts

* Use collbacks to integrate

* Add a blank line between queue_as and discard_on

* Break checks into methods

* Inline methods

* Rename Account::IncinerateJob

* Run migrations

* Migrate SQLite
This commit is contained in:
Stanko Krtalić
2026-01-12 14:21:05 +01:00
committed by GitHub
parent 2067d76e62
commit eceb6c9b43
51 changed files with 836 additions and 12 deletions
@@ -0,0 +1,13 @@
class Account::CancellationsController < ApplicationController
before_action :ensure_owner
def create
Current.account.cancel
redirect_to session_menu_path(script_name: nil), notice: "Account deleted"
end
private
def ensure_owner
head :forbidden unless Current.user.owner?
end
end
+6 -2
View File
@@ -2,7 +2,7 @@ module Authorization
extend ActiveSupport::Concern
included do
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
before_action :ensure_can_access_account, if: :authenticated_account_access?
end
class_methods do
@@ -25,8 +25,12 @@ module Authorization
head :forbidden unless Current.identity.staff?
end
def authenticated_account_access?
Current.account.present? && authenticated?
end
def ensure_can_access_account
if Current.user.blank? || !Current.user.active?
unless Current.account.active? && Current.user&.active?
respond_to do |format|
format.html { redirect_to session_menu_path(script_name: nil) }
format.json { head :forbidden }
+1 -1
View File
@@ -4,7 +4,7 @@ class My::MenusController < ApplicationController
@boards = Current.user.boards.ordered_by_recently_accessed
@tags = Current.account.tags.all.alphabetically
@users = Current.account.users.active.alphabetically
@accounts = Current.identity.accounts
@accounts = Current.identity.accounts.active
fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]
end
@@ -2,6 +2,7 @@ class Public::BaseController < ApplicationController
allow_unauthenticated_access
before_action :set_board, :set_card, :set_public_cache_expiration
before_action :ensure_board_accessible
layout "public"
@@ -17,4 +18,8 @@ class Public::BaseController < ApplicationController
def set_public_cache_expiration
expires_in 30.seconds, public: true
end
def ensure_board_accessible
raise ActionController::RoutingError, "Not Found" if @board&.account&.cancelled?
end
end
+1 -1
View File
@@ -4,7 +4,7 @@ class Sessions::MenusController < ApplicationController
layout "public"
def show
@accounts = Current.identity.accounts
@accounts = Current.identity.accounts.active
if @accounts.one?
redirect_to root_url(script_name: @accounts.first.slug)
+14
View File
@@ -0,0 +1,14 @@
class Account::IncinerateDueJob < ApplicationJob
include ActiveJob::Continuable
queue_as :incineration
def perform
step :incineration do |step|
Account.due_for_incineration.find_each do |account|
account.incinerate
step.checkpoint!
end
end
end
end
@@ -1,4 +1,6 @@
class Board::CleanInaccessibleDataJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(user, board)
board.clean_inaccessible_data_for(user)
end
@@ -1,4 +1,6 @@
class Card::ActivitySpike::DetectionJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.detect_activity_spikes
end
@@ -1,4 +1,6 @@
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.remove_inaccessible_notifications
end
+2
View File
@@ -5,6 +5,8 @@ class Event::WebhookDispatchJob < ApplicationJob
queue_as :webhooks
discard_on ActiveJob::DeserializationError
def perform(event)
step :dispatch do |step|
Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook|
+2
View File
@@ -1,6 +1,8 @@
class ExportAccountDataJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(export)
export.build
end
+2
View File
@@ -1,4 +1,6 @@
class Mention::CreateJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(record, mentioner:)
record.create_mentions(mentioner:)
end
@@ -1,6 +1,8 @@
class Notification::Bundle::DeliverAllJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform
Notification::Bundle.deliver_all
end
@@ -3,6 +3,8 @@ class Notification::Bundle::DeliverJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
def perform(bundle)
bundle.deliver
end
+2
View File
@@ -1,4 +1,6 @@
class NotifyRecipientsJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notifiable)
notifiable.notify_recipients
end
+2
View File
@@ -1,4 +1,6 @@
class PushNotificationJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notification)
NotificationPusher.new(notification).push
end
+1
View File
@@ -5,6 +5,7 @@ class Storage::ReconcileJob < ApplicationJob
limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
retry_on ReconcileAborted, wait: 1.minute, attempts: 3
def perform(owner)
+2
View File
@@ -1,6 +1,8 @@
class Webhook::DeliveryJob < ApplicationJob
queue_as :webhooks
discard_on ActiveJob::DeserializationError
def perform(delivery)
delivery.deliver
end
+11
View File
@@ -0,0 +1,11 @@
class AccountMailer < ApplicationMailer
def cancellation(cancellation)
@account = cancellation.account
@user = cancellation.initiated_by
mail(
to: @user.identity.email_address,
subject: "Your Fizzy account was cancelled"
)
end
end
+2 -2
View File
@@ -1,7 +1,7 @@
class Account < ApplicationRecord
include Account::Storage, Entropic, MultiTenantable, Seedeable
include Account::Storage, Cancellable, Entropic, Incineratable, MultiTenantable, Seedeable
has_one :join_code
has_one :join_code, dependent: :destroy
has_many :users, dependent: :destroy
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
+46
View File
@@ -0,0 +1,46 @@
module Account::Cancellable
extend ActiveSupport::Concern
included do
has_one :cancellation, dependent: :destroy
scope :active, -> { where.missing(:cancellation) }
define_callbacks :cancel
define_callbacks :reactivate
end
def cancel(initiated_by: Current.user)
with_lock do
if cancellable? && active?
run_callbacks :cancel do
create_cancellation!(initiated_by: initiated_by)
end
AccountMailer.cancellation(cancellation).deliver_later
end
end
end
def reactivate
with_lock do
if cancelled?
run_callbacks :reactivate do
cancellation.destroy
end
end
end
end
def active?
!cancelled?
end
def cancelled?
cancellation.present?
end
def cancellable?
Account.accepting_signups?
end
end
+4
View File
@@ -0,0 +1,4 @@
class Account::Cancellation < ApplicationRecord
belongs_to :account
belongs_to :initiated_by, class_name: "User"
end
+17
View File
@@ -0,0 +1,17 @@
module Account::Incineratable
extend ActiveSupport::Concern
INCINERATION_GRACE_PERIOD = 30.days
included do
scope :due_for_incineration, -> { joins(:cancellation).where(account_cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) }
define_callbacks :incinerate
end
def incinerate
run_callbacks :incinerate do
account.destroy
end
end
end
+1 -1
View File
@@ -74,7 +74,7 @@ class Notification::Bundle < ApplicationRecord
end
def deliverable?
user.settings.bundling_emails? && notifications.any?
user.settings.bundling_emails? && notifications.any? && account.active?
end
def overlapping_bundles
+2 -1
View File
@@ -20,7 +20,8 @@ class NotificationPusher
def should_push?
notification.user.push_subscriptions.any? &&
!notification.creator.system? &&
notification.user.active?
notification.user.active? &&
notification.account.active?
end
def build_payload
+1 -1
View File
@@ -7,6 +7,6 @@ module Webhook::Triggerable
end
def trigger(event)
deliveries.create!(event: event)
deliveries.create!(event: event) unless account.cancelled?
end
end
@@ -0,0 +1,26 @@
<% if Current.account.cancellable? && Current.user.owner? %>
<header class="margin-block-start-double">
<h2 class="divider txt-large txt-negative">Cancel account</h2>
<p class="margin-none-block">Delete your Fizzy account</p>
</header>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
<ul class="txt-align-start">
<li>All users, including you, will lose access</li>
<% if Current.account.try(:active_subscription) %>
<li>Your subscription will be canceled</li>
<% end %>
<li>After 30 days your data will be permanently deleted</li>
</ul>
<div class="flex gap justify-center">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
</div>
</dialog>
</div>
<% end %>
+1
View File
@@ -18,6 +18,7 @@
<div class="settings__panel settings__panel--entropy panel shadow center">
<%= render "account/settings/entropy", account: @account %>
<%= render "account/settings/export" %>
<%= render "account/settings/cancellation" %>
</div>
</section>
@@ -0,0 +1,14 @@
<p>Your Fizzy account <strong><%= @account.name %></strong> was cancelled.</p>
<h2>What happens now?</h2>
<ul>
<li>No one can access the account anymore</li>
<% if @account.try(:subscription) %>
<li>We won't charge you anymore</li>
<% end %>
<li>Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %></li>
</ul>
<i>Changed your mind?</i>
<p>If you want to cancel this deletion and restore your account, send us an email to <a href="mailto:support@fizzy.do">support@fizzy.do</a> as soon as possible.</p>
@@ -0,0 +1,13 @@
Your Fizzy account "<%= @account.name %>" was cancelled.
WHAT HAPPENS NOW?
- No one can access the account anymore
<% if @account.try(:subscription) %>
- We won't charge you anymore
<% end %>
- Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %>
CHANGED YOUR MIND?
If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.
+3
View File
@@ -27,6 +27,9 @@ production: &production
cleanup_exports:
command: "Account::Export.cleanup"
schedule: every hour at minute 20
incineration:
job: Account::IncinerateDueJob
schedule: every 8 hours at minute 16
<% if Fizzy.saas? %>
# Metrics
+1
View File
@@ -2,6 +2,7 @@ Rails.application.routes.draw do
root "events#index"
namespace :account do
resource :cancellation, only: [ :create ]
resource :entropy
resource :join_code
resource :settings
@@ -0,0 +1,10 @@
class CreateAccountCancellations < ActiveRecord::Migration[8.2]
def change
create_table :account_cancellations, id: :uuid do |t|
t.uuid :account_id, null: false, index: { unique: true }
t.uuid :initiated_by_id, null: false
t.timestamps
end
end
end
Generated
+9 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "account_cancellations", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.uuid "initiated_by_id", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end
create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "completed_at"
+10 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "account_cancellations", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.uuid "initiated_by_id", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end
create_table "account_exports", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "completed_at"
@@ -469,7 +477,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
t.string "operation", limit: 255, null: false
t.uuid "recordable_id"
t.string "recordable_type", limit: 255
t.string "request_id"
t.string "request_id", limit: 255
t.uuid "user_id"
t.index ["account_id"], name: "index_storage_entries_on_account_id"
t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
+4
View File
@@ -4,6 +4,10 @@ module Account::Billing
included do
has_one :subscription, class_name: "Account::Subscription", dependent: :destroy
has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy
set_callback :incinerate, :before, -> { subscription&.cancel }
set_callback :cancel, :after, -> { subscription&.pause }
set_callback :reactivate, :before, -> { subscription&.resume }
end
def plan
+25
View File
@@ -22,4 +22,29 @@ class Account::Subscription < SaasRecord
def next_amount_due
next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price
end
def pause
if stripe_subscription_id.present?
Stripe::Subscription.update(
stripe_subscription_id,
pause_collection: { behavior: "void" }
)
end
end
def resume
if stripe_subscription_id.present?
Stripe::Subscription.update(
stripe_subscription_id,
pause_collection: ""
)
end
end
def cancel
Stripe::Subscription.cancel(stripe_subscription_id) if stripe_subscription_id.present?
rescue Stripe::InvalidRequestError => e
# Subscription already deleted/canceled in Stripe - treat as success
Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
end
end
+40
View File
@@ -28,4 +28,44 @@ class Account::BillingTest < ActiveSupport::TestCase
account.comp
assert_equal 1, Account::BillingWaiver.where(account: account).count
end
test "cancel callback pauses subscription" do
account = accounts(:"37s")
user = users(:david)
subscription = mock("subscription")
subscription.expects(:pause).once
account.stubs(:subscription).returns(subscription)
account.cancel(initiated_by: user)
end
test "reactivate callback resumes subscription" do
account = accounts(:"37s")
user = users(:david)
# First cancel with a subscription mock
subscription_for_cancel = mock("subscription")
subscription_for_cancel.expects(:pause).once
account.stubs(:subscription).returns(subscription_for_cancel)
account.cancel(initiated_by: user)
# Now stub for reactivation
subscription_for_reactivate = mock("subscription")
subscription_for_reactivate.expects(:resume).once
account.stubs(:subscription).returns(subscription_for_reactivate)
account.reactivate
end
test "incinerate callback cancels subscription before destroying account" do
account = accounts(:"37s")
subscription = mock("subscription")
subscription.expects(:cancel).once
account.stubs(:subscription).returns(subscription)
account.incinerate
end
end
@@ -15,4 +15,106 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid?
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
end
test "pause pauses Stripe subscription with void behavior" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.expects(:update).with(
"sub_123",
pause_collection: { behavior: "void" }
).returns(true)
subscription.pause
end
test "pause does nothing when no stripe_subscription_id" do
subscription = Account::Subscription.new(stripe_subscription_id: nil)
Stripe::Subscription.expects(:update).never
subscription.pause
end
test "pause raises on Stripe errors" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.stubs(:update).raises(
Stripe::APIConnectionError.new("Network error")
)
assert_raises Stripe::APIConnectionError do
subscription.pause
end
end
test "resume resumes Stripe subscription" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.expects(:update).with(
"sub_123",
pause_collection: ""
).returns(true)
subscription.resume
end
test "resume does nothing when no stripe_subscription_id" do
subscription = Account::Subscription.new(stripe_subscription_id: nil)
Stripe::Subscription.expects(:update).never
subscription.resume
end
test "resume raises on Stripe errors" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.stubs(:update).raises(
Stripe::AuthenticationError.new("Invalid API key")
)
assert_raises Stripe::AuthenticationError do
subscription.resume
end
end
test "cancel cancels Stripe subscription" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.expects(:cancel).with("sub_123").returns(true)
subscription.cancel
end
test "cancel does nothing when no stripe_subscription_id" do
subscription = Account::Subscription.new(stripe_subscription_id: nil)
Stripe::Subscription.expects(:cancel).never
subscription.cancel
end
test "cancel treats 404 as success" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_deleted")
Stripe::Subscription.stubs(:cancel).raises(
Stripe::InvalidRequestError.new("No such subscription", {})
)
assert_nothing_raised do
subscription.cancel
end
end
test "cancel raises on other Stripe errors" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
Stripe::Subscription.stubs(:cancel).raises(
Stripe::RateLimitError.new("Rate limit exceeded")
)
assert_raises Stripe::RateLimitError do
subscription.cancel
end
end
end
@@ -0,0 +1,46 @@
require "test_helper"
class Account::CancellationsControllerTest < ActionDispatch::IntegrationTest
setup do
@account = accounts(:"37s")
@user = users(:jason)
sign_in_as @user
if @account.respond_to?(:subscription)
Account.any_instance.stubs(:subscription).returns(nil)
end
end
test "an owner can cancel the account" do
assert_difference -> { Account::Cancellation.count }, 1 do
assert_enqueued_emails 1 do
post account_cancellation_url
end
end
assert_redirected_to session_menu_path(script_name: nil)
assert_equal "Account deleted", flash[:notice]
assert @account.reload.cancelled?
assert_equal @user, @account.cancellation.initiated_by
end
test "non-owner cannot cancel the account" do
logout_and_sign_in_as users(:david)
assert_no_difference -> { Account::Cancellation.count } do
post account_cancellation_url
end
assert_response :forbidden
end
test "cancelling an account while in single-tenant mode does nothing" do
with_multi_tenant_mode(false) do
assert_no_difference -> { Account::Cancellation.count } do
post account_cancellation_url
end
assert_not @account.reload.cancelled?
end
end
end
@@ -78,4 +78,20 @@ class My::MenusControllerTest < ActionDispatch::IntegrationTest
get my_menu_path, headers: { "If-None-Match" => etag }
assert_response :not_modified
end
test "show excludes cancelled accounts" do
# Create another account for the same identity
another_account = Account.create!(external_account_id: 9999996, name: "Cancelled Account")
another_user = @user.identity.users.create!(account: another_account, name: "Kevin", role: "owner")
# Cancel the other account
another_account.cancel(initiated_by: another_user)
get my_menu_path
assert_response :success
# The response should include active account but not cancelled one
assert_select "a[href*='#{@account.slug}']"
assert_select "a[href*='#{another_account.slug}']", count: 0
end
end
@@ -47,4 +47,24 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "show excludes cancelled accounts" do
sign_in_as @identity
@identity.users.delete_all
account1 = Account.create!(external_account_id: 9999994, name: "Active Account")
account2 = Account.create!(external_account_id: 9999995, name: "Cancelled Account")
user1 = @identity.users.create!(account: account1, name: "Kevin", role: "owner")
user2 = @identity.users.create!(account: account2, name: "Kevin", role: "owner")
# Cancel one account
account2.cancel(initiated_by: user2)
untenanted do
get session_menu_url
end
# Should redirect to the only active account
assert_response :redirect
assert_redirected_to root_url(script_name: account1.slug)
end
end
+6
View File
@@ -9,3 +9,9 @@ initech:
name: Initech LLC
external_account_id: <%= ActiveRecord::FixtureSet.identify("initech") %>
cards_count: 0
acme:
id: <%= ActiveRecord::FixtureSet.identify("acme", :uuid) %>
name: ACME
external_account_id: <%= ActiveRecord::FixtureSet.identify("acme") %>
cards_count: 0
@@ -0,0 +1,67 @@
require "test_helper"
class Account::IncinerateDueJobTest < ActiveJob::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
# Stub Stripe methods only in SaaS mode
if defined?(Stripe::Subscription)
Stripe::Subscription.stubs(:update).returns(true)
Stripe::Subscription.stubs(:cancel).returns(true)
end
end
test "finds accounts up for incineration" do
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
Account.any_instance.expects(:incinerate).once
Account::IncinerateDueJob.perform_now
end
test "incinerates each old cancelled account" do
# Cancel the test account
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
# Just verify it gets incinerated
assert_difference -> { Account.count }, -1 do
Account::IncinerateDueJob.perform_now
end
end
test "skips recent cancellations" do
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 29.days.ago)
assert_no_difference -> { Account.count } do
Account::IncinerateDueJob.perform_now
end
end
test "handles cursor pagination correctly" do
# Cancel and age the account
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
# Verify it processes accounts in the scope
assert_difference -> { Account.count }, -1 do
Account::IncinerateDueJob.perform_now
end
end
test "only incinerates accounts past grace period" do
# Account at 29 days (within grace period - should not be incinerated)
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 29.days.ago)
assert_no_difference -> { Account.count } do
Account::IncinerateDueJob.perform_now
end
# The account should still exist
assert Account.exists?(@account.id)
end
end
+57
View File
@@ -0,0 +1,57 @@
require "test_helper"
class AccountMailerTest < ActionMailer::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
@account.cancel(initiated_by: @user)
@cancellation = @account.cancellation
end
test "cancellation sends to initiating user" do
email = AccountMailer.cancellation(@cancellation)
assert_emails 1 do
email.deliver_now
end
assert_equal [ @user.identity.email_address ], email.to
end
test "cancellation includes account name" do
email = AccountMailer.cancellation(@cancellation)
assert_match @account.name, email.body.encoded
end
test "cancellation includes support email" do
email = AccountMailer.cancellation(@cancellation)
assert_match "support@fizzy.do", email.body.encoded
end
test "cancellation has correct subject" do
email = AccountMailer.cancellation(@cancellation)
assert_equal "Your Fizzy account was cancelled", email.subject
end
test "cancellation has both HTML and text parts" do
email = AccountMailer.cancellation(@cancellation)
assert email.html_part.present?, "Email should have HTML part"
assert email.text_part.present?, "Email should have text part"
end
test "cancellation mentions account access is removed" do
email = AccountMailer.cancellation(@cancellation)
assert_match /no one can access/i, email.body.encoded
end
test "cancellation mentions data will be deleted" do
email = AccountMailer.cancellation(@cancellation)
assert_match /deleted/i, email.body.encoded
end
end
+79
View File
@@ -0,0 +1,79 @@
require "test_helper"
class Account::CancellableTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
end
test "cancel" do
assert_difference -> { Account::Cancellation.count }, 1 do
assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do
@account.cancel(initiated_by: @user)
end
end
assert @account.cancelled?
assert_equal @user, @account.cancellation.initiated_by
end
test "cancel does nothing if already cancelled" do
@account.cancel(initiated_by: @user)
assert_no_changes -> { @account.cancellation.reload.created_at } do
@account.cancel(initiated_by: @user)
end
end
test "cancel does nothing when in single-tenant mode" do
Account.stubs(:accepting_signups?).returns(false)
assert_no_difference -> { Account::Cancellation.count } do
@account.cancel(initiated_by: @user)
end
assert_not @account.cancelled?
end
test "cancelled? returns true when cancellation exists" do
assert_not @account.cancelled?
@account.cancel(initiated_by: @user)
assert @account.cancelled?
end
test "reactivate" do
@account.cancel(initiated_by: @user)
assert @account.cancelled?
@account.reactivate
@account.reload
assert_not @account.cancelled?
assert_nil @account.cancellation
end
test "reactivate does nothing if not cancelled" do
assert_not @account.cancelled?
assert_nothing_raised do
@account.reactivate
end
assert_not @account.cancelled?
end
test "active scope excludes cancelled accounts" do
account2 = accounts(:initech)
initial_active_count = Account.active.count
@account.cancel(initiated_by: @user)
assert_equal initial_active_count - 1, Account.active.count
assert_not_includes Account.active, @account
assert_includes Account.active, account2
end
end
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class Account::CancellationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+26
View File
@@ -0,0 +1,26 @@
require "test_helper"
class Account::IncineratableTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@user = users(:david)
end
test "incinerate destroys account" do
assert_difference -> { Account.count }, -1 do
@account.incinerate
end
assert_not Account.exists?(@account.id)
end
test "due_for_incineration finds old cancellations" do
@account.cancel(initiated_by: @user)
@account.cancellation.update!(created_at: 31.days.ago)
assert_equal [ @account ], Account.due_for_incineration
@account.cancellation.update!(created_at: 29.days.ago)
assert Account.due_for_incineration.empty?
end
end
+17
View File
@@ -1,6 +1,8 @@
require "test_helper"
class Notification::BundleTest < ActiveSupport::TestCase
include ActionMailer::TestHelper
setup do
@user = users(:david)
@user.settings.bundle_email_every_few_hours!
@@ -160,4 +162,19 @@ class Notification::BundleTest < ActiveSupport::TestCase
assert_includes @user.notification_bundles.last.notifications, first_notification
assert_includes @user.notification_bundles.last.notifications, second_notification
end
test "deliver does not send email for cancelled accounts" do
@user.notifications.create!(source: events(:logo_published), creator: @user)
bundle = @user.notification_bundles.pending.last
@user.account.cancel(initiated_by: @user)
assert_no_emails do
deliver_enqueued_emails do
bundle.deliver
end
end
assert bundle.delivered?, "Bundle should be marked as delivered even if not sent"
end
end
+32
View File
@@ -0,0 +1,32 @@
require "test_helper"
class NotificationPusherTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@notification = @user.notifications.create!(
source: events(:logo_published),
creator: users(:jason)
)
@pusher = NotificationPusher.new(@notification)
@user.push_subscriptions.create!(
endpoint: "https://fcm.googleapis.com/fcm/send/test123",
p256dh_key: "test_key",
auth_key: "test_auth"
)
end
test "push does not send notifications for cancelled accounts" do
@user.account.cancel(initiated_by: @user)
result = @pusher.push
assert_nil result, "Should not push notifications for cancelled accounts"
end
test "push sends notifications for active accounts with subscriptions" do
result = @pusher.push
assert_not_nil result, "Should push notifications for active accounts with subscriptions"
end
end
+58
View File
@@ -0,0 +1,58 @@
require "test_helper"
class Webhook::TriggerableTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@board = boards(:writebook)
@card = @board.cards.first
@webhook = @board.webhooks.create!(
name: "Test Webhook",
url: "https://example.com/webhook",
subscribed_actions: [ "card_published" ]
)
# Create a test event
@event = @board.events.create!(
creator: users(:david),
eventable: @card,
action: "card_published"
)
@user = users(:david)
end
test "trigger creates delivery for active accounts" do
assert_difference -> { Webhook::Delivery.count }, 1 do
@webhook.trigger(@event)
end
delivery = Webhook::Delivery.last
assert_equal @event, delivery.event
assert_equal @webhook, delivery.webhook
end
test "trigger skips cancelled accounts" do
@account.cancel(initiated_by: @user)
assert_no_difference -> { Webhook::Delivery.count } do
@webhook.trigger(@event)
end
end
test "triggered_by scope finds webhooks for event" do
other_webhook = @board.webhooks.create!(
name: "Other Webhook",
url: "https://example.com/other",
subscribed_actions: [ "card_closed" ]
)
matching_webhooks = Webhook.triggered_by(@event)
assert_includes matching_webhooks, @webhook
assert_not_includes matching_webhooks, other_webhook
end
test "active scope only returns active webhooks" do
@webhook.update!(active: false)
assert_not_includes Webhook.active, @webhook
end
end