Files
portfolio-hub/tests/test_strategy_catalog_postgres_integration.py

330 lines
12 KiB
Python

"""Real-PostgreSQL concurrency and migration verification for Issue #26.
Run with PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION=1 against a disposable/local
PostgreSQL endpoint. Normal unit-test environments skip this module.
"""
from __future__ import annotations
import os
import subprocess
import threading
import uuid
from pathlib import Path
import psycopg
import pytest
from psycopg import sql
from app.domain.errors import StrategyNotFoundError
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
pytestmark = pytest.mark.skipif(
os.getenv("PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION") != "1",
reason="set PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION=1 to run real PostgreSQL tests",
)
_ROOT = Path(__file__).parents[1]
def _db_values(dbname: str | None = None) -> dict[str, object]:
return {
"host": os.getenv("TIMESCALEDB_HOST", "127.0.0.1"),
"port": int(os.getenv("TIMESCALEDB_PORT", "5432")),
"dbname": dbname or os.getenv("TIMESCALEDB_NAME", "portfolio_hub_migration_test"),
"user": os.getenv("TIMESCALEDB_USER", "portfolio_hub_user"),
"password": os.getenv("TIMESCALEDB_PASSWORD", "change-me"),
"sslmode": os.getenv("TIMESCALEDB_SSLMODE", "prefer"),
}
def _repo(dbname: str | None = None) -> PostgresPortfolioRepository:
values = _db_values(dbname)
return PostgresPortfolioRepository(
PortfolioDBConfig(
host=str(values["host"]),
port=int(values["port"]),
dbname=str(values["dbname"]),
user=str(values["user"]),
password=str(values["password"]),
sslmode=str(values["sslmode"]),
)
)
def _prepare_catalog_fixture(repo: PostgresPortfolioRepository) -> tuple[str, str, str]:
repo.ensure_schema()
strategy = repo.create_strategy_set(f"concurrency-{uuid.uuid4().hex}", "{}")
asset_id = str(uuid.uuid4())
owner_user_id = f"qc-user-{uuid.uuid4().hex}"
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(owner_user_id, owner_user_id),
)
cur.execute(
"""
INSERT INTO assets (asset_id, symbol, markets, status)
VALUES (%s, %s, ARRAY['KRX'], 'active')
""",
(asset_id, f"QC{uuid.uuid4().hex[:10]}"),
)
return strategy.id, asset_id, owner_user_id
def test_concurrent_strategy_patch_preserves_existing_nick() -> None:
owner = _repo()
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
initial = owner.get_strategy_set(strategy_id)
assert initial is not None
initial_nick = initial.nick
renamed = f"concurrent-renamed-{uuid.uuid4().hex}"
barrier = threading.Barrier(3)
errors: list[BaseException] = []
def patch_name() -> None:
repo = _repo()
try:
barrier.wait()
repo.update_strategy_set(strategy_id, name=renamed)
except BaseException as exc: # pragma: no cover - only populated on failure
errors.append(exc)
finally:
repo.close()
def patch_config() -> None:
repo = _repo()
try:
barrier.wait()
repo.update_strategy_set(strategy_id, config='{"window":21}')
except BaseException as exc: # pragma: no cover - only populated on failure
errors.append(exc)
finally:
repo.close()
threads = [threading.Thread(target=patch_name), threading.Thread(target=patch_config)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
final = owner.get_strategy_set(strategy_id)
assert errors == []
assert final is not None
assert final.name == renamed
assert final.config == '{"window":21}'
assert final.revision == 3
assert final.nick == initial_nick
portfolio_id = str(uuid.uuid4())
owner.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="latest-revision-check",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
)
portfolio = owner.get(portfolio_id)
assert portfolio.strategy_params == {"window": 21}
assert portfolio.strategy_revision == 3
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
owner.delete_strategy_set(strategy_id)
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
owner.close()
def test_portfolio_selection_and_strategy_delete_never_create_orphan() -> None:
owner = _repo()
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
portfolio_id = str(uuid.uuid4())
barrier = threading.Barrier(3)
outcomes: list[str] = []
def select_strategy() -> None:
repo = _repo()
try:
barrier.wait()
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="race-check",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
)
outcomes.append("selected")
except StrategyNotFoundError:
outcomes.append("selection-rejected")
finally:
repo.close()
def delete_strategy() -> None:
repo = _repo()
try:
barrier.wait()
repo.delete_strategy_set(strategy_id)
outcomes.append("deleted")
except ValueError as exc:
assert "in use" in str(exc)
outcomes.append("delete-rejected")
finally:
repo.close()
threads = [threading.Thread(target=select_strategy), threading.Thread(target=delete_strategy)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
with owner._connect().cursor() as cur:
cur.execute("SELECT COUNT(*) FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
portfolio_exists = cur.fetchone()[0] == 1
cur.execute("SELECT COUNT(*) FROM strategy_sets WHERE id = %s", (strategy_id,))
strategy_exists = cur.fetchone()[0] == 1
assert not (portfolio_exists and not strategy_exists)
assert sorted(outcomes) in (["delete-rejected", "selected"], ["deleted", "selection-rejected"])
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
owner.close()
def test_stale_trade_save_cannot_restore_a_deleted_strategy_reference() -> None:
owner = _repo()
strategy_a, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
strategy_b = owner.create_strategy_set(f"replacement-{uuid.uuid4().hex}", "{}")
portfolio_id = str(uuid.uuid4())
owner.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="stale-trade-check",
asset_id=asset_id,
cash_balance=1_000,
asset_quantity=0,
asset_price=100,
profit_buffer=0,
active_strategy_id=strategy_a,
avabile_strategies=[strategy_a],
)
stale = owner.get(portfolio_id)
concurrent = _repo()
concurrent.update_portfolio(
portfolio_id,
active_strategy_id=strategy_b.id,
avabile_strategies=[strategy_b.id],
)
concurrent.delete_strategy_set(strategy_a)
concurrent.close()
stale.cash_balance = 900
stale.asset_quantity = 1
owner.save(
stale,
current_price=100,
action="BUY",
trade_price=100,
quantity=1,
fee_amount=0,
tax_amount=0,
total_value_after=1_000,
strategy_set_id=strategy_a,
note="stale snapshot regression",
)
current = owner.get(portfolio_id)
assert current.active_strategy_id == strategy_b.id
assert current.avabile_strategies == [strategy_b.id]
assert owner.get_strategy_set(strategy_a) is None
with owner._connect().cursor() as cur:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_b.id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
owner.close()
def test_alembic_upgrade_is_repeatable_on_fresh_postgres_database() -> None:
database_name = f"portfolio_hub_0029_{uuid.uuid4().hex[:12]}"
maintenance = _db_values("postgres")
connection = psycopg.connect(**maintenance, autocommit=True)
try:
with connection.cursor() as cur:
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(database_name)))
env = os.environ.copy()
env.update(
{
"TIMESCALEDB_HOST": str(maintenance["host"]),
"TIMESCALEDB_PORT": str(maintenance["port"]),
"TIMESCALEDB_NAME": database_name,
"TIMESCALEDB_USER": str(maintenance["user"]),
"TIMESCALEDB_PASSWORD": str(maintenance["password"]),
"TIMESCALEDB_SSLMODE": str(maintenance["sslmode"]),
}
)
for _ in range(2):
subprocess.run(
[str(_ROOT / ".venv/bin/pfhub"), "migrate-db"],
cwd=_ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
test_db = psycopg.connect(**_db_values(database_name))
try:
with test_db.cursor() as cur:
cur.execute(
"""
SELECT id, status, revision, nick
FROM strategy_sets
WHERE name = 'target_value' AND config = '{}'
"""
)
rows = cur.fetchall()
assert rows == [
(
"00000000-0000-0000-0000-000000000029",
"active",
1,
"target_value:bf21a9e8fbc5",
)
]
cur.execute("SELECT version_num FROM alembic_version")
assert cur.fetchone()[0] == "20260814_0029"
finally:
test_db.close()
finally:
with connection.cursor() as cur:
cur.execute(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s",
(database_name,),
)
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(database_name)))
connection.close()