mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* feat(goals): show what each account still has room to earmark `Account#free_to_earmark` has existed, unused, since earmarks shipped — its own comment said the UI was a follow-up. This is that follow-up, and the wording is the substance of it. It does not say "over-allocated". `free_to_earmark` is negative for as long as the saving is unfinished, which is the normal condition of anyone with goals in progress: a 6,000 account backing two goals of 5,000 gives −4,000 and is a perfectly correct setup. A warning phrased as a fault would fire permanently and teach people to ignore it. The message states the consequence instead — the goals come to X for a balance of Y, so they progress pro rata — and is never styled as an error. The trap is the goal being edited. `goal_earmarked_total` counts every goal including that one, so reopening a goal that earmarks 5,000 on a 6,000 account shows 1,000 of headroom, and re-entering the same 5,000 trips a message about a setup the user has not touched. `earmarked_by_other_goals` excludes it, and only when it is persisted — a goal being created has nothing to exclude. The pool is read once per render and passed down, never per account: the form lists every fundable account the user can see. A test counts the query and fails at two. The Stimulus controller is its own, with 3 targets. goal_form_controller is at 10 against the 7 the project guidelines suggest, needs none of this state, and is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): read the typed amount strictly, and format it in the app's locale Addresses review feedback on #3166. `Number.parseFloat` accepts prefixes, so "500abc" became 500, and the bare comma-to-dot swap turned a thousands-separated "1,500" into 1.5. Either way the preview described an amount the user had not typed — and the second case is a habit from another locale, not a typo, so it would have gone unnoticed. The value now has to match a complete number before anything is computed. `Intl.NumberFormat(undefined, ...)` let the BROWSER pick the locale, so a French user on an English-locale browser read separators and symbol placement matching nothing else on the page. The amounts cannot be formatted server-side — they change with every keystroke — so the server passes `I18n.locale` and the client applies it. That puts the decision where the rest of the app's formatting already lives. bin/rails test: 6954 runs, 0 failures. RuboCop, erb_lint and biome clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): let the assistant create a second goal on a claimed account Review on #3166. The function always built whole-account links and had no way to express an earmark, so once exclusivity landed, asking for a second goal on an account another goal already claimed came back as a bare `validation_failed` — while the account list still advertised the account as available. A common request became an unexplained refusal. Three changes, and the list is the important one: it now says what is left on each account and which are claimed in full, because the assistant reasons from that list and had no way to know otherwise. `earmarks` is an optional map of account name to amount, so the assistant can reserve a slice rather than the whole balance. Accounts left out keep the previous behaviour and take whatever is spare. The refusal is named before the save — `account_claimed_in_full`, with the account names — so the assistant gets a reason it can act on and ask about, rather than a validation message it can only relay. Checked after the currency check, which is the more fundamental of the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): move the spend tests back out of the private section The merge of `main` into this branch landed #3176's tests between `count_pool_queries` and the helpers below it, inside the `private` section and at the wrong indentation. `ci / lint` has been failing on `Layout/IndentationConsistency` since. They still ran — `test` is a class method, so `private` does not hide them — which is why the unit job stayed green while lint went red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
756 lines
28 KiB
Ruby
756 lines
28 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
|
|
|
|
# A goal can be backed by an account the viewer is not allowed to see. Both
|
|
# halves of that leak matter: the dialog naming it, and a direct POST moving
|
|
# its earmark — the figures afterwards saying how much was in it.
|
|
test "the consumption dialog does not name a linked account the viewer cannot see" do
|
|
private_account = private_linked_account
|
|
|
|
get consume_goal_url(@goal)
|
|
|
|
assert_response :success
|
|
assert_no_match(/Member Private Checking/, response.body)
|
|
assert_no_match(/#{private_account.id}/, response.body)
|
|
end
|
|
|
|
test "consumption is refused against a linked account the viewer cannot see" do
|
|
private_account = private_linked_account
|
|
link = @goal.goal_accounts.find_by(account_id: private_account.id)
|
|
|
|
post consume_goal_url(@goal), params: { amount: "100", account_id: private_account.id }
|
|
|
|
assert_redirected_to goal_path(@goal)
|
|
assert_equal 0, @goal.reload.consumed_amount
|
|
assert_equal 500, link.reload.allocated_amount
|
|
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
|
|
|
|
# --- Lot B6: earmark headroom in the form ---
|
|
|
|
test "the new form renders each account's balance and what other goals hold" do
|
|
account = unclaimed_account("Headroom Pot")
|
|
@user.family.goals.create!(name: "Neighbour", target_amount: 5_000, currency: "USD") do |g|
|
|
g.goal_accounts.build(account: account, allocated_amount: 400)
|
|
end
|
|
|
|
get new_goal_url
|
|
|
|
assert_response :success
|
|
assert_select "[data-balance][data-earmarked-by-others]", minimum: 1
|
|
assert_match 'data-earmarked-by-others="400.0"', response.body
|
|
end
|
|
|
|
# The N+1 this lot exists to avoid: the form lists every fundable account,
|
|
# so reading the pool per account would scale with the account list.
|
|
test "the form reads the shared pool exactly once, however many accounts" do
|
|
3.times { |i| unclaimed_account("Pool Pot #{i}") }
|
|
|
|
assert_equal 1, count_pool_queries { get new_goal_url }
|
|
assert_response :success
|
|
end
|
|
|
|
# The acceptance criterion of this lot: reopening a goal must not count its
|
|
# own earmark, or re-entering the same figure would trip a message about a
|
|
# setup the user has not touched.
|
|
test "the edit form does not count the edited goal's own earmark" do
|
|
account = unclaimed_account("Reopen Pot")
|
|
goal = @user.family.goals.create!(name: "Reopened", target_amount: 5_000, currency: "USD") do |g|
|
|
g.goal_accounts.build(account: account, allocated_amount: 5_000)
|
|
end
|
|
|
|
get edit_goal_url(goal)
|
|
|
|
assert_response :success
|
|
assert_match 'data-earmarked-by-others="0.0"', response.body
|
|
assert_no_match(/data-earmarked-by-others="5000/, response.body)
|
|
end
|
|
|
|
# The error paths re-render the same form, so they need the pool too —
|
|
# without it the row helper is handed nil and the render blows up.
|
|
test "the pool is available again when create re-renders after an error" do
|
|
unclaimed_account("Error Pot")
|
|
|
|
post goals_url, params: { goal: { name: "No accounts", target_amount: "1000", color: "#4da568" } }
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_select "[data-balance][data-earmarked-by-others]", minimum: 1
|
|
end
|
|
|
|
# --- Lot B4: recording a partial spend ---
|
|
|
|
test "recording a spend keeps the goal at full progress and frees the earmark" do
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Trip Pot", currency: @user.family.currency, balance: 5_000
|
|
)
|
|
goal = @user.family.goals.create!(name: "Trip", target_amount: 5_000, currency: @user.family.currency) do |g|
|
|
g.goal_accounts.build(account: account, allocated_amount: 5_000)
|
|
end
|
|
|
|
post consume_goal_url(goal), params: { amount: 2_000 }
|
|
|
|
assert_redirected_to goal_url(goal)
|
|
assert_equal 2_000, goal.reload.consumed_amount
|
|
assert_equal 3_000, goal.goal_accounts.first.reload.allocated_amount
|
|
end
|
|
|
|
test "a refused spend says which rule refused it" do
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Reserve Pot", currency: @user.family.currency, balance: 4_000
|
|
)
|
|
reserve = @user.family.goals.create!(
|
|
name: "Precaution", target_amount: 6_000, currency: @user.family.currency, kind: "maintained"
|
|
) { |g| g.goal_accounts.build(account: account) }
|
|
|
|
post consume_goal_url(reserve), params: { amount: 1_000 }
|
|
|
|
assert_redirected_to goal_url(reserve)
|
|
assert_equal I18n.t("goals.consume.errors.maintained"), flash[:alert]
|
|
assert_equal 0, reserve.reload.consumed_amount
|
|
end
|
|
|
|
# A blank account id means "this goal has one link". An id resolving to
|
|
# nothing must not fall back to that, or a single-link goal would record a
|
|
# spend against an account the user never named.
|
|
test "an unknown account id is refused rather than falling through" do
|
|
account = Account.create!(
|
|
family: @user.family, accountable: Depository.new,
|
|
name: "Trip Pot", currency: @user.family.currency, balance: 5_000
|
|
)
|
|
goal = @user.family.goals.create!(name: "Trip", target_amount: 5_000, currency: @user.family.currency) do |g|
|
|
g.goal_accounts.build(account: account, allocated_amount: 5_000)
|
|
end
|
|
|
|
post consume_goal_url(goal), params: { amount: 1_000, account_id: SecureRandom.uuid }
|
|
|
|
assert_equal I18n.t("goals.consume.errors.account_not_linked"), flash[:alert]
|
|
assert_equal 0, goal.reload.consumed_amount
|
|
end
|
|
|
|
private
|
|
# SQL the pooled-allocation read issues, and nothing else: goal_accounts
|
|
# joined to goals.
|
|
def count_pool_queries
|
|
count = 0
|
|
sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
|
|
sql = payload[:sql].to_s
|
|
count += 1 if sql.include?("FROM \"goal_accounts\"") && sql.include?("INNER JOIN \"goals\"")
|
|
end
|
|
yield
|
|
count
|
|
ensure
|
|
ActiveSupport::Notifications.unsubscribe(sub)
|
|
end
|
|
|
|
private
|
|
|
|
# A private account of another member, linked to the goal under test.
|
|
def private_linked_account
|
|
account = Account.create!(
|
|
family: @user.family,
|
|
owner: users(:family_member),
|
|
accountable: Depository.new,
|
|
name: "Member Private Checking",
|
|
currency: @goal.currency,
|
|
balance: 1_000
|
|
)
|
|
@goal.goal_accounts.create!(account: account, allocated_amount: 500)
|
|
account
|
|
end
|
|
# 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
|