Files
sure/test/helpers/insights_helper_test.rb
T
Guillem Arias Fauste 440e04b942 fix(insights): blur amounts in insight prose under privacy mode (#2865)
Insight titles and bodies are stored as finished prose with the amounts
already interpolated (by the i18n template or the LLM writer), so the
dashboard feed and insight cards rendered raw figures even when the
hide-numbers toggle was active — only the right-aligned key figure was
tagged privacy-sensitive.

Add InsightsHelper#insight_privacy_text, which wraps each numeric
fragment (currency amounts, percentages, bare counts, including
suffix-currency and no-break-space locale formats) in a
privacy-sensitive span at render time, and use it for the title and
body in both the dashboard insights feed and the insight card. The
sentence stays readable while privacy mode blurs the numbers.

The helper splits the raw text before escaping and reassembles it with
safe_join, so HTML in stored prose is still escaped and digit-bearing
entities like ' are never mangled by the number regex.
2026-08-09 02:07:08 +02:00

198 lines
7.2 KiB
Ruby
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require "test_helper"
class InsightsHelperTest < ActionView::TestCase
test "positive types render success regardless of priority" do
insight = build_insight("net_worth_milestone", priority: "high", metadata: { "milestone" => 500_000 })
assert_equal :positive, insight_sentiment(insight)
assert_equal "success", insight_icon_color(insight)
end
test "savings rate improvement is positive even at high priority" do
insight = build_insight(
"savings_rate_change",
priority: "high",
metadata: { "current_rate" => 32.5, "previous_rate" => 20.1 }
)
assert_equal :positive, insight_sentiment(insight)
assert_equal "success", insight_icon_color(insight)
end
test "savings rate drop warns without going red" do
insight = build_insight(
"savings_rate_change",
priority: "high",
metadata: { "current_rate" => -5.4, "previous_rate" => 45.2 }
)
assert_equal :warning, insight_sentiment(insight)
assert_equal "warning", insight_icon_color(insight)
end
test "spending anomaly direction decides sentiment" do
above = build_insight("spending_anomaly", metadata: { "direction" => "above" })
below = build_insight("spending_anomaly", metadata: { "direction" => "below" })
assert_equal :warning, insight_sentiment(above)
assert_equal :positive, insight_sentiment(below)
end
test "only a projected-negative balance renders destructive" do
negative = build_insight("cash_flow_warning", priority: "high", metadata: { "negative" => true })
low = build_insight("cash_flow_warning", priority: "medium", metadata: { "negative" => false })
assert_equal "destructive", insight_icon_color(negative)
assert_equal "warning", insight_icon_color(low)
end
test "informational types stay neutral" do
%w[subscription_audit idle_cash].each do |type|
assert_equal :neutral, insight_sentiment(build_insight(type))
assert_equal "default", insight_icon_color(build_insight(type))
end
end
test "rows written before the metadata shape change degrade safely" do
stale = build_insight("cash_flow_warning", priority: "high", metadata: { "projected_low_amount" => 320.0 })
assert_equal :warning, insight_sentiment(stale)
end
test "meta line shows the type and a month-aligned period as the month name" do
insight = build_insight(
"savings_rate_change",
period_start: Date.new(Date.current.year, 6, 1),
period_end: Date.new(Date.current.year, 6, 30)
)
assert_equal "Savings rate · June", insight_meta_line(insight)
end
test "meta line labels a forward-looking window as next N days" do
travel_to Date.new(2026, 8, 1) do
insight = build_insight(
"cash_flow_warning",
period_start: Date.current,
period_end: Date.current + 30
)
assert_equal "Cash flow · Next 30 days", insight_meta_line(insight)
end
end
test "meta line labels a backward-looking rolling window as last N days" do
travel_to Date.new(2026, 8, 31) do
insight = build_insight(
"net_worth_milestone",
period_start: Date.current - 30,
period_end: Date.current
)
assert_equal "Net worth · Last 30 days", insight_meta_line(insight)
end
end
test "meta line keeps monthly insight periods labeled as the month on boundaries" do
travel_to Date.new(2026, 8, 1) do
insight = build_insight(
"budget_at_risk",
period_start: Date.current.beginning_of_month,
period_end: Date.current.end_of_month
)
assert_equal "Budget · August", insight_meta_line(insight)
end
end
test "meta line falls back to the subject when there is no period" do
insight = build_insight("idle_cash", facts: { "account" => "Emergency fund" })
assert_equal "Idle cash · Emergency fund", insight_meta_line(insight)
end
test "key figure comes from facts and hides for rows without them" do
with_facts = build_insight("idle_cash", facts: { "balance" => "$28,400.00", "idle_days" => 60 })
without_facts = build_insight("idle_cash")
assert_equal "$28,400.00", insight_key_figure(with_facts).first
assert_nil insight_key_figure(without_facts)
end
# The two budget cards share `budget_spent_pct` in facts but not a subject:
# at-risk is about how many categories are in trouble, on-track is about
# overall consumption. Showing consumption on the at-risk card put a
# reassuring figure next to a warning headline.
test "budget at risk leads with the flagged count, not overall consumption" do
insight = build_insight("budget_at_risk", facts: { "count" => 2, "budget_spent_pct" => 14 })
figure, caption = insight_key_figure(insight)
assert_equal "2", figure
assert_equal "need attention", caption
end
test "budget on track still leads with overall consumption" do
insight = build_insight("budget_on_track", facts: { "budget_spent_pct" => 62 })
figure, caption = insight_key_figure(insight)
assert_equal "62%", figure
assert_equal "of budget", caption
end
test "privacy text wraps amounts, percentages and counts in privacy-sensitive spans" do
body = "Your grocery spending is at €288.59, which is 142% above your usual €119.01."
rendered = insight_privacy_text(body)
assert_predicate rendered, :html_safe?
assert_equal <<~HTML.strip, rendered
Your grocery spending is at <span class="privacy-sensitive">288.59</span>, which is <span class="privacy-sensitive">142%</span> above your usual <span class="privacy-sensitive">119.01</span>.
HTML
end
test "privacy text handles locale formats with suffix currency and no-break-space grouping" do
rendered = insight_privacy_text("Checking holds 1234,56 € with no activity in the last 45 days.")
assert_includes rendered, %(<span class="privacy-sensitive">1234,56 €</span>)
assert_includes rendered, %(<span class="privacy-sensitive">45</span> days)
end
test "privacy text leaves numberless prose untouched and escapes HTML" do
assert_equal "Is Netflix still active?", insight_privacy_text("Is Netflix still active?")
assert_equal "", insight_privacy_text(nil)
rendered = insight_privacy_text("It's <b>big</b>: $1,200.50")
assert_includes rendered, "It&#39;s &lt;b&gt;big&lt;/b&gt;:"
assert_includes rendered, %(<span class="privacy-sensitive">$1,200.50</span>)
end
test "action link resolves the stored subject and disappears when it cannot" do
account = families(:dylan_family).accounts.visible.first
resolvable = build_insight("idle_cash", metadata: { "account_id" => account.id })
dangling = build_insight("idle_cash", metadata: { "account_id" => SecureRandom.uuid })
assert_equal account_path(account), insight_action(resolvable)[:href]
assert_nil insight_action(dangling)
end
private
def build_insight(insight_type, priority: "medium", metadata: {}, facts: {}, period_start: nil, period_end: nil)
Insight.new(
family: families(:dylan_family),
insight_type: insight_type,
priority: priority,
status: "active",
title: "t",
body: "b",
metadata: metadata,
facts: facts,
period_start: period_start,
period_end: period_end,
dedup_key: "#{insight_type}:test"
)
end
end