Files
sure/test/models/user_test.rb
Guillem Arias Fauste c29380ce57 feat(dashboard): masonry packing + per-widget size controls (#2328)
* feat(dashboard): masonry packing + per-widget size controls

In two-column mode the dashboard used a row-based CSS grid, so cards
stretched to equal row height and left dead space (e.g. the Net Worth
chart padded out to match the tall Balance Sheet table). Replace the
row-based layout with masonry packing and add per-widget size guardrails.

- Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven
  by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load).
  The DOM stays a single flat list, so drag/keyboard reorder is unaffected.
  Active only in multi-column mode; single column falls back to normal flow.
- Internal sizing: the net worth chart height is now driven by a
  --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads
  out below the chart.
- Guardrails: per-widget layout metadata (col_span, grow, min_height,
  width_toggle) in PagesController, with per-user overrides persisted under
  preferences["dashboard_section_layout"], deep-merged so width and height coexist.
- Size menu: a hover control on size-capable cards — Width (Half/Full) for the
  cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow
  widgets. The sankey defaults to full width.

Adds model + controller tests for preference persistence and i18n keys.

* refactor(dashboard): redesign size menu with segmented controls

The size menu used a plain radio list and a diagonal maximize-2 trigger that
collided with the cashflow sankey's modal-expand button. Replace it with a
layout-config popover: a sliders-horizontal trigger plus two labeled axis
groups (Width, Height), each rendered as a DS::SegmentedControl with the
active option filled. Clearer, more compact, and it reads as a card-layout
control. The widget-size controller now mirrors the segmented control's
active-class + aria-pressed contract.

* feat(dashboard): expose width toggle on balance sheet and investments

Tables benefit from horizontal room, so give Balance Sheet and Investments
the same Width (Half/Full) control as the sankey and net worth chart. Height
presets stay chart-only — a table sized to a fixed height would just add
whitespace or force scrolling. The Outflows donut is intentionally left out
(full width is mostly whitespace for a donut).

* fix(dashboard): address review feedback on size controls

- Only apply the full-width col-span and show the Width control when the
  two-column layout is enabled. A full widget previously leaked
  2xl:col-span-2 into the single-column grid, creating an implicit second
  column at 2xl widths and breaking the single-column preference. This also
  keeps the Width control coherent with the Appearance two-column setting
  (it now appears only where it does something). [Codex]
- Stop size-menu keydowns from bubbling to the section reorder handler, so
  keyboard users can open the menu and pick options without entering
  grab/reorder mode. [Codex]
- Harden preferences params: ignore a malformed (non-hash)
  dashboard_section_layout / collapsed_sections instead of raising a 500,
  and require section_order to be an array. [CodeRabbit]
- Localize the dashboard sections aria-label. [CodeRabbit]

* chore(settings): mention per-widget size controls in two-column copy

Surface the new per-widget width/height controls in the Appearance
"Two-column layout" description so the capability is discoverable.

* test(dashboard): assert non-mutation for malformed layout input

Addresses review feedback: asserting assert_nil made the test depend on
the fixture happening to have no dashboard height for net_worth_chart.
Capture the pre-PATCH value and assert it is unchanged, so the test
stays valid (malformed input ignored) even if the fixture later gets a
default height.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-15 20:34:34 +02:00

760 lines
25 KiB
Ruby

require "test_helper"
class UserTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
def setup
@user = users(:family_admin)
end
def teardown
clear_enqueued_jobs
clear_performed_jobs
end
test "should be valid" do
assert @user.valid?, @user.errors.full_messages.to_sentence
end
# email
test "email must be present" do
potential_user = User.new(
email: "david@davidbowie.com",
password_digest: BCrypt::Password.create("password"),
first_name: "David",
last_name: "Bowie"
)
potential_user.email = " "
assert_not potential_user.valid?
end
test "has email address" do
assert_equal "bob@bobdylan.com", @user.email
end
test "can update email" do
@user.update(email: "new_email@example.com")
assert_equal "new_email@example.com", @user.email
end
test "email addresses must be unique" do
duplicate_user = @user.dup
duplicate_user.email = @user.email.upcase
@user.save
assert_not duplicate_user.valid?
end
test "email address is normalized" do
@user.update!(email: " UNIQUE-User@ExAMPle.CoM ")
assert_equal "unique-user@example.com", @user.reload.email
end
test "display name" do
user = User.new(email: "user@example.com")
assert_equal "user@example.com", user.display_name
user.first_name = "Bob"
assert_equal "Bob", user.display_name
user.last_name = "Dylan"
assert_equal "Bob Dylan", user.display_name
end
test "initial" do
user = User.new(email: "user@example.com")
assert_equal "U", user.initial
user.first_name = "Bob"
assert_equal "B", user.initial
user.first_name = nil
user.last_name = "Dylan"
assert_equal "D", user.initial
end
test "names are normalized" do
@user.update!(first_name: "", last_name: "")
assert_nil @user.first_name
assert_nil @user.last_name
@user.update!(first_name: " Bob ", last_name: " Dylan ")
assert_equal "Bob", @user.first_name
assert_equal "Dylan", @user.last_name
end
# MFA Tests
test "setup_mfa! generates required fields" do
user = users(:family_member)
user.setup_mfa!
assert user.otp_secret.present?
assert_not user.otp_required?
assert_empty user.otp_backup_codes
end
test "enable_mfa! enables MFA and generates backup codes" do
user = users(:family_member)
user.setup_mfa!
backup_codes = user.enable_mfa!
assert user.otp_required?
assert_equal 8, backup_codes.length
assert backup_codes.all? { |code| code.match?(/\A[0-9a-f]{16}\z/) }
assert_equal 8, user.otp_backup_codes.length
assert user.otp_backup_codes.all? { |code| code.start_with?("$2") }
assert_empty backup_codes & user.otp_backup_codes
end
test "enable_mfa! requires an OTP secret" do
user = users(:family_member)
user.setup_mfa!
user.update_column(:otp_secret, nil)
assert_raises(ArgumentError) { user.enable_mfa! }
assert_not user.reload.otp_required?
assert_empty user.otp_backup_codes
end
test "disable_mfa! removes all MFA data" do
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
user.webauthn_credentials.create!(
nickname: "YubiKey",
credential_id: "credential-id",
public_key: "public-key"
)
user.disable_mfa!
assert_nil user.otp_secret
assert_not user.otp_required?
assert_empty user.otp_backup_codes
assert_empty user.webauthn_credentials
end
test "ensure_webauthn_id! generates a stable credential user handle" do
user = users(:family_member)
assert_nil user.webauthn_id
webauthn_id = user.ensure_webauthn_id!
assert webauthn_id.present?
assert_equal webauthn_id, user.reload.ensure_webauthn_id!
end
test "webauthn_enabled? requires MFA and at least one credential" do
user = users(:family_member)
assert_not user.webauthn_enabled?
user.setup_mfa!
user.enable_mfa!
assert_not user.webauthn_enabled?
user.webauthn_credentials.create!(
nickname: "Touch ID",
credential_id: "touch-id-credential",
public_key: "public-key"
)
assert user.webauthn_enabled?
end
test "verify_otp? validates TOTP codes" do
user = users(:family_member)
user.setup_mfa!
totp = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances")
valid_code = totp.now
assert user.verify_otp?(valid_code)
assert_not user.verify_otp?("invalid")
assert_not user.verify_otp?("123456")
end
test "verify_otp? does not check backup code digests for normal TOTP input" do
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
valid_code = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances").now
BCrypt::Password.expects(:new).never
assert user.verify_otp?(valid_code)
end
test "verify_otp? fast rejects non-backup-code input before digest checks" do
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
BCrypt::Password.expects(:new).never
assert_not user.verify_otp?("not-a-backup-code")
end
test "verify_otp? rejects unmatched legacy-shaped backup input" do
user = users(:family_member)
user.setup_mfa!
user.enable_mfa!
assert_not user.verify_otp?("deadbeef")
end
test "verify_otp? accepts backup codes" do
user = users(:family_member)
user.setup_mfa!
backup_codes = user.enable_mfa!
backup_code = backup_codes.first
matching_digest = user.otp_backup_codes.find { |digest| BCrypt::Password.new(digest).is_password?(backup_code) }
assert_not_nil matching_digest
assert user.verify_otp?(backup_code)
# Backup code should be consumed
assert_equal 7, user.otp_backup_codes.length
assert_not_includes user.otp_backup_codes, matching_digest
# Used backup code should not work again
assert_not user.verify_otp?(backup_code)
end
test "verify_otp? reloads backup codes while consuming under lock" do
user = users(:family_member)
user.setup_mfa!
backup_code = user.enable_mfa!.first
stale_user = User.find(user.id)
user.update!(otp_backup_codes: [])
assert_not stale_user.verify_otp?(backup_code)
assert_empty stale_user.reload.otp_backup_codes
end
test "verify_otp? accepts and consumes legacy plaintext backup codes once" do
user = users(:family_member)
user.setup_mfa!
user.update!(otp_required: true, otp_backup_codes: [ "deadbeef" ])
assert user.verify_otp?("deadbeef")
assert_empty user.reload.otp_backup_codes
assert_not user.verify_otp?("deadbeef")
end
test "verify_otp? accepts and consumes migrated legacy backup code digests" do
user = users(:family_member)
user.setup_mfa!
user.update!(
otp_required: true,
otp_backup_codes: [ BCrypt::Password.create("deadbeef", cost: BCrypt::Engine::MIN_COST).to_s ]
)
assert user.verify_otp?("deadbeef")
assert_empty user.reload.otp_backup_codes
assert_not user.verify_otp?("deadbeef")
end
test "provisioning_uri generates correct URI" do
user = users(:family_member)
user.setup_mfa!
assert_match %r{otpauth://totp/}, user.provisioning_uri
assert_match %r{secret=#{user.otp_secret}}, user.provisioning_uri
assert_match %r{issuer=Sure}, user.provisioning_uri
end
test "ai_available? returns true when openai access token set in settings" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
Setting.openai_access_token = nil
assert_not @user.ai_available?
Setting.openai_access_token = "token"
assert @user.ai_available?
end
ensure
Setting.openai_access_token = previous
end
test "ai_available? returns true when external assistant is configured and family type is external" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
@user.family.update!(assistant_type: "external")
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
Setting.openai_access_token = nil
assert @user.ai_available?
end
ensure
Setting.openai_access_token = previous
@user.family.update!(assistant_type: "builtin")
end
test "ai_available? returns false when external assistant is configured but family type is builtin" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
Setting.openai_access_token = nil
assert_not @user.ai_available?
end
ensure
Setting.openai_access_token = previous
end
test "ai_available? returns false when external assistant is configured but user is not in allowlist" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
@user.family.update!(assistant_type: "external")
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do
Setting.openai_access_token = nil
assert_not @user.ai_available?
end
ensure
Setting.openai_access_token = previous
@user.family.update!(assistant_type: "builtin")
end
test "intro layout collapses sidebars and enables ai" do
user = User.new(
family: families(:empty),
email: "intro-new@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :guest,
ui_layout: :intro
)
assert user.save, user.errors.full_messages.to_sentence
assert user.ui_layout_intro?
assert_not user.show_sidebar?
assert_not user.show_ai_sidebar?
assert user.ai_enabled?
end
test "non-guest role cannot persist intro layout" do
user = User.new(
family: families(:empty),
email: "dashboard-only@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :member,
ui_layout: :intro
)
assert user.save, user.errors.full_messages.to_sentence
assert user.ui_layout_dashboard?
end
test "upgrading guest role restores dashboard layout defaults" do
user = users(:intro_user)
user.update!(role: :member)
user.reload
assert user.ui_layout_dashboard?
assert user.show_sidebar?
assert user.show_ai_sidebar?
end
test "new member defaults show_ai_sidebar to false when AI is not available" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
Setting.openai_access_token = nil
user = User.new(
family: families(:empty),
email: "member-no-ai@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :member
)
assert user.save, user.errors.full_messages.to_sentence
assert_not user.show_ai_sidebar?
end
ensure
Setting.openai_access_token = previous
end
test "new admin defaults show_ai_sidebar to true even when AI is not available" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
Setting.openai_access_token = nil
user = User.new(
family: families(:empty),
email: "admin-no-ai@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :admin
)
assert user.save, user.errors.full_messages.to_sentence
assert user.show_ai_sidebar?
end
ensure
Setting.openai_access_token = previous
end
test "new member defaults show_ai_sidebar to true when AI is available" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
user = User.new(
family: families(:empty),
email: "member-with-ai@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :member
)
assert user.save, user.errors.full_messages.to_sentence
assert user.show_ai_sidebar?
end
test "new guest defaults show_ai_sidebar to false when AI is not available" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
previous = Setting.openai_access_token
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
Setting.openai_access_token = nil
user = User.new(
family: families(:empty),
email: "guest-no-ai@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :guest
)
assert user.save, user.errors.full_messages.to_sentence
assert_not user.show_ai_sidebar?
end
ensure
Setting.openai_access_token = previous
end
test "new guest defaults show_ai_sidebar to false when AI is available" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
user = User.new(
family: families(:empty),
email: "guest-with-ai@example.com",
password: "Password1!",
password_confirmation: "Password1!",
role: :guest
)
assert user.save, user.errors.full_messages.to_sentence
assert_not user.show_ai_sidebar?
end
test "update_dashboard_preferences handles concurrent updates atomically" do
@user.update!(preferences: {})
# Simulate concurrent updates from multiple requests
# Each thread collapses a different section simultaneously
threads = []
sections = %w[net_worth_chart outflows_donut cashflow_sankey balance_sheet]
sections.each_with_index do |section, index|
threads << Thread.new do
# Small staggered delays to increase chance of race conditions
sleep(index * 0.01)
# Each thread loads its own instance and updates
user = User.find(@user.id)
user.update_dashboard_preferences({
"collapsed_sections" => { section => true }
})
end
end
# Wait for all threads to complete
threads.each(&:join)
# Verify all updates persisted (no data loss from race conditions)
@user.reload
sections.each do |section|
assert @user.dashboard_section_collapsed?(section),
"Expected #{section} to be collapsed, but it was not. " \
"Preferences: #{@user.preferences.inspect}"
end
# Verify all sections are in the preferences hash
assert_equal sections.sort,
@user.preferences.dig("collapsed_sections")&.keys&.sort,
"Expected all sections to be in preferences"
end
test "update_dashboard_preferences merges nested hashes correctly" do
@user.update!(preferences: {})
# First update: collapse net_worth
@user.update_dashboard_preferences({
"collapsed_sections" => { "net_worth_chart" => true }
})
@user.reload
assert @user.dashboard_section_collapsed?("net_worth_chart")
assert_not @user.dashboard_section_collapsed?("outflows_donut")
# Second update: collapse outflows (should preserve net_worth)
@user.update_dashboard_preferences({
"collapsed_sections" => { "outflows_donut" => true }
})
@user.reload
assert @user.dashboard_section_collapsed?("net_worth_chart"),
"First collapsed section should still be collapsed"
assert @user.dashboard_section_collapsed?("outflows_donut"),
"Second collapsed section should be collapsed"
end
test "update_dashboard_preferences handles section_order updates" do
@user.update!(preferences: {})
# Set initial order
new_order = %w[outflows_donut net_worth_chart cashflow_sankey balance_sheet]
@user.update_dashboard_preferences({ "section_order" => new_order })
@user.reload
assert_equal new_order, @user.dashboard_section_order
end
test "dashboard_section_height returns stored preset or nil" do
@user.update!(preferences: {})
assert_nil @user.dashboard_section_height("net_worth_chart")
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
})
@user.reload
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
assert_nil @user.dashboard_section_height("balance_sheet")
end
test "dashboard_section_width returns stored col_span or nil" do
@user.update!(preferences: {})
assert_nil @user.dashboard_section_width("cashflow_sankey")
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "cashflow_sankey" => { "col_span" => "single" } }
})
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
end
test "dashboard_section_layout merges width and height without clobbering" do
@user.update!(preferences: {})
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
})
@user.update_dashboard_preferences({
"dashboard_section_layout" => { "net_worth_chart" => { "col_span" => "full" } }
})
@user.reload
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
assert_equal "full", @user.dashboard_section_width("net_worth_chart")
end
test "handles empty preferences gracefully for dashboard methods" do
@user.update!(preferences: {})
# dashboard_section_collapsed? should return false when key is missing
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
"Should return false when collapsed_sections key is missing"
# dashboard_section_order should return default order when key is missing
assert_equal %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet],
@user.dashboard_section_order,
"Should return default order when section_order key is missing"
# update_dashboard_preferences should work with empty preferences
@user.update_dashboard_preferences({ "section_order" => %w[balance_sheet] })
@user.reload
assert_equal %w[balance_sheet], @user.preferences["section_order"]
end
test "handles empty preferences gracefully for reports methods" do
@user.update!(preferences: {})
# reports_section_collapsed? should return false when key is missing
assert_not @user.reports_section_collapsed?("trends_insights"),
"Should return false when reports_collapsed_sections key is missing"
# reports_section_order should return default order when key is missing
assert_equal %w[trends_insights transactions_breakdown],
@user.reports_section_order,
"Should return default order when reports_section_order key is missing"
# update_reports_preferences should work with empty preferences
@user.update_reports_preferences({ "reports_section_order" => %w[transactions_breakdown] })
@user.reload
assert_equal %w[transactions_breakdown], @user.preferences["reports_section_order"]
end
test "handles missing nested keys in preferences for collapsed sections" do
@user.update!(preferences: { "section_order" => %w[cashflow] })
# Should return false when collapsed_sections key is missing entirely
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
"Should return false when collapsed_sections key is missing"
# Should return false when section_key is missing from collapsed_sections
@user.update!(preferences: { "collapsed_sections" => {} })
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
"Should return false when section key is missing from collapsed_sections"
end
# Default account for transactions
test "default_account_for_transactions returns account when active and manual" do
account = accounts(:depository)
@user.update!(default_account: account)
assert_equal account, @user.default_account_for_transactions
end
test "default_account_for_transactions returns nil when account is disabled" do
account = accounts(:depository)
@user.update!(default_account: account)
account.disable!
assert_nil @user.default_account_for_transactions
end
test "default_account_for_transactions returns nil when account is linked" do
account = accounts(:depository)
@user.update!(default_account: account)
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: account, provider: plaid_account)
account.reload
assert_nil @user.default_account_for_transactions
end
test "default_account_for_transactions returns nil when no default set" do
assert_nil @user.default_account_for_transactions
end
# SSO-only user security tests
test "sso_only? returns true for user with OIDC identity and no password" do
sso_user = users(:sso_only)
assert_nil sso_user.password_digest
assert sso_user.oidc_identities.exists?
assert sso_user.sso_only?
end
test "sso_only? returns false for user with password and OIDC identity" do
# family_admin has both password and OIDC identity
assert @user.password_digest.present?
assert @user.oidc_identities.exists?
assert_not @user.sso_only?
end
test "sso_only? returns false for user with password but no OIDC identity" do
user_without_oidc = users(:empty)
assert user_without_oidc.password_digest.present?
assert_not user_without_oidc.oidc_identities.exists?
assert_not user_without_oidc.sso_only?
end
test "has_local_password? returns true when password_digest is present" do
assert @user.has_local_password?
end
test "has_local_password? returns false when password_digest is nil" do
sso_user = users(:sso_only)
assert_not sso_user.has_local_password?
end
test "user can be created without password when skip_password_validation is true" do
user = User.new(
email: "newssuser@example.com",
first_name: "New",
last_name: "SSO User",
skip_password_validation: true,
family: families(:empty)
)
assert user.valid?, user.errors.full_messages.to_sentence
assert user.save
assert_nil user.password_digest
end
test "user requires password on create when skip_password_validation is false" do
user = User.new(
email: "needspassword@example.com",
first_name: "Needs",
last_name: "Password",
family: families(:empty)
)
assert_not user.valid?
assert_includes user.errors[:password], "can't be blank"
end
# First user role assignment tests
test "role_for_new_family_creator returns super_admin when no users exist" do
# Delete all users to simulate fresh instance
User.destroy_all
assert_equal :super_admin, User.role_for_new_family_creator
end
test "role_for_new_family_creator returns fallback role when users exist" do
# Users exist from fixtures
assert User.exists?
assert_equal :admin, User.role_for_new_family_creator
assert_equal :member, User.role_for_new_family_creator(fallback_role: :member)
assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
end
# Preview features preference tests
test "preview_features_enabled? defaults to false" do
@user.update!(preferences: {})
assert_not @user.preview_features_enabled?
end
test "preview_features_enabled? true only when explicitly true" do
@user.update!(preferences: { "preview_features_enabled" => true })
assert @user.preview_features_enabled?
@user.update!(preferences: { "preview_features_enabled" => false })
assert_not @user.preview_features_enabled?
@user.update!(preferences: { "preview_features_enabled" => "yes" })
assert_not @user.preview_features_enabled?, "truthy non-boolean should not enable"
end
# ActiveStorage attachment cleanup tests
test "purging a user removes attached profile image" do
user = users(:family_admin)
user.profile_image.attach(
io: StringIO.new("profile-image-data"),
filename: "profile.png",
content_type: "image/png"
)
attachment_id = user.profile_image.id
assert ActiveStorage::Attachment.exists?(attachment_id)
perform_enqueued_jobs do
user.purge
end
assert_not User.exists?(user.id)
assert_not ActiveStorage::Attachment.exists?(attachment_id)
end
test "purging the last user cascades to remove family and its export attachments" do
family = Family.create!(name: "Solo Family", locale: "en", date_format: "%m-%d-%Y", currency: "USD")
user = User.create!(family: family, email: "solo@example.com", password: "password123")
export = family.family_exports.create!
export.export_file.attach(
io: StringIO.new("export-data"),
filename: "export.zip",
content_type: "application/zip"
)
export_attachment_id = export.export_file.id
assert ActiveStorage::Attachment.exists?(export_attachment_id)
perform_enqueued_jobs do
user.purge
end
assert_not Family.exists?(family.id)
assert_not ActiveStorage::Attachment.exists?(export_attachment_id)
end
end