mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 16:25:24 +00:00
* feat(insights): proactive financial intelligence feed Adds a nightly job that analyzes each family's finances in pure Ruby and surfaces typed, stateful insights on the dashboard and a new /insights page. - Insight model (active/read/dismissed) with a per-family dedup_key unique index so nightly re-runs refresh rows instead of duplicating them - Seven generators (spending anomaly, cash-flow warning, net worth milestone, subscription audit, savings rate change, idle cash, budget health) built on IncomeStatement, BalanceSheet, RecurringTransaction, and BudgetCategory - LLM used as a writer, not a reasoner: Insight::BodyWriter narrates pre-computed facts via the configured provider, with an i18n template fallback so self-hosted installs without API keys work identically; bodies are only (re)written when an insight is new or its numbers changed - GenerateInsightsJob: cron fan-out per family, per-family advisory lock, metadata-diff upsert that preserves read/dismissed state for unchanged signals and reactivates on material change - Dashboard insights_feed section (top 3, collapsible/reorderable) and an /insights feed page with turbo-stream dismissal; viewing the feed marks insights read - Tests for the model, job upsert semantics, and controller flows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): address Codex review — expiry, budget metadata, LLM usage - Expire visible insights whose condition cleared: generators declare the insight types they produce, the registry reports which generators ran to completion, and the job expires visible insights of those types whose dedup_key was not regenerated. A crashing generator can't wipe out its healthy insights, and an expired insight reactivates when its condition returns — unlike a user-dismissed one, which stays dismissed. - Include a bucketed budget-spent percent in budget_at_risk metadata so the body refreshes when overall usage moves >=10 points even if the same categories remain flagged. - Pass family to chat_response so LLM narration is recorded in llm_usages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): address CodeRabbit review - Skip the mark-as-read write for Turbo hover-prefetch requests (X-Sec-Purpose) so unread badges don't clear before a real visit - Show the New pill on the dashboard feed (active = unread there; the feed never marks insights read) - Filter idle accounts in SQL instead of a per-account exists? loop - Eager-load merchants in the subscription audit query - Widen the advisory-lock key to the signed-bigint range and log when acquisition fails so a skipped nightly run is observable - Mirror the dedup_key unique index as a model validation - Assert the refresh action enqueues for the signed-in family Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): clear stale lifecycle timestamps on reactivation When an insight resurfaces, the row now leaves no contradictory state behind: the material-change path clears both read_at and dismissed_at, and the expired-recovery path clears read_at. Tests assert the contract, and the dashboard feed test now also locks in the unread "New" badge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): drop redundant standalone family_id index Every composite index on insights already leads with family_id, so the auto-created single-column index was pure write overhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): stop nightly drift from resurrecting dismissed insights Three generators stored continuously drifting values (projected amounts, starting balance, current net worth) in metadata, and any metadata diff counts as a material change: the body is rewritten (an LLM call when a provider is configured) and read/dismissed state is cleared. Dismissing a cash-flow warning was undone at the next 6:00 run. Metadata now carries only the signal identity plus a coarse bucket, the same damping the budget generators already use; display values live in facts. Also orders the idle-cash pick by balance so the selected accounts don't flip between runs, and documents the dollar-scale threshold assumption on the relevant constants. * fix(ds): map DS::Button aria_label into the aria hash A bare aria_label: option reaches the tag helpers as a literal aria_label attribute (Rails only dasherizes the nested aria: hash), so the icon-only fallback overrode it and screen readers announced the insight dismiss button and the popover trigger as "X". * fix(insights): gate LLM narration behind AI consent The nightly job runs unprompted for every family, so narration now requires someone in the family to have AI enabled: consent to share financial facts with the provider, and a cost cap in managed mode. The template fallback keeps behavior identical otherwise. Narration failures are captured via DebugLogEntry so support can see them. Adds BodyWriter coverage, including a template-interpolation test for every generator template key. * feat(insights): rework the dashboard feed The feed rendered full insight cards inside the section shell — the only widget nesting card-on-card — and appended below the fold for every family with a saved section order. It now mirrors the outflows and balance-sheet list idiom (inset well with an uppercase mini-header, white row block, 28px sentiment-tinted icon circles via color-mix on the DS CSS variables, right-aligned key figures) and leads the dashboard for saved orders that predate it. Icon color comes from sentiment, not priority — a savings-rate improvement is high priority AND good news, and must not render red; red is reserved for a projected-negative balance. Rows have no hover wash (cursor plus a gentle icon scale, like the sibling widgets), links to /insights disable Turbo prefetch so the mark-as-read actually fires for mouse users, and the standard widget-size popover offers Half/Full. Full stays the default: the feed is far shorter than any other single-width widget, so a half default leaves a grid hole the masonry cannot backfill. * feat(insights): actionable cards, dismiss undo, live refresh Each row now persists its display facts (new jsonb column) alongside the change-detection metadata. Facts refresh every run without touching the body or user state, which is exactly why they are not part of the material-change comparison. The card gains what the stored data always supported: a type-and-period meta line replacing "x minutes ago", a right-aligned key figure (green only for good news), and a contextual link resolved from the subject ids in metadata — category, account, recurring transaction, budget month — omitted when the subject no longer exists. Dismiss is forgiving: a toast in the notification tray offers undo, and undismissing restores the row as read rather than re-badging it. Milestone insights can never regenerate once dismissed, so this closes a real loss path. Upsert failures are captured via DebugLogEntry. Manual refresh gets feedback: the button swaps to a disabled checking state, the page subscribes to a family-scoped stream, and the job broadcasts the refreshed list and the idle button when it finishes (also after a lock-skipped run, so the button cannot stay stuck). Savings copy is sign-aware: a negative rate reads "you spent more than you earned" with true minus signs. The empty state swaps Lucide sparkles for the brand assistant glyph (DS::EmptyState learns icon_custom:). * feat(insights): top-bar entry with unread count The dashboard feed hides at zero insights and nothing else linked to /insights, so the manual refresh (and the empty state) were unreachable for exactly the families who need them: fresh setups before the first 6:00 run. The sticky top bar travels to every screen and its right cluster had room, so insights get an icon entry there with a monochrome unread badge. Prefetch is disabled on the link for the same mark-as-read reason as the feed. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com>
760 lines
25 KiB
Ruby
760 lines
25 KiB
Ruby
require "test_helper"
|
|
|
|
class UserTest < ActiveSupport::TestCase
|
|
include ActiveJob::TestHelper
|
|
|
|
def setup
|
|
@user = users(:family_admin)
|
|
end
|
|
|
|
def teardown
|
|
clear_enqueued_jobs
|
|
clear_performed_jobs
|
|
end
|
|
|
|
test "should be valid" do
|
|
assert @user.valid?, @user.errors.full_messages.to_sentence
|
|
end
|
|
|
|
# email
|
|
test "email must be present" do
|
|
potential_user = User.new(
|
|
email: "david@davidbowie.com",
|
|
password_digest: BCrypt::Password.create("password"),
|
|
first_name: "David",
|
|
last_name: "Bowie"
|
|
)
|
|
potential_user.email = " "
|
|
assert_not potential_user.valid?
|
|
end
|
|
|
|
test "has email address" do
|
|
assert_equal "bob@bobdylan.com", @user.email
|
|
end
|
|
|
|
test "can update email" do
|
|
@user.update(email: "new_email@example.com")
|
|
assert_equal "new_email@example.com", @user.email
|
|
end
|
|
|
|
test "email addresses must be unique" do
|
|
duplicate_user = @user.dup
|
|
duplicate_user.email = @user.email.upcase
|
|
@user.save
|
|
assert_not duplicate_user.valid?
|
|
end
|
|
|
|
test "email address is normalized" do
|
|
@user.update!(email: " UNIQUE-User@ExAMPle.CoM ")
|
|
assert_equal "unique-user@example.com", @user.reload.email
|
|
end
|
|
|
|
test "display name" do
|
|
user = User.new(email: "user@example.com")
|
|
assert_equal "user@example.com", user.display_name
|
|
user.first_name = "Bob"
|
|
assert_equal "Bob", user.display_name
|
|
user.last_name = "Dylan"
|
|
assert_equal "Bob Dylan", user.display_name
|
|
end
|
|
|
|
test "initial" do
|
|
user = User.new(email: "user@example.com")
|
|
assert_equal "U", user.initial
|
|
user.first_name = "Bob"
|
|
assert_equal "B", user.initial
|
|
user.first_name = nil
|
|
user.last_name = "Dylan"
|
|
assert_equal "D", user.initial
|
|
end
|
|
|
|
test "names are normalized" do
|
|
@user.update!(first_name: "", last_name: "")
|
|
assert_nil @user.first_name
|
|
assert_nil @user.last_name
|
|
|
|
@user.update!(first_name: " Bob ", last_name: " Dylan ")
|
|
assert_equal "Bob", @user.first_name
|
|
assert_equal "Dylan", @user.last_name
|
|
end
|
|
|
|
# MFA Tests
|
|
test "setup_mfa! generates required fields" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
assert user.otp_secret.present?
|
|
assert_not user.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
end
|
|
|
|
test "enable_mfa! enables MFA and generates backup codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
backup_codes = user.enable_mfa!
|
|
|
|
assert user.otp_required?
|
|
assert_equal 8, backup_codes.length
|
|
assert backup_codes.all? { |code| code.match?(/\A[0-9a-f]{16}\z/) }
|
|
assert_equal 8, user.otp_backup_codes.length
|
|
assert user.otp_backup_codes.all? { |code| code.start_with?("$2") }
|
|
assert_empty backup_codes & user.otp_backup_codes
|
|
end
|
|
|
|
test "enable_mfa! requires an OTP secret" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.update_column(:otp_secret, nil)
|
|
|
|
assert_raises(ArgumentError) { user.enable_mfa! }
|
|
assert_not user.reload.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
end
|
|
|
|
test "disable_mfa! removes all MFA data" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
user.webauthn_credentials.create!(
|
|
nickname: "YubiKey",
|
|
credential_id: "credential-id",
|
|
public_key: "public-key"
|
|
)
|
|
|
|
user.disable_mfa!
|
|
|
|
assert_nil user.otp_secret
|
|
assert_not user.otp_required?
|
|
assert_empty user.otp_backup_codes
|
|
assert_empty user.webauthn_credentials
|
|
end
|
|
|
|
test "ensure_webauthn_id! generates a stable credential user handle" do
|
|
user = users(:family_member)
|
|
assert_nil user.webauthn_id
|
|
|
|
webauthn_id = user.ensure_webauthn_id!
|
|
|
|
assert webauthn_id.present?
|
|
assert_equal webauthn_id, user.reload.ensure_webauthn_id!
|
|
end
|
|
|
|
test "webauthn_enabled? requires MFA and at least one credential" do
|
|
user = users(:family_member)
|
|
assert_not user.webauthn_enabled?
|
|
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
assert_not user.webauthn_enabled?
|
|
|
|
user.webauthn_credentials.create!(
|
|
nickname: "Touch ID",
|
|
credential_id: "touch-id-credential",
|
|
public_key: "public-key"
|
|
)
|
|
|
|
assert user.webauthn_enabled?
|
|
end
|
|
|
|
test "verify_otp? validates TOTP codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
totp = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances")
|
|
valid_code = totp.now
|
|
|
|
assert user.verify_otp?(valid_code)
|
|
assert_not user.verify_otp?("invalid")
|
|
assert_not user.verify_otp?("123456")
|
|
end
|
|
|
|
test "verify_otp? does not check backup code digests for normal TOTP input" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
valid_code = ROTP::TOTP.new(user.otp_secret, issuer: "Sure Finances").now
|
|
|
|
BCrypt::Password.expects(:new).never
|
|
|
|
assert user.verify_otp?(valid_code)
|
|
end
|
|
|
|
test "verify_otp? fast rejects non-backup-code input before digest checks" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
BCrypt::Password.expects(:new).never
|
|
|
|
assert_not user.verify_otp?("not-a-backup-code")
|
|
end
|
|
|
|
test "verify_otp? rejects unmatched legacy-shaped backup input" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.enable_mfa!
|
|
|
|
assert_not user.verify_otp?("deadbeef")
|
|
end
|
|
|
|
test "verify_otp? accepts backup codes" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
backup_codes = user.enable_mfa!
|
|
|
|
backup_code = backup_codes.first
|
|
matching_digest = user.otp_backup_codes.find { |digest| BCrypt::Password.new(digest).is_password?(backup_code) }
|
|
assert_not_nil matching_digest
|
|
|
|
assert user.verify_otp?(backup_code)
|
|
|
|
# Backup code should be consumed
|
|
assert_equal 7, user.otp_backup_codes.length
|
|
assert_not_includes user.otp_backup_codes, matching_digest
|
|
|
|
# Used backup code should not work again
|
|
assert_not user.verify_otp?(backup_code)
|
|
end
|
|
|
|
test "verify_otp? reloads backup codes while consuming under lock" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
backup_code = user.enable_mfa!.first
|
|
stale_user = User.find(user.id)
|
|
|
|
user.update!(otp_backup_codes: [])
|
|
|
|
assert_not stale_user.verify_otp?(backup_code)
|
|
assert_empty stale_user.reload.otp_backup_codes
|
|
end
|
|
|
|
test "verify_otp? accepts and consumes legacy plaintext backup codes once" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.update!(otp_required: true, otp_backup_codes: [ "deadbeef" ])
|
|
|
|
assert user.verify_otp?("deadbeef")
|
|
|
|
assert_empty user.reload.otp_backup_codes
|
|
assert_not user.verify_otp?("deadbeef")
|
|
end
|
|
|
|
test "verify_otp? accepts and consumes migrated legacy backup code digests" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
user.update!(
|
|
otp_required: true,
|
|
otp_backup_codes: [ BCrypt::Password.create("deadbeef", cost: BCrypt::Engine::MIN_COST).to_s ]
|
|
)
|
|
|
|
assert user.verify_otp?("deadbeef")
|
|
|
|
assert_empty user.reload.otp_backup_codes
|
|
assert_not user.verify_otp?("deadbeef")
|
|
end
|
|
|
|
test "provisioning_uri generates correct URI" do
|
|
user = users(:family_member)
|
|
user.setup_mfa!
|
|
|
|
assert_match %r{otpauth://totp/}, user.provisioning_uri
|
|
assert_match %r{secret=#{user.otp_secret}}, user.provisioning_uri
|
|
assert_match %r{issuer=Sure}, user.provisioning_uri
|
|
end
|
|
|
|
test "ai_available? returns true when openai access token set in settings" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
|
|
Setting.openai_access_token = "token"
|
|
assert @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "ai_available? returns true when external assistant is configured and family type is external" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
@user.family.update!(assistant_type: "external")
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
|
|
Setting.openai_access_token = nil
|
|
assert @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
@user.family.update!(assistant_type: "builtin")
|
|
end
|
|
|
|
test "ai_available? returns false when external assistant is configured but family type is builtin" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token" do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "ai_available? returns false when external assistant is configured but user is not in allowlist" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
@user.family.update!(assistant_type: "external")
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: "http://localhost:18789/v1/chat", EXTERNAL_ASSISTANT_TOKEN: "test-token", EXTERNAL_ASSISTANT_ALLOWED_EMAILS: "other@example.com" do
|
|
Setting.openai_access_token = nil
|
|
assert_not @user.ai_available?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
@user.family.update!(assistant_type: "builtin")
|
|
end
|
|
|
|
test "intro layout collapses sidebars and enables ai" do
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "intro-new@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest,
|
|
ui_layout: :intro
|
|
)
|
|
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.ui_layout_intro?
|
|
assert_not user.show_sidebar?
|
|
assert_not user.show_ai_sidebar?
|
|
assert user.ai_enabled?
|
|
end
|
|
|
|
test "non-guest role cannot persist intro layout" do
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "dashboard-only@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member,
|
|
ui_layout: :intro
|
|
)
|
|
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.ui_layout_dashboard?
|
|
end
|
|
|
|
test "upgrading guest role restores dashboard layout defaults" do
|
|
user = users(:intro_user)
|
|
user.update!(role: :member)
|
|
user.reload
|
|
|
|
assert user.ui_layout_dashboard?
|
|
assert user.show_sidebar?
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
|
|
test "new member defaults show_ai_sidebar to false when AI is not available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "member-no-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert_not user.show_ai_sidebar?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "new admin defaults show_ai_sidebar to true even when AI is not available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "admin-no-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :admin
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "new member defaults show_ai_sidebar to true when AI is available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "member-with-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :member
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert user.show_ai_sidebar?
|
|
end
|
|
|
|
test "new guest defaults show_ai_sidebar to false when AI is not available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
|
previous = Setting.openai_access_token
|
|
with_env_overrides OPENAI_ACCESS_TOKEN: nil, EXTERNAL_ASSISTANT_URL: nil, EXTERNAL_ASSISTANT_TOKEN: nil do
|
|
Setting.openai_access_token = nil
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "guest-no-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert_not user.show_ai_sidebar?
|
|
end
|
|
ensure
|
|
Setting.openai_access_token = previous
|
|
end
|
|
|
|
test "new guest defaults show_ai_sidebar to false when AI is available" do
|
|
Rails.application.config.app_mode.stubs(:self_hosted?).returns(false)
|
|
user = User.new(
|
|
family: families(:empty),
|
|
email: "guest-with-ai@example.com",
|
|
password: "Password1!",
|
|
password_confirmation: "Password1!",
|
|
role: :guest
|
|
)
|
|
assert user.save, user.errors.full_messages.to_sentence
|
|
assert_not user.show_ai_sidebar?
|
|
end
|
|
|
|
test "update_dashboard_preferences handles concurrent updates atomically" do
|
|
@user.update!(preferences: {})
|
|
|
|
# Simulate concurrent updates from multiple requests
|
|
# Each thread collapses a different section simultaneously
|
|
threads = []
|
|
sections = %w[net_worth_chart outflows_donut cashflow_sankey balance_sheet]
|
|
|
|
sections.each_with_index do |section, index|
|
|
threads << Thread.new do
|
|
# Small staggered delays to increase chance of race conditions
|
|
sleep(index * 0.01)
|
|
|
|
# Each thread loads its own instance and updates
|
|
user = User.find(@user.id)
|
|
user.update_dashboard_preferences({
|
|
"collapsed_sections" => { section => true }
|
|
})
|
|
end
|
|
end
|
|
|
|
# Wait for all threads to complete
|
|
threads.each(&:join)
|
|
|
|
# Verify all updates persisted (no data loss from race conditions)
|
|
@user.reload
|
|
sections.each do |section|
|
|
assert @user.dashboard_section_collapsed?(section),
|
|
"Expected #{section} to be collapsed, but it was not. " \
|
|
"Preferences: #{@user.preferences.inspect}"
|
|
end
|
|
|
|
# Verify all sections are in the preferences hash
|
|
assert_equal sections.sort,
|
|
@user.preferences.dig("collapsed_sections")&.keys&.sort,
|
|
"Expected all sections to be in preferences"
|
|
end
|
|
|
|
test "update_dashboard_preferences merges nested hashes correctly" do
|
|
@user.update!(preferences: {})
|
|
|
|
# First update: collapse net_worth
|
|
@user.update_dashboard_preferences({
|
|
"collapsed_sections" => { "net_worth_chart" => true }
|
|
})
|
|
@user.reload
|
|
|
|
assert @user.dashboard_section_collapsed?("net_worth_chart")
|
|
assert_not @user.dashboard_section_collapsed?("outflows_donut")
|
|
|
|
# Second update: collapse outflows (should preserve net_worth)
|
|
@user.update_dashboard_preferences({
|
|
"collapsed_sections" => { "outflows_donut" => true }
|
|
})
|
|
@user.reload
|
|
|
|
assert @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"First collapsed section should still be collapsed"
|
|
assert @user.dashboard_section_collapsed?("outflows_donut"),
|
|
"Second collapsed section should be collapsed"
|
|
end
|
|
|
|
test "update_dashboard_preferences handles section_order updates" do
|
|
@user.update!(preferences: {})
|
|
|
|
# Set initial order
|
|
new_order = %w[outflows_donut net_worth_chart cashflow_sankey balance_sheet]
|
|
@user.update_dashboard_preferences({ "section_order" => new_order })
|
|
@user.reload
|
|
|
|
assert_equal new_order, @user.dashboard_section_order
|
|
end
|
|
|
|
test "dashboard_section_height returns stored preset or nil" do
|
|
@user.update!(preferences: {})
|
|
assert_nil @user.dashboard_section_height("net_worth_chart")
|
|
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
|
|
})
|
|
@user.reload
|
|
|
|
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
|
|
assert_nil @user.dashboard_section_height("balance_sheet")
|
|
end
|
|
|
|
test "dashboard_section_width returns stored col_span or nil" do
|
|
@user.update!(preferences: {})
|
|
assert_nil @user.dashboard_section_width("cashflow_sankey")
|
|
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "cashflow_sankey" => { "col_span" => "single" } }
|
|
})
|
|
|
|
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
|
|
end
|
|
|
|
test "dashboard_section_layout merges width and height without clobbering" do
|
|
@user.update!(preferences: {})
|
|
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
|
|
})
|
|
@user.update_dashboard_preferences({
|
|
"dashboard_section_layout" => { "net_worth_chart" => { "col_span" => "full" } }
|
|
})
|
|
@user.reload
|
|
|
|
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
|
|
assert_equal "full", @user.dashboard_section_width("net_worth_chart")
|
|
end
|
|
|
|
test "handles empty preferences gracefully for dashboard methods" do
|
|
@user.update!(preferences: {})
|
|
|
|
# dashboard_section_collapsed? should return false when key is missing
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when collapsed_sections key is missing"
|
|
|
|
# dashboard_section_order should return default order when key is missing
|
|
assert_equal %w[insights_feed cashflow_sankey outflows_donut net_worth_chart balance_sheet],
|
|
@user.dashboard_section_order,
|
|
"Should return default order when section_order key is missing"
|
|
|
|
# update_dashboard_preferences should work with empty preferences
|
|
@user.update_dashboard_preferences({ "section_order" => %w[balance_sheet] })
|
|
@user.reload
|
|
|
|
assert_equal %w[balance_sheet], @user.preferences["section_order"]
|
|
end
|
|
|
|
test "handles empty preferences gracefully for reports methods" do
|
|
@user.update!(preferences: {})
|
|
|
|
# reports_section_collapsed? should return false when key is missing
|
|
assert_not @user.reports_section_collapsed?("trends_insights"),
|
|
"Should return false when reports_collapsed_sections key is missing"
|
|
|
|
# reports_section_order should return default order when key is missing
|
|
assert_equal %w[trends_insights transactions_breakdown],
|
|
@user.reports_section_order,
|
|
"Should return default order when reports_section_order key is missing"
|
|
|
|
# update_reports_preferences should work with empty preferences
|
|
@user.update_reports_preferences({ "reports_section_order" => %w[transactions_breakdown] })
|
|
@user.reload
|
|
|
|
assert_equal %w[transactions_breakdown], @user.preferences["reports_section_order"]
|
|
end
|
|
|
|
test "handles missing nested keys in preferences for collapsed sections" do
|
|
@user.update!(preferences: { "section_order" => %w[cashflow] })
|
|
|
|
# Should return false when collapsed_sections key is missing entirely
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when collapsed_sections key is missing"
|
|
|
|
# Should return false when section_key is missing from collapsed_sections
|
|
@user.update!(preferences: { "collapsed_sections" => {} })
|
|
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
|
|
"Should return false when section key is missing from collapsed_sections"
|
|
end
|
|
|
|
# Default account for transactions
|
|
test "default_account_for_transactions returns account when active and manual" do
|
|
account = accounts(:depository)
|
|
@user.update!(default_account: account)
|
|
assert_equal account, @user.default_account_for_transactions
|
|
end
|
|
|
|
test "default_account_for_transactions returns nil when account is disabled" do
|
|
account = accounts(:depository)
|
|
@user.update!(default_account: account)
|
|
account.disable!
|
|
assert_nil @user.default_account_for_transactions
|
|
end
|
|
|
|
test "default_account_for_transactions returns nil when account is linked" do
|
|
account = accounts(:depository)
|
|
@user.update!(default_account: account)
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: account, provider: plaid_account)
|
|
account.reload
|
|
assert_nil @user.default_account_for_transactions
|
|
end
|
|
|
|
test "default_account_for_transactions returns nil when no default set" do
|
|
assert_nil @user.default_account_for_transactions
|
|
end
|
|
|
|
# SSO-only user security tests
|
|
test "sso_only? returns true for user with OIDC identity and no password" do
|
|
sso_user = users(:sso_only)
|
|
assert_nil sso_user.password_digest
|
|
assert sso_user.oidc_identities.exists?
|
|
assert sso_user.sso_only?
|
|
end
|
|
|
|
test "sso_only? returns false for user with password and OIDC identity" do
|
|
# family_admin has both password and OIDC identity
|
|
assert @user.password_digest.present?
|
|
assert @user.oidc_identities.exists?
|
|
assert_not @user.sso_only?
|
|
end
|
|
|
|
test "sso_only? returns false for user with password but no OIDC identity" do
|
|
user_without_oidc = users(:empty)
|
|
assert user_without_oidc.password_digest.present?
|
|
assert_not user_without_oidc.oidc_identities.exists?
|
|
assert_not user_without_oidc.sso_only?
|
|
end
|
|
|
|
test "has_local_password? returns true when password_digest is present" do
|
|
assert @user.has_local_password?
|
|
end
|
|
|
|
test "has_local_password? returns false when password_digest is nil" do
|
|
sso_user = users(:sso_only)
|
|
assert_not sso_user.has_local_password?
|
|
end
|
|
|
|
test "user can be created without password when skip_password_validation is true" do
|
|
user = User.new(
|
|
email: "newssuser@example.com",
|
|
first_name: "New",
|
|
last_name: "SSO User",
|
|
skip_password_validation: true,
|
|
family: families(:empty)
|
|
)
|
|
assert user.valid?, user.errors.full_messages.to_sentence
|
|
assert user.save
|
|
assert_nil user.password_digest
|
|
end
|
|
|
|
test "user requires password on create when skip_password_validation is false" do
|
|
user = User.new(
|
|
email: "needspassword@example.com",
|
|
first_name: "Needs",
|
|
last_name: "Password",
|
|
family: families(:empty)
|
|
)
|
|
assert_not user.valid?
|
|
assert_includes user.errors[:password], "can't be blank"
|
|
end
|
|
|
|
# First user role assignment tests
|
|
test "role_for_new_family_creator returns super_admin when no users exist" do
|
|
# Delete all users to simulate fresh instance
|
|
User.destroy_all
|
|
|
|
assert_equal :super_admin, User.role_for_new_family_creator
|
|
end
|
|
|
|
test "role_for_new_family_creator returns fallback role when users exist" do
|
|
# Users exist from fixtures
|
|
assert User.exists?
|
|
|
|
assert_equal :admin, User.role_for_new_family_creator
|
|
assert_equal :member, User.role_for_new_family_creator(fallback_role: :member)
|
|
assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
|
|
end
|
|
|
|
# Preview features preference tests
|
|
test "preview_features_enabled? defaults to false" do
|
|
@user.update!(preferences: {})
|
|
assert_not @user.preview_features_enabled?
|
|
end
|
|
|
|
test "preview_features_enabled? true only when explicitly true" do
|
|
@user.update!(preferences: { "preview_features_enabled" => true })
|
|
assert @user.preview_features_enabled?
|
|
|
|
@user.update!(preferences: { "preview_features_enabled" => false })
|
|
assert_not @user.preview_features_enabled?
|
|
|
|
@user.update!(preferences: { "preview_features_enabled" => "yes" })
|
|
assert_not @user.preview_features_enabled?, "truthy non-boolean should not enable"
|
|
end
|
|
|
|
# ActiveStorage attachment cleanup tests
|
|
test "purging a user removes attached profile image" do
|
|
user = users(:family_admin)
|
|
user.profile_image.attach(
|
|
io: StringIO.new("profile-image-data"),
|
|
filename: "profile.png",
|
|
content_type: "image/png"
|
|
)
|
|
|
|
attachment_id = user.profile_image.id
|
|
assert ActiveStorage::Attachment.exists?(attachment_id)
|
|
|
|
perform_enqueued_jobs do
|
|
user.purge
|
|
end
|
|
|
|
assert_not User.exists?(user.id)
|
|
assert_not ActiveStorage::Attachment.exists?(attachment_id)
|
|
end
|
|
|
|
test "purging the last user cascades to remove family and its export attachments" do
|
|
family = Family.create!(name: "Solo Family", locale: "en", date_format: "%m-%d-%Y", currency: "USD")
|
|
user = User.create!(family: family, email: "solo@example.com", password: "password123")
|
|
export = family.family_exports.create!
|
|
export.export_file.attach(
|
|
io: StringIO.new("export-data"),
|
|
filename: "export.zip",
|
|
content_type: "application/zip"
|
|
)
|
|
|
|
export_attachment_id = export.export_file.id
|
|
assert ActiveStorage::Attachment.exists?(export_attachment_id)
|
|
|
|
perform_enqueued_jobs do
|
|
user.purge
|
|
end
|
|
|
|
assert_not Family.exists?(family.id)
|
|
assert_not ActiveStorage::Attachment.exists?(export_attachment_id)
|
|
end
|
|
end
|