Family sharing (#1272)

* Initial account sharing changes

* Update schema.rb

* Update schema.rb

* Change sharing UI to modal

* UX fixes and sharing controls

* Scope include in finances better

* Update totals.rb

* Update totals.rb

* Scope reports to finance account scope

* Update impersonation_sessions_controller_test.rb

* Review fixes

* Update schema.rb

* Update show.html.erb

* FIX db validation

* Refine edit permissions

* Review items

* Review

* Review

* Add application level helper

* Critical review

* Address remaining review items

* Fix modals

* more scoping

* linter

* small UI fix

* Fix: Sync broadcasts push unscoped balance sheet to all users

* Update sync_complete_event.rb

 The fix removes the sidebar broadcasts (which rendered unscoped account groups using family.balance_sheet without user context)
  along with the now-unused sidebar_targets, account_group, and family_balance_sheet private methods.

  The sidebar will still update correctly — when the sync completes, Family::SyncCompleteEvent#broadcast fires family.broadcast_refresh, which triggers a
  morph-based page refresh for each user with their own authenticated session, rendering properly scoped sidebar content.
This commit is contained in:
soky srm
2026-03-25 10:50:23 +01:00
committed by GitHub
parent 6cf7d20010
commit 560c9fbff3
75 changed files with 1520 additions and 401 deletions

View File

@@ -0,0 +1,61 @@
require "test_helper"
class AccountShareTest < ActiveSupport::TestCase
setup do
@admin = users(:family_admin)
@member = users(:family_member)
@account = accounts(:depository)
end
test "valid share" do
# Use an account that doesn't already have a share with member
account = accounts(:investment)
account.account_shares.where(user: @member).destroy_all
share = AccountShare.new(account: account, user: @member, permission: "read_only")
assert share.valid?
end
test "invalid permission" do
share = AccountShare.new(account: @account, user: @member, permission: "invalid")
assert_not share.valid?
assert_includes share.errors[:permission], "is not included in the list"
end
test "cannot share with account owner" do
share = AccountShare.new(account: @account, user: @admin, permission: "read_only")
assert_not share.valid?
assert_includes share.errors[:user], "is already the owner of this account"
end
test "cannot duplicate share for same user and account" do
# depository already shared with member via fixture
duplicate = AccountShare.new(account: @account, user: @member, permission: "read_only")
assert_not duplicate.valid?
end
test "permission helper methods" do
share = AccountShare.new(permission: "full_control")
assert share.full_control?
assert_not share.read_write?
assert_not share.read_only?
assert share.can_annotate?
assert share.can_edit?
share.permission = "read_write"
assert share.read_write?
assert share.can_annotate?
assert_not share.can_edit?
share.permission = "read_only"
assert share.read_only?
assert_not share.can_annotate?
assert_not share.can_edit?
end
test "cannot share with user from different family" do
other_user = users(:empty)
share = AccountShare.new(account: @account, user: other_user, permission: "read_only")
assert_not share.valid?
assert_includes share.errors[:user], "must be in the same family"
end
end

View File

@@ -6,6 +6,8 @@ class AccountTest < ActiveSupport::TestCase
setup do
@account = @syncable = accounts(:depository)
@family = families(:dylan_family)
@admin = users(:family_admin)
@member = users(:family_member)
end
test "can destroy" do
@@ -19,6 +21,7 @@ class AccountTest < ActiveSupport::TestCase
account = Account.create_and_sync({
family: @family,
owner: @admin,
name: "Test Account",
balance: 100,
currency: "USD",
@@ -37,6 +40,7 @@ class AccountTest < ActiveSupport::TestCase
account = Account.create_and_sync(
{
family: @family,
owner: @admin,
name: "Linked Account",
balance: 500,
currency: "EUR",
@@ -57,6 +61,7 @@ class AccountTest < ActiveSupport::TestCase
account = Account.create_and_sync(
{
family: @family,
owner: @admin,
name: "Test Account",
balance: 1000,
currency: "GBP",
@@ -79,6 +84,7 @@ class AccountTest < ActiveSupport::TestCase
account = Account.create_and_sync(
{
family: @family,
owner: @admin,
name: "Test Account",
balance: 1000,
currency: "USD",
@@ -96,6 +102,7 @@ class AccountTest < ActiveSupport::TestCase
test "gets short/long subtype label" do
investment = Investment.new(subtype: "hsa")
account = @family.accounts.create!(
owner: @admin,
name: "Test Investment",
balance: 1000,
currency: "USD",
@@ -116,6 +123,7 @@ class AccountTest < ActiveSupport::TestCase
test "tax_treatment delegates to accountable for Investment" do
investment = Investment.new(subtype: "401k")
account = @family.accounts.create!(
owner: @admin,
name: "Test 401k",
balance: 1000,
currency: "USD",
@@ -129,6 +137,7 @@ class AccountTest < ActiveSupport::TestCase
test "tax_treatment delegates to accountable for Crypto" do
crypto = Crypto.new(tax_treatment: :taxable)
account = @family.accounts.create!(
owner: @admin,
name: "Test Crypto",
balance: 500,
currency: "USD",
@@ -148,6 +157,7 @@ class AccountTest < ActiveSupport::TestCase
test "tax_advantaged? returns true for tax-advantaged accounts" do
investment = Investment.new(subtype: "401k")
account = @family.accounts.create!(
owner: @admin,
name: "Test 401k",
balance: 1000,
currency: "USD",
@@ -161,6 +171,7 @@ class AccountTest < ActiveSupport::TestCase
test "tax_advantaged? returns false for taxable accounts" do
investment = Investment.new(subtype: "brokerage")
account = @family.accounts.create!(
owner: @admin,
name: "Test Brokerage",
balance: 1000,
currency: "USD",
@@ -193,4 +204,86 @@ class AccountTest < ActiveSupport::TestCase
assert_not ActiveStorage::Attachment.exists?(attachment_id)
end
# Account sharing tests
test "owned_by? returns true for account owner" do
assert @account.owned_by?(@admin)
assert_not @account.owned_by?(@member)
end
test "shared_with? returns true for owner and shared users" do
assert @account.shared_with?(@admin) # owner
# depository already shared with member via fixture
assert @account.shared_with?(@member)
end
test "shared? returns true when account has shares" do
account = accounts(:investment)
account.account_shares.destroy_all
assert_not account.shared?
account.share_with!(@member, permission: "read_only")
assert account.shared?
end
test "permission_for returns correct permission level" do
assert_equal :owner, @account.permission_for(@admin)
# depository already shared with member via fixture
share = @account.account_shares.find_by(user: @member)
share.update!(permission: "read_write")
assert_equal :read_write, @account.permission_for(@member)
end
test "accessible_by scope returns owned and shared accounts" do
# Clear existing shares for clean test
AccountShare.delete_all
admin_accessible = @family.accounts.accessible_by(@admin)
member_accessible = @family.accounts.accessible_by(@member)
# Admin owns all fixture accounts
assert_equal @family.accounts.count, admin_accessible.count
# Member has no access (no shares, no owned accounts)
assert_equal 0, member_accessible.count
# Share one account
@account.share_with!(@member, permission: "read_only")
member_accessible = @family.accounts.accessible_by(@member)
assert_equal 1, member_accessible.count
assert_includes member_accessible, @account
end
test "included_in_finances_for scope respects include_in_finances flag" do
AccountShare.delete_all
@account.share_with!(@member, permission: "read_only", include_in_finances: true)
assert_includes @family.accounts.included_in_finances_for(@member), @account
share = @account.account_shares.find_by(user: @member)
share.update!(include_in_finances: false)
assert_not_includes @family.accounts.included_in_finances_for(@member), @account
end
test "auto_share_with_family creates shares for all non-owner members" do
account = Account.create_and_sync({
family: @family,
owner: @admin,
name: "New Shared Account",
balance: 100,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
})
assert_difference -> { AccountShare.count }, @family.users.where.not(id: @admin.id).count do
account.auto_share_with_family!
end
share = account.account_shares.find_by(user: @member)
assert_not_nil share
assert_equal "read_write", share.permission
assert share.include_in_finances?
end
end