Files
sure/test/controllers/insights_controller_test.rb
Juan José Mata 375dd060dc feat(insights): gate the insights feed behind preview features (#2788)
* feat(insights): gate the insights feed behind preview features

Insights shipped to everyone in #2550. Make it opt-in via Settings →
Preferences until it's proven, so users who haven't enabled preview
features see nothing and cost nothing.

Entry points gated:
- InsightsController — require_preview_features! covers all four actions,
  including the refresh action that enqueues the job
- Dashboard — the insights_feed section is omitted from the section list
  rather than left in it hidden, so the saved-order lookup and the
  insights_feed unshift special-case never fire; the feed query is skipped
- Top bar — the lightbulb entry and its unread COUNT, which previously ran
  on every page render

The job is gated too, departing from the guide's default that background
jobs keep running. That default fits a job like SweepExpiredGoalPledgesJob,
which only walks records opted-in users created and is naturally inert.
GenerateInsightsJob instead manufactures data for every family nightly —
seven generators over the income statement and balance sheet, plus paid LLM
narration — so it would have kept spending on families who can't see the
result. The fan-out filters with Family.with_preview_features (one indexed
jsonb containment query, not load-and-iterate), and generate_for_family
re-checks above the advisory lock so a gated family skips the broadcast too.

Adds Family#preview_features_enabled? and the matching scopes, keeping the
predicate name identical on User and Family so the guide's GA-removal grep
finds every call site. Verified the SQL scope and the Ruby predicate agree
for true / false / "yes" / nil. Documents the job-gating pattern in
docs/llm-guides/gating-a-preview-feature.md, which previously said the gate
does nothing for jobs.

Existing insight rows are left alone: invisible without the flag, and the
next nightly run refreshes facts and expires anything stale if a family
opts in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* perf(insights): use EXISTS for the family preview rollup

Family#preview_features_enabled? is asked once per family by the nightly
job; the block form loaded and instantiated every member to answer a
boolean. Delegate to the scope instead.

The predicate now shares an implementation with the scope, so the
truthy-non-boolean test asserts against User#preview_features_enabled? —
the predicate the UI actually gates on — to keep the cross-check
meaningful rather than tautological.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* docs(insights): fix guide/code drift and stale cron description

Review follow-ups from @gariasf:

- The guide's family-rollup snippet still showed the block form after the
  EXISTS commit changed it. It mattered more than normal doc drift: the
  paragraph below calls GenerateInsightsJob "the reference implementation",
  so the next person writing a gated job would have copied the form
  family.rb's comment explicitly rejects.
- schedule.yml still described the job as running for "all families" — the
  string someone reads while debugging why a family got no insights.
- Document that the shared predicate name is per-user on User but
  "anyone in the household" on Family, and prohibit gating UI on the family
  form: Current.family.preview_features_enabled? reads naturally and would
  show the feature to a user who explicitly opted out. Noted in both the
  model and the guide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-07-26 07:18:23 +02:00

153 lines
4.7 KiB
Ruby

require "test_helper"
class InsightsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
enable_preview_features
@insight = insights(:spending_anomaly_dining)
ensure_tailwind_build
end
test "index renders visible insights and marks them read" do
get insights_url
assert_response :success
assert_match CGI.escapeHTML(@insight.title), response.body
assert @insight.reload.read?
end
test "turbo prefetch requests do not mark insights read" do
get insights_url, headers: { "X-Sec-Purpose" => "prefetch" }
assert_response :success
assert @insight.reload.active?
end
test "dashboard renders the insights feed section with unread badges" do
get root_url
assert_response :success
assert_select "#insights-feed", count: 1
assert_select "#insights-feed span", text: I18n.t("insights.card.new")
end
test "insights feed leads the dashboard for users with a saved order that predates it" do
@user.update!(preferences: (@user.preferences || {}).merge(
"section_order" => %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet]
))
get root_url
assert_response :success
feed_position = response.body.index('data-section-key="insights_feed"')
sankey_position = response.body.index('data-section-key="cashflow_sankey"')
assert feed_position.present? && feed_position < sankey_position,
"insights_feed should be prepended, not appended, for saved orders that predate it"
end
test "dismiss removes the insight from the feed and offers undo via turbo stream" do
patch dismiss_insight_url(@insight), as: :turbo_stream
assert_response :success
assert_match "turbo-stream", response.body
assert_match undismiss_insight_path(@insight), response.body
assert @insight.reload.dismissed?
end
test "undismiss restores the insight as read and re-renders the list" do
@insight.dismiss!
patch undismiss_insight_url(@insight), as: :turbo_stream
assert_response :success
assert_match "insights-list", response.body
assert_match CGI.escapeHTML(@insight.title), response.body
assert @insight.reload.read?
assert_nil @insight.dismissed_at
end
test "refresh swaps the button into a pending state via turbo stream" do
assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @user.family_id } ]) do
post refresh_insights_url, as: :turbo_stream
end
assert_response :success
assert_match "insights-refresh", response.body
assert_match CGI.escapeHTML(I18n.t("insights.refresh.checking")), response.body
end
test "cannot dismiss another family's insight" do
other_insight = families(:empty).insights.create!(
insight_type: "idle_cash",
priority: "low",
title: "Someone else's insight",
body: "Body",
dedup_key: "idle_cash:other:2026-07"
)
patch dismiss_insight_url(other_insight), as: :turbo_stream
assert_response :not_found
assert other_insight.reload.active?
end
test "refresh enqueues insight generation for the family" do
assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @user.family_id } ]) do
post refresh_insights_url
end
assert_redirected_to insights_path
end
# Preview gate. Insights is opt-in via Settings → Preferences, so a user
# without the flag reaches none of it — not the page, not the dashboard
# section, not the top-bar entry, and not the job the refresh action would
# otherwise enqueue.
test "redirects users without preview access" do
disable_preview_features
get insights_url
assert_redirected_to root_path
assert_match(/preview/i, flash[:alert])
end
test "refresh does not enqueue generation for users without preview access" do
disable_preview_features
assert_no_enqueued_jobs only: GenerateInsightsJob do
post refresh_insights_url
end
assert_redirected_to root_path
end
test "dismiss is blocked for users without preview access" do
disable_preview_features
patch dismiss_insight_url(@insight), as: :turbo_stream
assert_redirected_to root_path
assert @insight.reload.active?
end
test "dashboard omits the insights feed and top-bar entry without preview access" do
disable_preview_features
get root_url
assert_response :success
assert_select "#insights-feed", count: 0
assert_select "a[href=?]", insights_path, count: 0
end
private
def enable_preview_features
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
end
def disable_preview_features
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
end
end