Files
sure/test/controllers/pages_controller_test.rb
T
Juan José Mata 82519192ac Improve spending chart mobile layout (#3435)
* Improve spending chart mobile layout

- Name the compared month ("August 2026") instead of the generic
  "Previous month" label that truncated on narrow viewports
- Keep the signed delta on one line: nowrap amounts, wrap as a unit
- On mobile widths, label x-axis ticks for the selected month only so a
  longer previous month's tail tick ("Aug 31") doesn't read like a bug
- Show a compact date range ("Sep 01 - 6, 2026") on narrow viewports

* Compute comparison label and compact date range in the controller

Addresses review on #3435: moves previous_label and date_range_short out
of the partial into build_spending_trend_data, and puts the single-day
compact range behind its own i18n key instead of a hard-coded format.

* Revert accidental local-env ruby version bump

.ruby-version and Gemfile.lock were swept into the previous commit by a
git add -A from the local verification sandbox (3.4.10 vs the repo's
pinned 3.4.9, which setup-ruby cannot provision on ubuntu-24.04).

* Use the controller t() helper for compact date range translations

Project convention is t() over I18n.t for user-facing strings in
controllers (CodeRabbit review on #3435).

* Don't over-explain in comments
2026-09-08 01:10:02 +02:00

487 lines
19 KiB
Ruby

require "test_helper"
class PagesControllerTest < ActionDispatch::IntegrationTest
include EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@intro_user = users(:intro_user)
@family = @user.family
end
test "dashboard" do
get root_path
assert_response :ok
end
test "inactive user's existing session is revoked" do
session_record = @user.sessions.order(:created_at).last
@user.update_column(:active, false)
get root_path
assert_redirected_to new_session_path
assert_not Session.exists?(session_record.id)
end
test "update_preferences persists dashboard section layout height" do
patch "/dashboard/preferences", params: {
preferences: { dashboard_section_layout: { net_worth_chart: { height: "compact" } } }
}, as: :json
assert_response :ok
assert_equal "compact", @user.reload.dashboard_section_height("net_worth_chart")
end
test "update_preferences persists dashboard section width" do
patch "/dashboard/preferences", params: {
preferences: { dashboard_section_layout: { cashflow_sankey: { col_span: "single" } } }
}, as: :json
assert_response :ok
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
end
test "update_preferences ignores malformed dashboard_section_layout without erroring" do
previous_height = @user.reload.dashboard_section_height("net_worth_chart")
patch "/dashboard/preferences", params: {
preferences: { dashboard_section_layout: "not-a-hash" }
}, as: :json
assert_response :ok
assert_equal previous_height, @user.reload.dashboard_section_height("net_worth_chart")
end
test "dashboard memoizes income statement period totals while rendering" do
income_statement = IncomeStatement.new(@family)
IncomeStatement.stubs(:new).returns(income_statement)
fake_expense_period_total = IncomeStatement::PeriodTotal.new(
classification: "expense",
total: 0,
currency: @family.currency,
category_totals: []
)
fake_income_period_total = IncomeStatement::PeriodTotal.new(
classification: "income",
total: 0,
currency: @family.currency,
category_totals: []
)
income_statement.expects(:build_period_total)
.with(classification: "expense", period: kind_of(Period))
.once
.returns(fake_expense_period_total)
income_statement.expects(:build_period_total)
.with(classification: "income", period: kind_of(Period))
.once
.returns(fake_income_period_total)
get root_path
assert_response :ok
end
test "intro page requires guest role" do
get intro_path
assert_redirected_to root_path
assert_equal "Intro is only available to guest users.", flash[:alert]
end
test "intro page is accessible for guest users" do
sign_in @intro_user
get intro_path
assert_response :ok
end
test "dashboard renders sankey chart with subcategories" do
# Create parent category with subcategory
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
# Create transactions using helper
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
get root_path
assert_response :ok
assert_select "[data-controller='sankey-chart']"
end
test "dashboard renders sankey chart zoom controls and stable node ids" do
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
get root_path
assert_response :ok
assert_select "[data-sankey-chart-target='zoomOutButton'][hidden]", count: 2
chart = css_select("[data-controller='sankey-chart']").first
sankey_data = JSON.parse(chart["data-sankey-chart-data-value"])
assert_includes sankey_data.fetch("nodes").map { |node| node.fetch("id") }, "cash_flow_node"
assert sankey_data.fetch("nodes").any? { |node| node.fetch("id").start_with?("expense_") }
end
test "dashboard renders money flow widget" do
get root_path
assert_response :ok
assert_select "[data-controller='bar-chart']"
end
test "dashboard scopes money flow widget to selected month and accounts" do
# Dedicated account (rather than @family.accounts.first) so fixture
# transactions on other accounts can't skew the computed totals.
account = @family.accounts.create!(name: "Money Flow Test Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
selected_month = 1.month.ago.beginning_of_month.to_date
create_transaction(account: account, name: "Groceries", amount: 50, date: selected_month + 1.day)
create_transaction(account: account, name: "Paycheck", amount: -200, date: selected_month + 2.days)
get root_path, params: {
money_flow_month: selected_month.iso8601,
money_flow_account_ids: [ account.id ]
}
assert_response :ok
bars = money_flow_bars
assert_equal 6, bars.size
highlighted = bars.find { |bar| bar["highlighted"] }
assert_equal selected_month.iso8601, highlighted["date"]
assert_equal 50.0, highlighted["expense"]
assert_equal 200.0, highlighted["income"]
end
test "dashboard money flow widget ignores account ids not accessible to the current user" do
other_family = Family.create!(name: "Other Family", currency: "USD")
other_account = other_family.accounts.create!(name: "Other Family Checking", currency: "USD", balance: 0, accountable: Depository.new)
create_transaction(account: other_account, name: "Not mine", amount: 999)
get root_path
default_bars = money_flow_bars
get root_path, params: { money_flow_account_ids: [ other_account.id ] }
assert_response :ok
filtered_bars = money_flow_bars
# An id outside the current user's accessible accounts is dropped entirely
# (money_flow_account_ids_param intersects against accessible ids), so the
# widget falls back to its unfiltered "all accessible accounts" state
# rather than scoping to a foreign account or erroring.
assert_equal default_bars, filtered_bars
end
test "dashboard money flow widget excludes accounts ineligible for cashflow totals from its account filter" do
excluded_account = @family.accounts.create!(
name: "Excluded From Reports",
currency: @family.currency,
balance: 0,
exclude_from_reports: true,
accountable: Depository.new
)
get root_path
assert_response :ok
assert_select "input[type='checkbox'][value=?]", excluded_account.id.to_s, count: 0
end
test "dashboard money flow widget ignores account ids excluded from cashflow totals" do
excluded_account = @family.accounts.create!(
name: "Excluded From Reports",
currency: @family.currency,
balance: 0,
exclude_from_reports: true,
accountable: Depository.new
)
create_transaction(account: excluded_account, name: "Not counted", amount: 999)
get root_path
default_bars = money_flow_bars
get root_path, params: { money_flow_account_ids: [ excluded_account.id ] }
assert_response :ok
filtered_bars = money_flow_bars
# An account excluded from reports is visible/accessible but not eligible
# for cashflow totals, so selecting only it must fall back to the
# unfiltered state instead of silently computing to zero.
assert_equal default_bars, filtered_bars
end
test "dashboard clamps a future money flow month instead of erroring" do
get root_path, params: { money_flow_month: 1.month.from_now.beginning_of_month.iso8601 }
assert_response :ok
bars = money_flow_bars
assert_equal Date.current.beginning_of_month.iso8601, bars.last["date"]
end
test "dashboard money flow income/expense links exclude pending transactions" do
get root_path
assert_response :ok
assert_select "a[href*='q%5Btypes%5D%5B%5D=income'][href*='q%5Bstatus%5D%5B%5D=confirmed']"
assert_select "a[href*='q%5Btypes%5D%5B%5D=expense'][href*='q%5Bstatus%5D%5B%5D=confirmed']"
end
test "dashboard money flow income/expense links stay scoped to eligible accounts by default" do
excluded_account = @family.accounts.create!(
name: "Excluded From Reports",
currency: @family.currency,
balance: 0,
exclude_from_reports: true,
accountable: Depository.new
)
get root_path
assert_response :ok
income_href = css_select("a[href*='q%5Btypes%5D%5B%5D=income']").first["href"]
expense_href = css_select("a[href*='q%5Btypes%5D%5B%5D=expense']").first["href"]
# The default (unfiltered) state must still pin the drill-down links to
# the eligible accounts backing the displayed totals, not the broader
# accessible-accounts set transactions_path defaults to when account_ids
# is absent.
assert_includes income_href, "q%5Baccount_ids%5D%5B%5D="
assert_not_includes income_href, excluded_account.id.to_s
assert_includes expense_href, "q%5Baccount_ids%5D%5B%5D="
assert_not_includes expense_href, excluded_account.id.to_s
end
test "dashboard money flow income/expense links omit account_ids when the default selection matches all accessible accounts" do
# Plain @family fixture: every account is owned outright by family_admin,
# none excluded from reports or tax-advantaged, so the widget's eligible
# accounts exactly match Current.user.accessible_accounts (see #2955).
get root_path
assert_response :ok
income_href = css_select("a[href*='q%5Btypes%5D%5B%5D=income']").first["href"]
expense_href = css_select("a[href*='q%5Btypes%5D%5B%5D=expense']").first["href"]
# With nothing to scope down from the transactions page's own default,
# the link should skip enumerating every account id so the URL stays
# short (long q[account_ids][] lists break forward-auth proxies in front
# of self-hosted deployments, see #2955).
assert_not_includes income_href, "q%5Baccount_ids%5D"
assert_not_includes expense_href, "q%5Baccount_ids%5D"
end
test "dashboard money flow income/expense links keep account_ids when a subset of accounts is explicitly selected" do
account = @family.accounts.first
get root_path, params: { money_flow_account_ids: [ account.id ] }
assert_response :ok
income_href = css_select("a[href*='q%5Btypes%5D%5B%5D=income']").first["href"]
expense_href = css_select("a[href*='q%5Btypes%5D%5B%5D=expense']").first["href"]
# A deliberate, narrower selection never matches the full
# accessible-accounts set, so the links must keep scoping to it instead
# of silently falling back to "all accounts".
assert_includes income_href, "q%5Baccount_ids%5D%5B%5D=#{account.id}"
assert_includes expense_href, "q%5Baccount_ids%5D%5B%5D=#{account.id}"
account_filter = "q%5Baccount_ids%5D%5B%5D="
assert_equal 1, income_href.scan(account_filter).length
assert_equal 1, expense_href.scan(account_filter).length
end
test "changelog" do
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
get changelog_path
assert_response :ok
assert_select "[data-breadcrumbs]", text: /What's new/
end
end
test "changelog with nil release notes" do
# Mock the GitHub provider to return nil (simulating API failure or no releases)
github_provider = mock
github_provider.expects(:fetch_latest_release_notes).returns(nil)
Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)
get changelog_path
assert_response :ok
assert_select "h2", text: "Release notes unavailable"
assert_select "a[href='https://github.com/we-promise/sure/releases']"
end
test "changelog with incomplete release notes" do
# Mock the GitHub provider to return incomplete data (missing some fields)
github_provider = mock
incomplete_data = {
avatar: nil,
username: "maybe-finance",
name: "Test Release",
published_at: nil,
body: nil
}
github_provider.expects(:fetch_latest_release_notes).returns(incomplete_data)
Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)
get changelog_path
assert_response :ok
assert_select "h2", text: "Test Release"
# Should not crash even with nil values
end
test "feedback" do
get feedback_path
assert_response :ok
assert_select "[data-breadcrumbs]", text: /Feedback/
end
test "dashboard renders spending trend widget" do
get root_path
assert_response :ok
assert_select "#spending-trend-section"
end
test "dashboard spending trend widget accumulates the selected month against the previous one" do
account = @family.accounts.create!(name: "Spending Trend Test Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
# A fully past month: fixture transactions are dated relative to today and
# would otherwise leak into the expected totals.
selected_month = 2.months.ago.beginning_of_month.to_date
previous_month = 3.months.ago.beginning_of_month.to_date
create_transaction(account: account, name: "Selected month", amount: 50, date: selected_month)
create_transaction(account: account, name: "Selected month again", amount: 25, date: selected_month + 1.day)
create_transaction(account: account, name: "Previous month", amount: 200, date: previous_month)
get root_path, params: { spending_month: selected_month.iso8601 }
assert_response :ok
chart = spending_trend_chart_data
current = chart.fetch("current")
previous = chart.fetch("previous")
# Both months are past, so both curves run their full length.
assert_equal selected_month.end_of_month.day, current.size
assert_equal previous_month.end_of_month.day, previous.size
# Cumulative: each month's final point carries the month's total.
assert_equal 75.0, current.last.fetch("value")
assert_equal 200.0, previous.last.fetch("value")
assert_equal [ selected_month.end_of_month.day, previous_month.end_of_month.day ].max, chart.fetch("days")
# The chart needs the selected month's own length to label only its days
# on narrow (mobile) widths.
assert_equal selected_month.end_of_month.day, chart.fetch("current_days")
end
test "dashboard spending trend widget caps an in-progress month at today" do
account = @family.accounts.create!(name: "Spending Trend Current Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
create_transaction(account: account, name: "Today", amount: 10, date: Date.current)
get root_path, params: { spending_month: Date.current.beginning_of_month.iso8601 }
assert_response :ok
chart = spending_trend_chart_data
assert_equal Date.current.day, chart.fetch("current").size
assert chart.fetch("days") >= Date.current.day
end
test "dashboard spending trend axis labels follow the month that owns each day" do
account = @family.accounts.create!(name: "Spending Trend Axis Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
# Find a recent past month whose previous month is longer (e.g. February
# after January), so the axis has tail days owned by the previous month.
selected_month = (1..11).map { |i| i.months.ago.beginning_of_month.to_date }
.find { |m| (m - 1.month).end_of_month.day > m.end_of_month.day }
previous_month = (selected_month - 1.month).beginning_of_month
# Spending in both months so the widget renders the chart, not the empty state.
create_transaction(account: account, name: "Spend", amount: 10, date: selected_month)
create_transaction(account: account, name: "Prior spend", amount: 10, date: previous_month)
get root_path, params: { spending_month: selected_month.iso8601 }
assert_response :ok
chart = spending_trend_chart_data
labels = chart.fetch("axis_labels")
assert_equal previous_month.end_of_month.day, chart.fetch("days")
assert_equal chart.fetch("days"), labels.size
assert_equal I18n.l(selected_month, format: :short), labels.first
# The tail day belongs to the previous, longer month - not a date rolled
# past the selected month's end (e.g. "Jan 31", not "Mar 3").
assert_equal I18n.l(previous_month.end_of_month, format: :short), labels.last
end
test "dashboard spending trend names the compared month in the comparison header" do
account = @family.accounts.create!(name: "Spending Trend Label Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
selected_month = 2.months.ago.beginning_of_month.to_date
previous_month = 3.months.ago.beginning_of_month.to_date
create_transaction(account: account, name: "Spend", amount: 10, date: selected_month)
get root_path, params: { spending_month: selected_month.iso8601 }
assert_response :ok
# The real month name replaces the generic "Previous month" label, which
# truncated on mobile ("Previous mon…").
expected_label = I18n.l(previous_month, format: :month_year).capitalize
assert_select "#spending-trend-section p", text: expected_label
chart_element = css_select("[data-controller='spending-chart']").first
assert_equal expected_label, chart_element["data-spending-chart-previous-label-value"]
end
test "dashboard spending trend renders a compact date range for mobile" do
account = @family.accounts.create!(name: "Spending Trend Range Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
selected_month = 2.months.ago.beginning_of_month.to_date
create_transaction(account: account, name: "Spend", amount: 10, date: selected_month)
get root_path, params: { spending_month: selected_month.iso8601 }
assert_response :ok
# Full form for wide viewports, compact form for narrow ones.
assert_select "p[class*='hidden sm:block']",
text: I18n.t("pages.dashboard.spending_trend.date_range",
start_date: I18n.l(selected_month, format: :long),
end_date: I18n.l(selected_month.end_of_month, format: :long))
assert_select "p[class*='sm:hidden']",
text: "#{I18n.l(selected_month, format: :short)} - #{selected_month.end_of_month.day}, #{selected_month.year}"
end
test "dashboard spending trend widget clamps invalid and future month params" do
account = @family.accounts.create!(name: "Spending Trend Clamp Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
create_transaction(account: account, name: "Today", amount: 10, date: Date.current)
get root_path, params: { spending_month: "not-a-date" }
assert_response :ok
get root_path, params: { spending_month: 2.months.from_now.to_date.iso8601 }
assert_response :ok
chart = spending_trend_chart_data
assert_equal Date.current.beginning_of_month.iso8601, chart.fetch("current").first.fetch("date")
end
private
def money_flow_bars
JSON.parse(css_select("[data-controller='bar-chart']").first["data-bar-chart-data-value"])
end
def spending_trend_chart_data
JSON.parse(css_select("[data-controller='spending-chart']").first["data-spending-chart-data-value"])
end
end