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:
Juan José Mata
2026-02-15 23:29:42 +01:00
committed by GitHub
479 changed files with 21907 additions and 2925 deletions

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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,

View File

@@ -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

View 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

View 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

View File

@@ -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 = {

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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: [

View 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

View 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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View 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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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