"""LedgerOutbox + LedgerWriter direct unit tests (REQ-019).""" from __future__ import annotations from datetime import UTC, datetime, timedelta from trex_core.adapters.ledger.in_memory import InMemoryLedger from trex_core.application.ledger_outbox import ( InMemoryLedgerOutbox, LedgerWriter, OutboxOutcome, WorkItemStatus, ) from trex_core.application.ledger_port import LedgerTrade def _trade(execution_id="paper:PAPER-1:10") -> LedgerTrade: return LedgerTrade( asset_id="PF-1", order_id="PAPER-1", execution_id=execution_id, side="buy", qty=10, price=1000.0, executed_at=datetime(2026, 6, 4, tzinfo=UTC), ) def test_enqueue_is_idempotent_on_correlation_id(): outbox = InMemoryLedgerOutbox() a = outbox.enqueue(_trade()) b = outbox.enqueue(_trade()) assert a.correlation_id == b.correlation_id assert len(outbox.all_items()) == 1 def test_writer_run_once_drains_pending_and_marks_completed(): outbox = InMemoryLedgerOutbox() ledger = InMemoryLedger() writer = LedgerWriter(outbox, ledger) outbox.enqueue(_trade(execution_id="e-1")) outbox.enqueue(_trade(execution_id="e-2")) stats = writer.run_once() assert stats.completed == 2 and ledger.count() == 2 assert all(it.status is WorkItemStatus.COMPLETED for it in outbox.all_items()) assert all(it.last_outcome is OutboxOutcome.COMPLETED for it in outbox.all_items()) def test_writer_marks_conflict_blocked_and_does_not_retry(): outbox = InMemoryLedgerOutbox() ledger = InMemoryLedger(conflict_executions={"e-x"}) writer = LedgerWriter(outbox, ledger) outbox.enqueue(_trade(execution_id="e-x")) writer.run_once() [item] = outbox.all_items() assert item.status is WorkItemStatus.CONFLICT_BLOCKED assert item.last_outcome is OutboxOutcome.CONFLICT_BLOCKED # No pending items remain, so a subsequent run is a no-op. assert outbox.list_pending() == [] def test_writer_retries_until_success(): # dcy-015: retryable rows now back off; share an injected clock between the # outbox (list_pending "now") and the writer (next_retry_at) to advance past it. now = {"t": datetime(2026, 6, 4, tzinfo=UTC)} outbox = InMemoryLedgerOutbox(clock=lambda: now["t"]) ledger = InMemoryLedger(fail_n_then_succeed=1) writer = LedgerWriter(outbox, ledger, clock=lambda: now["t"]) outbox.enqueue(_trade(execution_id="e-r")) s1 = writer.run_once() assert s1.retryable == 1 and ledger.count() == 0 assert writer.run_once().completed == 0 # still in backoff -> skipped now["t"] += timedelta(seconds=600) # past the backoff window s2 = writer.run_once() assert s2.completed == 1 and ledger.count() == 1 def test_process_correlation_id_only_drains_requested_row(): outbox = InMemoryLedgerOutbox(instance_key="inst-a") ledger = InMemoryLedger() writer = LedgerWriter(outbox, ledger, instance_key="inst-a") first = outbox.enqueue(_trade(execution_id="e-1")) second = outbox.enqueue(_trade(execution_id="e-2")) outcome = writer.process_correlation_id(first.correlation_id) assert outcome is OutboxOutcome.COMPLETED assert ledger.count() == 1 assert writer.counters["claim:claimed"] == 1 assert writer.counters["outcome:completed"] == 1 assert outbox.get(first.correlation_id).status is WorkItemStatus.COMPLETED assert outbox.get(second.correlation_id).status is WorkItemStatus.PENDING def test_process_correlation_id_skips_foreign_instance_row(): outbox = InMemoryLedgerOutbox(instance_key="inst-a") ledger = InMemoryLedger() writer = LedgerWriter(outbox, ledger, instance_key="inst-b") item = outbox.enqueue(_trade(execution_id="e-foreign")) outcome = writer.process_correlation_id(item.correlation_id) assert outcome is None assert ledger.count() == 0 assert writer.counters["claim:foreign_instance"] == 1 assert outbox.get(item.correlation_id).status is WorkItemStatus.PENDING def test_writer_exposes_claim_and_outcome_counters(): outbox = InMemoryLedgerOutbox(instance_key="inst-a") ledger = InMemoryLedger() writer = LedgerWriter(outbox, ledger, instance_key="inst-a") outbox.enqueue(_trade(execution_id="e-count")) stats = writer.run_once() assert stats.completed == 1 assert writer.counters["claim:claimed"] == 1 assert writer.counters["outcome:completed"] == 1