mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Correctness: - GoalPledge#matches? rejects outflows on transfer pledges so a +$200 purchase no longer satisfies a $200 deposit pledge after .abs - GoalsController#sync_linked_accounts! saves through the goal so currency/depository/family validations actually run on update - AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and reconciler rescues the dedicated class - SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue - Assistant::Function::CreateGoal disambiguates duplicate account names and returns an absolute URL via mailer host config - Family#savings_inflow_velocity defensively scopes from the family's accounts (was Account.joins(:goal_accounts).where(goal_id: ...)) - GoalPledgesController#set_goal preloads linked_accounts + providers to drop the N+1 on any_connected_account? - Stepper subtitle update walks to the enclosing dialog before querySelector so two stepper instances don't fight over one header - categories/_form.html.erb data-action targets color-icon-picker, not the non-existent "category" controller UX / visual: - Projection chart drops preserveAspectRatio="none" and pins endDate at today for past-due goals so the today marker stays in-domain - _color_picker / categories form swap non-standard border-1 for border - Goals index search input uses ring-alpha-black-100 (was raw gray-500) Refactors: - Goal#header_summary extracts the multi-line ERB header block - Goal#catch_up_delta_money sums open_pledges in SQL - Goal#projection_summary uses I18n.l for the on-track month label - Account#default_pledge_kind moves the manual/transfer decision out of GoalPledgesController - GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim wins is deterministic under non-sequential PKs - Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent use clamp(0..) instead of Float::INFINITY / [x, 0].max - Goals::StatusPillComponent#label provides a titleize fallback - Goal projection chart skips the redundant initial _draw and reuses the snapped point in the past branch (no double-bisect) - Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show cents while JPY/KRW stay whole-unit - Demo generator captures the Wedding fund goal in the seed loop instead of looking it up by hardcoded name Tests: - GoalPledgeTest: outflow rejection - GoalsControllerTest: cross-currency attachment rejected on update - SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue - GoalTest: pledge_action_label_key flips to manual_save without an unconditional guard
146 lines
5.0 KiB
Ruby
146 lines
5.0 KiB
Ruby
require "test_helper"
|
|
|
|
class GoalPledgeTest < ActiveSupport::TestCase
|
|
setup do
|
|
@goal = goals(:vacation_italy)
|
|
@account = accounts(:depository)
|
|
@pledge = goal_pledges(:open_transfer)
|
|
end
|
|
|
|
test "valid fixture pledge saves" do
|
|
assert @pledge.valid?
|
|
end
|
|
|
|
test "amount must be positive" do
|
|
@pledge.amount = 0
|
|
assert_not @pledge.valid?
|
|
end
|
|
|
|
test "account must be linked to goal" do
|
|
other_account = accounts(:investment)
|
|
pledge = @goal.goal_pledges.new(account: other_account, amount: 50, currency: "USD")
|
|
assert_not pledge.valid?
|
|
assert_includes pledge.errors[:account], "Pick one of the goal's linked accounts."
|
|
end
|
|
|
|
test "currency must match goal currency" do
|
|
@pledge.currency = "EUR"
|
|
assert_not @pledge.valid?
|
|
assert_includes @pledge.errors[:currency], "Pledge currency must match the goal currency."
|
|
end
|
|
|
|
test "defaults populate on create" do
|
|
pledge = @goal.goal_pledges.new(account: @account, amount: 50)
|
|
pledge.valid?
|
|
assert_equal "open", pledge.status
|
|
assert_equal "transfer", pledge.kind
|
|
assert_not_nil pledge.expires_at
|
|
assert pledge.expires_at > Time.current
|
|
assert_equal @goal.currency, pledge.currency
|
|
end
|
|
|
|
test "matches? returns true within tolerances" do
|
|
entry = build_entry(account: @account, amount: -200.25, date: @pledge.created_at.to_date + 1.day)
|
|
assert @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? returns false outside date window" do
|
|
entry = build_entry(account: @account, amount: -200, date: @pledge.created_at.to_date + 10.days)
|
|
assert_not @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? returns false outside amount tolerance" do
|
|
entry = build_entry(account: @account, amount: -250, date: @pledge.created_at.to_date)
|
|
assert_not @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? returns true within ratio tolerance" do
|
|
entry = build_entry(account: @account, amount: -201.99, date: @pledge.created_at.to_date)
|
|
assert @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? returns false on wrong account" do
|
|
other_account = accounts(:connected)
|
|
entry = build_entry(account: other_account, amount: -200, date: @pledge.created_at.to_date)
|
|
assert_not @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? rejects outflows of the same magnitude on transfer pledges" do
|
|
# Sure convention: outflow > 0, inflow < 0. A +$200 purchase must not
|
|
# satisfy a $200 transfer pledge after the .abs amount-tolerance step.
|
|
entry = build_entry(account: @account, amount: 200, date: @pledge.created_at.to_date)
|
|
assert_not @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? returns false on already-matched pledge" do
|
|
matched = goal_pledges(:matched_transfer)
|
|
entry = build_entry(account: matched.account, amount: -matched.amount.to_d, date: matched.created_at.to_date)
|
|
assert_not matched.matches?(entry)
|
|
end
|
|
|
|
test "extend! pushes expires_at forward" do
|
|
before = @pledge.expires_at
|
|
@pledge.extend!
|
|
assert @pledge.expires_at > before + 6.days
|
|
end
|
|
|
|
test "matches? widens upper bound to expires_at after extend!" do
|
|
# Day 8 — past the default 5-day creation-anchored window but inside the
|
|
# extended expiry window. Without the widening this would be a regression
|
|
# of B7 (extend doesn't actually buy match runway).
|
|
@pledge.extend!
|
|
far_date = @pledge.created_at.to_date + 8.days
|
|
assert far_date <= @pledge.expires_at.to_date
|
|
entry = build_entry(account: @account, amount: -200, date: far_date)
|
|
assert @pledge.matches?(entry)
|
|
end
|
|
|
|
test "matches? rejects entries past extended expires_at" do
|
|
@pledge.extend!
|
|
far_date = @pledge.expires_at.to_date + 1.day
|
|
entry = build_entry(account: @account, amount: -200, date: far_date)
|
|
assert_not @pledge.matches?(entry)
|
|
end
|
|
|
|
test "duplicate open pledge for same goal+account+amount is rejected on create" do
|
|
dup = @goal.goal_pledges.new(account: @account, amount: @pledge.amount, currency: @goal.currency)
|
|
assert_not dup.valid?
|
|
assert dup.errors[:base].any? { |m| m.include?("open pledge") }
|
|
end
|
|
|
|
test "duplicate validation does not block different amounts" do
|
|
dup = @goal.goal_pledges.new(account: @account, amount: @pledge.amount.to_d + 1, currency: @goal.currency)
|
|
assert dup.valid?, dup.errors.full_messages.to_sentence
|
|
end
|
|
|
|
test "extend! raises for non-open pledge" do
|
|
pledge = goal_pledges(:matched_transfer)
|
|
assert_raises(GoalPledge::NotOpenError) { pledge.extend! }
|
|
end
|
|
|
|
test "cancel! transitions open to cancelled" do
|
|
@pledge.cancel!
|
|
assert @pledge.status_cancelled?
|
|
end
|
|
|
|
test "expire! transitions open to expired" do
|
|
@pledge.expire!
|
|
assert @pledge.status_expired?
|
|
end
|
|
|
|
test "days_left counts down" do
|
|
@pledge.expires_at = 3.days.from_now
|
|
assert_includes 2..3, @pledge.days_left
|
|
end
|
|
|
|
test "days_left returns 0 for non-open" do
|
|
pledge = goal_pledges(:matched_transfer)
|
|
assert_equal 0, pledge.days_left
|
|
end
|
|
|
|
private
|
|
def build_entry(account:, amount:, date:)
|
|
OpenStruct.new(account_id: account.id, amount: BigDecimal(amount.to_s), date: date.to_date)
|
|
end
|
|
end
|