Files
portfolio-hub/tests/test_ledger_consolidation_integration.py
T
genichinandClaude Opus 4.8 2a19b7f058 fix: ledger write idempotency for TimescaleDB hypertable (BAT-016 rework)
BAT-016's initial implementation passed only against mocked repositories and
broke on a real database. Two defects fixed:

1. insert_ledger_trade used `ON CONFLICT (portfolio_id, order_id, execution_id)
   WHERE order_id IS NOT NULL` but portfolio_ledger is a TimescaleDB hypertable,
   which cannot have that partial unique index (must include the partitioning
   column recorded_at). The statement errored against Postgres. Replaced with
   application-level idempotency: SELECT-before-insert inside the existing
   `SELECT ... FOR UPDATE` portfolio row lock, which serializes concurrent
   same-portfolio writes (race-safe). Duplicate returns current authoritative
   portfolio state (dcy-006 OQ-4 b).

2. `with self._connect() as conn:` closes the shared psycopg3 connection on exit,
   leaving the reused repository connection unusable. Switched to
   `with conn.transaction():` (commits without closing).

Added tests/test_ledger_consolidation_integration.py — real-Postgres integration
tests (new / duplicate / negative-balance) that close the mock-only verification
gap. Full suite: 376 passed.

SDLC: REQ-036/037 CHG-20260605-03 reconcile OQ-7 (ii) (DB partial unique →
application-level idempotency) since the hypertable makes the DB constraint
infeasible. REQ-036/037/038 → Implemented; BAT-016 → release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:11:17 +09:00

149 lines
5.2 KiB
Python

"""Real-DB integration tests for BAT-016 ledger consolidation (REQ-036/037).
These exercise `insert_ledger_trade` against an actual PostgreSQL/TimescaleDB
instance (docker-compose.local-test). The mock-based suite cannot catch DB-level
defects such as the hypertable `ON CONFLICT` regression that BAT-016 originally
shipped; this module closes that gap.
Skipped automatically when the local DB is unreachable.
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timezone
import pytest
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
def _db_config() -> PortfolioDBConfig:
return PortfolioDBConfig(
host=os.environ.get("TIMESCALEDB_HOST", "localhost"),
port=int(os.environ.get("TIMESCALEDB_PORT", "5432")),
dbname=os.environ.get("TIMESCALEDB_NAME", "portfolio_hub"),
user=os.environ.get("TIMESCALEDB_USER", "portfolio_hub_user"),
password=os.environ.get("TIMESCALEDB_PASSWORD", "change-me"),
sslmode=os.environ.get("TIMESCALEDB_SSLMODE", "prefer"),
connect_timeout_sec=3,
application_name="ledger-integration-test",
)
@pytest.fixture()
def repo():
config = _db_config()
repository = PostgresPortfolioRepository(config)
try:
repository.ensure_schema()
except Exception as exc: # pragma: no cover - environment dependent
pytest.skip(f"local DB unreachable: {exc}")
yield repository
if callable(getattr(type(repository), "close", None)):
repository.close()
@pytest.fixture()
def seeded_portfolio(repo):
"""Seed a unique asset + portfolio, yield portfolio_id, then clean up."""
pid = f"itest-{uuid.uuid4()}"
symbol = f"ITEST-{uuid.uuid4().hex[:8]}"
conn = repo._connect()
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"INSERT INTO assets (symbol, markets) VALUES (%s, ARRAY[]::text[]) RETURNING asset_id",
(symbol,),
)
asset_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO portfolios (portfolio_id, name, cash_balance, asset_quantity, asset_id, broker, status)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(pid, "itest-pf", 1_000_000.0, 0.0, asset_id, "itest-broker", "enabled"),
)
yield pid
conn = repo._connect()
with conn.transaction():
with conn.cursor() as cur:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (pid,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (pid,))
cur.execute("DELETE FROM assets WHERE symbol = %s", (symbol,))
def _now():
return datetime.now(timezone.utc)
def test_new_trade_updates_portfolio_and_returns_state(repo, seeded_portfolio):
"""REQ-037 AC-1: new broker write applies accounting and returns post-trade state."""
entry, created = repo.insert_ledger_trade(
portfolio_id=seeded_portfolio,
order_id="ord-1",
execution_id="exec-1",
side="buy",
quantity=10.0,
price=80_000.0,
executed_at=_now(),
fee_amount=100.0,
tax_amount=0.0,
)
assert created is True
assert entry.cash_balance_after == pytest.approx(1_000_000.0 - 800_000.0 - 100.0)
assert entry.asset_quantity_after == pytest.approx(10.0)
portfolio = repo.get(seeded_portfolio)
assert float(portfolio.cash_balance) == pytest.approx(199_900.0)
assert float(portfolio.asset_quantity) == pytest.approx(10.0)
def test_duplicate_broker_write_is_idempotent(repo, seeded_portfolio):
"""REQ-037 AC-2 + OQ-4(b): duplicate does not re-insert; returns current authoritative state."""
args = dict(
portfolio_id=seeded_portfolio,
order_id="ord-dup",
execution_id="exec-dup",
side="buy",
quantity=5.0,
price=10_000.0,
executed_at=_now(),
fee_amount=0.0,
tax_amount=0.0,
)
first, created1 = repo.insert_ledger_trade(**args)
assert created1 is True
assert first.cash_balance_after == pytest.approx(950_000.0)
second, created2 = repo.insert_ledger_trade(**args)
assert created2 is False
# duplicate returns CURRENT authoritative portfolio state (OQ-4 b)
assert second.cash_balance_after == pytest.approx(950_000.0)
assert second.asset_quantity_after == pytest.approx(5.0)
# exactly one ledger row for this broker key
conn = repo._connect()
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM portfolio_ledger WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s",
(seeded_portfolio, "ord-dup", "exec-dup"),
)
assert cur.fetchone()[0] == 1
def test_negative_balance_allowed(repo, seeded_portfolio):
"""REQ-037 AC-3 + OQ-3: oversell produces negative asset_quantity without rejection."""
entry, created = repo.insert_ledger_trade(
portfolio_id=seeded_portfolio,
order_id="ord-neg",
execution_id="exec-neg",
side="sell",
quantity=999.0,
price=1_000.0,
executed_at=_now(),
)
assert created is True
assert entry.asset_quantity_after == pytest.approx(-999.0)