mirror of
https://github.com/we-promise/sure.git
synced 2026-09-07 15:44:21 +00:00
* feat(goals): reserves you maintain, not goals you finish An emergency fund is not a goal you reach and close — it is a level you hold, and every withdrawal is a shortfall to make good. Sure treated it like anything else: at 100% it offered to close it, which would release the very money being set aside; a withdrawal dropped the bar with no sign that anything was owed. `kind` (added by the lifecycle lot without behavior) now means something. A maintained goal is `funded` or `depleted`, never `reached` — sitting at its floor is a steady state, not an achievement to file away. `complete` is refused by an AASM guard rather than merely hidden, so no path can release a reserve's earmark. Two ordering traps, both of which would have made a drained reserve invisible: `ACTIVE_DISPLAY_STATUS_RANK` falls back to 4 for any status it does not know, so an unranked `:depleted` would sort a drained emergency fund below everything else — the exact opposite of what it means. It ranks alongside `:behind` now, and `:funded` sorts near the end with the goals that need nothing. `behind_pace?` excludes reserves. `monthly_target_amount` and `pace` both derive from `target_date`, which a reserve does not have, so "save X/month to catch up" would be advice about a deadline that does not exist. The form leads with the choice, since it changes what the rest of it means, and hides the target date for a reserve rather than disabling it — a hidden field cannot submit a stale value that would then drive a pace. The card states the shortfall, which is exactly `remaining_amount`. The panel that offers a one-off its closing action tells a reserve it is intact and offers nothing, because there is nothing to do. Scope: fixed targets only. Targets expressed in months of expenses, the monthly refresh job, and the depletion insight are the next two PRs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): let a reserve behave like one everywhere it is shown Addresses review feedback on #3167. The kind selector never hid the target date. `data-controller="goal-kind"` sat on the selector div while its `dateField` target is a sibling, so `dateFieldTargets` came back empty and picking "Reserve to maintain" left the deadline on screen and submittable. The controller moves to the form wrapper, which encloses both. Hiding a field is not enforcement, so the model now clears `target_date` for a maintained goal. Normalising rather than rejecting: the field is hidden, and an error about something the user cannot see is not actionable. A date could only arrive through a conversion or a crafted request, and either way a stored deadline would drive a pace the reserve does not have. A completed goal could be switched to `maintained` from the edit form. It then sat in a released state — one that has handed its earmark back — while the show page promised its money stays reserved, and `complete` for reserves is refused precisely to prevent that state. `kind` is now locked while released: reopen first. Reserves counted against the "goals on track" tile. Their statuses are `funded`/`depleted`, which match none of the exclusions in `tracked_total`, so they could never reach the numerator and a family with one reserve read "0 of 1 on track" for a goal working exactly as intended. Two more places still spoke of pace to something that has none. `pace_line` is suppressed for reserves on the card, and a depleted reserve gets its own panel before the projection card — the projection's summary, catch-up line and colour are all built from a deadline. What a drained reserve needs is the number the projection cannot show: how much is missing from the floor. The French celebration copy read "Votre réserve est à son niveau", which never says which level. Each guard was confirmed load-bearing by removing it and watching its test fail. bin/rails test: 6962 runs, 28003 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Left open deliberately: extracting the show page's lifecycle panel into a ViewComponent. The guideline behind it is right, but the refactor is wider than this round of fixes and belongs on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): finish teaching the status consumers about reserves Second round of review feedback on #3167: two consumers still had no branch for the reserve statuses. `ProgressRingComponent#percent_text_class` styled only `:reached` as success, so a funded reserve — a floor the user is holding exactly as intended — fell back to the neutral colour and read as unfinished. `status_callout_context` had no `:depleted` branch, so a drained reserve showed no callout at all: the one status that most deserves a line of explanation was the only one saying nothing. It now names the shortfall. `:funded` deliberately keeps no callout — a reserve at its level has nothing to report, and the celebration panel already says so. A test pins that, so the silence reads as a decision rather than another missing branch. bin/rails test: 6964 runs, 28008 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): lock the kind on the state the goal is actually in Addresses review feedback on #3167, on the guard that landed in c1c7f4f7. `kind_locked_while_released` read the in-memory `state`, so a single write setting `state: "active"` alongside the new kind saw the goal as already reopened and waved it through. The end state looks legitimate — active and maintained — which is why the hole is easy to miss. It is not: the direct write skipped the `reopen` transition, and with it `thaw_completed_amount!`. `completed_amount` survived, so `current_balance` returned that frozen snapshot forever on a live reserve. Reopening has to be its own gesture, because it is the gesture that thaws. Now reads `state_in_database`, with a regression test on the combined write asserting both that it is refused and that the frozen amount is untouched. Confirmed load-bearing by reading the attribute again and watching it fail. bin/rails test: 6969 runs, 28019 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): send an empty reserve to the shortfall panel, not the empty state Addresses the last review thread on #3167. The `maintained?` branch sat after the zero-balance/zero-pace one, so a brand-new reserve matched the generic "make your first transfer" card. I had put it there on purpose, thinking a reserve with nothing in it wanted the first-transfer nudge. The review is right that it does not: it is still a reserve short of its floor, and the shortfall panel says so with the saved, target and missing amounts, where the generic card says none of them. Ordering it after also meant evaluating `pace` on a goal that has no pace to evaluate. bin/rails test: 6965 runs, 0 failures. RuboCop and erb_lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a paused goal outranking a reserve that is whole Review on #3175. `:funded` and paused both ranked 3 in `active_display_sort`, so the tie broke on name and a paused goal called "Alpha" sat above a reserve called "Zeta" that was fully funded — the list saying the paused one wanted attention more. Paused now ranks behind every status, which is what the comment above the table already claimed. The seven panels on the goal page were hand-rolled repetitions of `DS::Card`'s exact shell, two of them adjacent and identical. They render through the primitive now, so their surface styling cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * refactor(goals): move the lifecycle panel decision out of the template Review on #3167 and #3180. Which panel a goal gets is a lifecycle question with five answers, and the template worked it out inline from `completed?`, `maintained?`, `one_off?`, `status` and `may_complete?` — five predicates deep in ERB where the ordering between them was load-bearing and nothing said so. `Goals::LifecyclePanelComponent` answers it in Ruby and the template renders the answer. The markup moves across unchanged, keys made absolute because a relative `t(".x")` in a component resolves against the component's own path rather than the page these strings belong to. The order is now stated once, where it can be read and tested: `:reserve_shortfall` before `:empty`, because a brand-new reserve sits at zero balance and zero pace and the generic "make your first transfer" card would otherwise swallow it. Closing from the panel now confirms, as the header menu already did. Completing releases the goal's earmarked money, and the panel offered that in one click. Both go through `goal_complete_confirm` rather than building the wording twice — two copies drifting apart is how one ends up describing the wrong consequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): refresh the pace suggestion when the deadline is cleared Assigning `input.value = ""` fires no event, so `goal-form#suggestedChanged` never ran: selecting "Reserve to maintain" cleared the date but left the monthly pace suggestion on screen, derived from a deadline the goal no longer has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): let a depleted reserve look as urgent as it is Review on #3179 and #3180. `Goal#needs_attention?` names the pair of statuses that mean "this one wants looking at" — a goal off its pace and a reserve below its floor. Three places were spelling that out and the Plan hub's progress bar had fallen behind, so a depleted reserve got a neutral bar an inch from its own amber status pill: the same goal reported as needing attention and not. `projection_summary` told a funded reserve it had "hit the target, no projection needed". A reserve holds a level; there is no finish line to project toward and no target to have hit. It does not reach that panel today — the shortfall and celebration panels catch it first — but the method reads as the single source of truth for that subtitle and should not hand a caller a one-off's wording. The legend swatches are bordered spans now rather than inline SVG, and the label takes `text-xs` instead of an arbitrary 11px. The projection swatch keeps the chart's own colour variables in an inline style rather than `border-success` / `border-warning`: the chart hard-codes green-600 and yellow-600, and a legend whose colour does not match the line it describes is worse than the markup it would save. The Plan-card test counts warning bars rather than matching one. A fixture goal is already off its pace, so the markup is on the page either way and a presence check passed without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
600 lines
22 KiB
Ruby
600 lines
22 KiB
Ruby
require "test_helper"
|
|
|
|
class GoalsControllerTest < ActionDispatch::IntegrationTest
|
|
include EntriesTestHelper
|
|
|
|
setup do
|
|
@user = users(:family_admin)
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
sign_in @user
|
|
@goal = goals(:vacation_italy)
|
|
@depository = accounts(:depository)
|
|
@connected = accounts(:connected)
|
|
ensure_tailwind_build
|
|
end
|
|
|
|
test "redirects users without preview access" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
|
|
get goals_url
|
|
|
|
assert_redirected_to root_path
|
|
assert_match(/preview/i, flash[:alert])
|
|
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
|
|
# Fresh accounts: the goal fixtures already claim @depository and
|
|
# @connected in full, and GoalAccount refuses a second whole-balance
|
|
# link on a contested account. Blank allocations here keep this test on
|
|
# the default "dedicate the whole balance" path.
|
|
first = unclaimed_account("Holiday Pot")
|
|
second = unclaimed_account("House Pot")
|
|
|
|
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: [ first.id, second.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 "new form excludes same-family accounts not shared with the current user" do
|
|
# Regression for #2168: funding-account picker leaked accounts owned by
|
|
# other family members that were never shared with the current user.
|
|
private_account = Account.create!(
|
|
family: @user.family,
|
|
owner: users(:family_member),
|
|
accountable: Depository.new,
|
|
name: "Member Private Checking",
|
|
currency: "USD",
|
|
balance: 100
|
|
)
|
|
|
|
get new_goal_url
|
|
assert_response :success
|
|
assert_no_match(/Member Private Checking/, response.body)
|
|
assert_no_match(/goal_account_ids_#{private_account.id}/, response.body)
|
|
end
|
|
|
|
test "create rejects a same-family account not shared with the current user" do
|
|
private_account = Account.create!(
|
|
family: @user.family,
|
|
owner: users(:family_member),
|
|
accountable: Depository.new,
|
|
name: "Member Private Checking",
|
|
currency: "USD",
|
|
balance: 100
|
|
)
|
|
|
|
assert_no_difference "Goal.count" do
|
|
post goals_url, params: {
|
|
goal: {
|
|
name: "Sneaky goal",
|
|
target_amount: "1000",
|
|
color: "#4da568",
|
|
account_ids: [ private_account.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 preserves a linked account the current user cannot access" do
|
|
# Regression for #2172 review: a family goal can be linked to a private
|
|
# account owned by another member. That account is never rendered in the
|
|
# picker, so its absence from the submitted set must not unlink it.
|
|
private_account = Account.create!(
|
|
family: @user.family,
|
|
owner: users(:family_member),
|
|
accountable: Depository.new,
|
|
name: "Member Private Checking",
|
|
currency: @goal.currency,
|
|
balance: 100
|
|
)
|
|
@goal.goal_accounts.create!(account: private_account)
|
|
|
|
patch goal_url(@goal), params: { goal: { account_ids: [ @depository.id ] } }
|
|
|
|
assert_redirected_to goal_path(@goal)
|
|
linked = @goal.reload.goal_accounts.pluck(:account_id)
|
|
assert_includes linked, private_account.id, "inaccessible private link must be preserved"
|
|
assert_includes linked, @depository.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
|
|
|
|
# The reported bug: Delete rendered only when the goal was archived, so an
|
|
# active goal had no delete affordance anywhere in the UI. The kebab is the
|
|
# only route to it, so assert the form is actually in the markup per state —
|
|
# a 200 alone would not have caught the original miss.
|
|
test "show exposes delete for a goal in every state" do
|
|
delete_form = "form[action='#{goal_path(@goal)}'] input[name='_method'][value='delete']"
|
|
|
|
%w[active paused completed archived].each do |state|
|
|
@goal.update_column(:state, state)
|
|
|
|
get goal_url(@goal)
|
|
|
|
assert_response :success
|
|
assert_select delete_form, 1, "no delete affordance on a #{state} goal"
|
|
end
|
|
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
|
|
rescue_account = unclaimed_account("Rescue Pot")
|
|
|
|
patch goal_url(orphan), params: {
|
|
goal: { name: orphan.name, target_amount: orphan.target_amount, account_ids: [ rescue_account.id ] }
|
|
}
|
|
|
|
assert_redirected_to goal_path(orphan)
|
|
assert_equal [ rescue_account.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 deletes an active goal and cascades to its links and pledges" do
|
|
assert_difference -> { Goal.count } => -1,
|
|
-> { GoalAccount.count } => -2,
|
|
-> { GoalPledge.count } => -2 do
|
|
delete goal_url(@goal)
|
|
end
|
|
assert_redirected_to goals_path
|
|
end
|
|
|
|
test "destroy deletes an archived goal" do
|
|
@goal.archive!
|
|
assert_difference "Goal.count", -1 do
|
|
delete goal_url(@goal)
|
|
end
|
|
assert_redirected_to goals_path
|
|
end
|
|
|
|
# The one thing a goal delete reaches outside its own tables: a matched
|
|
# pledge stamps `extra["goal"]["pledge_id"]` onto the transaction it claimed,
|
|
# and GoalPledge#clear_matched_transaction_extra must unstamp it on the way
|
|
# out. The transaction itself must survive untouched.
|
|
test "destroy unstamps the transaction a matched pledge claimed" do
|
|
txn = create_transaction(account: @connected, amount: -300).entryable
|
|
pledge = goal_pledges(:matched_transfer)
|
|
txn.update!(extra: { "goal" => { "pledge_id" => pledge.id } })
|
|
pledge.update_column(:matched_transaction_id, txn.id)
|
|
|
|
delete goal_url(@goal)
|
|
|
|
assert_redirected_to goals_path
|
|
assert Transaction.exists?(txn.id), "deleting a goal must not delete the transaction"
|
|
assert_nil txn.reload.extra.dig("goal", "pledge_id")
|
|
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
|
|
# Real reached state: target $1 against the depository fixture's
|
|
# $5000 balance. Stubbing :status hides whether the controller
|
|
# actually reads the right method on each goal.
|
|
build_goal(family, "Wedding", target_amount: 1, target_date: 1.year.from_now)
|
|
|
|
get goals_url
|
|
assert_response :success
|
|
assert_match(/All caught up/i, response.body)
|
|
assert_match(/1\s*reached/i, response.body)
|
|
end
|
|
|
|
test "index KPI 'on track' denominator excludes no-target-date goals" do
|
|
family = users(:family_admin).family
|
|
family.goals.destroy_all
|
|
# One trackable goal (has target_date) + one open-ended (no target_date).
|
|
# The trackable one should be the only thing in the denominator;
|
|
# open-ended goals can't be off pace because they have no required pace.
|
|
build_goal(family, "House", target_amount: 1_000_000, target_date: 1.year.from_now)
|
|
build_goal(family, "Emergency", target_amount: 1_000_000, target_date: nil)
|
|
|
|
get goals_url
|
|
assert_response :success
|
|
# Expect "0 of 1" — the open-ended goal stays out of the fraction
|
|
# even though it's active.
|
|
assert_match(/0\s*of\s*1/i, response.body)
|
|
assert_match(/without a deadline/i, response.body)
|
|
end
|
|
|
|
# The form reads its ticks from a separate local, not from the built links,
|
|
# so a failed create rendered every account unchecked while the amounts the
|
|
# user typed survived — an error telling them to enter an amount, on a form
|
|
# whose accounts had silently cleared.
|
|
test "a rejected creation keeps the accounts the user ticked" do
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Contested Pot", currency: @user.family.currency, balance: 3_000
|
|
)
|
|
holder = @user.family.goals.create!(name: "Holder", target_amount: 5_000, currency: @user.family.currency) do |g|
|
|
g.goal_accounts.build(account: account)
|
|
end
|
|
assert holder.persisted?
|
|
|
|
post goals_url, params: {
|
|
goal: {
|
|
name: "Second claim", target_amount: 5_000,
|
|
account_ids: [ account.id ], allocations: { account.id.to_s => "" }
|
|
}
|
|
}
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_select "input[type=checkbox][name='goal[account_ids][]'][value=?][checked]", account.id
|
|
end
|
|
|
|
# --- Lot B2: closing a reached goal ---
|
|
|
|
# The panel used to say "Goal closed at ..." for a goal that was merely at
|
|
# 100%, and offer Archive — the gesture that does NOT release the money.
|
|
test "a one_off goal at 100 percent is offered closing, not archiving" do
|
|
goal = fully_funded_goal
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("goals.show.celebration.close_cta"), response.body
|
|
assert_match I18n.t("goals.show.celebration.close_hint"), response.body
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.archive_cta"))}/, response.body)
|
|
end
|
|
|
|
test "a goal below 100 percent is offered no closing action" do
|
|
goal = fully_funded_goal
|
|
goal.update!(target_amount: 10_000)
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.close_cta"))}/, response.body)
|
|
end
|
|
|
|
# Reaching the target is the normal state of a reserve, not a prompt to
|
|
# close it — and B3 forbids `complete` for them outright.
|
|
test "a maintained goal at 100 percent is never offered closing" do
|
|
goal = fully_funded_goal
|
|
goal.update_column(:kind, "maintained")
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.close_cta"))}/, response.body)
|
|
end
|
|
|
|
test "a completed goal shows the amount and date it froze" do
|
|
goal = fully_funded_goal
|
|
goal.complete!
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("goals.show.celebration.frozen",
|
|
date: I18n.l(goal.reload.completed_at.to_date, format: :long),
|
|
amount: goal.current_balance_money.format(precision: 0)),
|
|
response.body
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.close_cta"))}/, response.body)
|
|
end
|
|
|
|
test "an archived goal is offered no closing action" do
|
|
goal = fully_funded_goal
|
|
goal.archive!
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.close_cta"))}/, response.body)
|
|
end
|
|
|
|
|
|
# --- Lot B3a: reserves ---
|
|
|
|
test "create accepts the maintained kind" do
|
|
account = unclaimed_account("Reserve Pot")
|
|
|
|
assert_difference -> { Goal.count }, 1 do
|
|
post goals_url, params: {
|
|
goal: { name: "Emergency", target_amount: "6000", color: "#4da568", kind: "maintained", account_ids: [ account.id ] }
|
|
}
|
|
end
|
|
|
|
assert Goal.order(created_at: :desc).first.maintained?
|
|
end
|
|
|
|
# The AASM guard refuses the transition; the controller must report that
|
|
# rather than claim success or blow up.
|
|
test "completing a reserve is refused at the controller too" do
|
|
goal = fully_funded_goal
|
|
goal.update_column(:kind, "maintained")
|
|
|
|
patch complete_goal_url(goal)
|
|
|
|
assert_redirected_to goal_path(goal)
|
|
assert_match(/./, flash[:alert].to_s)
|
|
assert_equal "active", goal.reload.state
|
|
end
|
|
|
|
test "a funded reserve reads as intact, with nothing to do" do
|
|
goal = fully_funded_goal
|
|
goal.update_column(:kind, "maintained")
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("goals.show.celebration.heading_reserve"), response.body
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.close_cta"))}/, response.body)
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.celebration.archive_cta"))}/, response.body)
|
|
end
|
|
|
|
# A reserve has no deadline and no pace benchmark, so it can never land in
|
|
# the on-track numerator. Leaving it in the denominator made a family with
|
|
# one reserve read "0 of 1 on track" for a goal working exactly as intended.
|
|
test "a reserve stays out of the on-track denominator" do
|
|
@user.family.goals.where.not(state: "archived").find_each(&:archive!)
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Reserve Pot", currency: @user.family.currency, balance: 1_000
|
|
)
|
|
@user.family.goals.create!(
|
|
name: "Precaution", target_amount: 6_000, currency: @user.family.currency, kind: "maintained"
|
|
) { |g| g.goal_accounts.build(account: account) }
|
|
|
|
get goals_url
|
|
|
|
assert_response :success
|
|
# The tile prints "<on track> of <tracked total>". The reserve must not
|
|
# appear in the denominator: before this, the page read "0 of 1".
|
|
assert_no_match(/0 of 1/, response.body)
|
|
assert_match(/0 of 0/, response.body)
|
|
end
|
|
|
|
# A brand-new reserve is depleted with a zero balance, which used to match the
|
|
# generic "make your first transfer" branch first. It is still a reserve short
|
|
# of its floor, and the shortfall panel is what says so.
|
|
test "a reserve with nothing in it yet gets the shortfall panel" do
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Empty Reserve Pot", currency: @user.family.currency, balance: 0
|
|
)
|
|
goal = @user.family.goals.create!(
|
|
name: "Precaution", target_amount: 6_000, currency: @user.family.currency, kind: "maintained"
|
|
) { |g| g.goal_accounts.build(account: account) }
|
|
|
|
get goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("goals.show.reserve_shortfall.heading"), response.body
|
|
assert_no_match(/#{Regexp.escape(I18n.t("goals.show.empty.heading"))}/, response.body)
|
|
end
|
|
|
|
private
|
|
# An active one_off goal sitting exactly at its target, on an account no
|
|
# other goal claims.
|
|
def fully_funded_goal
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Funded Pot", currency: "USD", balance: 2_000
|
|
)
|
|
@user.family.goals.create!(name: "Funded", target_amount: 2_000, currency: "USD") do |g|
|
|
g.goal_accounts.build(account: account)
|
|
end
|
|
end
|
|
|
|
# A fundable account no goal fixture claims. The fixtures link
|
|
# @depository and @connected as whole-balance earmarks, and GoalAccount
|
|
# refuses a second whole-balance link on an account already claimed in
|
|
# full — so any test that wants the default "dedicate the whole balance"
|
|
# link needs an account of its own.
|
|
def unclaimed_account(name)
|
|
Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: name, currency: "USD", balance: 1_000
|
|
)
|
|
end
|
|
|
|
# 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
|
|
|
|
# Each goal gets its own funding account, mirroring @depository's balance.
|
|
# These goals used to share @depository as a whole-balance link, which
|
|
# GoalAccount now refuses — and which was the double count in the first
|
|
# place: every goal read the same 5,000 as if it were its own. One account
|
|
# each keeps every goal's current_balance identical to what it was, without
|
|
# the overlap.
|
|
def build_goal(family, name, target_amount: 1_000_000, target_date: nil)
|
|
funding = Account.create!(
|
|
family: family, accountable: Depository.new,
|
|
name: "#{name} Funding", currency: "USD", balance: @depository.balance
|
|
)
|
|
g = family.goals.new(name: name, target_amount: target_amount, target_date: target_date, currency: "USD")
|
|
g.goal_accounts.build(account: funding)
|
|
g.save!
|
|
g
|
|
end
|
|
|
|
public
|
|
|
|
test "create ignores forbidden params (family_id, state)" do
|
|
family = users(:family_admin).family
|
|
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
|
|
|
assert_difference -> { family.goals.count }, 1 do
|
|
post goals_url, params: {
|
|
goal: {
|
|
name: "Hijack target",
|
|
target_amount: 100,
|
|
currency: "USD",
|
|
state: "archived",
|
|
family_id: other_family.id,
|
|
account_ids: [ unclaimed_account("Hijack Pot").id ]
|
|
}
|
|
}
|
|
end
|
|
|
|
goal = family.goals.order(:created_at).last
|
|
# Strong params must strip both `state` (AASM-managed) and `family_id`
|
|
# (cross-family pivot) — otherwise a crafted POST would create rows
|
|
# outside the current family or skip the active-state assumption.
|
|
assert_equal "active", goal.state
|
|
assert_equal family.id, goal.family_id
|
|
end
|
|
|
|
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
|