Files
sure/test/models/lunchflow_item/importer_blank_id_test.rb
AdamWHY2K 3f5fff27ea feat: process pending transactions from lunchflow (#731)
* feat(config): add Lunchflow runtime configuration flags

* feat(api): add include_pending parameter to Lunchflow API

* feat(processor): add pending metadata support to Lunchflow processor

* feat(processor): generate temporary IDs for pending transactions

* feat(importer): integrate pending transaction support in sync

* fix(importer): improve deduplication for transactions without IDs

* feat(model): add Lunchflow pending support to Transaction scopes

* test: add Lunchflow processor pending metadata tests

* docs: update AGENTS.md for Lunchflow pending support

* chore: remove unused variable

* fix: simplify key check

* fix: dotenv-linter key order

* fix: avoid collapsing distinct pending transactions

* fix: prevent unbounded raw payload growth for blank IDs
2026-01-23 00:53:24 +01:00

219 lines
7.6 KiB
Ruby

require "test_helper"
class LunchflowItem::ImporterBlankIdTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = LunchflowItem.create!(
family: @family,
name: "Test Lunchflow",
api_key: "test_key_123",
status: :good
)
@lunchflow_account = @item.lunchflow_accounts.create!(
account_id: "test_account_123",
name: "Test Account",
currency: "GBP"
)
@sure_account = @family.accounts.create!(
name: "Test Account",
balance: 1000,
currency: "GBP",
accountable: Depository.new(subtype: "checking")
)
AccountProvider.create!(
account: @sure_account,
provider: @lunchflow_account
)
@lunchflow_account.reload
end
test "prevents unbounded growth when same blank-ID transaction synced multiple times" do
# Simulate a pending transaction with blank ID that Lunchflow keeps returning
pending_transaction = {
"id" => "",
"accountId" => @lunchflow_account.account_id,
"amount" => -15.50,
"currency" => "GBP",
"date" => Date.today.to_s,
"merchant" => "UBER",
"description" => "Ride to office",
"isPending" => true
}
# Mock the API to return this transaction
mock_provider = mock()
mock_provider.stubs(:get_account_transactions)
.with(@lunchflow_account.account_id, anything)
.returns({
transactions: [ pending_transaction ],
count: 1
})
mock_provider.stubs(:get_account_balance)
.with(@lunchflow_account.account_id)
.returns({ balance: 100.0, currency: "GBP" })
# First sync - should store the transaction
importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider)
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
first_payload_size = @lunchflow_account.reload.raw_transactions_payload.size
assert_equal 1, first_payload_size, "First sync should store 1 transaction"
# Second sync - same transaction returned again
# Without fix: would append again (size = 2)
# With fix: should detect duplicate via content hash (size = 1)
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
second_payload_size = @lunchflow_account.reload.raw_transactions_payload.size
assert_equal 1, second_payload_size, "Second sync should NOT append duplicate blank-ID transaction"
# Third sync - verify it stays at 1
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
third_payload_size = @lunchflow_account.reload.raw_transactions_payload.size
assert_equal 1, third_payload_size, "Third sync should NOT append duplicate blank-ID transaction"
end
test "allows multiple DISTINCT blank-ID transactions to be stored" do
# Two different pending transactions, both with blank IDs
transaction1 = {
"id" => "",
"accountId" => @lunchflow_account.account_id,
"amount" => -10.00,
"currency" => "GBP",
"date" => Date.today.to_s,
"merchant" => "UBER",
"description" => "Morning ride",
"isPending" => true
}
transaction2 = {
"id" => "",
"accountId" => @lunchflow_account.account_id,
"amount" => -15.50,
"currency" => "GBP",
"date" => Date.today.to_s,
"merchant" => "UBER",
"description" => "Evening ride", # Different description
"isPending" => true
}
# First sync with transaction1
mock_provider = mock()
mock_provider.stubs(:get_account_transactions)
.with(@lunchflow_account.account_id, anything)
.returns({
transactions: [ transaction1 ],
count: 1
})
mock_provider.stubs(:get_account_balance)
.with(@lunchflow_account.account_id)
.returns({ balance: 100.0, currency: "GBP" })
importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider)
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
assert_equal 1, @lunchflow_account.reload.raw_transactions_payload.size
# Second sync with BOTH transactions
mock_provider.stubs(:get_account_transactions)
.with(@lunchflow_account.account_id, anything)
.returns({
transactions: [ transaction1, transaction2 ],
count: 2
})
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
payload = @lunchflow_account.reload.raw_transactions_payload
assert_equal 2, payload.size, "Should store both distinct transactions despite blank IDs"
# Verify both transactions are different
descriptions = payload.map { |tx| tx.with_indifferent_access[:description] }
assert_includes descriptions, "Morning ride"
assert_includes descriptions, "Evening ride"
end
test "content hash uses same attributes as processor for consistency" do
pending_transaction = {
"id" => "",
"accountId" => @lunchflow_account.account_id,
"amount" => -10.00,
"currency" => "GBP",
"date" => Date.today.to_s,
"merchant" => "TEST",
"description" => "Test transaction",
"isPending" => true
}
# Store the transaction
@lunchflow_account.upsert_lunchflow_transactions_snapshot!([ pending_transaction ])
# Process it through the processor to generate external_id
processor = LunchflowEntry::Processor.new(
pending_transaction.with_indifferent_access,
lunchflow_account: @lunchflow_account
)
processor.process
# Check that the entry was created
entry = @sure_account.entries.last
assert entry.present?
assert entry.external_id.start_with?("lunchflow_pending_")
# Extract the hash from the external_id (remove prefix and any collision suffix)
external_id_hash = entry.external_id.sub("lunchflow_pending_", "").split("_").first
# Generate content hash using importer method
mock_provider = mock()
importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider)
content_hash = importer.send(:content_hash_for_transaction, pending_transaction.with_indifferent_access)
# They should match (processor adds prefix, importer is just the hash)
assert_equal content_hash, external_id_hash, "Importer content hash should match processor's MD5 hash"
end
test "transactions with IDs are not affected by content hash logic" do
# Transaction with a proper ID
transaction_with_id = {
"id" => "txn_123",
"accountId" => @lunchflow_account.account_id,
"amount" => -20.00,
"currency" => "GBP",
"date" => Date.today.to_s,
"merchant" => "TESCO",
"description" => "Groceries",
"isPending" => false
}
# Sync twice - should only store once based on ID
mock_provider = mock()
mock_provider.stubs(:get_account_transactions)
.with(@lunchflow_account.account_id, anything)
.returns({
transactions: [ transaction_with_id ],
count: 1
})
mock_provider.stubs(:get_account_balance)
.with(@lunchflow_account.account_id)
.returns({ balance: 100.0, currency: "GBP" })
importer = LunchflowItem::Importer.new(@item, lunchflow_provider: mock_provider)
# First sync
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
assert_equal 1, @lunchflow_account.reload.raw_transactions_payload.size
# Second sync - should not duplicate
result = importer.send(:fetch_and_store_transactions, @lunchflow_account)
assert result[:success]
assert_equal 1, @lunchflow_account.reload.raw_transactions_payload.size
end
end