Providers factory (#250)

* Implement providers factory

* Multiple providers sync support

- Proper Multi-Provider Syncing: When you click sync on an account with multiple providers (e.g., both Plaid and SimpleFin), all provider items are synced
- Better API: The existing account.providers method already returns all providers, and account.provider returns the first one for backward compatibility
- Correct Holdings Deletion Logic: Holdings can only be deleted if ALL providers allow it, preventing accidental deletions that would be recreated on next sync
TODO: validate this is the way we want to go? We would need to check holdings belong to which account, and then check provider allows deletion. More complex
- Database Constraints: The existing validations ensure an account can have at most one provider of each type (one PlaidAccount, one SimplefinAccount, etc.)

* Add generic provider_import_adapter

* Finish unified import strategy

* Update app/models/plaid_account.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: soky srm <sokysrm@gmail.com>

* Update app/models/provider/factory.rb

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: soky srm <sokysrm@gmail.com>

* Fix account linked by plaid_id instead of external_id

* Parse numerics to BigDecimal

Parse numerics to BigDecimal before computing amount; guard nils.

Avoid String * String and float drift; also normalize date.

* Fix incorrect usage of assert_raises.

* Fix linter

* Fix processor test.

* Update current_balance_manager.rb

* Test fixes

* Fix plaid linked account test

* Add support for holding per account_provider

* Fix proper account access

Also fix account deletion for simpefin too

* FIX match tests for consistency

* Some more factory updates

* Fix account schema for multipe providers

  Can do:
  - Account #1 → PlaidAccount + SimplefinAccount (multiple different providers)
  - Account #2 → PlaidAccount only
  - Account #3 → SimplefinAccount only

  Cannot do:
  - Account #1 → PlaidAccount + PlaidAccount (duplicate provider type)
  - PlaidAccount #123 → Account #1 + Account #2 (provider linked to multiple accounts)

* Fix account setup

- An account CAN have multiple providers (the schema shows account_providers with unique index on [account_id, provider_type])
  - Each provider should maintain its own separate entries
  - We should NOT update one provider's entry when another provider syncs

* Fix linter and guard migration

* FIX linter issues.

* Fixes

- Remove duplicated index
- Pass account_provider_id
- Guard holdings call to avoid NoMethodError

* Update schema and provider import fix

* Plaid doesn't allow holdings deletion

* Use ClimateControl for proper env setup

* No need for this in .git

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-10-28 19:32:27 +01:00
committed by GitHub
parent 72e7d7736b
commit 4fb0a3856e
67 changed files with 2338 additions and 315 deletions

View File

@@ -32,4 +32,29 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_enqueued_with job: DestroyJob
assert_equal "Account scheduled for deletion", flash[:notice]
end
test "syncing linked account triggers sync for all provider items" do
plaid_account = plaid_accounts(:one)
plaid_item = plaid_account.plaid_item
AccountProvider.create!(account: @account, provider: plaid_account)
# Reload to ensure the account has the provider association loaded
@account.reload
# Mock at the class level since controller loads account from DB
Account.any_instance.expects(:syncing?).returns(false)
PlaidItem.any_instance.expects(:syncing?).returns(false)
PlaidItem.any_instance.expects(:sync_later).once
post sync_account_url(@account)
assert_redirected_to account_url(@account)
end
test "syncing unlinked account calls account sync_later" do
Account.any_instance.expects(:syncing?).returns(false)
Account.any_instance.expects(:sync_later).once
post sync_account_url(@account)
assert_redirected_to account_url(@account)
end
end

View File

@@ -179,8 +179,8 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
# Verify old SimpleFin accounts no longer reference Maybe accounts
old_simplefin_account1.reload
old_simplefin_account2.reload
assert_nil old_simplefin_account1.account
assert_nil old_simplefin_account2.account
assert_nil old_simplefin_account1.current_account
assert_nil old_simplefin_account2.current_account
# Verify old SimpleFin item is scheduled for deletion
@simplefin_item.reload
@@ -229,7 +229,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
maybe_account.reload
old_simplefin_account.reload
assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id
assert_equal maybe_account, old_simplefin_account.account
assert_equal maybe_account, old_simplefin_account.current_account
# Old item still scheduled for deletion
@simplefin_item.reload

View File

@@ -4,6 +4,13 @@ class Account::CurrentBalanceManagerTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
@linked_account = accounts(:connected)
# Create account_provider to make the account actually linked
# (The fixture has plaid_account but that's the legacy association)
@linked_account.account_providers.find_or_create_by!(
provider_type: "PlaidAccount",
provider_id: plaid_accounts(:one).id
)
end
# -------------------------------------------------------------------------------------------------

View File

@@ -0,0 +1,69 @@
require "test_helper"
class Account::LinkableTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@account = accounts(:depository)
end
test "linked? returns true when account has providers" do
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: @account, provider: plaid_account)
assert @account.linked?
end
test "linked? returns false when account has no providers" do
assert @account.unlinked?
end
test "providers returns all provider adapters" do
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: @account, provider: plaid_account)
providers = @account.providers
assert_equal 1, providers.count
assert_kind_of Provider::PlaidAdapter, providers.first
end
test "provider_for returns specific provider adapter" do
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: @account, provider: plaid_account)
adapter = @account.provider_for("PlaidAccount")
assert_kind_of Provider::PlaidAdapter, adapter
end
test "linked_to? checks if account is linked to specific provider type" do
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: @account, provider: plaid_account)
assert @account.linked_to?("PlaidAccount")
refute @account.linked_to?("SimplefinAccount")
end
test "can_delete_holdings? returns true for unlinked accounts" do
assert @account.unlinked?
assert @account.can_delete_holdings?
end
test "can_delete_holdings? returns false when any provider disallows deletion" do
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: @account, provider: plaid_account)
# PlaidAdapter.can_delete_holdings? returns false by default
refute @account.can_delete_holdings?
end
test "can_delete_holdings? returns true only when all providers allow deletion" do
plaid_account = plaid_accounts(:one)
AccountProvider.create!(account: @account, provider: plaid_account)
# Stub all providers to return true
@account.providers.each do |provider|
provider.stubs(:can_delete_holdings?).returns(true)
end
assert @account.can_delete_holdings?
end
end

View File

@@ -0,0 +1,569 @@
require "test_helper"
class Account::ProviderImportAdapterTest < ActiveSupport::TestCase
setup do
@account = accounts(:depository)
@adapter = Account::ProviderImportAdapter.new(@account)
@family = families(:dylan_family)
end
test "imports transaction with all parameters" do
category = categories(:income)
merchant = ProviderMerchant.create!(
provider_merchant_id: "test_merchant_123",
name: "Test Merchant",
source: "plaid"
)
assert_difference "@account.entries.count", 1 do
entry = @adapter.import_transaction(
external_id: "plaid_test_123",
amount: 100.50,
currency: "USD",
date: Date.today,
name: "Test Transaction",
source: "plaid",
category_id: category.id,
merchant: merchant
)
assert_equal 100.50, entry.amount
assert_equal "USD", entry.currency
assert_equal Date.today, entry.date
assert_equal "Test Transaction", entry.name
assert_equal category.id, entry.transaction.category_id
assert_equal merchant.id, entry.transaction.merchant_id
end
end
test "imports transaction with minimal parameters" do
assert_difference "@account.entries.count", 1 do
entry = @adapter.import_transaction(
external_id: "simplefin_abc",
amount: 50.00,
currency: "USD",
date: Date.today,
name: "Simple Transaction",
source: "simplefin"
)
assert_equal 50.00, entry.amount
assert_equal "simplefin_abc", entry.external_id
assert_equal "simplefin", entry.source
assert_nil entry.transaction.category_id
assert_nil entry.transaction.merchant_id
end
end
test "updates existing transaction instead of creating duplicate" do
# Create initial transaction
entry = @adapter.import_transaction(
external_id: "plaid_duplicate_test",
amount: 100.00,
currency: "USD",
date: Date.today,
name: "Original Name",
source: "plaid"
)
# Import again with different data - should update, not create new
assert_no_difference "@account.entries.count" do
updated_entry = @adapter.import_transaction(
external_id: "plaid_duplicate_test",
amount: 200.00,
currency: "USD",
date: Date.today,
name: "Updated Name",
source: "plaid"
)
assert_equal entry.id, updated_entry.id
assert_equal 200.00, updated_entry.amount
assert_equal "Updated Name", updated_entry.name
end
end
test "allows same external_id from different sources without collision" do
# Create transaction from SimpleFin with ID "transaction_123"
simplefin_entry = @adapter.import_transaction(
external_id: "transaction_123",
amount: 100.00,
currency: "USD",
date: Date.today,
name: "SimpleFin Transaction",
source: "simplefin"
)
# Create transaction from Plaid with same ID "transaction_123" - should NOT collide
# because external_id is unique per (account, source) combination
assert_difference "@account.entries.count", 1 do
plaid_entry = @adapter.import_transaction(
external_id: "transaction_123",
amount: 200.00,
currency: "USD",
date: Date.today,
name: "Plaid Transaction",
source: "plaid"
)
# Should be different entries
assert_not_equal simplefin_entry.id, plaid_entry.id
assert_equal "simplefin", simplefin_entry.source
assert_equal "plaid", plaid_entry.source
assert_equal "transaction_123", simplefin_entry.external_id
assert_equal "transaction_123", plaid_entry.external_id
end
end
test "raises error when external_id is missing" do
exception = assert_raises(ArgumentError) do
@adapter.import_transaction(
external_id: "",
amount: 100.00,
currency: "USD",
date: Date.today,
name: "Test",
source: "plaid"
)
end
assert_equal "external_id is required", exception.message
end
test "raises error when source is missing" do
exception = assert_raises(ArgumentError) do
@adapter.import_transaction(
external_id: "test_123",
amount: 100.00,
currency: "USD",
date: Date.today,
name: "Test",
source: ""
)
end
assert_equal "source is required", exception.message
end
test "finds or creates merchant with all data" do
assert_difference "ProviderMerchant.count", 1 do
merchant = @adapter.find_or_create_merchant(
provider_merchant_id: "plaid_merchant_123",
name: "Test Merchant",
source: "plaid",
website_url: "https://example.com",
logo_url: "https://example.com/logo.png"
)
assert_equal "Test Merchant", merchant.name
assert_equal "plaid", merchant.source
assert_equal "plaid_merchant_123", merchant.provider_merchant_id
assert_equal "https://example.com", merchant.website_url
assert_equal "https://example.com/logo.png", merchant.logo_url
end
end
test "returns nil when merchant data is insufficient" do
merchant = @adapter.find_or_create_merchant(
provider_merchant_id: "",
name: "",
source: "plaid"
)
assert_nil merchant
end
test "finds existing merchant instead of creating duplicate" do
existing_merchant = ProviderMerchant.create!(
provider_merchant_id: "existing_123",
name: "Existing Merchant",
source: "plaid"
)
assert_no_difference "ProviderMerchant.count" do
merchant = @adapter.find_or_create_merchant(
provider_merchant_id: "existing_123",
name: "Existing Merchant",
source: "plaid"
)
assert_equal existing_merchant.id, merchant.id
end
end
test "updates account balance" do
@adapter.update_balance(
balance: 5000.00,
cash_balance: 4500.00,
source: "plaid"
)
@account.reload
assert_equal 5000.00, @account.balance
assert_equal 4500.00, @account.cash_balance
end
test "updates account balance without cash_balance" do
@adapter.update_balance(
balance: 3000.00,
source: "simplefin"
)
@account.reload
assert_equal 3000.00, @account.balance
assert_equal 3000.00, @account.cash_balance
end
test "imports holding with all parameters" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
# Use a date that doesn't conflict with fixtures (fixtures use today and 1.day.ago)
holding_date = Date.today - 2.days
assert_difference "investment_account.holdings.count", 1 do
holding = adapter.import_holding(
security: security,
quantity: 10.5,
amount: 1575.00,
currency: "USD",
date: holding_date,
price: 150.00,
source: "plaid"
)
assert_equal security.id, holding.security_id
assert_equal 10.5, holding.qty
assert_equal 1575.00, holding.amount
assert_equal 150.00, holding.price
assert_equal holding_date, holding.date
end
end
test "raises error when security is missing for holding import" do
exception = assert_raises(ArgumentError) do
@adapter.import_holding(
security: nil,
quantity: 10,
amount: 1000,
currency: "USD",
date: Date.today,
source: "plaid"
)
end
assert_equal "security is required", exception.message
end
test "imports trade with all parameters" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
assert_difference "investment_account.entries.count", 1 do
entry = adapter.import_trade(
security: security,
quantity: 5,
price: 150.00,
amount: 750.00,
currency: "USD",
date: Date.today,
source: "plaid"
)
assert_kind_of Trade, entry.entryable
assert_equal 5, entry.entryable.qty
assert_equal 150.00, entry.entryable.price
assert_equal 750.00, entry.amount
assert_match(/Buy.*5.*shares/i, entry.name)
end
end
test "raises error when security is missing for trade import" do
exception = assert_raises(ArgumentError) do
@adapter.import_trade(
security: nil,
quantity: 5,
price: 100,
amount: 500,
currency: "USD",
date: Date.today,
source: "plaid"
)
end
assert_equal "security is required", exception.message
end
test "stores account_provider_id when importing holding" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
account_provider = AccountProvider.create!(
account: investment_account,
provider: plaid_accounts(:one)
)
holding = adapter.import_holding(
security: security,
quantity: 10,
amount: 1500,
currency: "USD",
date: Date.today,
price: 150,
source: "plaid",
account_provider_id: account_provider.id
)
assert_equal account_provider.id, holding.account_provider_id
end
test "does not delete future holdings when can_delete_holdings? returns false" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
# Create a future holding
future_holding = investment_account.holdings.create!(
security: security,
qty: 5,
amount: 750,
currency: "USD",
date: Date.today + 1.day,
price: 150
)
# Mock can_delete_holdings? to return false
investment_account.expects(:can_delete_holdings?).returns(false)
# Import a holding with delete_future_holdings flag
adapter.import_holding(
security: security,
quantity: 10,
amount: 1500,
currency: "USD",
date: Date.today,
price: 150,
source: "plaid",
delete_future_holdings: true
)
# Future holding should still exist
assert Holding.exists?(future_holding.id)
end
test "deletes only holdings from same provider when account_provider_id is provided" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
# Create an account provider
plaid_account = PlaidAccount.create!(
current_balance: 1000,
available_balance: 1000,
currency: "USD",
name: "Test Plaid Account",
plaid_item: plaid_items(:one),
plaid_id: "acc_mock_test_1",
plaid_type: "investment",
plaid_subtype: "brokerage"
)
provider = AccountProvider.create!(
account: investment_account,
provider: plaid_account
)
# Create future holdings - one from the provider, one without a provider
future_holding_with_provider = investment_account.holdings.create!(
security: security,
qty: 5,
amount: 750,
currency: "USD",
date: Date.today + 1.day,
price: 150,
account_provider_id: provider.id
)
future_holding_without_provider = investment_account.holdings.create!(
security: security,
qty: 3,
amount: 450,
currency: "USD",
date: Date.today + 2.days,
price: 150,
account_provider_id: nil
)
# Mock can_delete_holdings? to return true
investment_account.expects(:can_delete_holdings?).returns(true)
# Import a holding with provider ID and delete_future_holdings flag
adapter.import_holding(
security: security,
quantity: 10,
amount: 1500,
currency: "USD",
date: Date.today,
price: 150,
source: "plaid",
account_provider_id: provider.id,
delete_future_holdings: true
)
# Only the holding from the same provider should be deleted
assert_not Holding.exists?(future_holding_with_provider.id)
assert Holding.exists?(future_holding_without_provider.id)
end
test "deletes all future holdings when account_provider_id is not provided and can_delete_holdings? returns true" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
# Create two future holdings
future_holding_1 = investment_account.holdings.create!(
security: security,
qty: 5,
amount: 750,
currency: "USD",
date: Date.today + 1.day,
price: 150
)
future_holding_2 = investment_account.holdings.create!(
security: security,
qty: 3,
amount: 450,
currency: "USD",
date: Date.today + 2.days,
price: 150
)
# Mock can_delete_holdings? to return true
investment_account.expects(:can_delete_holdings?).returns(true)
# Import a holding without account_provider_id
adapter.import_holding(
security: security,
quantity: 10,
amount: 1500,
currency: "USD",
date: Date.today,
price: 150,
source: "plaid",
delete_future_holdings: true
)
# All future holdings should be deleted
assert_not Holding.exists?(future_holding_1.id)
assert_not Holding.exists?(future_holding_2.id)
end
test "updates existing trade attributes instead of keeping stale data" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
aapl = securities(:aapl)
msft = securities(:msft)
# Create initial trade
entry = adapter.import_trade(
external_id: "plaid_trade_123",
security: aapl,
quantity: 5,
price: 150.00,
amount: 750.00,
currency: "USD",
date: Date.today,
source: "plaid"
)
# Import again with updated attributes - should update Trade, not keep stale data
assert_no_difference "investment_account.entries.count" do
updated_entry = adapter.import_trade(
external_id: "plaid_trade_123",
security: msft,
quantity: 10,
price: 200.00,
amount: 2000.00,
currency: "USD",
date: Date.today,
source: "plaid"
)
assert_equal entry.id, updated_entry.id
# Trade attributes should be updated
assert_equal msft.id, updated_entry.entryable.security_id
assert_equal 10, updated_entry.entryable.qty
assert_equal 200.00, updated_entry.entryable.price
assert_equal "USD", updated_entry.entryable.currency
# Entry attributes should also be updated
assert_equal 2000.00, updated_entry.amount
end
end
test "raises error when external_id collision occurs across different entryable types for transaction" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
# Create a trade with external_id "collision_test"
adapter.import_trade(
external_id: "collision_test",
security: security,
quantity: 5,
price: 150.00,
amount: 750.00,
currency: "USD",
date: Date.today,
source: "plaid"
)
# Try to create a transaction with the same external_id and source
exception = assert_raises(ArgumentError) do
adapter.import_transaction(
external_id: "collision_test",
amount: 100.00,
currency: "USD",
date: Date.today,
name: "Test Transaction",
source: "plaid"
)
end
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
end
test "raises error when external_id collision occurs across different entryable types for trade" do
investment_account = accounts(:investment)
adapter = Account::ProviderImportAdapter.new(investment_account)
security = securities(:aapl)
# Create a transaction with external_id "collision_test_2"
adapter.import_transaction(
external_id: "collision_test_2",
amount: 100.00,
currency: "USD",
date: Date.today,
name: "Test Transaction",
source: "plaid"
)
# Try to create a trade with the same external_id and source
exception = assert_raises(ArgumentError) do
adapter.import_trade(
external_id: "collision_test_2",
security: security,
quantity: 5,
price: 150.00,
amount: 750.00,
currency: "USD",
date: Date.today,
source: "plaid"
)
end
assert_match(/Entry with external_id.*already exists with different entryable type/i, exception.message)
end
end

View File

@@ -0,0 +1,132 @@
require "test_helper"
class AccountProviderTest < ActiveSupport::TestCase
setup do
@account = accounts(:depository)
@family = families(:dylan_family)
# Create provider items
@plaid_item = PlaidItem.create!(
family: @family,
plaid_id: "test_plaid_item",
access_token: "test_token",
name: "Test Bank"
)
@simplefin_item = SimplefinItem.create!(
family: @family,
name: "Test SimpleFin Bank",
access_url: "https://example.com/access"
)
# Create provider accounts
@plaid_account = PlaidAccount.create!(
plaid_item: @plaid_item,
name: "Plaid Checking",
plaid_id: "plaid_123",
plaid_type: "depository",
currency: "USD",
current_balance: 1000
)
@simplefin_account = SimplefinAccount.create!(
simplefin_item: @simplefin_item,
name: "SimpleFin Checking",
account_id: "sf_123",
account_type: "checking",
currency: "USD",
current_balance: 2000
)
end
test "allows an account to have multiple different provider types" do
# Should be able to link both Plaid and SimpleFin to same account
plaid_provider = AccountProvider.create!(
account: @account,
provider: @plaid_account
)
simplefin_provider = AccountProvider.create!(
account: @account,
provider: @simplefin_account
)
assert_equal 2, @account.account_providers.count
assert_includes @account.account_providers, plaid_provider
assert_includes @account.account_providers, simplefin_provider
end
test "prevents duplicate provider type for same account" do
# Create first PlaidAccount link
AccountProvider.create!(
account: @account,
provider: @plaid_account
)
# Create another PlaidAccount
another_plaid_account = PlaidAccount.create!(
plaid_item: @plaid_item,
name: "Another Plaid Account",
plaid_id: "plaid_456",
plaid_type: "savings",
currency: "USD",
current_balance: 5000
)
# Should not be able to link another PlaidAccount to same account
duplicate_provider = AccountProvider.new(
account: @account,
provider: another_plaid_account
)
assert_not duplicate_provider.valid?
assert_includes duplicate_provider.errors[:account_id], "has already been taken"
end
test "prevents same provider account from linking to multiple accounts" do
# Link provider to first account
AccountProvider.create!(
account: @account,
provider: @plaid_account
)
# Try to link same provider to another account
another_account = accounts(:investment)
duplicate_link = AccountProvider.new(
account: another_account,
provider: @plaid_account
)
assert_not duplicate_link.valid?
assert_includes duplicate_link.errors[:provider_id], "has already been taken"
end
test "adapter method returns correct adapter" do
provider = AccountProvider.create!(
account: @account,
provider: @plaid_account
)
adapter = provider.adapter
assert_kind_of Provider::PlaidAdapter, adapter
assert_equal "plaid", adapter.provider_name
assert_equal @account, adapter.account
end
test "provider_name delegates to adapter" do
plaid_provider = AccountProvider.create!(
account: @account,
provider: @plaid_account
)
simplefin_provider = AccountProvider.create!(
account: accounts(:investment),
provider: @simplefin_account
)
assert_equal "plaid", plaid_provider.provider_name
assert_equal "simplefin", simplefin_provider.provider_name
end
end

View File

@@ -55,7 +55,7 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
processor.process
end
holdings = Holding.where(account: @plaid_account.account).order(:date)
holdings = Holding.where(account: @plaid_account.current_account).order(:date)
assert_equal 100, holdings.first.qty
assert_equal 100, holdings.first.price
@@ -70,31 +70,36 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
assert_equal Date.current, holdings.second.date
end
# When Plaid provides holdings data, it includes an "institution_price_as_of" date
# which represents when the holdings were last updated. Any holdings in our database
# after this date are now stale and should be deleted, as the Plaid data is the
# authoritative source of truth for the current holdings.
test "deletes stale holdings per security based on institution price date" do
account = @plaid_account.account
# Plaid does not delete future holdings because it doesn't support holdings deletion
# (PlaidAdapter#can_delete_holdings? returns false). This test verifies that future
# holdings are NOT deleted when processing Plaid holdings data.
test "does not delete future holdings when processing Plaid holdings" do
account = @plaid_account.current_account
# Create account_provider
account_provider = AccountProvider.create!(
account: account,
provider: @plaid_account
)
# Create a third security for testing
third_security = Security.create!(ticker: "GOOGL", name: "Google", exchange_operating_mic: "XNAS", country_code: "US")
# Scenario 3: AAPL has a stale holding that should be deleted
stale_aapl_holding = account.holdings.create!(
# Create a future AAPL holding that should NOT be deleted
future_aapl_holding = account.holdings.create!(
security: securities(:aapl),
date: Date.current,
qty: 80,
price: 180,
amount: 14400,
currency: "USD"
currency: "USD",
account_provider_id: account_provider.id
)
# Plaid returns 3 holdings with different scenarios
# Plaid returns holdings from yesterday - future holdings should remain
test_investments_payload = {
securities: [],
holdings: [
# Scenario 1: Current date holding (no deletions needed)
{
"security_id" => "current",
"quantity" => 50,
@@ -102,7 +107,6 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
"iso_currency_code" => "USD",
"institution_price_as_of" => Date.current
},
# Scenario 2: Yesterday's holding with no future holdings
{
"security_id" => "clean",
"quantity" => 75,
@@ -110,9 +114,8 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
"iso_currency_code" => "USD",
"institution_price_as_of" => 1.day.ago.to_date
},
# Scenario 3: Yesterday's holding with stale future holding
{
"security_id" => "stale",
"security_id" => "past",
"quantity" => 100,
"institution_price" => 100,
"iso_currency_code" => "USD",
@@ -134,17 +137,17 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
.returns(OpenStruct.new(security: third_security, cash_equivalent?: false, brokerage_cash?: false))
@security_resolver.expects(:resolve)
.with(plaid_security_id: "stale")
.with(plaid_security_id: "past")
.returns(OpenStruct.new(security: securities(:aapl), cash_equivalent?: false, brokerage_cash?: false))
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
processor.process
# Should have created 3 new holdings
assert_equal 3, account.holdings.count
# Should have created 3 new holdings PLUS the existing future holding (total 4)
assert_equal 4, account.holdings.count
# Scenario 3: Should have deleted the stale AAPL holding
assert_not account.holdings.exists?(stale_aapl_holding.id)
# Future AAPL holding should still exist (NOT deleted)
assert account.holdings.exists?(future_aapl_holding.id)
# Should have the correct holdings from Plaid
assert account.holdings.exists?(security: securities(:msft), date: Date.current, qty: 50)
@@ -192,6 +195,134 @@ class PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
end
# Should have created the successful holding
assert @plaid_account.account.holdings.exists?(security: securities(:aapl), qty: 200)
assert @plaid_account.current_account.holdings.exists?(security: securities(:aapl), qty: 200)
end
test "handles string values and computes amount using BigDecimal arithmetic" do
test_investments_payload = {
securities: [],
holdings: [
{
"security_id" => "string_values",
"quantity" => "10.5",
"institution_price" => "150.75",
"iso_currency_code" => "USD",
"institution_price_as_of" => "2025-01-15"
}
],
transactions: []
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve)
.with(plaid_security_id: "string_values")
.returns(OpenStruct.new(security: securities(:aapl)))
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference "Holding.count", 1 do
processor.process
end
holding = @plaid_account.current_account.holdings.find_by(
security: securities(:aapl),
date: Date.parse("2025-01-15"),
currency: "USD"
)
assert_not_nil holding, "Expected to find holding for AAPL on 2025-01-15"
assert_equal BigDecimal("10.5"), holding.qty
assert_equal BigDecimal("150.75"), holding.price
assert_equal BigDecimal("1582.875"), holding.amount # 10.5 * 150.75 using BigDecimal
assert_equal Date.parse("2025-01-15"), holding.date
end
test "skips holdings with nil quantity or price" do
test_investments_payload = {
securities: [],
holdings: [
{
"security_id" => "missing_quantity",
"quantity" => nil,
"institution_price" => 100,
"iso_currency_code" => "USD"
},
{
"security_id" => "missing_price",
"quantity" => 100,
"institution_price" => nil,
"iso_currency_code" => "USD"
},
{
"security_id" => "valid",
"quantity" => 50,
"institution_price" => 50,
"iso_currency_code" => "USD"
}
],
transactions: []
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve)
.with(plaid_security_id: "missing_quantity")
.returns(OpenStruct.new(security: securities(:aapl)))
@security_resolver.expects(:resolve)
.with(plaid_security_id: "missing_price")
.returns(OpenStruct.new(security: securities(:msft)))
@security_resolver.expects(:resolve)
.with(plaid_security_id: "valid")
.returns(OpenStruct.new(security: securities(:aapl)))
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
# Should create only 1 holding (the valid one)
assert_difference "Holding.count", 1 do
processor.process
end
# Should have created only the valid holding
assert @plaid_account.current_account.holdings.exists?(security: securities(:aapl), qty: 50, price: 50)
assert_not @plaid_account.current_account.holdings.exists?(security: securities(:msft))
end
test "uses account currency as fallback when Plaid omits iso_currency_code" do
account = @plaid_account.current_account
# Ensure the account has a currency
account.update!(currency: "EUR")
test_investments_payload = {
securities: [],
holdings: [
{
"security_id" => "no_currency",
"quantity" => 100,
"institution_price" => 100,
"iso_currency_code" => nil, # Plaid omits currency
"institution_price_as_of" => Date.current
}
],
transactions: []
}
@plaid_account.update!(raw_investments_payload: test_investments_payload)
@security_resolver.expects(:resolve)
.with(plaid_security_id: "no_currency")
.returns(OpenStruct.new(security: securities(:aapl)))
processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)
assert_difference "Holding.count", 1 do
processor.process
end
holding = account.holdings.find_by(security: securities(:aapl))
assert_equal "EUR", holding.currency # Should use account's currency
end
end

View File

@@ -10,7 +10,7 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"investment_transaction_id" => "123",
"security_id" => "123",
"type" => "buy",
"quantity" => 1, # Positive, so "buy 1 share"
@@ -47,7 +47,7 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"investment_transaction_id" => "cash_123",
"type" => "cash",
"subtype" => "withdrawal",
"amount" => 100, # Positive, so moving money OUT of the account
@@ -80,7 +80,7 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"investment_transaction_id" => "fee_123",
"type" => "fee",
"subtype" => "miscellaneous fee",
"amount" => 10.25,
@@ -113,7 +113,8 @@ class PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::Test
test_investments_payload = {
transactions: [
{
"transaction_id" => "123",
"investment_transaction_id" => "123",
"security_id" => "123",
"type" => "sell", # Correct type
"subtype" => "sell", # Correct subtype
"quantity" => 1, # ***Incorrect signage***, this should be negative

View File

@@ -8,7 +8,7 @@ class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
plaid_subtype: "credit_card"
)
@plaid_account.account.update!(
@plaid_account.current_account.update!(
accountable: CreditCard.new,
)
end
@@ -24,8 +24,8 @@ class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
processor.process
assert_equal 100, @plaid_account.account.credit_card.minimum_payment
assert_equal 15.0, @plaid_account.account.credit_card.apr
assert_equal 100, @plaid_account.current_account.credit_card.minimum_payment
assert_equal 15.0, @plaid_account.current_account.credit_card.apr
end
test "does nothing when liability data absent" do
@@ -33,7 +33,7 @@ class PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase
processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)
processor.process
assert_nil @plaid_account.account.credit_card.minimum_payment
assert_nil @plaid_account.account.credit_card.apr
assert_nil @plaid_account.current_account.credit_card.minimum_payment
assert_nil @plaid_account.current_account.credit_card.apr
end
end

View File

@@ -8,7 +8,7 @@ class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
plaid_subtype: "mortgage"
)
@plaid_account.account.update!(accountable: Loan.new)
@plaid_account.current_account.update!(accountable: Loan.new)
end
test "updates loan interest rate and type from Plaid data" do
@@ -24,7 +24,7 @@ class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
loan = @plaid_account.current_account.loan
assert_equal "fixed", loan.rate_type
assert_equal 4.25, loan.interest_rate
@@ -36,7 +36,7 @@ class PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase
processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
loan = @plaid_account.current_account.loan
assert_nil loan.rate_type
assert_nil loan.interest_rate

View File

@@ -9,7 +9,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
)
# Change the underlying accountable to a Loan so the helper method `loan` is available
@plaid_account.account.update!(accountable: Loan.new)
@plaid_account.current_account.update!(accountable: Loan.new)
end
test "updates loan details including term months from Plaid data" do
@@ -25,7 +25,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
loan = @plaid_account.current_account.loan
assert_equal "fixed", loan.rate_type
assert_equal 5.5, loan.interest_rate
@@ -46,7 +46,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
loan = @plaid_account.current_account.loan
assert_nil loan.term_months
assert_equal 4.8, loan.interest_rate
@@ -59,7 +59,7 @@ class PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestC
processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)
processor.process
loan = @plaid_account.account.loan
loan = @plaid_account.current_account.loan
assert_nil loan.interest_rate
assert_nil loan.initial_balance

View File

@@ -29,31 +29,36 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
account = Account.order(created_at: :desc).first
assert_equal "Test Plaid Account", account.name
assert_equal @plaid_account.id, account.plaid_account_id
assert_equal "checking", account.subtype
assert_equal 1000, account.balance
assert_equal 1000, account.cash_balance
assert_equal "USD", account.currency
assert_equal "Depository", account.accountable_type
assert_equal "checking", account.subtype
# Verify AccountProvider was created
assert account.linked?
assert_equal 1, account.account_providers.count
assert_equal @plaid_account.id, account.account_providers.first.provider_id
assert_equal "PlaidAccount", account.account_providers.first.provider_type
end
test "processing is idempotent with updates and enrichments" do
expect_default_subprocessor_calls
assert_equal "Plaid Depository Account", @plaid_account.account.name
assert_equal "checking", @plaid_account.account.subtype
assert_equal "Plaid Depository Account", @plaid_account.current_account.name
assert_equal "checking", @plaid_account.current_account.subtype
@plaid_account.account.update!(
@plaid_account.current_account.update!(
name: "User updated name",
balance: 2000 # User cannot override balance. This will be overridden by the processor on next processing
)
@plaid_account.account.accountable.update!(subtype: "savings")
@plaid_account.current_account.accountable.update!(subtype: "savings")
@plaid_account.account.lock_attr!(:name)
@plaid_account.account.accountable.lock_attr!(:subtype)
@plaid_account.account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
@plaid_account.current_account.lock_attr!(:name)
@plaid_account.current_account.accountable.lock_attr!(:subtype)
@plaid_account.current_account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
assert_no_difference "Account.count" do
PlaidAccount::Processor.new(@plaid_account).process
@@ -61,9 +66,9 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
@plaid_account.reload
assert_equal "User updated name", @plaid_account.account.name
assert_equal "savings", @plaid_account.account.subtype
assert_equal @plaid_account.current_balance, @plaid_account.account.balance # Overriden by processor
assert_equal "User updated name", @plaid_account.current_account.name
assert_equal "savings", @plaid_account.current_account.subtype
assert_equal @plaid_account.current_balance, @plaid_account.current_account.balance # Overriden by processor
end
test "account processing failure halts further processing" do
@@ -102,7 +107,7 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
PlaidAccount::Processor.new(@plaid_account).process
# Verify that the balance was set correctly
account = @plaid_account.account
account = @plaid_account.current_account
assert_equal 1000, account.balance
assert_equal 1000, account.cash_balance
@@ -196,7 +201,7 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
expect_default_subprocessor_calls
PlaidAccount::Processor.new(@plaid_account).process
account = @plaid_account.account
account = @plaid_account.current_account
original_anchor = account.valuations.current_anchor.first
assert_not_nil original_anchor
original_anchor_id = original_anchor.id

View File

@@ -37,7 +37,7 @@ class PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
test "removes transactions no longer in plaid" do
destroyable_transaction_id = "destroy_me"
@plaid_account.account.entries.create!(
@plaid_account.current_account.entries.create!(
plaid_id: destroyable_transaction_id,
date: Date.current,
amount: 100,

View File

@@ -61,8 +61,9 @@ class PlaidEntry::ProcessorTest < ActiveSupport::TestCase
@category_matcher.expects(:match).with("Food").returns(categories(:food_and_drink))
# Create an existing entry
@plaid_account.account.entries.create!(
plaid_id: existing_plaid_id,
@plaid_account.current_account.entries.create!(
external_id: existing_plaid_id,
source: "plaid",
amount: 100,
currency: "USD",
date: Date.current,

View File

@@ -0,0 +1,41 @@
require "test_helper"
class Provider::PlaidAdapterTest < ActiveSupport::TestCase
include ProviderAdapterTestInterface
setup do
@plaid_account = plaid_accounts(:one)
@account = accounts(:depository)
@adapter = Provider::PlaidAdapter.new(@plaid_account, account: @account)
end
def adapter
@adapter
end
# Run shared interface tests
test_provider_adapter_interface
test_syncable_interface
test_institution_metadata_interface
# Provider-specific tests
test "returns correct provider name" do
assert_equal "plaid", @adapter.provider_name
end
test "returns correct provider type" do
assert_equal "PlaidAccount", @adapter.provider_type
end
test "returns plaid item" do
assert_equal @plaid_account.plaid_item, @adapter.item
end
test "returns account" do
assert_equal @account, @adapter.account
end
test "can_delete_holdings? returns false" do
assert_equal false, @adapter.can_delete_holdings?
end
end

View File

@@ -44,19 +44,22 @@ class Provider::RegistryTest < ActiveSupport::TestCase
end
test "openai provider falls back to Setting when ENV is empty string" do
# Simulate ENV being set to empty string (common in Docker/env files)
ENV.stubs(:[]).with("OPENAI_ACCESS_TOKEN").returns("")
ENV.stubs(:[]).with("OPENAI_URI_BASE").returns("")
ENV.stubs(:[]).with("OPENAI_MODEL").returns("")
# Mock ENV to return empty string (common in Docker/env files)
# Use stub_env helper which properly stubs ENV access
ClimateControl.modify(
"OPENAI_ACCESS_TOKEN" => "",
"OPENAI_URI_BASE" => "",
"OPENAI_MODEL" => ""
) do
Setting.stubs(:openai_access_token).returns("test-token-from-setting")
Setting.stubs(:openai_uri_base).returns(nil)
Setting.stubs(:openai_model).returns(nil)
Setting.stubs(:openai_access_token).returns("test-token-from-setting")
Setting.stubs(:openai_uri_base).returns(nil)
Setting.stubs(:openai_model).returns(nil)
provider = Provider::Registry.get_provider(:openai)
provider = Provider::Registry.get_provider(:openai)
# Should successfully create provider using Setting value
assert_not_nil provider
assert_instance_of Provider::Openai, provider
# Should successfully create provider using Setting value
assert_not_nil provider
assert_instance_of Provider::Openai, provider
end
end
end

View File

@@ -0,0 +1,72 @@
require "test_helper"
class Provider::SimplefinAdapterTest < ActiveSupport::TestCase
include ProviderAdapterTestInterface
setup do
@family = families(:dylan_family)
@simplefin_item = SimplefinItem.create!(
family: @family,
name: "Test SimpleFin Bank",
access_url: "https://example.com/access_token"
)
@simplefin_account = SimplefinAccount.create!(
simplefin_item: @simplefin_item,
name: "SimpleFin Depository Account",
account_id: "sf_mock_1",
account_type: "checking",
currency: "USD",
current_balance: 1000,
available_balance: 1000,
org_data: {
"name" => "SimpleFin Test Bank",
"domain" => "testbank.com",
"url" => "https://testbank.com"
}
)
@account = accounts(:depository)
@adapter = Provider::SimplefinAdapter.new(@simplefin_account, account: @account)
end
def adapter
@adapter
end
# Run shared interface tests
test_provider_adapter_interface
test_syncable_interface
test_institution_metadata_interface
# Provider-specific tests
test "returns correct provider name" do
assert_equal "simplefin", @adapter.provider_name
end
test "returns correct provider type" do
assert_equal "SimplefinAccount", @adapter.provider_type
end
test "returns simplefin item" do
assert_equal @simplefin_account.simplefin_item, @adapter.item
end
test "returns account" do
assert_equal @account, @adapter.account
end
test "can_delete_holdings? returns false" do
assert_equal false, @adapter.can_delete_holdings?
end
test "parses institution domain from org_data" do
assert_equal "testbank.com", @adapter.institution_domain
end
test "parses institution name from org_data" do
assert_equal "SimpleFin Test Bank", @adapter.institution_name
end
test "parses institution url from org_data" do
assert_equal "https://testbank.com", @adapter.institution_url
end
end

View File

@@ -0,0 +1,140 @@
# Shared test interface for all provider adapters
# Include this module in your provider adapter test to ensure it implements the required interface
#
# Usage:
# class Provider::AcmeAdapterTest < ActiveSupport::TestCase
# include ProviderAdapterTestInterface
#
# setup do
# @adapter = Provider::AcmeAdapter.new(...)
# end
#
# def adapter
# @adapter
# end
#
# test_provider_adapter_interface
# end
module ProviderAdapterTestInterface
extend ActiveSupport::Concern
class_methods do
# Tests the core provider adapter interface
# Call this method in your test class to run all interface tests
def test_provider_adapter_interface
test "adapter implements provider_name" do
assert_respond_to adapter, :provider_name
assert_kind_of String, adapter.provider_name
assert adapter.provider_name.present?, "provider_name should not be blank"
end
test "adapter implements provider_type" do
assert_respond_to adapter, :provider_type
assert_kind_of String, adapter.provider_type
assert adapter.provider_type.present?, "provider_type should not be blank"
end
test "adapter implements can_delete_holdings?" do
assert_respond_to adapter, :can_delete_holdings?
assert_includes [ true, false ], adapter.can_delete_holdings?
end
test "adapter implements metadata" do
assert_respond_to adapter, :metadata
metadata = adapter.metadata
assert_kind_of Hash, metadata
assert_includes metadata.keys, :provider_name
assert_includes metadata.keys, :provider_type
assert_equal adapter.provider_name, metadata[:provider_name]
assert_equal adapter.provider_type, metadata[:provider_type]
end
test "adapter implements raw_payload" do
assert_respond_to adapter, :raw_payload
# raw_payload can be nil or a Hash
assert adapter.raw_payload.nil? || adapter.raw_payload.is_a?(Hash)
end
test "adapter is registered with factory" do
provider_type = adapter.provider_type
assert_includes Provider::Factory.registered_provider_types, provider_type,
"#{provider_type} should be registered with Provider::Factory"
end
end
# Tests for adapters that include Provider::Syncable
def test_syncable_interface
test "syncable adapter implements sync_path" do
assert_respond_to adapter, :sync_path
assert_kind_of String, adapter.sync_path
assert adapter.sync_path.present?, "sync_path should not be blank"
end
test "syncable adapter implements item" do
assert_respond_to adapter, :item
assert_not_nil adapter.item, "item should not be nil for syncable providers"
end
test "syncable adapter implements syncing?" do
assert_respond_to adapter, :syncing?
assert_includes [ true, false ], adapter.syncing?
end
test "syncable adapter implements status" do
assert_respond_to adapter, :status
# status can be nil or a String
assert adapter.status.nil? || adapter.status.is_a?(String)
end
test "syncable adapter implements requires_update?" do
assert_respond_to adapter, :requires_update?
assert_includes [ true, false ], adapter.requires_update?
end
end
# Tests for adapters that include Provider::InstitutionMetadata
def test_institution_metadata_interface
test "institution metadata adapter implements institution_domain" do
assert_respond_to adapter, :institution_domain
# Can be nil or String
assert adapter.institution_domain.nil? || adapter.institution_domain.is_a?(String)
end
test "institution metadata adapter implements institution_name" do
assert_respond_to adapter, :institution_name
# Can be nil or String
assert adapter.institution_name.nil? || adapter.institution_name.is_a?(String)
end
test "institution metadata adapter implements institution_url" do
assert_respond_to adapter, :institution_url
# Can be nil or String
assert adapter.institution_url.nil? || adapter.institution_url.is_a?(String)
end
test "institution metadata adapter implements institution_color" do
assert_respond_to adapter, :institution_color
# Can be nil or String
assert adapter.institution_color.nil? || adapter.institution_color.is_a?(String)
end
test "institution metadata adapter implements institution_metadata" do
assert_respond_to adapter, :institution_metadata
metadata = adapter.institution_metadata
assert_kind_of Hash, metadata
# Metadata should only contain non-nil values
metadata.each do |key, value|
assert_not_nil value, "#{key} in institution_metadata should not be nil (it should be omitted instead)"
end
end
end
end
# Override this method in your test to provide the adapter instance
def adapter
raise NotImplementedError, "Test must implement #adapter method"
end
end