mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* 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>
360 lines
13 KiB
Ruby
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
|