Files
sure/test/controllers/goals_controller_test.rb
Guillem Arias 87817213be ux(goals): fix "0 of N · N reached" KPI weirdness
When every active goal already hit its target, the "Goals on track"
tile read "0 of 2 · 2 reached" — logically correct but emotionally
upside-down. Reached goals aren't being tracked toward pace anymore;
they belong in the trophy column, not in the fraction.

- New `tracked_total` excludes reached and paused goals from the
  denominator. Paused stops the pace clock on purpose; reached has
  already cleared it.
- When `tracked_total` hits zero and at least one goal is reached, the
  tile swaps to a celebratory empty state ("All caught up · N reached")
  instead of trying to render a fraction with no denominator.
- Drop "reached" from the subline when the fraction is calculable. The
  fraction is a needle, "N reached" is a trophy — surfacing them
  together muddied the message. Reached only appears in the all-caught-
  up empty state from here on.

Active-first / reached-last grid order already drops out of the
existing ACTIVE_STATUS_RANK sort (reached defaults to the lowest rank
so it naturally lands after behind / on_track / no_target_date /
paused).
2026-05-18 15:52:14 +02:00

188 lines
5.9 KiB
Ruby

require "test_helper"
class GoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@goal = goals(:vacation_italy)
@depository = accounts(:depository)
@connected = accounts(:connected)
ensure_tailwind_build
end
test "index renders with active filter by default" do
get goals_url
assert_response :success
assert_match(/Goals/i, response.body)
end
test "index honors state filter" do
get goals_url(state: "paused")
assert_response :success
end
test "show renders the goal" do
get goal_url(@goal)
assert_response :success
assert_match(@goal.name, response.body)
end
test "new renders the modal form" do
get new_goal_url
assert_response :success
end
test "create persists a goal with linked accounts" do
assert_difference -> { Goal.count } => 1,
-> { GoalAccount.count } => 2 do
post goals_url, params: {
goal: {
name: "New goal",
target_amount: "1000",
target_date: 3.months.from_now.to_date.iso8601,
color: "#4da568",
account_ids: [ @depository.id, @connected.id ]
}
}
end
goal = Goal.order(created_at: :desc).first
assert_redirected_to goal_path(goal)
end
test "create rejects missing account_ids" do
assert_no_difference "Goal.count" do
post goals_url, params: {
goal: {
name: "Bad goal",
target_amount: "1000",
color: "#4da568"
}
}
end
assert_response :unprocessable_entity
end
test "create rejects foreign accounts" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
foreign = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100)
assert_no_difference "Goal.count" do
post goals_url, params: {
goal: {
name: "Foreign goal",
target_amount: "1000",
color: "#4da568",
account_ids: [ foreign.id ]
}
}
end
assert_response :unprocessable_entity
end
test "update modifies identity fields" do
patch goal_url(@goal), params: { goal: { name: "Renamed" } }
assert_redirected_to goal_path(@goal)
assert_equal "Renamed", @goal.reload.name
end
test "update without account_ids leaves linked accounts intact" do
before = @goal.goal_accounts.pluck(:account_id).sort
patch goal_url(@goal), params: { goal: { name: "Still here" } }
assert_redirected_to goal_path(@goal)
assert_equal before, @goal.reload.goal_accounts.pluck(:account_id).sort
end
test "update with account_ids syncs linked accounts (add + remove)" do
patch goal_url(@goal), params: { goal: { account_ids: [ @connected.id ] } }
assert_redirected_to goal_path(@goal)
assert_equal [ @connected.id ], @goal.reload.goal_accounts.pluck(:account_id)
end
test "update with empty account_ids re-renders with error" do
patch goal_url(@goal), params: { goal: { account_ids: [ "" ] } }
assert_response :unprocessable_entity
assert_not_empty @goal.reload.goal_accounts
end
test "update rejects a cross-currency account attachment" do
# Regression: sync_linked_accounts! used to call goal_accounts.create!
# directly, bypassing Goal#linked_accounts_must_match_goal_currency.
eur_account = Account.create!(
family: @goal.family,
accountable: Depository.new,
name: "EUR Checking",
currency: "EUR",
balance: 100
)
before_ids = @goal.goal_accounts.pluck(:account_id).sort
patch goal_url(@goal), params: { goal: { account_ids: [ eur_account.id ] } }
assert_response :unprocessable_entity
assert_equal before_ids, @goal.reload.goal_accounts.pluck(:account_id).sort
end
test "pause/resume/complete/archive/unarchive flow" do
fresh = goals(:emergency_fund)
patch pause_goal_url(fresh)
assert fresh.reload.paused?
patch resume_goal_url(fresh)
assert fresh.reload.active?
patch complete_goal_url(fresh)
assert fresh.reload.completed?
patch archive_goal_url(fresh)
assert fresh.reload.archived?
patch unarchive_goal_url(fresh)
assert fresh.reload.active?
end
test "destroy on non-archived is rejected" do
assert_no_difference "Goal.count" do
delete goal_url(@goal)
end
assert_redirected_to goal_path(@goal)
end
test "destroy on archived deletes" do
@goal.archive!
assert_difference "Goal.count", -1 do
delete goal_url(@goal)
end
assert_redirected_to goals_path
end
test "index KPI swaps to 'All caught up' when every tracked goal is reached" do
family = users(:family_admin).family
family.goals.destroy_all
build_goal(family, "Wedding", target_amount: 1, target_date: 1.year.from_now)
Goal.any_instance.stubs(:status).returns(:reached)
get goals_url
assert_response :success
assert_match(/All caught up/i, response.body)
assert_match(/1\s*reached/i, response.body)
end
private
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)
g.save!
g
end
public
test "another family's goal returns 404" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
other_account = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100)
other_goal = other_family.goals.new(name: "Foreign goal", target_amount: 100, currency: "USD")
other_goal.goal_accounts.build(account: other_account)
other_goal.save!
get goal_url(other_goal)
assert_redirected_to goals_path
assert_equal I18n.t("goals.errors.not_found"), flash[:alert]
end
end