Skip to content

Commit 88246ef

Browse files
sgbettclaude
andcommitted
test(wallet-postgres): add shared examples and conformance specs for auto-fund methods (#356)
Extends the storage adapter shared suite with conformance specs for all four auto-fund methods: find_spendable_outputs, update_output_state, lock_utxos, and release_stale_pending!. The examples use timeout: 0 to trigger stale-lock behaviour portably across all adapters (no DB-level time manipulation needed). State values are normalised via .to_s to handle Symbol vs String differences between MemoryStore and serialising adapters. All three adapters (MemoryStore, FileStore, PostgresStore) now exercise the same contract automatically via it_behaves_like. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8e6e456 commit 88246ef

File tree

1 file changed

+205
-0
lines changed

1 file changed

+205
-0
lines changed

spec/support/shared_examples_for_storage_adapter.rb

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,211 @@
395395
end
396396
end
397397

398+
# ---------------------------------------------------------------------------
399+
# Auto-fund interface contract
400+
# These four methods form the UTXO state-management contract. All adapter
401+
# implementations must pass this suite unchanged. State values may be
402+
# returned as Symbols or Strings depending on serialisation (MemoryStore
403+
# uses Symbols; FileStore and PostgresStore use Strings after a round-trip).
404+
# Examples use `.to_s` to normalise before comparing.
405+
# ---------------------------------------------------------------------------
406+
407+
describe 'auto-fund interface' do
408+
describe '#find_spendable_outputs' do
409+
before do
410+
store.store_output(basket: 'default', outpoint: 'aa.0', spendable: true, satoshis: 500,
411+
state: :spendable)
412+
store.store_output(basket: 'default', outpoint: 'bb.0', spendable: true, satoshis: 1000,
413+
state: :spendable)
414+
store.store_output(basket: 'default', outpoint: 'cc.0', spendable: true, satoshis: 200,
415+
state: :spendable)
416+
store.store_output(basket: 'other', outpoint: 'dd.0', spendable: true, satoshis: 800,
417+
state: :spendable)
418+
store.store_output(basket: 'default', outpoint: 'ee.0', spendable: false, satoshis: 300,
419+
state: :spent)
420+
end
421+
422+
it 'returns outputs sorted largest-first by default' do
423+
results = store.find_spendable_outputs(basket: 'default')
424+
expect(results.map { |o| o[:satoshis] }).to eq([1000, 500, 200])
425+
end
426+
427+
it 'returns outputs sorted smallest-first when sort_order is :asc' do
428+
results = store.find_spendable_outputs(basket: 'default', sort_order: :asc)
429+
expect(results.map { |o| o[:satoshis] }).to eq([200, 500, 1000])
430+
end
431+
432+
it 'filters by basket' do
433+
results = store.find_spendable_outputs(basket: 'default')
434+
expect(results.map { |o| o[:outpoint] }).not_to include('dd.0')
435+
end
436+
437+
it 'returns outputs from all baskets when no basket filter is given' do
438+
results = store.find_spendable_outputs
439+
expect(results.map { |o| o[:outpoint] }).to include('aa.0', 'dd.0')
440+
end
441+
442+
it 'filters by min_satoshis' do
443+
results = store.find_spendable_outputs(basket: 'default', min_satoshis: 500)
444+
expect(results.map { |o| o[:outpoint] }).to contain_exactly('aa.0', 'bb.0')
445+
end
446+
447+
it 'excludes non-spendable outputs' do
448+
results = store.find_spendable_outputs(basket: 'default')
449+
expect(results.map { |o| o[:outpoint] }).not_to include('ee.0')
450+
end
451+
452+
it 'returns an empty array when the basket is empty' do
453+
expect(store.find_spendable_outputs(basket: 'nonexistent')).to be_empty
454+
end
455+
456+
it 'treats a legacy output with spendable: true and no state as spendable' do
457+
store.store_output(basket: 'legacy', outpoint: 'leg.0', satoshis: 999, spendable: true)
458+
results = store.find_spendable_outputs(basket: 'legacy')
459+
expect(results.map { |o| o[:outpoint] }).to include('leg.0')
460+
end
461+
end
462+
463+
describe '#update_output_state' do
464+
before do
465+
store.store_output(basket: 'default', outpoint: 'tx.0', spendable: true, satoshis: 1000,
466+
state: :spendable)
467+
end
468+
469+
it 'transitions to :pending and records pending metadata' do
470+
store.update_output_state('tx.0', :pending, pending_reference: 'ref-001', no_send: false)
471+
results = store.find_outputs(outpoint: 'tx.0', include_spent: true, limit: 1, offset: 0)
472+
row = results.first
473+
expect(row[:state].to_s).to eq('pending')
474+
expect(row[:pending_reference]).to eq('ref-001')
475+
expect(row[:pending_since]).not_to be_nil
476+
end
477+
478+
it 'transitions to :spent' do
479+
store.update_output_state('tx.0', :spent)
480+
results = store.find_outputs(outpoint: 'tx.0', include_spent: true, limit: 1, offset: 0)
481+
expect(results.first[:state].to_s).to eq('spent')
482+
end
483+
484+
it 'transitions back to :spendable and clears pending metadata' do
485+
store.update_output_state('tx.0', :pending, pending_reference: 'ref-x', no_send: false)
486+
store.update_output_state('tx.0', :spendable)
487+
results = store.find_outputs(outpoint: 'tx.0', include_spent: true, limit: 1, offset: 0)
488+
row = results.first
489+
expect(row[:state].to_s).to eq('spendable')
490+
expect(row[:pending_since]).to be_nil
491+
expect(row[:pending_reference]).to be_nil
492+
end
493+
494+
it 'returns the updated output hash' do
495+
result = store.update_output_state('tx.0', :spent)
496+
expect(result).to be_a(Hash)
497+
expect(result[:outpoint]).to eq('tx.0')
498+
end
499+
500+
it 'raises WalletError when the outpoint does not exist' do
501+
expect do
502+
store.update_output_state('nonexistent.0', :spent)
503+
end.to raise_error(BSV::Wallet::WalletError, /nonexistent\.0/)
504+
end
505+
506+
it 'sets no_send: true when passed' do
507+
store.update_output_state('tx.0', :pending, pending_reference: 'ref-ns', no_send: true)
508+
result = store.update_output_state('tx.0', :spendable)
509+
# After clearing, no_send must be absent or falsy
510+
expect(result[:no_send]).to be_falsy
511+
end
512+
end
513+
514+
describe '#lock_utxos' do
515+
before do
516+
store.store_output(basket: 'default', outpoint: 'tx1.0', spendable: true, satoshis: 500,
517+
state: :spendable)
518+
store.store_output(basket: 'default', outpoint: 'tx2.0', spendable: true, satoshis: 1000,
519+
state: :spendable)
520+
store.store_output(basket: 'default', outpoint: 'tx3.0', spendable: false, satoshis: 200,
521+
state: :spent)
522+
end
523+
524+
it 'locks spendable outputs and returns the locked outpoints' do
525+
locked = store.lock_utxos(%w[tx1.0 tx2.0], reference: 'ref-abc', no_send: false)
526+
expect(locked).to contain_exactly('tx1.0', 'tx2.0')
527+
end
528+
529+
it 'returns an empty array when given an empty list' do
530+
expect(store.lock_utxos([], reference: 'ref')).to eq([])
531+
end
532+
533+
it 'skips outputs that are not in spendable state' do
534+
locked = store.lock_utxos(%w[tx1.0 tx3.0], reference: 'ref', no_send: false)
535+
expect(locked).to contain_exactly('tx1.0')
536+
end
537+
538+
it 'skips outputs already locked (pending)' do
539+
store.update_output_state('tx1.0', :pending, pending_reference: 'first', no_send: false)
540+
locked = store.lock_utxos(['tx1.0'], reference: 'second', no_send: false)
541+
expect(locked).to be_empty
542+
end
543+
544+
it 'marks locked outputs as pending so they disappear from find_spendable_outputs' do
545+
store.lock_utxos(['tx1.0'], reference: 'ref-001', no_send: false)
546+
results = store.find_spendable_outputs(basket: 'default')
547+
expect(results.map { |o| o[:outpoint] }).not_to include('tx1.0')
548+
end
549+
550+
it 'sets no_send flag on the locked output when passed' do
551+
store.lock_utxos(['tx1.0'], reference: 'ref-ns', no_send: true)
552+
results = store.find_outputs(outpoint: 'tx1.0', include_spent: true, limit: 1, offset: 0)
553+
expect(results.first[:no_send]).to be true
554+
end
555+
end
556+
557+
describe '#release_stale_pending!' do
558+
before do
559+
store.store_output(basket: 'default', outpoint: 'tx1.0', spendable: true, satoshis: 500,
560+
state: :spendable)
561+
store.store_output(basket: 'default', outpoint: 'tx2.0', spendable: true, satoshis: 500,
562+
state: :spendable)
563+
store.store_output(basket: 'default', outpoint: 'tx3.0', spendable: true, satoshis: 500,
564+
state: :spendable)
565+
end
566+
567+
it 'returns 0 when no pending outputs exist' do
568+
expect(store.release_stale_pending!(timeout: 300)).to eq(0)
569+
end
570+
571+
it 'returns 0 when pending outputs are within the timeout window' do
572+
store.lock_utxos(['tx1.0'], reference: 'fresh', no_send: false)
573+
expect(store.release_stale_pending!(timeout: 300)).to eq(0)
574+
end
575+
576+
it 'releases a pending output that is past the timeout and returns count' do
577+
# timeout: 0 makes any lock immediately stale (cutoff = now, locked_at < now is true)
578+
store.lock_utxos(['tx1.0'], reference: 'stale', no_send: false)
579+
released = store.release_stale_pending!(timeout: 0)
580+
expect(released).to eq(1)
581+
end
582+
583+
it 'restores state to spendable after releasing a stale lock' do
584+
store.lock_utxos(['tx2.0'], reference: 'stale', no_send: false)
585+
store.release_stale_pending!(timeout: 0)
586+
587+
results = store.find_spendable_outputs(basket: 'default')
588+
expect(results.map { |o| o[:outpoint] }).to include('tx2.0')
589+
end
590+
591+
it 'does not release no_send pending outputs even when stale' do
592+
# timeout: 0 means cutoff = now; any pending lock is already "stale"
593+
# but no_send: true must exempt it from automatic release
594+
store.lock_utxos(['tx3.0'], reference: 'ns', no_send: true)
595+
expect(store.release_stale_pending!(timeout: 0)).to eq(0)
596+
597+
results = store.find_spendable_outputs(basket: 'default')
598+
expect(results.map { |o| o[:outpoint] }).not_to include('tx3.0')
599+
end
600+
end
601+
end
602+
398603
describe 'settings' do
399604
describe '#store_setting and #find_setting' do
400605
it 'persists a setting and retrieves it by key' do

0 commit comments

Comments
 (0)