mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 21:54:58 +00:00
Merge branch 'main' into copilot/fix-twelvedata-api-limit-bug
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class AccountTest < ActiveSupport::TestCase
|
||||
include SyncableInterfaceTest, EntriesTestHelper
|
||||
include SyncableInterfaceTest, EntriesTestHelper, ActiveJob::TestHelper
|
||||
|
||||
setup do
|
||||
@account = @syncable = accounts(:depository)
|
||||
@@ -155,4 +155,21 @@ class AccountTest < ActiveSupport::TestCase
|
||||
assert @account.taxable?
|
||||
assert_not @account.tax_advantaged?
|
||||
end
|
||||
|
||||
test "destroying account purges attached logo" do
|
||||
@account.logo.attach(
|
||||
io: StringIO.new("fake-logo-content"),
|
||||
filename: "logo.png",
|
||||
content_type: "image/png"
|
||||
)
|
||||
|
||||
attachment_id = @account.logo.id
|
||||
assert ActiveStorage::Attachment.exists?(attachment_id)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
@account.destroy!
|
||||
end
|
||||
|
||||
assert_not ActiveStorage::Attachment.exists?(attachment_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -163,6 +163,20 @@ class ApiKeyTest < ActiveSupport::TestCase
|
||||
assert second_key.valid?
|
||||
end
|
||||
|
||||
test "should allow active monitoring key alongside active web key" do
|
||||
@api_key.save!
|
||||
|
||||
monitoring_key = ApiKey.new(
|
||||
user: @user,
|
||||
name: "Monitoring API Key",
|
||||
key: "monitoring_key_123",
|
||||
scopes: [ "read" ],
|
||||
source: "monitoring"
|
||||
)
|
||||
|
||||
assert monitoring_key.valid?
|
||||
end
|
||||
|
||||
test "should include active api keys in active scope" do
|
||||
@api_key.save!
|
||||
active_keys = ApiKey.active
|
||||
@@ -204,4 +218,54 @@ class ApiKeyTest < ActiveSupport::TestCase
|
||||
assert_not @api_key.valid?
|
||||
assert_includes @api_key.errors[:scopes], "must be either 'read' or 'read_write'"
|
||||
end
|
||||
|
||||
test "should prevent destroying demo monitoring api key" do
|
||||
demo_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Demo Monitoring Key",
|
||||
display_key: ApiKey::DEMO_MONITORING_KEY,
|
||||
scopes: [ "read" ]
|
||||
)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotDestroyed) { demo_key.destroy! }
|
||||
assert ApiKey.exists?(demo_key.id)
|
||||
end
|
||||
|
||||
test "should prevent revoking demo monitoring api key" do
|
||||
demo_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Demo Monitoring Key",
|
||||
display_key: ApiKey::DEMO_MONITORING_KEY,
|
||||
scopes: [ "read" ]
|
||||
)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotDestroyed) { demo_key.revoke! }
|
||||
demo_key.reload
|
||||
assert_nil demo_key.revoked_at
|
||||
end
|
||||
|
||||
test "should prevent deleting demo monitoring api key" do
|
||||
demo_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Demo Monitoring Key",
|
||||
display_key: ApiKey::DEMO_MONITORING_KEY,
|
||||
scopes: [ "read" ]
|
||||
)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotDestroyed) { demo_key.delete }
|
||||
assert ApiKey.exists?(demo_key.id)
|
||||
end
|
||||
|
||||
test "should allow destroying non-demo api key" do
|
||||
api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Disposable API Key",
|
||||
display_key: "disposable_key_123",
|
||||
scopes: [ "read" ]
|
||||
)
|
||||
|
||||
assert_difference("ApiKey.count", -1) do
|
||||
api_key.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
21
test/models/assistant/configurable_test.rb
Normal file
21
test/models/assistant/configurable_test.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
require "test_helper"
|
||||
|
||||
class AssistantConfigurableTest < ActiveSupport::TestCase
|
||||
test "returns dashboard configuration by default" do
|
||||
chat = chats(:one)
|
||||
|
||||
config = Assistant.config_for(chat)
|
||||
|
||||
assert_not_empty config[:functions]
|
||||
assert_includes config[:instructions], "You help users understand their financial data"
|
||||
end
|
||||
|
||||
test "returns intro configuration without functions" do
|
||||
chat = chats(:intro)
|
||||
|
||||
config = Assistant.config_for(chat)
|
||||
|
||||
assert_equal [], config[:functions]
|
||||
assert_includes config[:instructions], "stage of life"
|
||||
end
|
||||
end
|
||||
129
test/models/assistant/function/search_family_files_test.rb
Normal file
129
test/models/assistant/function/search_family_files_test.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::SearchFamilyFilesTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@function = Assistant::Function::SearchFamilyFiles.new(@user)
|
||||
end
|
||||
|
||||
test "has correct name" do
|
||||
assert_equal "search_family_files", @function.name
|
||||
end
|
||||
|
||||
test "has a description" do
|
||||
assert_not_empty @function.description
|
||||
end
|
||||
|
||||
test "is not in strict mode" do
|
||||
assert_not @function.strict_mode?
|
||||
end
|
||||
|
||||
test "params_schema requires query" do
|
||||
schema = @function.params_schema
|
||||
assert_includes schema[:required], "query"
|
||||
assert schema[:properties].key?(:query)
|
||||
end
|
||||
|
||||
test "generates valid tool definition" do
|
||||
definition = @function.to_definition
|
||||
assert_equal "search_family_files", definition[:name]
|
||||
assert_not_nil definition[:description]
|
||||
assert_not_nil definition[:params_schema]
|
||||
assert_equal false, definition[:strict]
|
||||
end
|
||||
|
||||
test "returns no_documents error when family has no vector store" do
|
||||
@user.family.update!(vector_store_id: nil)
|
||||
|
||||
result = @function.call("query" => "tax return")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "no_documents", result[:error]
|
||||
end
|
||||
|
||||
test "returns provider_not_configured when no adapter is available" do
|
||||
@user.family.update!(vector_store_id: "vs_test123")
|
||||
VectorStore::Registry.stubs(:adapter).returns(nil)
|
||||
|
||||
result = @function.call("query" => "tax return")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "provider_not_configured", result[:error]
|
||||
end
|
||||
|
||||
test "returns search results on success" do
|
||||
@user.family.update!(vector_store_id: "vs_test123")
|
||||
|
||||
mock_adapter = mock("vector_store_adapter")
|
||||
mock_adapter.stubs(:search).returns(
|
||||
VectorStore::Response.new(
|
||||
success?: true,
|
||||
data: [
|
||||
{ content: "Total income: $85,000", filename: "2024_tax_return.pdf", score: 0.95, file_id: "file-abc" },
|
||||
{ content: "W-2 wages: $80,000", filename: "2024_tax_return.pdf", score: 0.87, file_id: "file-abc" }
|
||||
],
|
||||
error: nil
|
||||
)
|
||||
)
|
||||
|
||||
VectorStore::Registry.stubs(:adapter).returns(mock_adapter)
|
||||
|
||||
result = @function.call("query" => "What was my total income?")
|
||||
|
||||
assert_equal true, result[:success]
|
||||
assert_equal 2, result[:result_count]
|
||||
assert_equal "Total income: $85,000", result[:results].first[:content]
|
||||
assert_equal "2024_tax_return.pdf", result[:results].first[:filename]
|
||||
end
|
||||
|
||||
test "returns empty results message when no matches found" do
|
||||
@user.family.update!(vector_store_id: "vs_test123")
|
||||
|
||||
mock_adapter = mock("vector_store_adapter")
|
||||
mock_adapter.stubs(:search).returns(
|
||||
VectorStore::Response.new(success?: true, data: [], error: nil)
|
||||
)
|
||||
|
||||
VectorStore::Registry.stubs(:adapter).returns(mock_adapter)
|
||||
|
||||
result = @function.call("query" => "nonexistent document")
|
||||
|
||||
assert_equal true, result[:success]
|
||||
assert_empty result[:results]
|
||||
end
|
||||
|
||||
test "handles search failure gracefully" do
|
||||
@user.family.update!(vector_store_id: "vs_test123")
|
||||
|
||||
mock_adapter = mock("vector_store_adapter")
|
||||
mock_adapter.stubs(:search).returns(
|
||||
VectorStore::Response.new(
|
||||
success?: false,
|
||||
data: nil,
|
||||
error: VectorStore::Error.new("API rate limit exceeded")
|
||||
)
|
||||
)
|
||||
|
||||
VectorStore::Registry.stubs(:adapter).returns(mock_adapter)
|
||||
|
||||
result = @function.call("query" => "tax return")
|
||||
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "search_failed", result[:error]
|
||||
end
|
||||
|
||||
test "caps max_results at 20" do
|
||||
@user.family.update!(vector_store_id: "vs_test123")
|
||||
|
||||
mock_adapter = mock("vector_store_adapter")
|
||||
mock_adapter.expects(:search).with(
|
||||
store_id: "vs_test123",
|
||||
query: "test",
|
||||
max_results: 20
|
||||
).returns(VectorStore::Response.new(success?: true, data: [], error: nil))
|
||||
|
||||
VectorStore::Registry.stubs(:adapter).returns(mock_adapter)
|
||||
|
||||
@function.call("query" => "test", "max_results" => 50)
|
||||
end
|
||||
end
|
||||
@@ -75,6 +75,130 @@ class BudgetTest < ActiveSupport::TestCase
|
||||
assert_nil budget.previous_budget_param
|
||||
end
|
||||
|
||||
test "actual_spending nets refunds against expenses in same category" do
|
||||
family = families(:dylan_family)
|
||||
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
|
||||
|
||||
healthcare = Category.create!(
|
||||
name: "Healthcare #{Time.now.to_f}",
|
||||
family: family,
|
||||
color: "#e74c3c",
|
||||
classification: "expense"
|
||||
)
|
||||
|
||||
budget.sync_budget_categories
|
||||
budget_category = budget.budget_categories.find_by(category: healthcare)
|
||||
budget_category.update!(budgeted_spending: 200)
|
||||
|
||||
account = accounts(:depository)
|
||||
|
||||
# Create a $500 expense
|
||||
Entry.create!(
|
||||
account: account,
|
||||
entryable: Transaction.create!(category: healthcare),
|
||||
date: Date.current,
|
||||
name: "Doctor visit",
|
||||
amount: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Create a $200 refund (negative amount = income classification in the SQL)
|
||||
Entry.create!(
|
||||
account: account,
|
||||
entryable: Transaction.create!(category: healthcare),
|
||||
date: Date.current,
|
||||
name: "Insurance reimbursement",
|
||||
amount: -200,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Clear memoized values
|
||||
budget = Budget.find(budget.id)
|
||||
budget.sync_budget_categories
|
||||
|
||||
# Budget category should show net spending: $500 - $200 = $300
|
||||
assert_equal 300, budget.budget_category_actual_spending(
|
||||
budget.budget_categories.find_by(category: healthcare)
|
||||
)
|
||||
end
|
||||
|
||||
test "budget_category_actual_spending does not go below zero" do
|
||||
family = families(:dylan_family)
|
||||
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
|
||||
|
||||
category = Category.create!(
|
||||
name: "Returns Only #{Time.now.to_f}",
|
||||
family: family,
|
||||
color: "#3498db",
|
||||
classification: "expense"
|
||||
)
|
||||
|
||||
budget.sync_budget_categories
|
||||
budget_category = budget.budget_categories.find_by(category: category)
|
||||
budget_category.update!(budgeted_spending: 100)
|
||||
|
||||
account = accounts(:depository)
|
||||
|
||||
# Only a refund, no expense
|
||||
Entry.create!(
|
||||
account: account,
|
||||
entryable: Transaction.create!(category: category),
|
||||
date: Date.current,
|
||||
name: "Full refund",
|
||||
amount: -50,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
budget = Budget.find(budget.id)
|
||||
budget.sync_budget_categories
|
||||
|
||||
assert_equal 0, budget.budget_category_actual_spending(
|
||||
budget.budget_categories.find_by(category: category)
|
||||
)
|
||||
end
|
||||
|
||||
test "actual_spending subtracts uncategorized refunds" do
|
||||
family = families(:dylan_family)
|
||||
budget = Budget.find_or_bootstrap(family, start_date: Date.current.beginning_of_month)
|
||||
account = accounts(:depository)
|
||||
|
||||
# Create an uncategorized expense
|
||||
Entry.create!(
|
||||
account: account,
|
||||
entryable: Transaction.create!(category: nil),
|
||||
date: Date.current,
|
||||
name: "Uncategorized purchase",
|
||||
amount: 400,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
# Create an uncategorized refund
|
||||
Entry.create!(
|
||||
account: account,
|
||||
entryable: Transaction.create!(category: nil),
|
||||
date: Date.current,
|
||||
name: "Uncategorized refund",
|
||||
amount: -150,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
budget = Budget.find(budget.id)
|
||||
budget.sync_budget_categories
|
||||
|
||||
# The uncategorized refund should reduce overall actual_spending
|
||||
# Other fixtures may contribute spending, so check that the net
|
||||
# uncategorized amount (400 - 150 = 250) is reflected by comparing
|
||||
# with and without the refund rather than asserting an exact total.
|
||||
spending_with_refund = budget.actual_spending
|
||||
|
||||
# Remove the refund and check spending increases
|
||||
Entry.find_by(name: "Uncategorized refund").destroy!
|
||||
budget = Budget.find(budget.id)
|
||||
spending_without_refund = budget.actual_spending
|
||||
|
||||
assert_equal 150, spending_without_refund - spending_with_refund
|
||||
end
|
||||
|
||||
test "previous_budget_param returns param when date is valid" do
|
||||
budget = Budget.create!(
|
||||
family: @family,
|
||||
|
||||
@@ -30,4 +30,14 @@ class CategoryTest < ActiveSupport::TestCase
|
||||
|
||||
assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message
|
||||
end
|
||||
|
||||
test "all_investment_contributions_names returns all locale variants" do
|
||||
names = Category.all_investment_contributions_names
|
||||
|
||||
assert_includes names, "Investment Contributions" # English
|
||||
assert_includes names, "Contributions aux investissements" # French
|
||||
assert_includes names, "Investeringsbijdragen" # Dutch
|
||||
assert names.all? { |name| name.is_a?(String) }
|
||||
assert_equal names, names.uniq # No duplicates
|
||||
end
|
||||
end
|
||||
|
||||
165
test/models/concerns/ssl_configurable_test.rb
Normal file
165
test/models/concerns/ssl_configurable_test.rb
Normal file
@@ -0,0 +1,165 @@
|
||||
require "test_helper"
|
||||
|
||||
class SslConfigurableTest < ActiveSupport::TestCase
|
||||
# Create a simple test host that extends SslConfigurable, mirroring how
|
||||
# providers use it in the actual codebase.
|
||||
class SslTestHost
|
||||
extend SslConfigurable
|
||||
end
|
||||
|
||||
setup do
|
||||
# Snapshot original config so we can restore it in teardown
|
||||
@original_verify = Rails.configuration.x.ssl.verify
|
||||
@original_ca_file = Rails.configuration.x.ssl.ca_file
|
||||
@original_debug = Rails.configuration.x.ssl.debug
|
||||
end
|
||||
|
||||
teardown do
|
||||
Rails.configuration.x.ssl.verify = @original_verify
|
||||
Rails.configuration.x.ssl.ca_file = @original_ca_file
|
||||
Rails.configuration.x.ssl.debug = @original_debug
|
||||
end
|
||||
|
||||
# -- ssl_verify? --
|
||||
|
||||
test "ssl_verify? returns true when verify is nil (default)" do
|
||||
Rails.configuration.x.ssl.verify = nil
|
||||
assert SslTestHost.ssl_verify?
|
||||
end
|
||||
|
||||
test "ssl_verify? returns true when verify is true" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
assert SslTestHost.ssl_verify?
|
||||
end
|
||||
|
||||
test "ssl_verify? returns false when verify is explicitly false" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
refute SslTestHost.ssl_verify?
|
||||
end
|
||||
|
||||
# -- ssl_ca_file --
|
||||
|
||||
test "ssl_ca_file returns nil when no CA file is configured" do
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
assert_nil SslTestHost.ssl_ca_file
|
||||
end
|
||||
|
||||
test "ssl_ca_file returns the configured path" do
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
assert_equal "/certs/my-ca.crt", SslTestHost.ssl_ca_file
|
||||
end
|
||||
|
||||
# -- ssl_debug? --
|
||||
|
||||
test "ssl_debug? returns false when debug is nil" do
|
||||
Rails.configuration.x.ssl.debug = nil
|
||||
refute SslTestHost.ssl_debug?
|
||||
end
|
||||
|
||||
test "ssl_debug? returns true when debug is true" do
|
||||
Rails.configuration.x.ssl.debug = true
|
||||
assert SslTestHost.ssl_debug?
|
||||
end
|
||||
|
||||
# -- faraday_ssl_options --
|
||||
|
||||
test "faraday_ssl_options returns verify true with no CA file by default" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_nil options[:ca_file]
|
||||
end
|
||||
|
||||
test "faraday_ssl_options includes ca_file when configured" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_equal "/certs/my-ca.crt", options[:ca_file]
|
||||
end
|
||||
|
||||
test "faraday_ssl_options returns verify false when verification disabled" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal false, options[:verify]
|
||||
end
|
||||
|
||||
test "faraday_ssl_options includes both verify false and ca_file when both configured" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal false, options[:verify]
|
||||
assert_equal "/certs/my-ca.crt", options[:ca_file]
|
||||
end
|
||||
|
||||
# -- httparty_ssl_options --
|
||||
|
||||
test "httparty_ssl_options returns verify true with no CA file by default" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.httparty_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_nil options[:ssl_ca_file]
|
||||
end
|
||||
|
||||
test "httparty_ssl_options includes ssl_ca_file when configured" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.httparty_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_equal "/certs/my-ca.crt", options[:ssl_ca_file]
|
||||
end
|
||||
|
||||
test "httparty_ssl_options returns verify false when verification disabled" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.httparty_ssl_options
|
||||
|
||||
assert_equal false, options[:verify]
|
||||
end
|
||||
|
||||
# -- net_http_verify_mode --
|
||||
|
||||
test "net_http_verify_mode returns VERIFY_PEER when verification enabled" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode
|
||||
end
|
||||
|
||||
test "net_http_verify_mode returns VERIFY_NONE when verification disabled" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
assert_equal OpenSSL::SSL::VERIFY_NONE, SslTestHost.net_http_verify_mode
|
||||
end
|
||||
|
||||
test "net_http_verify_mode returns VERIFY_PEER when verify is nil" do
|
||||
Rails.configuration.x.ssl.verify = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode
|
||||
end
|
||||
end
|
||||
66
test/models/eval/langfuse_client_test.rb
Normal file
66
test/models/eval/langfuse_client_test.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
require "test_helper"
|
||||
require "ostruct"
|
||||
|
||||
class Eval::LangfuseClientTest < ActiveSupport::TestCase
|
||||
# -- CRL error list --
|
||||
|
||||
test "crl_errors includes standard CRL error codes" do
|
||||
errors = Eval::Langfuse::Client.crl_errors
|
||||
|
||||
assert_includes errors, OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL
|
||||
assert_includes errors, OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED
|
||||
assert_includes errors, OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
|
||||
end
|
||||
|
||||
test "crl_errors is frozen" do
|
||||
assert Eval::Langfuse::Client.crl_errors.frozen?
|
||||
end
|
||||
|
||||
# -- CRL verify callback behavior --
|
||||
# The callback should bypass only CRL-specific errors while preserving the
|
||||
# original verification result for all other error types.
|
||||
|
||||
test "CRL callback returns true for CRL-unavailable errors" do
|
||||
crl_error_codes = Eval::Langfuse::Client.crl_errors
|
||||
store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL)
|
||||
|
||||
callback = build_crl_callback(crl_error_codes)
|
||||
|
||||
assert callback.call(false, store_ctx), "CRL errors should be bypassed even when preverify_ok is false"
|
||||
end
|
||||
|
||||
test "CRL callback preserves preverify_ok for non-CRL errors" do
|
||||
crl_error_codes = Eval::Langfuse::Client.crl_errors
|
||||
# V_OK (0) is not a CRL error
|
||||
store_ctx = OpenStruct.new(error: 0)
|
||||
|
||||
callback = build_crl_callback(crl_error_codes)
|
||||
|
||||
assert callback.call(true, store_ctx), "Non-CRL errors with preverify_ok=true should pass"
|
||||
refute callback.call(false, store_ctx), "Non-CRL errors with preverify_ok=false should fail"
|
||||
end
|
||||
|
||||
test "CRL callback rejects cert errors that are not CRL-related" do
|
||||
crl_error_codes = Eval::Langfuse::Client.crl_errors
|
||||
# V_ERR_CERT_HAS_EXPIRED is a real cert error, not CRL
|
||||
store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED)
|
||||
|
||||
callback = build_crl_callback(crl_error_codes)
|
||||
|
||||
refute callback.call(false, store_ctx), "Non-CRL cert errors should not be bypassed"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Reconstructs the same lambda used in Eval::Langfuse::Client#execute_request
|
||||
# for isolated testing without needing a real Net::HTTP connection.
|
||||
def build_crl_callback(crl_error_codes)
|
||||
->(preverify_ok, store_ctx) {
|
||||
if crl_error_codes.include?(store_ctx.error)
|
||||
true
|
||||
else
|
||||
preverify_ok
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
||||
@family = families(:dylan_family)
|
||||
@depository = accounts(:depository)
|
||||
@credit_card = accounts(:credit_card)
|
||||
@loan = accounts(:loan)
|
||||
end
|
||||
|
||||
test "auto-matches transfers" do
|
||||
@@ -27,7 +28,7 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
||||
@family.auto_match_transfers!
|
||||
end
|
||||
|
||||
# test match within lower 5% bound
|
||||
# test match within lower 10% bound
|
||||
create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000)
|
||||
create_transaction(date: Date.current, account: @credit_card, amount: -1330, currency: "CAD")
|
||||
|
||||
@@ -35,7 +36,7 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
||||
@family.auto_match_transfers!
|
||||
end
|
||||
|
||||
# test match within upper 5% bound
|
||||
# test match within upper 10% bound
|
||||
create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1500)
|
||||
create_transaction(date: Date.current, account: @credit_card, amount: -2189, currency: "CAD")
|
||||
|
||||
@@ -45,7 +46,7 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
||||
|
||||
# test no match outside of slippage tolerance
|
||||
create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000)
|
||||
create_transaction(date: Date.current, account: @credit_card, amount: -1320, currency: "CAD")
|
||||
create_transaction(date: Date.current, account: @credit_card, amount: -1250, currency: "CAD")
|
||||
|
||||
assert_difference -> { Transfer.count } => 0 do
|
||||
@family.auto_match_transfers!
|
||||
@@ -108,6 +109,19 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "auto-matched cash to investment assigns investment contribution category" do
|
||||
investment = accounts(:investment)
|
||||
outflow_entry = create_transaction(date: Date.current, account: @depository, amount: 500)
|
||||
inflow_entry = create_transaction(date: Date.current, account: investment, amount: -500)
|
||||
|
||||
@family.auto_match_transfers!
|
||||
|
||||
outflow_entry.reload
|
||||
|
||||
category = @family.investment_contributions_category
|
||||
assert_equal category, outflow_entry.entryable.category
|
||||
end
|
||||
|
||||
test "does not match multi-currency transfer with missing exchange rate" do
|
||||
create_transaction(date: Date.current, account: @depository, amount: 500)
|
||||
create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: "GBP")
|
||||
@@ -117,6 +131,54 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
# Regression tests for loan transfer kind assignment bug
|
||||
# The kind should be determined by the DESTINATION account (inflow), not the source (outflow)
|
||||
test "loan payment (cash to loan) assigns loan_payment kind to outflow" do
|
||||
# Cash → Loan: outflow from depository, inflow to loan
|
||||
outflow_entry = create_transaction(date: Date.current, account: @depository, amount: 500)
|
||||
inflow_entry = create_transaction(date: Date.current, account: @loan, amount: -500)
|
||||
|
||||
@family.auto_match_transfers!
|
||||
|
||||
outflow_entry.reload
|
||||
inflow_entry.reload
|
||||
|
||||
# Destination is loan account, so outflow should be loan_payment
|
||||
assert_equal "loan_payment", outflow_entry.entryable.kind
|
||||
assert_equal "funds_movement", inflow_entry.entryable.kind
|
||||
end
|
||||
|
||||
test "loan disbursement (loan to cash) assigns funds_movement kind to outflow" do
|
||||
# Loan → Cash: outflow from loan, inflow to depository
|
||||
outflow_entry = create_transaction(date: Date.current, account: @loan, amount: 500)
|
||||
inflow_entry = create_transaction(date: Date.current, account: @depository, amount: -500)
|
||||
|
||||
@family.auto_match_transfers!
|
||||
|
||||
outflow_entry.reload
|
||||
inflow_entry.reload
|
||||
|
||||
# Destination is depository (not loan), so outflow should be funds_movement
|
||||
# This ensures loan disbursements don't incorrectly appear in cashflow
|
||||
assert_equal "funds_movement", outflow_entry.entryable.kind
|
||||
assert_equal "funds_movement", inflow_entry.entryable.kind
|
||||
end
|
||||
|
||||
test "credit card payment (cash to credit card) assigns cc_payment kind to outflow" do
|
||||
# Cash → Credit Card: outflow from depository, inflow to credit card
|
||||
outflow_entry = create_transaction(date: Date.current, account: @depository, amount: 500)
|
||||
inflow_entry = create_transaction(date: Date.current, account: @credit_card, amount: -500)
|
||||
|
||||
@family.auto_match_transfers!
|
||||
|
||||
outflow_entry.reload
|
||||
inflow_entry.reload
|
||||
|
||||
# Destination is credit card, so outflow should be cc_payment
|
||||
assert_equal "cc_payment", outflow_entry.entryable.kind
|
||||
assert_equal "funds_movement", inflow_entry.entryable.kind
|
||||
end
|
||||
|
||||
private
|
||||
def load_exchange_prices
|
||||
rates = {
|
||||
|
||||
70
test/models/family/month_start_day_test.rb
Normal file
70
test/models/family/month_start_day_test.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
require "test_helper"
|
||||
|
||||
class Family::MonthStartDayTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
end
|
||||
|
||||
test "month_start_day defaults to 1" do
|
||||
assert_equal 1, @family.month_start_day
|
||||
end
|
||||
|
||||
test "validates month_start_day is between 1 and 28" do
|
||||
@family.month_start_day = 0
|
||||
assert_not @family.valid?
|
||||
|
||||
@family.month_start_day = 29
|
||||
assert_not @family.valid?
|
||||
|
||||
@family.month_start_day = 15
|
||||
assert @family.valid?
|
||||
end
|
||||
|
||||
test "uses_custom_month_start? returns false when month_start_day is 1" do
|
||||
@family.month_start_day = 1
|
||||
assert_not @family.uses_custom_month_start?
|
||||
end
|
||||
|
||||
test "uses_custom_month_start? returns true when month_start_day is not 1" do
|
||||
@family.month_start_day = 25
|
||||
assert @family.uses_custom_month_start?
|
||||
end
|
||||
|
||||
test "custom_month_start_for returns correct start date when day is after month_start_day" do
|
||||
@family.month_start_day = 15
|
||||
|
||||
travel_to Date.new(2026, 1, 20) do
|
||||
result = @family.custom_month_start_for(Date.current)
|
||||
assert_equal Date.new(2026, 1, 15), result
|
||||
end
|
||||
end
|
||||
|
||||
test "custom_month_start_for returns correct start date when day is before month_start_day" do
|
||||
@family.month_start_day = 15
|
||||
|
||||
travel_to Date.new(2026, 1, 10) do
|
||||
result = @family.custom_month_start_for(Date.current)
|
||||
assert_equal Date.new(2025, 12, 15), result
|
||||
end
|
||||
end
|
||||
|
||||
test "custom_month_end_for returns one day before next custom month start" do
|
||||
@family.month_start_day = 15
|
||||
|
||||
travel_to Date.new(2026, 1, 20) do
|
||||
result = @family.custom_month_end_for(Date.current)
|
||||
assert_equal Date.new(2026, 2, 14), result
|
||||
end
|
||||
end
|
||||
|
||||
test "current_custom_month_period returns correct period" do
|
||||
@family.month_start_day = 25
|
||||
|
||||
travel_to Date.new(2026, 1, 27) do
|
||||
period = @family.current_custom_month_period
|
||||
|
||||
assert_equal Date.new(2026, 1, 25), period.start_date
|
||||
assert_equal Date.new(2026, 2, 24), period.end_date
|
||||
end
|
||||
end
|
||||
end
|
||||
54
test/models/family_document_test.rb
Normal file
54
test/models/family_document_test.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
require "test_helper"
|
||||
|
||||
class FamilyDocumentTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@document = family_documents(:tax_return)
|
||||
end
|
||||
|
||||
test "belongs to a family" do
|
||||
assert_equal @family, @document.family
|
||||
end
|
||||
|
||||
test "validates filename presence" do
|
||||
doc = FamilyDocument.new(family: @family, status: "pending")
|
||||
assert_not doc.valid?
|
||||
assert_includes doc.errors[:filename], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates status inclusion" do
|
||||
doc = FamilyDocument.new(family: @family, filename: "test.pdf", status: "invalid")
|
||||
assert_not doc.valid?
|
||||
assert_includes doc.errors[:status], "is not included in the list"
|
||||
end
|
||||
|
||||
test "ready scope returns only ready documents" do
|
||||
ready_docs = @family.family_documents.ready
|
||||
assert ready_docs.all? { |d| d.status == "ready" }
|
||||
assert_not_includes ready_docs, family_documents(:pending_doc)
|
||||
end
|
||||
|
||||
test "mark_ready! updates status" do
|
||||
doc = family_documents(:pending_doc)
|
||||
doc.mark_ready!
|
||||
assert_equal "ready", doc.reload.status
|
||||
end
|
||||
|
||||
test "mark_error! updates status and metadata" do
|
||||
doc = family_documents(:pending_doc)
|
||||
doc.mark_error!("Upload failed")
|
||||
doc.reload
|
||||
assert_equal "error", doc.status
|
||||
assert_equal "Upload failed", doc.metadata["error"]
|
||||
end
|
||||
|
||||
test "supported_extension? returns true for supported types" do
|
||||
doc = FamilyDocument.new(filename: "report.pdf")
|
||||
assert doc.supported_extension?
|
||||
end
|
||||
|
||||
test "supported_extension? returns false for unsupported types" do
|
||||
doc = FamilyDocument.new(filename: "video.mp4")
|
||||
assert_not doc.supported_extension?
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,169 @@ class FamilyTest < ActiveSupport::TestCase
|
||||
@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 "expense", category.classification
|
||||
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.classification = "expense"
|
||||
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",
|
||||
classification: "expense",
|
||||
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",
|
||||
classification: "expense",
|
||||
lucide_icon: "trending-up"
|
||||
)
|
||||
|
||||
french_category = family.categories.create!(
|
||||
name: "Contributions aux investissements",
|
||||
color: "#0d9488",
|
||||
classification: "expense",
|
||||
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)
|
||||
|
||||
@@ -14,4 +177,33 @@ class FamilyTest < ActiveSupport::TestCase
|
||||
|
||||
assert_includes family.available_merchants, new_merchant
|
||||
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
|
||||
end
|
||||
|
||||
@@ -75,4 +75,22 @@ class Holding::MaterializerTest < ActiveSupport::TestCase
|
||||
assert_equal BigDecimal("180.00"), holding.cost_basis,
|
||||
"Trade-derived cost_basis should override provider cost_basis when available"
|
||||
end
|
||||
|
||||
test "recalculates calculated cost_basis when new trades are added" do
|
||||
date = Date.current
|
||||
|
||||
create_trade(@aapl, account: @account, qty: 1, price: 3000, date: date)
|
||||
Holding::Materializer.new(@account, strategy: :forward).materialize_holdings
|
||||
|
||||
holding = @account.holdings.find_by!(security: @aapl, date: date, currency: "USD")
|
||||
assert_equal "calculated", holding.cost_basis_source
|
||||
assert_equal BigDecimal("3000.0"), holding.cost_basis
|
||||
|
||||
create_trade(@aapl, account: @account, qty: 1, price: 2500, date: date)
|
||||
Holding::Materializer.new(@account, strategy: :forward).materialize_holdings
|
||||
|
||||
holding.reload
|
||||
assert_equal "calculated", holding.cost_basis_source
|
||||
assert_equal BigDecimal("2750.0"), holding.cost_basis
|
||||
end
|
||||
end
|
||||
|
||||
@@ -129,17 +129,18 @@ class HoldingTest < ActiveSupport::TestCase
|
||||
assert_not @amzn.cost_basis_replaceable_by?("manual")
|
||||
end
|
||||
|
||||
test "cost_basis_replaceable_by? respects priority hierarchy" do
|
||||
# Provider data can be replaced by calculated or manual
|
||||
test "cost_basis_replaceable_by? respects priority hierarchy and allows refreshes" do
|
||||
# Provider data can be replaced by higher-priority sources (calculated/manual)
|
||||
# and can be refreshed by provider again.
|
||||
@amzn.update!(cost_basis: 200, cost_basis_source: "provider", cost_basis_locked: false)
|
||||
assert @amzn.cost_basis_replaceable_by?("calculated")
|
||||
assert @amzn.cost_basis_replaceable_by?("manual")
|
||||
assert_not @amzn.cost_basis_replaceable_by?("provider")
|
||||
assert @amzn.cost_basis_replaceable_by?("provider")
|
||||
|
||||
# Calculated data can be replaced by manual only
|
||||
# Calculated data can be replaced by manual and can be refreshed by calculated again.
|
||||
@amzn.update!(cost_basis: 200, cost_basis_source: "calculated", cost_basis_locked: false)
|
||||
assert @amzn.cost_basis_replaceable_by?("manual")
|
||||
assert_not @amzn.cost_basis_replaceable_by?("calculated")
|
||||
assert @amzn.cost_basis_replaceable_by?("calculated")
|
||||
assert_not @amzn.cost_basis_replaceable_by?("provider")
|
||||
|
||||
# Manual data when LOCKED cannot be replaced by anything
|
||||
|
||||
180
test/models/indexa_capital_account/data_helpers_test.rb
Normal file
180
test/models/indexa_capital_account/data_helpers_test.rb
Normal file
@@ -0,0 +1,180 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class IndexaCapitalAccount::DataHelpersTest < ActiveSupport::TestCase
|
||||
# Create a test class that includes the concern
|
||||
class TestHelper
|
||||
include IndexaCapitalAccount::DataHelpers
|
||||
|
||||
# Make private methods public for testing
|
||||
public :parse_decimal, :parse_date, :resolve_security, :extract_currency, :extract_security_name
|
||||
end
|
||||
|
||||
setup do
|
||||
@helper = TestHelper.new
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# parse_decimal tests
|
||||
# ==========================================================================
|
||||
|
||||
test "parse_decimal returns nil for nil input" do
|
||||
assert_nil @helper.parse_decimal(nil)
|
||||
end
|
||||
|
||||
test "parse_decimal parses string to BigDecimal" do
|
||||
result = @helper.parse_decimal("123.45")
|
||||
assert_instance_of BigDecimal, result
|
||||
assert_equal BigDecimal("123.45"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles integer input" do
|
||||
result = @helper.parse_decimal(100)
|
||||
assert_instance_of BigDecimal, result
|
||||
assert_equal BigDecimal("100"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles float input" do
|
||||
result = @helper.parse_decimal(99.99)
|
||||
assert_instance_of BigDecimal, result
|
||||
assert_in_delta 99.99, result.to_f, 0.001
|
||||
end
|
||||
|
||||
test "parse_decimal returns BigDecimal unchanged" do
|
||||
input = BigDecimal("50.25")
|
||||
result = @helper.parse_decimal(input)
|
||||
assert_equal input, result
|
||||
end
|
||||
|
||||
test "parse_decimal returns nil for invalid string" do
|
||||
assert_nil @helper.parse_decimal("not a number")
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# parse_date tests
|
||||
# ==========================================================================
|
||||
|
||||
test "parse_date returns nil for nil input" do
|
||||
assert_nil @helper.parse_date(nil)
|
||||
end
|
||||
|
||||
test "parse_date returns Date unchanged" do
|
||||
input = Date.new(2024, 6, 15)
|
||||
result = @helper.parse_date(input)
|
||||
assert_equal input, result
|
||||
end
|
||||
|
||||
test "parse_date parses ISO date string" do
|
||||
result = @helper.parse_date("2024-06-15")
|
||||
assert_instance_of Date, result
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date parses datetime string to date" do
|
||||
result = @helper.parse_date("2024-06-15T10:30:00Z")
|
||||
assert_instance_of Date, result
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date converts Time to Date" do
|
||||
input = Time.zone.parse("2024-06-15 10:30:00")
|
||||
result = @helper.parse_date(input)
|
||||
assert_instance_of Date, result
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date returns nil for invalid string" do
|
||||
assert_nil @helper.parse_date("not a date")
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# extract_currency tests
|
||||
# ==========================================================================
|
||||
|
||||
test "extract_currency returns fallback for nil currency" do
|
||||
result = @helper.extract_currency({}, fallback: "USD")
|
||||
assert_equal "USD", result
|
||||
end
|
||||
|
||||
test "extract_currency extracts string currency" do
|
||||
result = @helper.extract_currency({ currency: "cad" })
|
||||
assert_equal "CAD", result
|
||||
end
|
||||
|
||||
test "extract_currency extracts currency from hash with code key" do
|
||||
result = @helper.extract_currency({ currency: { code: "EUR" } })
|
||||
assert_equal "EUR", result
|
||||
end
|
||||
|
||||
test "extract_currency handles indifferent access" do
|
||||
result = @helper.extract_currency({ "currency" => { "code" => "GBP" } })
|
||||
assert_equal "GBP", result
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# resolve_security tests (investment providers only)
|
||||
# ==========================================================================
|
||||
|
||||
test "resolve_security returns nil for blank ticker" do
|
||||
assert_nil @helper.resolve_security("")
|
||||
assert_nil @helper.resolve_security(" ")
|
||||
assert_nil @helper.resolve_security(nil)
|
||||
end
|
||||
|
||||
test "resolve_security finds existing security" do
|
||||
existing = Security.create!(ticker: "XYZTEST", name: "Test Security Inc")
|
||||
|
||||
result = @helper.resolve_security("xyztest")
|
||||
assert_equal existing, result
|
||||
end
|
||||
|
||||
test "resolve_security creates new security when not found" do
|
||||
symbol_data = { name: "Test Company Inc" }
|
||||
|
||||
result = @helper.resolve_security("TEST", symbol_data)
|
||||
|
||||
assert_not_nil result
|
||||
assert_equal "TEST", result.ticker
|
||||
assert_equal "Test Company Inc", result.name
|
||||
end
|
||||
|
||||
test "resolve_security upcases ticker" do
|
||||
symbol_data = { name: "Lowercase Test" }
|
||||
|
||||
result = @helper.resolve_security("lower", symbol_data)
|
||||
|
||||
assert_equal "LOWER", result.ticker
|
||||
end
|
||||
|
||||
test "resolve_security uses ticker as fallback name" do
|
||||
# Use short ticker (<=4 chars) to avoid titleize behavior
|
||||
result = @helper.resolve_security("XYZ1", {})
|
||||
|
||||
assert_equal "XYZ1", result.name
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# extract_security_name tests (investment providers only)
|
||||
# ==========================================================================
|
||||
|
||||
test "extract_security_name uses name field" do
|
||||
result = @helper.extract_security_name({ name: "Apple Inc" }, "AAPL")
|
||||
assert_equal "Apple Inc", result
|
||||
end
|
||||
|
||||
test "extract_security_name falls back to description" do
|
||||
result = @helper.extract_security_name({ description: "Microsoft Corp" }, "MSFT")
|
||||
assert_equal "Microsoft Corp", result
|
||||
end
|
||||
|
||||
test "extract_security_name uses ticker as fallback" do
|
||||
result = @helper.extract_security_name({}, "GOOG")
|
||||
assert_equal "GOOG", result
|
||||
end
|
||||
|
||||
test "extract_security_name ignores generic type descriptions" do
|
||||
result = @helper.extract_security_name({ name: "COMMON STOCK" }, "IBM")
|
||||
assert_equal "IBM", result
|
||||
end
|
||||
end
|
||||
111
test/models/indexa_capital_account/processor_test.rb
Normal file
111
test/models/indexa_capital_account/processor_test.rb
Normal file
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class IndexaCapitalAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = indexa_capital_items(:configured_with_token)
|
||||
@indexa_capital_account = indexa_capital_accounts(:mutual_fund)
|
||||
|
||||
@account = @family.accounts.create!(
|
||||
name: "Test Investment",
|
||||
balance: 10000,
|
||||
currency: "EUR",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
@indexa_capital_account.ensure_account_provider!(@account)
|
||||
@indexa_capital_account.reload
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Processor tests
|
||||
# ==========================================================================
|
||||
|
||||
test "processor initializes with indexa_capital_account" do
|
||||
processor = IndexaCapitalAccount::Processor.new(@indexa_capital_account)
|
||||
assert_not_nil processor
|
||||
end
|
||||
|
||||
test "processor skips processing when no linked account" do
|
||||
unlinked = indexa_capital_accounts(:pension_plan)
|
||||
|
||||
processor = IndexaCapitalAccount::Processor.new(unlinked)
|
||||
assert_nothing_raised { processor.process }
|
||||
end
|
||||
|
||||
test "processor updates account balance from holdings value" do
|
||||
@indexa_capital_account.update!(
|
||||
current_balance: 38905.21,
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"amount" => 16333.96,
|
||||
"titles" => 32.26,
|
||||
"price" => 506.32,
|
||||
"instrument" => { "identifier" => "IE00BFPM9V94", "name" => "Vanguard US 500" }
|
||||
},
|
||||
{
|
||||
"amount" => 10759.05,
|
||||
"titles" => 40.34,
|
||||
"price" => 266.71,
|
||||
"instrument" => { "identifier" => "IE00BFPM9L96", "name" => "Vanguard European" }
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
@account.update!(balance: 0)
|
||||
|
||||
processor = IndexaCapitalAccount::Processor.new(@indexa_capital_account)
|
||||
processor.process
|
||||
|
||||
@account.reload
|
||||
assert_in_delta 27093.01, @account.balance.to_f, 0.01
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# HoldingsProcessor tests
|
||||
# ==========================================================================
|
||||
|
||||
test "holdings processor creates holdings from fiscal-results payload" do
|
||||
@indexa_capital_account.update!(raw_holdings_payload: [
|
||||
{
|
||||
"amount" => 16333.96,
|
||||
"titles" => 32.26,
|
||||
"price" => 506.32,
|
||||
"cost_price" => 390.60,
|
||||
"instrument" => {
|
||||
"identifier" => "IE00BFPM9V94",
|
||||
"name" => "Vanguard US 500 Stk Idx Eur -Ins Plus",
|
||||
"isin_code" => "IE00BFPM9V94"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
processor = IndexaCapitalAccount::HoldingsProcessor.new(@indexa_capital_account)
|
||||
|
||||
assert_difference "@account.holdings.count", 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
holding = @account.holdings.order(created_at: :desc).first
|
||||
assert_equal "IE00BFPM9V94", holding.security.ticker
|
||||
assert_equal 32.26, holding.qty.to_f
|
||||
end
|
||||
|
||||
test "holdings processor skips entries without instrument identifier" do
|
||||
@indexa_capital_account.update!(raw_holdings_payload: [
|
||||
{ "amount" => 100, "titles" => 1, "price" => 100, "instrument" => {} }
|
||||
])
|
||||
|
||||
processor = IndexaCapitalAccount::HoldingsProcessor.new(@indexa_capital_account)
|
||||
assert_nothing_raised { processor.process }
|
||||
end
|
||||
|
||||
test "holdings processor handles empty payload" do
|
||||
@indexa_capital_account.update!(raw_holdings_payload: [])
|
||||
|
||||
processor = IndexaCapitalAccount::HoldingsProcessor.new(@indexa_capital_account)
|
||||
assert_nothing_raised { processor.process }
|
||||
end
|
||||
end
|
||||
126
test/models/indexa_capital_account_test.rb
Normal file
126
test/models/indexa_capital_account_test.rb
Normal file
@@ -0,0 +1,126 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class IndexaCapitalAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = indexa_capital_items(:configured_with_token)
|
||||
@account = indexa_capital_accounts(:mutual_fund)
|
||||
end
|
||||
|
||||
test "belongs to indexa_capital_item" do
|
||||
assert_equal @item, @account.indexa_capital_item
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
@account.name = nil
|
||||
assert_not @account.valid?
|
||||
end
|
||||
|
||||
test "validates presence of currency" do
|
||||
@account.currency = nil
|
||||
assert_not @account.valid?
|
||||
end
|
||||
|
||||
test "upsert_from_indexa_capital! updates from API data" do
|
||||
data = {
|
||||
account_number: "NEWACCT1",
|
||||
name: "New Account",
|
||||
type: "mutual",
|
||||
status: "active",
|
||||
currency: "EUR",
|
||||
current_balance: 12345.67
|
||||
}
|
||||
|
||||
new_account = @item.indexa_capital_accounts.create!(
|
||||
name: "Placeholder", currency: "EUR",
|
||||
indexa_capital_account_id: "NEWACCT1"
|
||||
)
|
||||
new_account.upsert_from_indexa_capital!(data)
|
||||
|
||||
new_account.reload
|
||||
assert_equal "NEWACCT1", new_account.indexa_capital_account_id
|
||||
assert_equal "New Account", new_account.name
|
||||
assert_equal "mutual", new_account.account_type
|
||||
assert_equal "active", new_account.account_status
|
||||
assert_equal 12345.67, new_account.current_balance.to_f
|
||||
end
|
||||
|
||||
test "upsert_from_indexa_capital! without balance does not overwrite existing" do
|
||||
assert_equal 38905.2136, @account.current_balance.to_f
|
||||
|
||||
data = {
|
||||
account_number: "LPYH3MCQ",
|
||||
name: "Updated Name",
|
||||
type: "mutual",
|
||||
status: "active",
|
||||
currency: "EUR"
|
||||
# No current_balance
|
||||
}
|
||||
@account.upsert_from_indexa_capital!(data)
|
||||
@account.reload
|
||||
|
||||
assert_equal "Updated Name", @account.name
|
||||
assert_equal 38905.2136, @account.current_balance.to_f
|
||||
end
|
||||
|
||||
test "upsert_from_indexa_capital! stores zero balance correctly" do
|
||||
data = {
|
||||
account_number: "LPYH3MCQ",
|
||||
name: "Zero Balance Account",
|
||||
type: "mutual",
|
||||
status: "active",
|
||||
currency: "EUR",
|
||||
current_balance: 0
|
||||
}
|
||||
@account.upsert_from_indexa_capital!(data)
|
||||
@account.reload
|
||||
|
||||
assert_equal 0, @account.current_balance.to_f
|
||||
end
|
||||
|
||||
test "upsert_holdings_snapshot! stores holdings data" do
|
||||
holdings = [ { instrument: { identifier: "IE00BFPM9V94" }, titles: 32, price: 506.32, amount: 16333.96 } ]
|
||||
@account.upsert_holdings_snapshot!(holdings)
|
||||
|
||||
@account.reload
|
||||
assert_equal 1, @account.raw_holdings_payload.size
|
||||
assert_not_nil @account.last_holdings_sync
|
||||
end
|
||||
|
||||
test "upsert_holdings_snapshot! skips when empty" do
|
||||
@account.update!(last_holdings_sync: 1.day.ago)
|
||||
original_sync = @account.last_holdings_sync
|
||||
|
||||
@account.upsert_holdings_snapshot!([])
|
||||
@account.reload
|
||||
|
||||
assert_equal original_sync, @account.last_holdings_sync
|
||||
end
|
||||
|
||||
test "ensure_account_provider! creates link" do
|
||||
linked_account = Account.create!(
|
||||
family: @family, name: "My Fund", balance: 1000, currency: "EUR",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
assert_nil @account.account_provider
|
||||
@account.ensure_account_provider!(linked_account)
|
||||
|
||||
assert_not_nil @account.account_provider
|
||||
assert_equal linked_account, @account.account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! is idempotent" do
|
||||
linked_account = Account.create!(
|
||||
family: @family, name: "My Fund", balance: 1000, currency: "EUR",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
@account.ensure_account_provider!(linked_account)
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
@account.ensure_account_provider!(linked_account)
|
||||
end
|
||||
end
|
||||
end
|
||||
143
test/models/indexa_capital_item_test.rb
Normal file
143
test/models/indexa_capital_item_test.rb
Normal file
@@ -0,0 +1,143 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class IndexaCapitalItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = indexa_capital_items(:configured_with_token)
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal @family, @item.family
|
||||
end
|
||||
|
||||
test "has many indexa_capital_accounts" do
|
||||
assert_includes @item.indexa_capital_accounts, indexa_capital_accounts(:mutual_fund)
|
||||
end
|
||||
|
||||
test "has good status by default" do
|
||||
assert_equal "good", @item.status
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
item = IndexaCapitalItem.new(family: @family, api_token: "test")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "valid with api_token only" do
|
||||
item = IndexaCapitalItem.new(family: @family, name: "Test", api_token: "test_token")
|
||||
assert item.valid?
|
||||
end
|
||||
|
||||
test "valid with username/document/password credentials" do
|
||||
item = IndexaCapitalItem.new(
|
||||
family: @family, name: "Test",
|
||||
username: "user@example.com", document: "12345678A", password: "secret"
|
||||
)
|
||||
assert item.valid?
|
||||
end
|
||||
|
||||
test "invalid without any credentials on create" do
|
||||
item = IndexaCapitalItem.new(family: @family, name: "Test")
|
||||
assert_not item.valid?
|
||||
assert item.errors[:base].any?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns true with api_token" do
|
||||
assert @item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns true with username/document/password" do
|
||||
item = indexa_capital_items(:configured_with_credentials)
|
||||
assert item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns false when nothing set" do
|
||||
item = IndexaCapitalItem.new(family: @family, name: "Test")
|
||||
refute item.credentials_configured?
|
||||
end
|
||||
|
||||
test "indexa_capital_provider returns nil when not configured" do
|
||||
item = IndexaCapitalItem.new(family: @family, name: "Test")
|
||||
assert_nil item.indexa_capital_provider
|
||||
end
|
||||
|
||||
test "indexa_capital_provider returns provider with token auth" do
|
||||
provider = @item.indexa_capital_provider
|
||||
assert_instance_of Provider::IndexaCapital, provider
|
||||
end
|
||||
|
||||
test "indexa_capital_provider returns provider with credentials auth" do
|
||||
item = indexa_capital_items(:configured_with_credentials)
|
||||
provider = item.indexa_capital_provider
|
||||
assert_instance_of Provider::IndexaCapital, provider
|
||||
end
|
||||
|
||||
test "can be marked for deletion" do
|
||||
refute @item.scheduled_for_deletion?
|
||||
@item.destroy_later
|
||||
assert @item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "is syncable" do
|
||||
assert_respond_to @item, :sync_later
|
||||
assert_respond_to @item, :syncing?
|
||||
end
|
||||
|
||||
test "scopes work correctly" do
|
||||
item_for_deletion = IndexaCapitalItem.create!(
|
||||
family: @family, name: "Delete Me", api_token: "test",
|
||||
scheduled_for_deletion: true, created_at: 1.day.ago
|
||||
)
|
||||
|
||||
active_items = @family.indexa_capital_items.active
|
||||
assert_includes active_items, @item
|
||||
refute_includes active_items, item_for_deletion
|
||||
end
|
||||
|
||||
test "linked_accounts_count returns count of accounts with providers" do
|
||||
assert_equal 0, @item.linked_accounts_count
|
||||
|
||||
account = Account.create!(
|
||||
family: @family, name: "Linked Fund", balance: 1000, currency: "EUR",
|
||||
accountable: Investment.new
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: indexa_capital_accounts(:mutual_fund))
|
||||
|
||||
assert_equal 1, @item.linked_accounts_count
|
||||
end
|
||||
|
||||
test "unlinked_accounts_count returns count of accounts without providers" do
|
||||
assert_equal 2, @item.unlinked_accounts_count
|
||||
end
|
||||
|
||||
test "sync_status_summary with no accounts" do
|
||||
item = IndexaCapitalItem.create!(family: @family, name: "Empty", api_token: "test")
|
||||
assert_equal I18n.t("indexa_capital_items.sync_status.no_accounts"), item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with all linked" do
|
||||
# Link both accounts
|
||||
[ indexa_capital_accounts(:mutual_fund), indexa_capital_accounts(:pension_plan) ].each do |ica|
|
||||
account = Account.create!(
|
||||
family: @family, name: ica.name, balance: 1000, currency: "EUR",
|
||||
accountable: Investment.new
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ica)
|
||||
end
|
||||
|
||||
assert_equal I18n.t("indexa_capital_items.sync_status.synced", count: 2), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with partial setup" do
|
||||
account = Account.create!(
|
||||
family: @family, name: "Fund", balance: 1000, currency: "EUR",
|
||||
accountable: Investment.new
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: indexa_capital_accounts(:mutual_fund))
|
||||
|
||||
assert_equal I18n.t("indexa_capital_items.sync_status.synced_with_setup", linked: 1, unlinked: 1), @item.sync_status_summary
|
||||
end
|
||||
end
|
||||
87
test/models/invitation_test.rb
Normal file
87
test/models/invitation_test.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
require "test_helper"
|
||||
|
||||
class InvitationTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@invitation = invitations(:one)
|
||||
@family = @invitation.family
|
||||
@inviter = @invitation.inviter
|
||||
end
|
||||
|
||||
test "accept_for adds user to family when email matches" do
|
||||
user = users(:empty)
|
||||
user.update_columns(family_id: families(:empty).id, role: "admin")
|
||||
assert user.family_id != @family.id
|
||||
|
||||
invitation = @family.invitations.create!(email: user.email, role: "member", inviter: @inviter)
|
||||
assert invitation.pending?
|
||||
result = invitation.accept_for(user)
|
||||
|
||||
assert result
|
||||
user.reload
|
||||
assert_equal @family.id, user.family_id
|
||||
assert_equal "member", user.role
|
||||
invitation.reload
|
||||
assert invitation.accepted_at.present?
|
||||
end
|
||||
|
||||
test "accept_for returns false when user email does not match" do
|
||||
user = users(:family_member)
|
||||
assert user.email != @invitation.email
|
||||
|
||||
result = @invitation.accept_for(user)
|
||||
|
||||
assert_not result
|
||||
user.reload
|
||||
assert_equal families(:dylan_family).id, user.family_id
|
||||
@invitation.reload
|
||||
assert_nil @invitation.accepted_at
|
||||
end
|
||||
|
||||
test "accept_for updates role when user already in family" do
|
||||
user = users(:family_member)
|
||||
user.update!(family_id: @family.id, role: "member")
|
||||
invitation = @family.invitations.create!(email: user.email, role: "admin", inviter: @inviter)
|
||||
original_family_id = user.family_id
|
||||
|
||||
result = invitation.accept_for(user)
|
||||
|
||||
assert result
|
||||
user.reload
|
||||
assert_equal original_family_id, user.family_id
|
||||
assert_equal "admin", user.role
|
||||
invitation.reload
|
||||
assert invitation.accepted_at.present?
|
||||
end
|
||||
|
||||
test "accept_for returns false when invitation not pending" do
|
||||
@invitation.update!(accepted_at: 1.hour.ago)
|
||||
user = users(:empty)
|
||||
|
||||
result = @invitation.accept_for(user)
|
||||
|
||||
assert_not result
|
||||
end
|
||||
|
||||
test "accept_for applies guest role defaults" do
|
||||
user = users(:family_member)
|
||||
user.update!(
|
||||
family_id: @family.id,
|
||||
role: "member",
|
||||
ui_layout: "dashboard",
|
||||
show_sidebar: true,
|
||||
show_ai_sidebar: true,
|
||||
ai_enabled: false
|
||||
)
|
||||
invitation = @family.invitations.create!(email: user.email, role: "guest", inviter: @inviter)
|
||||
|
||||
result = invitation.accept_for(user)
|
||||
|
||||
assert result
|
||||
user.reload
|
||||
assert_equal "guest", user.role
|
||||
assert user.ui_layout_intro?
|
||||
assert_not user.show_sidebar?
|
||||
assert_not user.show_ai_sidebar?
|
||||
assert user.ai_enabled?
|
||||
end
|
||||
end
|
||||
@@ -151,15 +151,10 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
# Verify the entry has a generated external_id (since we can't have blank IDs)
|
||||
assert result.external_id.present?
|
||||
assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result.external_id
|
||||
|
||||
# Note: Calling the processor again with identical data will trigger collision
|
||||
# detection and create a SECOND entry (with _1 suffix). In real syncs, the
|
||||
# importer's deduplication prevents this. For true idempotency testing,
|
||||
# use the importer, not the processor directly.
|
||||
end
|
||||
|
||||
test "generates unique IDs for multiple pending transactions with identical attributes" do
|
||||
# Two pending transactions with same merchant, amount, date (e.g., two Uber rides)
|
||||
test "does not duplicate pending transaction when synced multiple times" do
|
||||
# Create a pending transaction
|
||||
transaction_data = {
|
||||
id: "",
|
||||
accountId: 456,
|
||||
@@ -178,9 +173,14 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
).process
|
||||
|
||||
assert_not_nil result1
|
||||
assert_match /^lunchflow_pending_[a-f0-9]{32}$/, result1.external_id
|
||||
transaction1 = result1.entryable
|
||||
assert transaction1.pending?
|
||||
assert_equal true, transaction1.extra.dig("lunchflow", "pending")
|
||||
|
||||
# Process second transaction with IDENTICAL attributes
|
||||
# Count entries before second sync
|
||||
entries_before = @account.entries.where(source: "lunchflow").count
|
||||
|
||||
# Second sync - same pending transaction (still hasn't posted)
|
||||
result2 = LunchflowEntry::Processor.new(
|
||||
transaction_data,
|
||||
lunchflow_account: @lunchflow_account
|
||||
@@ -188,15 +188,61 @@ class LunchflowEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
assert_not_nil result2
|
||||
|
||||
# Should create a DIFFERENT entry (not update the first one)
|
||||
assert_not_equal result1.id, result2.id, "Should create separate entries for distinct pending transactions"
|
||||
# Should return the SAME entry, not create a duplicate
|
||||
assert_equal result1.id, result2.id, "Should update existing pending transaction, not create duplicate"
|
||||
|
||||
# Second should have a counter appended to avoid collision
|
||||
assert_match /^lunchflow_pending_[a-f0-9]{32}_\d+$/, result2.external_id
|
||||
assert_not_equal result1.external_id, result2.external_id, "Should generate different external_ids to avoid collision"
|
||||
# Verify no new entries were created
|
||||
entries_after = @account.entries.where(source: "lunchflow").count
|
||||
assert_equal entries_before, entries_after, "Should not create duplicate entry on re-sync"
|
||||
end
|
||||
|
||||
# Verify both transactions exist
|
||||
entries = @account.entries.where(source: "lunchflow", "entries.date": "2025-01-15")
|
||||
assert_equal 2, entries.count, "Should have created 2 separate entries"
|
||||
test "does not duplicate pending transaction when user has edited it" do
|
||||
# User imports a pending transaction, then edits it (name, amount, date)
|
||||
# Next sync should update the same entry, not create a duplicate
|
||||
transaction_data = {
|
||||
id: "",
|
||||
accountId: 456,
|
||||
amount: -25.50,
|
||||
currency: "USD",
|
||||
date: "2025-01-20",
|
||||
merchant: "Coffee Shop",
|
||||
description: "Morning coffee",
|
||||
isPending: true
|
||||
}
|
||||
|
||||
# First sync - import the pending transaction
|
||||
result1 = LunchflowEntry::Processor.new(
|
||||
transaction_data,
|
||||
lunchflow_account: @lunchflow_account
|
||||
).process
|
||||
|
||||
assert_not_nil result1
|
||||
original_external_id = result1.external_id
|
||||
|
||||
# User edits the transaction (common scenario)
|
||||
result1.update!(name: "Coffee Shop Downtown", amount: 26.00)
|
||||
result1.reload
|
||||
|
||||
# Verify the edits were applied
|
||||
assert_equal "Coffee Shop Downtown", result1.name
|
||||
assert_equal 26.00, result1.amount
|
||||
|
||||
entries_before = @account.entries.where(source: "lunchflow").count
|
||||
|
||||
# Second sync - same pending transaction data from provider (unchanged)
|
||||
result2 = LunchflowEntry::Processor.new(
|
||||
transaction_data,
|
||||
lunchflow_account: @lunchflow_account
|
||||
).process
|
||||
|
||||
assert_not_nil result2
|
||||
|
||||
# Should return the SAME entry (same external_id, not a _1 suffix)
|
||||
assert_equal result1.id, result2.id, "Should reuse existing entry even when user edited it"
|
||||
assert_equal original_external_id, result2.external_id, "Should not create new external_id for user-edited entry"
|
||||
|
||||
# Verify no duplicate was created
|
||||
entries_after = @account.entries.where(source: "lunchflow").count
|
||||
assert_equal entries_before, entries_after, "Should not create duplicate when user has edited pending transaction"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
require "test_helper"
|
||||
|
||||
class MobileDeviceTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
setup do
|
||||
MobileDevice.instance_variable_set(:@shared_oauth_application, nil)
|
||||
end
|
||||
|
||||
teardown do
|
||||
MobileDevice.instance_variable_set(:@shared_oauth_application, nil)
|
||||
end
|
||||
|
||||
test "shared_oauth_application auto-creates application when missing" do
|
||||
Doorkeeper::Application.where(name: "Sure Mobile").destroy_all
|
||||
|
||||
assert_difference("Doorkeeper::Application.count", 1) do
|
||||
app = MobileDevice.shared_oauth_application
|
||||
assert_equal "Sure Mobile", app.name
|
||||
assert_equal MobileDevice::CALLBACK_URL, app.redirect_uri
|
||||
assert_equal "read_write", app.scopes.to_s
|
||||
assert_not app.confidential
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
152
test/models/pdf_import_test.rb
Normal file
152
test/models/pdf_import_test.rb
Normal file
@@ -0,0 +1,152 @@
|
||||
require "test_helper"
|
||||
|
||||
class PdfImportTest < ActiveSupport::TestCase
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
setup do
|
||||
@import = imports(:pdf)
|
||||
@processed_import = imports(:pdf_processed)
|
||||
@import_with_rows = imports(:pdf_with_rows)
|
||||
end
|
||||
|
||||
test "pdf_uploaded? returns false when no file attached" do
|
||||
assert_not @import.pdf_uploaded?
|
||||
end
|
||||
|
||||
test "ai_processed? returns false when no summary present" do
|
||||
assert_not @import.ai_processed?
|
||||
end
|
||||
|
||||
test "ai_processed? returns true when summary present" do
|
||||
assert @processed_import.ai_processed?
|
||||
end
|
||||
|
||||
test "uploaded? delegates to pdf_uploaded?" do
|
||||
assert_not @import.uploaded?
|
||||
end
|
||||
|
||||
test "configured? requires AI processed and rows" do
|
||||
assert_not @import.configured?
|
||||
assert_not @processed_import.configured?
|
||||
assert @import_with_rows.configured?
|
||||
end
|
||||
|
||||
test "cleaned? requires configured and valid rows" do
|
||||
assert_not @import.cleaned?
|
||||
assert_not @processed_import.cleaned?
|
||||
end
|
||||
|
||||
test "publishable? requires bank statement with cleaned rows and valid mappings" do
|
||||
assert_not @import.publishable?
|
||||
assert_not @processed_import.publishable?
|
||||
end
|
||||
|
||||
test "column_keys returns transaction columns" do
|
||||
assert_equal %i[date amount name category notes], @import.column_keys
|
||||
end
|
||||
|
||||
test "required_column_keys returns date and amount" do
|
||||
assert_equal %i[date amount], @import.required_column_keys
|
||||
end
|
||||
|
||||
test "document_type validates against allowed types" do
|
||||
@import.document_type = "bank_statement"
|
||||
assert @import.valid?
|
||||
|
||||
@import.document_type = "invalid_type"
|
||||
assert_not @import.valid?
|
||||
assert @import.errors[:document_type].present?
|
||||
end
|
||||
|
||||
test "document_type allows nil" do
|
||||
@import.document_type = nil
|
||||
assert @import.valid?
|
||||
end
|
||||
|
||||
test "process_with_ai_later enqueues ProcessPdfJob" do
|
||||
assert_enqueued_with job: ProcessPdfJob, args: [ @import ] do
|
||||
@import.process_with_ai_later
|
||||
end
|
||||
end
|
||||
|
||||
test "generate_rows_from_extracted_data creates import rows" do
|
||||
import = imports(:pdf_with_rows)
|
||||
import.rows.destroy_all
|
||||
import.update_column(:rows_count, 0)
|
||||
|
||||
import.generate_rows_from_extracted_data
|
||||
|
||||
assert_equal 2, import.rows.count
|
||||
assert_equal 2, import.rows_count
|
||||
|
||||
coffee_row = import.rows.find_by(name: "Coffee Shop")
|
||||
assert_not_nil coffee_row
|
||||
assert_equal "-50.0", coffee_row.amount
|
||||
assert_equal "Food & Drink", coffee_row.category
|
||||
|
||||
salary_row = import.rows.find_by(name: "Salary")
|
||||
assert_not_nil salary_row
|
||||
assert_equal "1500.0", salary_row.amount
|
||||
end
|
||||
|
||||
test "generate_rows_from_extracted_data does nothing without extracted transactions" do
|
||||
@import.generate_rows_from_extracted_data
|
||||
assert_equal 0, @import.rows.count
|
||||
end
|
||||
|
||||
test "extracted_transactions returns transactions from extracted_data" do
|
||||
assert_equal 2, @import_with_rows.extracted_transactions.size
|
||||
assert_equal "Coffee Shop", @import_with_rows.extracted_transactions.first["name"]
|
||||
end
|
||||
|
||||
test "extracted_transactions returns empty array when no data" do
|
||||
assert_equal [], @import.extracted_transactions
|
||||
end
|
||||
|
||||
test "has_extracted_transactions? returns true with transactions" do
|
||||
assert @import_with_rows.has_extracted_transactions?
|
||||
end
|
||||
|
||||
test "has_extracted_transactions? returns false without transactions" do
|
||||
assert_not @import.has_extracted_transactions?
|
||||
end
|
||||
|
||||
test "mapping_steps is empty when no categories in rows" do
|
||||
# PDF imports use direct account selection in UI, not AccountMapping
|
||||
assert_equal [], @import.mapping_steps
|
||||
end
|
||||
|
||||
test "mapping_steps includes CategoryMapping when rows have categories" do
|
||||
@import_with_rows.rows.create!(
|
||||
date: "01/15/2024",
|
||||
amount: -50.00,
|
||||
currency: "USD",
|
||||
name: "Test Transaction",
|
||||
category: "Groceries"
|
||||
)
|
||||
assert_equal [ Import::CategoryMapping ], @import_with_rows.mapping_steps
|
||||
end
|
||||
|
||||
test "mapping_steps does not include AccountMapping even when account is nil" do
|
||||
# PDF imports handle account selection via direct UI, not mapping system
|
||||
assert_nil @import.account
|
||||
assert_not_includes @import.mapping_steps, Import::AccountMapping
|
||||
end
|
||||
|
||||
test "destroying import purges attached pdf_file" do
|
||||
@import.pdf_file.attach(
|
||||
io: StringIO.new("fake-pdf-content"),
|
||||
filename: "statement.pdf",
|
||||
content_type: "application/pdf"
|
||||
)
|
||||
|
||||
attachment_id = @import.pdf_file.id
|
||||
assert ActiveStorage::Attachment.exists?(attachment_id)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
@import.destroy!
|
||||
end
|
||||
|
||||
assert_not ActiveStorage::Attachment.exists?(attachment_id)
|
||||
end
|
||||
end
|
||||
@@ -149,6 +149,70 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
|
||||
assert_equal -1, entry.trade.qty
|
||||
end
|
||||
|
||||
test "creates contribution transactions as cash transactions" do
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"investment_transaction_id" => "contrib_123",
|
||||
"type" => "contribution",
|
||||
"amount" => -500.0,
|
||||
"iso_currency_code" => "USD",
|
||||
"date" => Date.current,
|
||||
"name" => "401k Contribution"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_holdings_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve).never
|
||||
|
||||
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal(-500.0, entry.amount)
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal "401k Contribution", entry.name
|
||||
assert_instance_of Transaction, entry.entryable
|
||||
end
|
||||
|
||||
test "creates withdrawal transactions as cash transactions" do
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
{
|
||||
"investment_transaction_id" => "withdraw_123",
|
||||
"type" => "withdrawal",
|
||||
"amount" => 1000.0,
|
||||
"iso_currency_code" => "USD",
|
||||
"date" => Date.current,
|
||||
"name" => "IRA Withdrawal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@plaid_account.update!(raw_holdings_payload: test_investments_payload)
|
||||
|
||||
@security_resolver.expects(:resolve).never
|
||||
|
||||
processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
processor.process
|
||||
end
|
||||
|
||||
entry = Entry.order(created_at: :desc).first
|
||||
|
||||
assert_equal 1000.0, entry.amount
|
||||
assert_equal "USD", entry.currency
|
||||
assert_equal "IRA Withdrawal", entry.name
|
||||
assert_instance_of Transaction, entry.entryable
|
||||
end
|
||||
|
||||
test "creates transfer transactions as cash transactions" do
|
||||
test_investments_payload = {
|
||||
transactions: [
|
||||
|
||||
156
test/models/provider/indexa_capital_test.rb
Normal file
156
test/models/provider/indexa_capital_test.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Provider::IndexaCapitalTest < ActiveSupport::TestCase
|
||||
test "initializes with api_token" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
assert_instance_of Provider::IndexaCapital, provider
|
||||
end
|
||||
|
||||
test "initializes with username/document/password" do
|
||||
provider = Provider::IndexaCapital.new(
|
||||
username: "user@example.com",
|
||||
document: "12345678A",
|
||||
password: "secret"
|
||||
)
|
||||
assert_instance_of Provider::IndexaCapital, provider
|
||||
end
|
||||
|
||||
test "raises ConfigurationError without credentials" do
|
||||
assert_raises Provider::IndexaCapital::ConfigurationError do
|
||||
Provider::IndexaCapital.new
|
||||
end
|
||||
end
|
||||
|
||||
test "raises ConfigurationError with partial credentials" do
|
||||
assert_raises Provider::IndexaCapital::ConfigurationError do
|
||||
Provider::IndexaCapital.new(username: "user@example.com")
|
||||
end
|
||||
|
||||
assert_raises Provider::IndexaCapital::ConfigurationError do
|
||||
Provider::IndexaCapital.new(username: "user@example.com", document: "12345678A")
|
||||
end
|
||||
end
|
||||
|
||||
test "list_accounts calls API and returns accounts" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
stub_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: {
|
||||
accounts: [
|
||||
{ account_number: "ABC12345", type: "mutual", status: "active" },
|
||||
{ account_number: "DEF67890", type: "pension", status: "active" }
|
||||
]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
Provider::IndexaCapital.stubs(:get).returns(stub_response)
|
||||
|
||||
accounts = provider.list_accounts
|
||||
assert_equal 2, accounts.size
|
||||
assert_equal "ABC12345", accounts[0][:account_number]
|
||||
assert_equal "Indexa Capital Mutual Fund (ABC12345)", accounts[0][:name]
|
||||
assert_equal "EUR", accounts[0][:currency]
|
||||
assert_equal "DEF67890", accounts[1][:account_number]
|
||||
assert_equal "Indexa Capital Pension Plan (DEF67890)", accounts[1][:name]
|
||||
end
|
||||
|
||||
test "get_holdings calls fiscal-results endpoint" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
stub_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: {
|
||||
fiscal_results: [
|
||||
{ amount: 1814.77, titles: 9.14, price: 175.34, instrument: { identifier: "IE00BFPM9P35" } }
|
||||
],
|
||||
total_fiscal_results: []
|
||||
}.to_json
|
||||
)
|
||||
|
||||
Provider::IndexaCapital.stubs(:get).returns(stub_response)
|
||||
|
||||
data = provider.get_holdings(account_number: "ABC12345")
|
||||
assert data[:fiscal_results].is_a?(Array)
|
||||
assert_equal 1, data[:fiscal_results].size
|
||||
end
|
||||
|
||||
test "get_account_balance extracts total_amount from portfolios" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
stub_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: {
|
||||
portfolios: [
|
||||
{ date: "2026-02-05", total_amount: 38000.0 },
|
||||
{ date: "2026-02-06", total_amount: 38905.21 }
|
||||
]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
Provider::IndexaCapital.stubs(:get).returns(stub_response)
|
||||
|
||||
balance = provider.get_account_balance(account_number: "ABC12345")
|
||||
assert_equal 38905.21.to_d, balance
|
||||
end
|
||||
|
||||
test "get_account_balance returns 0 when no portfolios" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
stub_response = OpenStruct.new(
|
||||
code: 200,
|
||||
body: { portfolios: [] }.to_json
|
||||
)
|
||||
|
||||
Provider::IndexaCapital.stubs(:get).returns(stub_response)
|
||||
|
||||
balance = provider.get_account_balance(account_number: "ABC12345")
|
||||
assert_equal 0, balance
|
||||
end
|
||||
|
||||
test "get_activities returns empty array" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
result = provider.get_activities(account_number: "ABC12345")
|
||||
assert_equal [], result
|
||||
end
|
||||
|
||||
test "raises AuthenticationError on 401" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "bad_token")
|
||||
|
||||
stub_response = OpenStruct.new(code: 401, body: "Unauthorized")
|
||||
Provider::IndexaCapital.stubs(:get).returns(stub_response)
|
||||
|
||||
assert_raises Provider::IndexaCapital::AuthenticationError do
|
||||
provider.list_accounts
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects invalid account_number with path traversal" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
assert_raises Provider::IndexaCapital::Error do
|
||||
provider.get_holdings(account_number: "../admin")
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects blank account_number" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
assert_raises Provider::IndexaCapital::Error do
|
||||
provider.get_holdings(account_number: "")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises Error on server error" do
|
||||
provider = Provider::IndexaCapital.new(api_token: "test_token")
|
||||
|
||||
stub_response = OpenStruct.new(code: 500, body: "Internal Server Error")
|
||||
Provider::IndexaCapital.stubs(:get).returns(stub_response)
|
||||
|
||||
assert_raises Provider::IndexaCapital::Error do
|
||||
provider.list_accounts
|
||||
end
|
||||
end
|
||||
end
|
||||
197
test/models/provider/openai/bank_statement_extractor_test.rb
Normal file
197
test/models/provider/openai/bank_statement_extractor_test.rb
Normal file
@@ -0,0 +1,197 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::Openai::BankStatementExtractorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@client = mock("openai_client")
|
||||
@model = "gpt-4.1"
|
||||
end
|
||||
|
||||
test "extracts transactions from PDF content" do
|
||||
mock_response = {
|
||||
"choices" => [ {
|
||||
"message" => {
|
||||
"content" => {
|
||||
"bank_name" => "Test Bank",
|
||||
"account_holder" => "John Doe",
|
||||
"account_number" => "1234",
|
||||
"statement_period" => {
|
||||
"start_date" => "2024-01-01",
|
||||
"end_date" => "2024-01-31"
|
||||
},
|
||||
"opening_balance" => 5000.00,
|
||||
"closing_balance" => 4500.00,
|
||||
"transactions" => [
|
||||
{ "date" => "2024-01-15", "description" => "Coffee Shop", "amount" => -5.50 },
|
||||
{ "date" => "2024-01-20", "description" => "Salary Deposit", "amount" => 3000.00 }
|
||||
]
|
||||
}.to_json
|
||||
}
|
||||
} ]
|
||||
}
|
||||
|
||||
@client.expects(:chat).returns(mock_response)
|
||||
|
||||
extractor = Provider::Openai::BankStatementExtractor.new(
|
||||
client: @client,
|
||||
pdf_content: "dummy",
|
||||
model: @model
|
||||
)
|
||||
|
||||
# Mock the PDF text extraction
|
||||
extractor.stubs(:extract_pages_from_pdf).returns([ "Page 1 bank statement text" ])
|
||||
|
||||
result = extractor.extract
|
||||
|
||||
assert_equal "Test Bank", result[:bank_name]
|
||||
assert_equal "John Doe", result[:account_holder]
|
||||
assert_equal "1234", result[:account_number]
|
||||
assert_equal 5000.00, result[:opening_balance]
|
||||
assert_equal 4500.00, result[:closing_balance]
|
||||
assert_equal 2, result[:transactions].size
|
||||
|
||||
first_txn = result[:transactions].first
|
||||
assert_equal "2024-01-15", first_txn[:date]
|
||||
assert_equal "Coffee Shop", first_txn[:name]
|
||||
assert_equal(-5.50, first_txn[:amount])
|
||||
end
|
||||
|
||||
test "handles empty PDF content" do
|
||||
extractor = Provider::Openai::BankStatementExtractor.new(
|
||||
client: @client,
|
||||
pdf_content: "",
|
||||
model: @model
|
||||
)
|
||||
|
||||
assert_raises(Provider::Openai::Error) do
|
||||
extractor.extract
|
||||
end
|
||||
end
|
||||
|
||||
test "handles nil PDF content" do
|
||||
extractor = Provider::Openai::BankStatementExtractor.new(
|
||||
client: @client,
|
||||
pdf_content: nil,
|
||||
model: @model
|
||||
)
|
||||
|
||||
assert_raises(Provider::Openai::Error) do
|
||||
extractor.extract
|
||||
end
|
||||
end
|
||||
|
||||
test "deduplicates transactions across chunk boundaries" do
|
||||
# First chunk response
|
||||
first_response = {
|
||||
"choices" => [ {
|
||||
"message" => {
|
||||
"content" => {
|
||||
"bank_name" => "Test Bank",
|
||||
"account_holder" => "John Doe",
|
||||
"account_number" => "1234",
|
||||
"statement_period" => { "start_date" => "2024-01-01", "end_date" => "2024-01-31" },
|
||||
"opening_balance" => 5000.00,
|
||||
"closing_balance" => 4500.00,
|
||||
"transactions" => [
|
||||
{ "date" => "2024-01-15", "description" => "Coffee Shop", "amount" => -5.50 },
|
||||
{ "date" => "2024-01-16", "description" => "Grocery Store", "amount" => -50.00 }
|
||||
]
|
||||
}.to_json
|
||||
}
|
||||
} ]
|
||||
}
|
||||
|
||||
# Second chunk response with duplicate at boundary
|
||||
second_response = {
|
||||
"choices" => [ {
|
||||
"message" => {
|
||||
"content" => {
|
||||
"transactions" => [
|
||||
{ "date" => "2024-01-16", "description" => "Grocery Store", "amount" => -50.00 },
|
||||
{ "date" => "2024-01-17", "description" => "Gas Station", "amount" => -40.00 }
|
||||
]
|
||||
}.to_json
|
||||
}
|
||||
} ]
|
||||
}
|
||||
|
||||
@client.expects(:chat).twice.returns(first_response, second_response)
|
||||
|
||||
extractor = Provider::Openai::BankStatementExtractor.new(
|
||||
client: @client,
|
||||
pdf_content: "dummy",
|
||||
model: @model
|
||||
)
|
||||
|
||||
# Mock multiple pages that will create multiple chunks
|
||||
extractor.stubs(:extract_pages_from_pdf).returns([
|
||||
"Page 1 " * 500, # ~3500 chars, first chunk
|
||||
"Page 2 " * 500 # ~3500 chars, second chunk
|
||||
])
|
||||
|
||||
result = extractor.extract
|
||||
|
||||
# Should deduplicate the "Grocery Store" transaction at chunk boundary
|
||||
assert_equal 3, result[:transactions].size
|
||||
names = result[:transactions].map { |t| t[:name] }
|
||||
assert_includes names, "Coffee Shop"
|
||||
assert_includes names, "Grocery Store"
|
||||
assert_includes names, "Gas Station"
|
||||
end
|
||||
|
||||
test "normalizes transaction amounts" do
|
||||
mock_response = {
|
||||
"choices" => [ {
|
||||
"message" => {
|
||||
"content" => {
|
||||
"transactions" => [
|
||||
{ "date" => "2024-01-15", "description" => "Test 1", "amount" => "-$5.50" },
|
||||
{ "date" => "2024-01-16", "description" => "Test 2", "amount" => "1,234.56" },
|
||||
{ "date" => "2024-01-17", "description" => "Test 3", "amount" => -100 }
|
||||
]
|
||||
}.to_json
|
||||
}
|
||||
} ]
|
||||
}
|
||||
|
||||
@client.expects(:chat).returns(mock_response)
|
||||
|
||||
extractor = Provider::Openai::BankStatementExtractor.new(
|
||||
client: @client,
|
||||
pdf_content: "dummy",
|
||||
model: @model
|
||||
)
|
||||
|
||||
extractor.stubs(:extract_pages_from_pdf).returns([ "Page 1 text" ])
|
||||
|
||||
result = extractor.extract
|
||||
|
||||
assert_equal(-5.50, result[:transactions][0][:amount])
|
||||
assert_equal 1234.56, result[:transactions][1][:amount]
|
||||
assert_equal(-100.0, result[:transactions][2][:amount])
|
||||
end
|
||||
|
||||
test "handles malformed JSON response gracefully" do
|
||||
mock_response = {
|
||||
"choices" => [ {
|
||||
"message" => {
|
||||
"content" => "This is not valid JSON"
|
||||
}
|
||||
} ]
|
||||
}
|
||||
|
||||
@client.expects(:chat).returns(mock_response)
|
||||
|
||||
extractor = Provider::Openai::BankStatementExtractor.new(
|
||||
client: @client,
|
||||
pdf_content: "dummy",
|
||||
model: @model
|
||||
)
|
||||
|
||||
extractor.stubs(:extract_pages_from_pdf).returns([ "Page 1 text" ])
|
||||
|
||||
result = extractor.extract
|
||||
|
||||
# Should return empty transactions on parse error
|
||||
assert_equal [], result[:transactions]
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
# First call raises timeout, second call succeeds
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(2)
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
@@ -25,7 +25,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "retries on Net::OpenTimeout and succeeds on retry" do
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(2)
|
||||
.raises(Net::OpenTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
@@ -39,7 +39,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "retries on SocketError and succeeds on retry" do
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(2)
|
||||
.raises(SocketError.new("Failed to open TCP connection"))
|
||||
.then.returns(mock_response)
|
||||
@@ -51,7 +51,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raises SimplefinError after max retries exceeded" do
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(4) # Initial + 3 retries
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
|
||||
@@ -66,7 +66,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "does not retry on non-retryable errors" do
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(1)
|
||||
.raises(ArgumentError.new("Invalid argument"))
|
||||
|
||||
@@ -80,7 +80,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "handles HTTP 429 rate limit response" do
|
||||
mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded")
|
||||
|
||||
HTTParty.expects(:get).returns(mock_response)
|
||||
Provider::Simplefin.expects(:get).returns(mock_response)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
@@ -93,7 +93,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "handles HTTP 500 server error response" do
|
||||
mock_response = OpenStruct.new(code: 500, body: "Internal Server Error")
|
||||
|
||||
HTTParty.expects(:get).returns(mock_response)
|
||||
Provider::Simplefin.expects(:get).returns(mock_response)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
@@ -106,7 +106,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
setup_token = Base64.encode64("https://example.com/claim")
|
||||
mock_response = OpenStruct.new(code: 200, body: "https://example.com/access")
|
||||
|
||||
HTTParty.expects(:post)
|
||||
Provider::Simplefin.expects(:post)
|
||||
.times(2)
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
|
||||
@@ -165,6 +165,30 @@ class Rule::ActionTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "set_as_transfer_or_payment assigns investment_contribution kind and category for investment destination" do
|
||||
investment = accounts(:investment)
|
||||
|
||||
action = Rule::Action.new(
|
||||
rule: @transaction_rule,
|
||||
action_type: "set_as_transfer_or_payment",
|
||||
value: investment.id
|
||||
)
|
||||
|
||||
# Only apply to txn1 (positive amount = outflow)
|
||||
action.apply(Transaction.where(id: @txn1.id))
|
||||
|
||||
@txn1.reload
|
||||
|
||||
transfer = Transfer.find_by(outflow_transaction_id: @txn1.id) || Transfer.find_by(inflow_transaction_id: @txn1.id)
|
||||
assert transfer.present?, "Transfer should be created"
|
||||
|
||||
assert_equal "investment_contribution", transfer.outflow_transaction.kind
|
||||
assert_equal "funds_movement", transfer.inflow_transaction.kind
|
||||
|
||||
category = @family.investment_contributions_category
|
||||
assert_equal category, transfer.outflow_transaction.category
|
||||
end
|
||||
|
||||
test "set_investment_activity_label ignores invalid values" do
|
||||
action = Rule::Action.new(
|
||||
rule: @transaction_rule,
|
||||
|
||||
@@ -331,4 +331,171 @@ class Rule::ConditionTest < ActiveSupport::TestCase
|
||||
assert_equal 4, filtered.count
|
||||
assert_not filtered.map(&:id).include?(transaction_entry.transaction.id)
|
||||
end
|
||||
|
||||
test "applies transaction_type condition for income" do
|
||||
scope = @rule_scope
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "income"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
# transaction2 has amount -200 (income)
|
||||
assert_equal 1, filtered.count
|
||||
assert filtered.all? { |t| t.entry.amount.negative? }
|
||||
end
|
||||
|
||||
test "applies transaction_type condition for expense" do
|
||||
scope = @rule_scope
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "expense"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
# transaction1, 3, 4, 5 have positive amounts (expenses)
|
||||
assert_equal 4, filtered.count
|
||||
assert filtered.all? { |t| t.entry.amount.positive? && !t.transfer? }
|
||||
end
|
||||
|
||||
test "applies transaction_type condition for transfer" do
|
||||
scope = @rule_scope
|
||||
|
||||
# Create a transfer transaction
|
||||
transfer_entry = create_transaction(
|
||||
date: Date.current,
|
||||
account: @account,
|
||||
amount: 500,
|
||||
name: "Transfer to savings"
|
||||
)
|
||||
transfer_entry.transaction.update!(kind: "funds_movement")
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "transfer"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
assert_equal 1, filtered.count
|
||||
assert_equal transfer_entry.transaction.id, filtered.first.id
|
||||
assert filtered.first.transfer?
|
||||
end
|
||||
|
||||
test "transaction_type expense excludes transfers" do
|
||||
scope = @rule_scope
|
||||
|
||||
# Create a transfer with positive amount (would look like expense)
|
||||
transfer_entry = create_transaction(
|
||||
date: Date.current,
|
||||
account: @account,
|
||||
amount: 500,
|
||||
name: "Transfer to savings"
|
||||
)
|
||||
transfer_entry.transaction.update!(kind: "funds_movement")
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "expense"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
# Should NOT include the transfer even though it has positive amount
|
||||
assert_not filtered.map(&:id).include?(transfer_entry.transaction.id)
|
||||
end
|
||||
|
||||
test "transaction_type income excludes transfers" do
|
||||
scope = @rule_scope
|
||||
|
||||
# Create a transfer inflow (negative amount)
|
||||
transfer_entry = create_transaction(
|
||||
date: Date.current,
|
||||
account: @account,
|
||||
amount: -500,
|
||||
name: "Transfer from savings"
|
||||
)
|
||||
transfer_entry.transaction.update!(kind: "funds_movement")
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "income"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
# Should NOT include the transfer even though it has negative amount
|
||||
assert_not filtered.map(&:id).include?(transfer_entry.transaction.id)
|
||||
end
|
||||
|
||||
test "transaction_type expense includes investment_contribution regardless of amount sign" do
|
||||
scope = @rule_scope
|
||||
|
||||
# Create investment contribution with negative amount (inflow from provider)
|
||||
contribution_entry = create_transaction(
|
||||
date: Date.current,
|
||||
account: @account,
|
||||
amount: -1000,
|
||||
name: "401k contribution"
|
||||
)
|
||||
contribution_entry.transaction.update!(kind: "investment_contribution")
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "expense"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
# Should include investment_contribution even with negative amount
|
||||
assert filtered.map(&:id).include?(contribution_entry.transaction.id)
|
||||
end
|
||||
|
||||
test "transaction_type income excludes investment_contribution" do
|
||||
scope = @rule_scope
|
||||
|
||||
# Create investment contribution with negative amount
|
||||
contribution_entry = create_transaction(
|
||||
date: Date.current,
|
||||
account: @account,
|
||||
amount: -1000,
|
||||
name: "401k contribution"
|
||||
)
|
||||
contribution_entry.transaction.update!(kind: "investment_contribution")
|
||||
|
||||
condition = Rule::Condition.new(
|
||||
rule: @transaction_rule,
|
||||
condition_type: "transaction_type",
|
||||
operator: "=",
|
||||
value: "income"
|
||||
)
|
||||
|
||||
scope = condition.prepare(scope)
|
||||
filtered = condition.apply(scope)
|
||||
|
||||
# Should NOT include investment_contribution even with negative amount
|
||||
assert_not filtered.map(&:id).include?(contribution_entry.transaction.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -94,6 +94,30 @@ class RuleTest < ActiveSupport::TestCase
|
||||
assert_not transaction_entry.excluded, "Transaction should not be excluded when attribute is locked"
|
||||
end
|
||||
|
||||
test "transaction name rules normalize whitespace in comparisons" do
|
||||
transaction_entry = create_transaction(
|
||||
date: Date.current,
|
||||
account: @account,
|
||||
name: "Company - Mobile",
|
||||
amount: 80
|
||||
)
|
||||
|
||||
rule = Rule.create!(
|
||||
family: @family,
|
||||
resource_type: "transaction",
|
||||
effective_date: 1.day.ago.to_date,
|
||||
conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Company - Mobile") ],
|
||||
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
|
||||
)
|
||||
|
||||
assert_equal 1, rule.affected_resource_count
|
||||
|
||||
rule.apply
|
||||
transaction_entry.reload
|
||||
|
||||
assert_equal @groceries_category, transaction_entry.transaction.category
|
||||
end
|
||||
|
||||
# Artificial limitation put in place to prevent users from creating overly complex rules
|
||||
# Rules should be shallow and wide
|
||||
test "no nested compound conditions" do
|
||||
@@ -114,6 +138,40 @@ class RuleTest < ActiveSupport::TestCase
|
||||
assert_equal [ "Compound conditions cannot be nested" ], rule.errors.full_messages
|
||||
end
|
||||
|
||||
test "displayed_condition falls back to next valid condition when first compound condition is empty" do
|
||||
rule = Rule.new(
|
||||
family: @family,
|
||||
resource_type: "transaction",
|
||||
actions: [ Rule::Action.new(action_type: "exclude_transaction") ],
|
||||
conditions: [
|
||||
Rule::Condition.new(condition_type: "compound", operator: "and"),
|
||||
Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "starbucks")
|
||||
]
|
||||
)
|
||||
|
||||
displayed_condition = rule.displayed_condition
|
||||
|
||||
assert_not_nil displayed_condition
|
||||
assert_equal "transaction_name", displayed_condition.condition_type
|
||||
assert_equal "like", displayed_condition.operator
|
||||
assert_equal "starbucks", displayed_condition.value
|
||||
end
|
||||
|
||||
test "additional_displayable_conditions_count ignores empty compound conditions" do
|
||||
rule = Rule.new(
|
||||
family: @family,
|
||||
resource_type: "transaction",
|
||||
actions: [ Rule::Action.new(action_type: "exclude_transaction") ],
|
||||
conditions: [
|
||||
Rule::Condition.new(condition_type: "compound", operator: "and"),
|
||||
Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "first"),
|
||||
Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 100)
|
||||
]
|
||||
)
|
||||
|
||||
assert_equal 1, rule.additional_displayable_conditions_count
|
||||
end
|
||||
|
||||
test "rule matching on transaction details" do
|
||||
# Create PayPal transaction with underlying merchant in details
|
||||
paypal_entry = create_transaction(
|
||||
|
||||
115
test/models/security/plan_restriction_tracker_test.rb
Normal file
115
test/models/security/plan_restriction_tracker_test.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
require "test_helper"
|
||||
|
||||
class Security::PlanRestrictionTrackerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
# Use memory store for testing
|
||||
@original_cache = Rails.cache
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
end
|
||||
|
||||
teardown do
|
||||
Rails.cache = @original_cache
|
||||
end
|
||||
|
||||
test "plan_upgrade_required? detects Grow plan message" do
|
||||
message = "This endpoint is available starting with Grow subscription."
|
||||
assert Security.plan_upgrade_required?(message, provider: "TwelveData")
|
||||
end
|
||||
|
||||
test "plan_upgrade_required? detects Pro plan message" do
|
||||
message = "API error (code: 400): available starting with Pro plan"
|
||||
assert Security.plan_upgrade_required?(message, provider: "TwelveData")
|
||||
end
|
||||
|
||||
test "plan_upgrade_required? returns false for other errors" do
|
||||
message = "Some other error message"
|
||||
assert_not Security.plan_upgrade_required?(message, provider: "TwelveData")
|
||||
end
|
||||
|
||||
test "plan_upgrade_required? returns false for nil" do
|
||||
assert_not Security.plan_upgrade_required?(nil, provider: "TwelveData")
|
||||
end
|
||||
|
||||
test "plan_upgrade_required? returns false for unknown provider" do
|
||||
message = "This endpoint is available starting with Grow subscription."
|
||||
assert_not Security.plan_upgrade_required?(message, provider: "UnknownProvider")
|
||||
end
|
||||
|
||||
test "record_plan_restriction stores restriction in cache" do
|
||||
Security.record_plan_restriction(
|
||||
security_id: 999,
|
||||
error_message: "This endpoint is available starting with Grow subscription.",
|
||||
provider: "TwelveData"
|
||||
)
|
||||
|
||||
restriction = Security.plan_restriction_for(999, provider: "TwelveData")
|
||||
assert_not_nil restriction
|
||||
assert_equal "Grow", restriction[:required_plan]
|
||||
assert_equal "TwelveData", restriction[:provider]
|
||||
end
|
||||
|
||||
test "clear_plan_restriction removes restriction from cache" do
|
||||
Security.record_plan_restriction(
|
||||
security_id: 999,
|
||||
error_message: "available starting with Pro",
|
||||
provider: "TwelveData"
|
||||
)
|
||||
|
||||
Security.clear_plan_restriction(999, provider: "TwelveData")
|
||||
assert_nil Security.plan_restriction_for(999, provider: "TwelveData")
|
||||
end
|
||||
|
||||
test "plan_restrictions_for returns multiple restrictions" do
|
||||
Security.record_plan_restriction(security_id: 1001, error_message: "available starting with Grow", provider: "TwelveData")
|
||||
Security.record_plan_restriction(security_id: 1002, error_message: "available starting with Pro", provider: "TwelveData")
|
||||
|
||||
restrictions = Security.plan_restrictions_for([ 1001, 1002, 9999 ], provider: "TwelveData")
|
||||
|
||||
assert_equal 2, restrictions.keys.count
|
||||
assert_equal "Grow", restrictions[1001][:required_plan]
|
||||
assert_equal "Pro", restrictions[1002][:required_plan]
|
||||
assert_nil restrictions[9999]
|
||||
end
|
||||
|
||||
test "plan_restrictions_for returns empty hash for empty input" do
|
||||
assert_equal({}, Security.plan_restrictions_for([], provider: "TwelveData"))
|
||||
assert_equal({}, Security.plan_restrictions_for(nil, provider: "TwelveData"))
|
||||
end
|
||||
|
||||
test "record_plan_restriction does nothing for non-plan errors" do
|
||||
Security.record_plan_restriction(
|
||||
security_id: 999,
|
||||
error_message: "Some other error",
|
||||
provider: "TwelveData"
|
||||
)
|
||||
|
||||
assert_nil Security.plan_restriction_for(999, provider: "TwelveData")
|
||||
end
|
||||
|
||||
test "restrictions are scoped by provider" do
|
||||
# Record restriction for TwelveData
|
||||
Security.record_plan_restriction(security_id: 999, error_message: "available starting with Grow", provider: "TwelveData")
|
||||
|
||||
# Simulate a different provider by directly writing to cache (tests cache key scoping)
|
||||
Rails.cache.write("security_plan_restriction/otherprovider/999", { required_plan: "Pro", provider: "OtherProvider" }, expires_in: 7.days)
|
||||
|
||||
twelve_data_restriction = Security.plan_restriction_for(999, provider: "TwelveData")
|
||||
other_restriction = Security.plan_restriction_for(999, provider: "OtherProvider")
|
||||
|
||||
assert_equal "Grow", twelve_data_restriction[:required_plan]
|
||||
assert_equal "Pro", other_restriction[:required_plan]
|
||||
end
|
||||
|
||||
test "clearing restriction for one provider does not affect another" do
|
||||
# Record restriction for TwelveData
|
||||
Security.record_plan_restriction(security_id: 999, error_message: "available starting with Grow", provider: "TwelveData")
|
||||
|
||||
# Simulate another provider by directly writing to cache
|
||||
Rails.cache.write("security_plan_restriction/otherprovider/999", { required_plan: "Pro", provider: "OtherProvider" }, expires_in: 7.days)
|
||||
|
||||
Security.clear_plan_restriction(999, provider: "TwelveData")
|
||||
|
||||
assert_nil Security.plan_restriction_for(999, provider: "TwelveData")
|
||||
assert_not_nil Security.plan_restriction_for(999, provider: "OtherProvider")
|
||||
end
|
||||
end
|
||||
@@ -232,6 +232,38 @@ class SsoProviderTest < ActiveSupport::TestCase
|
||||
assert_equal 1, oidc_providers.count
|
||||
end
|
||||
|
||||
|
||||
|
||||
test "normalizes icon by stripping whitespace before validation" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "icon_normalized",
|
||||
label: "Icon Normalized",
|
||||
icon: " key ",
|
||||
issuer: "https://test.example.com",
|
||||
client_id: "test_client",
|
||||
client_secret: "test_secret"
|
||||
)
|
||||
|
||||
assert provider.valid?
|
||||
assert_equal "key", provider.icon
|
||||
end
|
||||
|
||||
test "normalizes whitespace-only icon to nil" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "icon_nil",
|
||||
label: "Icon Nil",
|
||||
icon: " ",
|
||||
issuer: "https://test.example.com",
|
||||
client_id: "test_client",
|
||||
client_secret: "test_secret"
|
||||
)
|
||||
|
||||
assert provider.valid?
|
||||
assert_nil provider.icon
|
||||
end
|
||||
|
||||
test "to_omniauth_config returns correct hash" do
|
||||
provider = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
|
||||
@@ -114,7 +114,7 @@ class Transaction::SearchTest < ActiveSupport::TestCase
|
||||
)
|
||||
|
||||
# Search for uncategorized transactions
|
||||
uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ "Uncategorized" ] }).transactions_scope
|
||||
uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] }).transactions_scope
|
||||
uncategorized_ids = uncategorized_results.pluck(:id)
|
||||
|
||||
# Should include standard uncategorized transactions
|
||||
@@ -126,6 +126,90 @@ class Transaction::SearchTest < ActiveSupport::TestCase
|
||||
assert_not_includes uncategorized_ids, uncategorized_transfer.entryable.id
|
||||
end
|
||||
|
||||
test "filtering for only Uncategorized returns only uncategorized transactions" do
|
||||
# Create a mix of categorized and uncategorized transactions
|
||||
categorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
|
||||
uncategorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200
|
||||
)
|
||||
|
||||
# Filter for only uncategorized
|
||||
results = Transaction::Search.new(@family, filters: { categories: [ Category.uncategorized.name ] }).transactions_scope
|
||||
result_ids = results.pluck(:id)
|
||||
|
||||
# Should only include uncategorized transaction
|
||||
assert_includes result_ids, uncategorized.entryable.id
|
||||
assert_not_includes result_ids, categorized.entryable.id
|
||||
assert_equal 1, result_ids.size
|
||||
end
|
||||
|
||||
test "filtering for Uncategorized plus a real category returns both" do
|
||||
# Create a travel category for testing
|
||||
travel_category = @family.categories.create!(
|
||||
name: "Travel",
|
||||
color: "#3b82f6",
|
||||
classification: "expense"
|
||||
)
|
||||
|
||||
# Create transactions with different categories
|
||||
food_transaction = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
|
||||
travel_transaction = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 150,
|
||||
category: travel_category
|
||||
)
|
||||
|
||||
uncategorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200
|
||||
)
|
||||
|
||||
# Filter for food category + uncategorized
|
||||
results = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink", Category.uncategorized.name ] }).transactions_scope
|
||||
result_ids = results.pluck(:id)
|
||||
|
||||
# Should include both food and uncategorized
|
||||
assert_includes result_ids, food_transaction.entryable.id
|
||||
assert_includes result_ids, uncategorized.entryable.id
|
||||
# Should NOT include travel
|
||||
assert_not_includes result_ids, travel_transaction.entryable.id
|
||||
assert_equal 2, result_ids.size
|
||||
end
|
||||
|
||||
test "filtering excludes uncategorized when not in filter" do
|
||||
# Create a mix of transactions
|
||||
categorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
category: categories(:food_and_drink)
|
||||
)
|
||||
|
||||
uncategorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200
|
||||
)
|
||||
|
||||
# Filter for only food category (without Uncategorized)
|
||||
results = Transaction::Search.new(@family, filters: { categories: [ "Food & Drink" ] }).transactions_scope
|
||||
result_ids = results.pluck(:id)
|
||||
|
||||
# Should only include categorized transaction
|
||||
assert_includes result_ids, categorized.entryable.id
|
||||
assert_not_includes result_ids, uncategorized.entryable.id
|
||||
assert_equal 1, result_ids.size
|
||||
end
|
||||
|
||||
test "new family-based API works correctly" do
|
||||
# Create transactions for testing
|
||||
transaction1 = create_transaction(
|
||||
@@ -410,4 +494,97 @@ class Transaction::SearchTest < ActiveSupport::TestCase
|
||||
# Should not match unrelated transactions
|
||||
assert_not_includes result_ids, no_match.entryable.id
|
||||
end
|
||||
|
||||
test "uncategorized filter returns same results across all supported locales" do
|
||||
# Create uncategorized transactions
|
||||
uncategorized1 = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
kind: "standard"
|
||||
)
|
||||
|
||||
uncategorized2 = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200,
|
||||
kind: "standard"
|
||||
)
|
||||
|
||||
# Create a categorized transaction to ensure filter is working
|
||||
categorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 300,
|
||||
category: categories(:food_and_drink),
|
||||
kind: "standard"
|
||||
)
|
||||
|
||||
# Get the expected count using English locale (known working case)
|
||||
I18n.with_locale(:en) do
|
||||
english_uncategorized_name = Category.uncategorized.name
|
||||
english_results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope
|
||||
@expected_count = english_results.count
|
||||
assert_equal 2, @expected_count, "English locale should return 2 uncategorized transactions"
|
||||
end
|
||||
|
||||
# Test every supported locale returns the same count when filtering by that locale's uncategorized name
|
||||
LanguagesHelper::SUPPORTED_LOCALES.each do |locale|
|
||||
I18n.with_locale(locale) do
|
||||
localized_uncategorized_name = Category.uncategorized.name
|
||||
results = Transaction::Search.new(@family, filters: { categories: [ localized_uncategorized_name ] }).transactions_scope
|
||||
result_count = results.count
|
||||
|
||||
assert_equal @expected_count, result_count,
|
||||
"Locale '#{locale}' with uncategorized name '#{localized_uncategorized_name}' should return #{@expected_count} transactions but got #{result_count}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "uncategorized filter works with English parameter name regardless of current locale" do
|
||||
# This tests the bug where URL contains English "Uncategorized" but user's locale is different
|
||||
# Bug: /transactions/?q[categories][]=Uncategorized fails when locale is French
|
||||
|
||||
# Create uncategorized transactions
|
||||
uncategorized1 = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 100,
|
||||
kind: "standard"
|
||||
)
|
||||
|
||||
uncategorized2 = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 200,
|
||||
kind: "standard"
|
||||
)
|
||||
|
||||
# Create a categorized transaction to ensure filter is working
|
||||
categorized = create_transaction(
|
||||
account: @checking_account,
|
||||
amount: 300,
|
||||
category: categories(:food_and_drink),
|
||||
kind: "standard"
|
||||
)
|
||||
|
||||
# Get the English uncategorized name (this is what URLs typically contain)
|
||||
english_uncategorized_name = I18n.t("models.category.uncategorized", locale: :en)
|
||||
|
||||
# Get the expected count using English locale (known working case)
|
||||
expected_count = nil
|
||||
I18n.with_locale(:en) do
|
||||
results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope
|
||||
expected_count = results.count
|
||||
assert_equal 2, expected_count, "English locale should return 2 uncategorized transactions"
|
||||
end
|
||||
|
||||
# Test that using the English parameter name works in every supported locale
|
||||
# This catches the bug where French locale fails with English "Uncategorized" parameter
|
||||
LanguagesHelper::SUPPORTED_LOCALES.each do |locale|
|
||||
I18n.with_locale(locale) do
|
||||
# Simulate URL parameter: q[categories][]=Uncategorized (English, regardless of user's locale)
|
||||
results = Transaction::Search.new(@family, filters: { categories: [ english_uncategorized_name ] }).transactions_scope
|
||||
result_count = results.count
|
||||
|
||||
assert_equal expected_count, result_count,
|
||||
"Locale '#{locale}' should return #{expected_count} transactions when filtering with English 'Uncategorized' parameter, but got #{result_count}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
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
|
||||
@@ -153,6 +160,47 @@ class UserTest < ActiveSupport::TestCase
|
||||
Setting.openai_access_token = previous
|
||||
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 "update_dashboard_preferences handles concurrent updates atomically" do
|
||||
@user.update!(preferences: {})
|
||||
|
||||
@@ -348,4 +396,45 @@ class UserTest < ActiveSupport::TestCase
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
42
test/models/vector_store/base_test.rb
Normal file
42
test/models/vector_store/base_test.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require "test_helper"
|
||||
|
||||
class VectorStore::BaseTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@adapter = VectorStore::Base.new
|
||||
end
|
||||
|
||||
test "create_store raises NotImplementedError" do
|
||||
assert_raises(NotImplementedError) { @adapter.create_store(name: "test") }
|
||||
end
|
||||
|
||||
test "delete_store raises NotImplementedError" do
|
||||
assert_raises(NotImplementedError) { @adapter.delete_store(store_id: "test") }
|
||||
end
|
||||
|
||||
test "upload_file raises NotImplementedError" do
|
||||
assert_raises(NotImplementedError) { @adapter.upload_file(store_id: "s", file_content: "c", filename: "f") }
|
||||
end
|
||||
|
||||
test "remove_file raises NotImplementedError" do
|
||||
assert_raises(NotImplementedError) { @adapter.remove_file(store_id: "s", file_id: "f") }
|
||||
end
|
||||
|
||||
test "search raises NotImplementedError" do
|
||||
assert_raises(NotImplementedError) { @adapter.search(store_id: "s", query: "q") }
|
||||
end
|
||||
|
||||
test "supported_extensions includes common file types" do
|
||||
exts = @adapter.supported_extensions
|
||||
assert_includes exts, ".pdf"
|
||||
assert_includes exts, ".docx"
|
||||
assert_includes exts, ".xlsx"
|
||||
assert_includes exts, ".csv"
|
||||
assert_includes exts, ".json"
|
||||
assert_includes exts, ".txt"
|
||||
assert_includes exts, ".md"
|
||||
end
|
||||
|
||||
test "SUPPORTED_EXTENSIONS is frozen" do
|
||||
assert VectorStore::Base::SUPPORTED_EXTENSIONS.frozen?
|
||||
end
|
||||
end
|
||||
132
test/models/vector_store/openai_test.rb
Normal file
132
test/models/vector_store/openai_test.rb
Normal file
@@ -0,0 +1,132 @@
|
||||
require "test_helper"
|
||||
|
||||
class VectorStore::OpenaiTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@adapter = VectorStore::Openai.new(access_token: "sk-test-key")
|
||||
end
|
||||
|
||||
test "create_store wraps response" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_vs = mock("vector_stores")
|
||||
mock_vs.expects(:create).with(parameters: { name: "Test Store" }).returns({ "id" => "vs_abc123" })
|
||||
mock_client.stubs(:vector_stores).returns(mock_vs)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.create_store(name: "Test Store")
|
||||
assert response.success?
|
||||
assert_equal "vs_abc123", response.data[:id]
|
||||
end
|
||||
|
||||
test "delete_store wraps response" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_vs = mock("vector_stores")
|
||||
mock_vs.expects(:delete).with(id: "vs_abc123").returns(true)
|
||||
mock_client.stubs(:vector_stores).returns(mock_vs)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.delete_store(store_id: "vs_abc123")
|
||||
assert response.success?
|
||||
end
|
||||
|
||||
test "upload_file uploads and attaches to store" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_files = mock("files")
|
||||
mock_files.expects(:upload).returns({ "id" => "file-xyz" })
|
||||
mock_vs_files = mock("vector_store_files")
|
||||
mock_vs_files.expects(:create).with(
|
||||
vector_store_id: "vs_abc123",
|
||||
parameters: { file_id: "file-xyz" }
|
||||
).returns(true)
|
||||
|
||||
mock_client.stubs(:files).returns(mock_files)
|
||||
mock_client.stubs(:vector_store_files).returns(mock_vs_files)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.upload_file(
|
||||
store_id: "vs_abc123",
|
||||
file_content: "Hello world",
|
||||
filename: "test.txt"
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_equal "file-xyz", response.data[:file_id]
|
||||
end
|
||||
|
||||
test "remove_file deletes from store" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_vs_files = mock("vector_store_files")
|
||||
mock_vs_files.expects(:delete).with(
|
||||
vector_store_id: "vs_abc123",
|
||||
id: "file-xyz"
|
||||
).returns(true)
|
||||
mock_client.stubs(:vector_store_files).returns(mock_vs_files)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.remove_file(store_id: "vs_abc123", file_id: "file-xyz")
|
||||
assert response.success?
|
||||
end
|
||||
|
||||
test "search uses gem client and parses results" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_vs = mock("vector_stores")
|
||||
mock_vs.expects(:search).with(
|
||||
id: "vs_abc123",
|
||||
parameters: { query: "income", max_num_results: 5 }
|
||||
).returns({
|
||||
"data" => [
|
||||
{
|
||||
"file_id" => "file-xyz",
|
||||
"filename" => "tax_return.pdf",
|
||||
"score" => 0.95,
|
||||
"content" => [ { "type" => "text", "text" => "Total income: $85,000" } ]
|
||||
}
|
||||
]
|
||||
})
|
||||
mock_client.stubs(:vector_stores).returns(mock_vs)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.search(store_id: "vs_abc123", query: "income", max_results: 5)
|
||||
assert response.success?
|
||||
assert_equal 1, response.data.size
|
||||
assert_equal "Total income: $85,000", response.data.first[:content]
|
||||
assert_equal "tax_return.pdf", response.data.first[:filename]
|
||||
assert_equal 0.95, response.data.first[:score]
|
||||
end
|
||||
|
||||
test "search returns empty array when no results" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_vs = mock("vector_stores")
|
||||
mock_vs.expects(:search).returns({ "data" => [] })
|
||||
mock_client.stubs(:vector_stores).returns(mock_vs)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.search(store_id: "vs_abc123", query: "nothing")
|
||||
assert response.success?
|
||||
assert_empty response.data
|
||||
end
|
||||
|
||||
test "wraps errors in failure response" do
|
||||
mock_client = mock("openai_client")
|
||||
mock_vs = mock("vector_stores")
|
||||
mock_vs.expects(:create).raises(StandardError, "API error")
|
||||
mock_client.stubs(:vector_stores).returns(mock_vs)
|
||||
|
||||
@adapter.instance_variable_set(:@client, mock_client)
|
||||
|
||||
response = @adapter.create_store(name: "Broken Store")
|
||||
assert_not response.success?
|
||||
assert_equal "API error", response.error.message
|
||||
end
|
||||
|
||||
test "supported_extensions returns the default list" do
|
||||
assert_includes @adapter.supported_extensions, ".pdf"
|
||||
assert_includes @adapter.supported_extensions, ".docx"
|
||||
assert_includes @adapter.supported_extensions, ".csv"
|
||||
end
|
||||
end
|
||||
53
test/models/vector_store/registry_test.rb
Normal file
53
test/models/vector_store/registry_test.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
require "test_helper"
|
||||
|
||||
class VectorStore::RegistryTest < ActiveSupport::TestCase
|
||||
test "adapter_name defaults to openai when access token present" do
|
||||
VectorStore::Registry.stubs(:openai_access_token).returns("sk-test")
|
||||
ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do
|
||||
assert_equal :openai, VectorStore::Registry.adapter_name
|
||||
end
|
||||
end
|
||||
|
||||
test "adapter_name returns nil when no credentials configured" do
|
||||
VectorStore::Registry.stubs(:openai_access_token).returns(nil)
|
||||
ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do
|
||||
assert_nil VectorStore::Registry.adapter_name
|
||||
end
|
||||
end
|
||||
|
||||
test "adapter_name respects explicit VECTOR_STORE_PROVIDER" do
|
||||
ClimateControl.modify(VECTOR_STORE_PROVIDER: "qdrant") do
|
||||
assert_equal :qdrant, VectorStore::Registry.adapter_name
|
||||
end
|
||||
end
|
||||
|
||||
test "adapter_name falls back to openai for unknown provider" do
|
||||
VectorStore::Registry.stubs(:openai_access_token).returns("sk-test")
|
||||
ClimateControl.modify(VECTOR_STORE_PROVIDER: "unknown_store") do
|
||||
assert_equal :openai, VectorStore::Registry.adapter_name
|
||||
end
|
||||
end
|
||||
|
||||
test "adapter returns VectorStore::Openai instance when openai configured" do
|
||||
VectorStore::Registry.stubs(:openai_access_token).returns("sk-test")
|
||||
ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do
|
||||
adapter = VectorStore::Registry.adapter
|
||||
assert_instance_of VectorStore::Openai, adapter
|
||||
end
|
||||
end
|
||||
|
||||
test "adapter returns nil when nothing configured" do
|
||||
VectorStore::Registry.stubs(:openai_access_token).returns(nil)
|
||||
ClimateControl.modify(VECTOR_STORE_PROVIDER: nil) do
|
||||
assert_nil VectorStore::Registry.adapter
|
||||
end
|
||||
end
|
||||
|
||||
test "configured? delegates to adapter presence" do
|
||||
VectorStore::Registry.stubs(:adapter).returns(nil)
|
||||
assert_not VectorStore.configured?
|
||||
|
||||
VectorStore::Registry.stubs(:adapter).returns(VectorStore::Openai.new(access_token: "sk-test"))
|
||||
assert VectorStore.configured?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user