Files
sure/test/models/family_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

360 lines
13 KiB
Ruby

require "test_helper"
class FamilyTest < ActiveSupport::TestCase
include SyncableInterfaceTest
def setup
@syncable = families(:dylan_family)
end
test "investment_contributions_category creates category when missing" do
family = families(:dylan_family)
family.categories.where(name: Category.investment_contributions_name).destroy_all
assert_nil family.categories.find_by(name: Category.investment_contributions_name)
category = family.investment_contributions_category
assert category.persisted?
assert_equal Category.investment_contributions_name, category.name
assert_equal "#0d9488", category.color
assert_equal "trending-up", category.lucide_icon
end
test "investment_contributions_category returns existing category" do
family = families(:dylan_family)
existing = family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c|
c.color = "#0d9488"
c.lucide_icon = "trending-up"
end
assert_no_difference "Category.count" do
result = family.investment_contributions_category
assert_equal existing, result
end
end
test "investment_contributions_category uses family locale consistently" do
family = families(:dylan_family)
family.update!(locale: "fr")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Simulate different request locales (e.g., from Accept-Language header)
# The category should always be created with the family's locale (French)
category_from_english_request = I18n.with_locale(:en) do
family.investment_contributions_category
end
assert_equal "Contributions aux investissements", category_from_english_request.name
# Second request with different locale should find the same category
assert_no_difference "Category.count" do
category_from_dutch_request = I18n.with_locale(:nl) do
family.investment_contributions_category
end
assert_equal category_from_english_request.id, category_from_dutch_request.id
assert_equal "Contributions aux investissements", category_from_dutch_request.name
end
end
test "investment_contributions_category prevents duplicate categories across locales" do
family = families(:dylan_family)
family.update!(locale: "en")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Create category under English family locale
english_category = family.investment_contributions_category
assert_equal "Investment Contributions", english_category.name
# Simulate a request with French locale (e.g., from browser Accept-Language)
# Should still return the English category, not create a French one
assert_no_difference "Category.count" do
I18n.with_locale(:fr) do
french_request_category = family.investment_contributions_category
assert_equal english_category.id, french_request_category.id
assert_equal "Investment Contributions", french_request_category.name
end
end
end
test "investment_contributions_category reuses legacy category with wrong locale" do
family = families(:dylan_family)
family.update!(locale: "fr")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Simulate legacy: category was created with English name (old bug behavior)
legacy_category = family.categories.create!(
name: "Investment Contributions",
color: "#0d9488",
lucide_icon: "trending-up"
)
# Should find and reuse the legacy category, updating its name to French
assert_no_difference "Category.count" do
result = family.investment_contributions_category
assert_equal legacy_category.id, result.id
assert_equal "Contributions aux investissements", result.name
end
end
test "investment_contributions_category merges multiple locale variants" do
family = families(:dylan_family)
family.update!(locale: "en")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Simulate legacy: multiple categories created under different locales
english_category = family.categories.create!(
name: "Investment Contributions",
color: "#0d9488",
lucide_icon: "trending-up"
)
french_category = family.categories.create!(
name: "Contributions aux investissements",
color: "#0d9488",
lucide_icon: "trending-up"
)
# Create transactions pointing to both categories
account = family.accounts.first
txn1 = Transaction.create!(category: english_category)
Entry.create!(
account: account,
entryable: txn1,
amount: 100,
currency: "USD",
date: Date.current,
name: "Test 1"
)
txn2 = Transaction.create!(category: french_category)
Entry.create!(
account: account,
entryable: txn2,
amount: 200,
currency: "USD",
date: Date.current,
name: "Test 2"
)
# Should merge both categories into one, keeping the oldest
assert_difference "Category.count", -1 do
result = family.investment_contributions_category
assert_equal english_category.id, result.id
assert_equal "Investment Contributions", result.name
# Both transactions should now point to the keeper
assert_equal english_category.id, txn1.reload.category_id
assert_equal english_category.id, txn2.reload.category_id
# French category should be deleted
assert_nil Category.find_by(id: french_category.id)
end
end
test "moniker helpers return expected singular and plural labels" do
family = families(:dylan_family)
family.update!(moniker: "Family")
assert_equal "Family", family.moniker_label
assert_equal "Families", family.moniker_label_plural
family.update!(moniker: "Group")
assert_equal "Group", family.moniker_label
assert_equal "Groups", family.moniker_label_plural
end
test "available_merchants includes family merchants without transactions" do
family = families(:dylan_family)
new_merchant = family.merchants.create!(name: "New Test Merchant")
assert_includes family.available_merchants, new_merchant
end
test "enabled currencies always include the base currency" do
family = families(:dylan_family)
family.update!(currency: "SGD", enabled_currencies: [ "USD" ])
family.update!(enabled_currencies: [ "USD" ])
assert_equal [ "SGD", "USD" ], family.reload.enabled_currency_codes
end
test "empty enabled currencies keeps all currencies available" do
family = families(:dylan_family)
family.update!(enabled_currencies: [])
assert_nil family.reload.enabled_currencies
assert_equal Money::Currency.as_options.map(&:iso_code), family.reload.enabled_currency_codes
end
test "enabled currencies are normalized and deduplicated" do
family = families(:dylan_family)
family.update!(currency: "SGD", enabled_currencies: [ "USD", "usd", "SGD" ])
assert_equal [ "SGD", "USD" ], family.reload.enabled_currencies
assert_equal [ "SGD", "USD" ], family.reload.enabled_currency_codes
end
test "all selected currencies collapse to default behavior" do
family = families(:dylan_family)
family.update!(enabled_currencies: Money::Currency.as_options.map(&:iso_code))
assert_nil family.reload.enabled_currencies
assert_equal Money::Currency.as_options.map(&:iso_code), family.reload.enabled_currency_codes
end
test "upload_document stores provided metadata on family document" do
family = families(:dylan_family)
family.update!(vector_store_id: nil)
adapter = mock("vector_store_adapter")
adapter.expects(:create_store).with(name: "Family #{family.id} Documents").returns(
VectorStore::Response.new(success?: true, data: { id: "vs_test123" }, error: nil)
)
adapter.expects(:upload_file).with(
store_id: "vs_test123",
file_content: "hello",
filename: "notes.txt"
).returns(
VectorStore::Response.new(success?: true, data: { file_id: "file-xyz" }, error: nil)
)
VectorStore::Registry.stubs(:adapter).returns(adapter)
document = family.upload_document(
file_content: "hello",
filename: "notes.txt",
metadata: { "type" => "financial_document" }
)
assert_not_nil document
assert_equal({ "type" => "financial_document" }, document.metadata)
assert_equal "vs_test123", family.reload.vector_store_id
end
# auto_share_existing_accounts_with -----------------------------------------
test "auto_share_existing_accounts_with shares existing family accounts read_write when sharing is default" do
family = families(:dylan_family)
family.update!(default_account_sharing: "shared")
newcomer = users(:empty)
newcomer.update_columns(family_id: family.id, role: "member")
expected_ids = family.accounts.where.not(owner_id: newcomer.id).pluck(:id).sort
assert expected_ids.any?, "fixture family must have shareable accounts"
family.auto_share_existing_accounts_with(newcomer)
shares = AccountShare.where(user: newcomer)
assert_equal expected_ids, shares.pluck(:account_id).sort
assert shares.all?(&:read_write?), "shares must grant read_write"
assert shares.all?(&:include_in_finances?), "shares must be included in finances"
end
test "auto_share_existing_accounts_with shares existing family accounts read_only for guests" do
family = families(:dylan_family)
family.update!(default_account_sharing: "shared")
newcomer = users(:empty)
newcomer.update_columns(family_id: family.id, role: "guest")
expected_ids = family.accounts.where.not(owner_id: newcomer.id).pluck(:id).sort
assert expected_ids.any?, "fixture family must have shareable accounts"
family.auto_share_existing_accounts_with(newcomer)
shares = AccountShare.where(user: newcomer)
assert_equal expected_ids, shares.pluck(:account_id).sort
assert shares.all?(&:read_only?), "guest shares must grant read_only"
assert shares.all?(&:include_in_finances?), "shares must be included in finances"
end
test "auto_share_existing_accounts_with is a no-op when family sharing is private" do
family = families(:dylan_family)
family.update!(default_account_sharing: "private")
newcomer = users(:empty)
newcomer.update_columns(family_id: family.id, role: "member")
assert_no_difference "AccountShare.count" do
family.auto_share_existing_accounts_with(newcomer)
end
end
test "auto_share_existing_accounts_with does not share with a user outside the family" do
family = families(:dylan_family)
family.update!(default_account_sharing: "shared")
outsider = users(:empty)
outsider.update_columns(family_id: families(:empty).id, role: "member")
assert_no_difference "AccountShare.count" do
family.auto_share_existing_accounts_with(outsider)
end
end
test "auto_share_existing_accounts_with never shares a user's own account and is idempotent" do
family = families(:dylan_family)
family.update!(default_account_sharing: "shared")
newcomer = users(:empty)
newcomer.update_columns(family_id: family.id, role: "member")
owned = family.accounts.first
owned.update!(owner: newcomer)
family.auto_share_existing_accounts_with(newcomer)
count_after_first = AccountShare.where(user: newcomer).count
assert_not AccountShare.exists?(user: newcomer, account: owned),
"must not share a user's own account with themselves"
family.auto_share_existing_accounts_with(newcomer) # re-run
assert_equal count_after_first, AccountShare.where(user: newcomer).count,
"re-running must not create duplicate shares"
end
# Preview access is per-user, but jobs that act on family-scoped data have no
# Current.user. One opted-in member enables the family.
test "preview_features_enabled? is true when any member has opted in" do
family = families(:dylan_family)
family.users.each { |user| set_preview_features(user, false) }
assert_not family.reload.preview_features_enabled?
set_preview_features(family.users.first, true)
assert family.reload.preview_features_enabled?
end
test "with_preview_features scope agrees with the predicate" do
family = families(:dylan_family)
family.users.each { |user| set_preview_features(user, false) }
assert_not_includes Family.with_preview_features, family.reload
set_preview_features(family.users.first, true)
assert_includes Family.with_preview_features, family.reload
end
# The family rollup is a jsonb containment match; the UI gates on
# User#preview_features_enabled?'s strict `== true`. If containment were the
# looser of the two, the nightly job would generate for families whose UI
# still hides the feature — so assert the user-level predicate agrees.
test "with_preview_features ignores truthy non-boolean values" do
family = families(:dylan_family)
family.users.each { |user| set_preview_features(user, false) }
set_preview_features(family.users.first, "yes")
assert_not family.users.first.reload.preview_features_enabled?,
"the per-user predicate the UI reads must reject a non-boolean"
assert_not family.reload.preview_features_enabled?
assert_not_includes Family.with_preview_features, family
end
private
def set_preview_features(user, enabled)
user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => enabled))
end
end