fix(goals): recover goals left invalid by account deletion (#2964)

Deleting an account destroys its `goal_accounts` rows (Account has_many
:goal_accounts, dependent: :destroy). Any goal funded only by that account
survives with zero links and permanently fails
`must_have_at_least_one_linked_account`. Two bugs made that state a dead end.

`#update` saved the attributes before attaching the submitted accounts, so
validation ran against the goal's old (empty) link set and raised before the
new links were applied. Editing was the only route back to a valid goal, and
it always returned 422. Assign, sync the links, then persist once — one save
over the fully assembled goal validates what the user actually submitted.

`perform_transition!` discarded the return value of AASM's bang event. AASM
returns false rather than raising when the save that persists the new state
fails validation, so an invalid goal flashed "Goal archived." while the state
never moved. Check the result and surface the validation error instead.

Neither fix changes behaviour for a valid goal: the bang events return true
on success, and the update path persists the same attributes and links as
before.

This does not change what happens to a goal when its account is deleted —
whether the goal should follow the account, block the deletion, or be
surfaced as needing attention is a product decision left open. It only makes
the resulting state recoverable and stops the UI reporting success when
nothing happened.
This commit is contained in:
Guillem Arias Fauste
2026-08-11 06:17:07 +02:00
committed by GitHub
parent 2bcef99441
commit f1ddbcd1b5
2 changed files with 80 additions and 11 deletions
+47
View File
@@ -210,6 +210,36 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
assert fresh.reload.active?
end
# A goal whose last funding account is deleted survives with zero links and
# fails `must_have_at_least_one_linked_account` from then on. Editing is the
# only way back, so update must validate the accounts the user SUBMITTED,
# not the stale (empty) set already on the record.
test "an orphaned goal can be repaired by re-linking an account" do
orphan = orphaned_goal
patch goal_url(orphan), params: {
goal: { name: orphan.name, target_amount: orphan.target_amount, account_ids: [ @depository.id ] }
}
assert_redirected_to goal_path(orphan)
assert_equal [ @depository.id ], orphan.reload.goal_accounts.pluck(:account_id)
assert orphan.valid?
end
# AASM's bang event returns false rather than raising when the post-transition
# save fails validation. The controller used to discard that, flashing
# "Goal archived." while the state never moved.
test "a transition that fails validation reports the error, not success" do
orphan = orphaned_goal
patch archive_goal_url(orphan)
assert_redirected_to goal_path(orphan)
assert_nil flash[:notice]
assert_match(/at least one account/i, flash[:alert])
assert_equal "active", orphan.reload.state
end
test "destroy on non-archived is rejected" do
assert_no_difference "Goal.count" do
delete goal_url(@goal)
@@ -257,6 +287,23 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
end
private
# A goal in the state account deletion leaves behind: still present, zero
# linked accounts, failing its own validations.
def orphaned_goal
family = @user.family
throwaway = Account.create!(
family: family, accountable: Depository.new, name: "Throwaway", currency: "USD", balance: 100
)
goal = family.goals.new(name: "Orphan", target_amount: 500, currency: "USD")
goal.goal_accounts.build(account: throwaway)
goal.save!
throwaway.destroy!
goal.reload
assert_empty goal.goal_accounts, "fixture setup failed to orphan the goal"
goal
end
def build_goal(family, name, target_amount: 1_000_000, target_date: nil)
g = family.goals.new(name: name, target_amount: target_amount, target_date: target_date, currency: "USD")
g.goal_accounts.build(account: @depository)