Merge branch 'main' into mobile/bridge-components

* main:
  Remove explicit queue definition
  Fix attribute name
  Explicitly define incineration priority
  Fix incorrect recurring job config
  Add self-service account deletion (#2246)
  Document DELETE endpoint for removing card header image
  Document goldness endpoints for marking cards as golden
  Document closed and column fields in card API response
This commit is contained in:
Adrien Maston
2026-01-12 17:33:29 +01:00
52 changed files with 872 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:
class: "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"
+36
View File
@@ -574,6 +574,7 @@ __Response:__
"description_html": "<div class=\"action-text-content\"><p>Hello, World!</p></div>",
"image_url": null,
"tags": ["programming"],
"closed": false,
"golden": false,
"last_active_at": "2025-12-05T19:38:48.553Z",
"created_at": "2025-12-05T19:38:48.540Z",
@@ -594,6 +595,15 @@ __Response:__
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
}
},
"column": {
"id": "03f5v9zkft4hj9qq0lsn9ohcn",
"name": "In Progress",
"color": {
"name": "Lime",
"value": "var(--color-card-4)"
},
"created_at": "2025-12-05T19:36:35.534Z"
},
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
"name": "David Heinemeier Hansson",
@@ -619,6 +629,8 @@ __Response:__
}
```
> **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field.
### `POST /:account_slug/boards/:board_id/cards`
Creates a new card in a board.
@@ -683,6 +695,14 @@ __Response:__
Returns `204 No Content` on success.
### `DELETE /:account_slug/cards/:card_number/image`
Removes the header image from a card.
__Response:__
Returns `204 No Content` on success.
### `POST /:account_slug/cards/:card_number/closure`
Closes a card.
@@ -767,6 +787,22 @@ __Response:__
Returns `204 No Content` on success.
### `POST /:account_slug/cards/:card_number/goldness`
Marks a card as golden.
__Response:__
Returns `204 No Content` on success.
### `DELETE /:account_slug/cards/:card_number/goldness`
Removes golden status from a card.
__Response:__
Returns `204 No Content` on success.
## Comments
Comments are attached to cards and support rich text.
+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