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

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