Add ProofLine criteria documentation and Strategy Catalog v1 implementation
- Add acceptance criteria documentation (AC-0013 through AC-0023) - Add Line-0003 discovery documentation - Implement Strategy Catalog v1 with new database migration - Update portfolio API endpoints and schemas - Refactor strategy and portfolio services - Add strategy management CLI commands - Update domain models with strategy catalog support - Add comprehensive tests for v1 strategies - Update design documentation (data model, interfaces, runtime flows)
This commit is contained in:
@@ -583,8 +583,11 @@ class _PortfolioServiceFakeRepo:
|
||||
"""FakeRepo for PortfolioService catalog validation tests."""
|
||||
|
||||
def __init__(self, asset: Asset | None = None) -> None:
|
||||
from app.domain.models import StrategySet
|
||||
|
||||
self._asset = asset
|
||||
self._strategy_sets: dict = {}
|
||||
strategy = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:default")
|
||||
self._strategy_sets: dict = {strategy.id: strategy}
|
||||
self._portfolios: dict = {}
|
||||
|
||||
def get_asset_by_id(self, asset_id: str) -> Asset | None:
|
||||
@@ -625,7 +628,7 @@ class _PortfolioServiceFakeRepo:
|
||||
return p
|
||||
|
||||
def get_strategy_set(self, id: str):
|
||||
return None
|
||||
return self._strategy_sets.get(id)
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, *, limit: int = 5):
|
||||
return []
|
||||
@@ -633,7 +636,7 @@ class _PortfolioServiceFakeRepo:
|
||||
def list_comments(self, portfolio_id: str):
|
||||
return []
|
||||
|
||||
def list_strategy_sets(self):
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True):
|
||||
return []
|
||||
|
||||
def list_portfolios(self, **kwargs):
|
||||
@@ -669,6 +672,7 @@ def test_portfolio_create_with_active_asset() -> None:
|
||||
name="Test_Portfolio",
|
||||
asset_id=_CATALOG_ASSET_ID,
|
||||
broker="kis",
|
||||
strategy_id="set-1",
|
||||
),
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
@@ -688,6 +692,7 @@ def test_portfolio_create_unknown_asset() -> None:
|
||||
name="Test_Portfolio",
|
||||
asset_id="nonexistent-uuid",
|
||||
broker="kis",
|
||||
strategy_id="set-1",
|
||||
),
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
@@ -706,6 +711,7 @@ def test_portfolio_create_inactive_asset() -> None:
|
||||
name="Test_Portfolio",
|
||||
asset_id=_CATALOG_ASSET_ID,
|
||||
broker="kis",
|
||||
strategy_id="set-1",
|
||||
),
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
@@ -745,6 +751,8 @@ def test_portfolio_create_by_symbol() -> None:
|
||||
"portfolio", "create", "Test_Portfolio",
|
||||
"--user", "alice",
|
||||
"--symbol", "005930",
|
||||
"--strategy-id",
|
||||
"set-1",
|
||||
"--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
+68
-4
@@ -469,9 +469,40 @@ class FakeRepo:
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self.strategy_set if self.strategy_set.id == id else None
|
||||
|
||||
def list_strategy_sets(self) -> list[StrategySet]:
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
|
||||
from datetime import datetime, timezone
|
||||
return sorted(self._strategy_sets.values(), key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True)
|
||||
values = list(self._strategy_sets.values())
|
||||
if not include_inactive:
|
||||
values = [item for item in values if item.status == "active"]
|
||||
return sorted(values, key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True)
|
||||
|
||||
def update_strategy_set(
|
||||
self,
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet:
|
||||
strategy = self._strategy_sets.get(id)
|
||||
if strategy is None:
|
||||
raise ValueError("전략을 찾을 수 없습니다")
|
||||
final_name = name if name is not None else strategy.name
|
||||
final_config = config if config is not None else strategy.config
|
||||
final_status = status if status is not None else strategy.status
|
||||
if (final_name, final_config, final_status) == (
|
||||
strategy.name,
|
||||
strategy.config,
|
||||
strategy.status,
|
||||
):
|
||||
return strategy
|
||||
strategy.name = final_name
|
||||
strategy.config = final_config
|
||||
strategy.status = final_status
|
||||
strategy.nick = f"{strategy.name}:updated"
|
||||
strategy.revision += 1
|
||||
strategy.updated_at = datetime.now(timezone.utc)
|
||||
return strategy
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None:
|
||||
if id in self._strategy_sets:
|
||||
@@ -670,8 +701,8 @@ def test_cli_create_buy_and_delete_flow() -> None:
|
||||
_TEST_ASSET_ID,
|
||||
"--cash-balance",
|
||||
"1000000",
|
||||
"--strategy-params",
|
||||
'{"target_asset_ratio": 0.5}',
|
||||
"--strategy-id",
|
||||
"set-1",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
@@ -717,6 +748,8 @@ def test_cli_write_command_prompts_before_create() -> None:
|
||||
_TEST_ASSET_ID,
|
||||
"--cash-balance",
|
||||
"1000000",
|
||||
"--strategy-id",
|
||||
"set-1",
|
||||
],
|
||||
input="y\n",
|
||||
)
|
||||
@@ -740,6 +773,8 @@ def test_cli_write_command_can_abort() -> None:
|
||||
_TEST_ASSET_ID,
|
||||
"--cash-balance",
|
||||
"1000000",
|
||||
"--strategy-id",
|
||||
"set-1",
|
||||
],
|
||||
input="n\n",
|
||||
)
|
||||
@@ -1434,3 +1469,32 @@ def test_cli_service_token_rebind_rejects_cross_user_daemon() -> None:
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "해당 토큰 사용자에 속하지 않습니다" in result.stdout
|
||||
|
||||
|
||||
def test_cli_strategy_commands_show_status_revision_and_preserve_id() -> None:
|
||||
repo = FakeRepo()
|
||||
runner = build_runner(repo)
|
||||
|
||||
listed = runner.invoke(runner.app, ["strategy", "list"])
|
||||
updated = runner.invoke(
|
||||
runner.app,
|
||||
["strategy", "update", "set-1", "--status", "inactive", "--name", "target_value_v2"],
|
||||
)
|
||||
no_change = runner.invoke(runner.app, ["strategy", "update", "set-1", "--status", "inactive"])
|
||||
|
||||
assert listed.exit_code == 0
|
||||
assert "Status" in listed.output and "Revision" in listed.output
|
||||
assert updated.exit_code == 0
|
||||
assert "ID: set-1" in updated.output
|
||||
assert "Status: inactive" in updated.output
|
||||
assert "Revision: 2" in updated.output
|
||||
assert no_change.exit_code == 0
|
||||
assert "Revision: 2" in no_change.output
|
||||
|
||||
|
||||
def test_cli_strategy_update_requires_a_field() -> None:
|
||||
repo = FakeRepo()
|
||||
runner = build_runner(repo)
|
||||
result = runner.invoke(runner.app, ["strategy", "update", "set-1"])
|
||||
assert result.exit_code == 1
|
||||
assert "at least one" in result.output
|
||||
|
||||
@@ -90,7 +90,7 @@ class FakeRepo:
|
||||
self._strategy_sets[self.strategy_set.id] = self.strategy_set
|
||||
return self.strategy_set
|
||||
|
||||
def list_strategy_sets(self) -> list[StrategySet]:
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
|
||||
return sorted(self._strategy_sets.values(), key=lambda s: s.created_at or datetime.now(timezone.utc), reverse=True)
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None:
|
||||
|
||||
@@ -70,7 +70,7 @@ class FakeRepo:
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self.strategy_set if id == self.strategy_set.id else None
|
||||
|
||||
def list_strategy_sets(self):
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True):
|
||||
return [self.strategy_set]
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None:
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_db_enabled_env_token_without_user_context_is_rejected(monkeypatch) -> N
|
||||
response = client.post(
|
||||
"/portfolios",
|
||||
headers={"Authorization": "Bearer trade-env-token"},
|
||||
json={"name": "new-1", "asset_id": "00000000-0000-0000-0000-000000000001"},
|
||||
json={"name": "new-1", "asset_id": "00000000-0000-0000-0000-000000000001", "strategy_id": "set-1"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -201,7 +201,7 @@ class FakeRepo:
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self._strategy_sets.get(id)
|
||||
|
||||
def list_strategy_sets(self) -> list[StrategySet]:
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
|
||||
return list(self._strategy_sets.values())
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None:
|
||||
@@ -350,7 +350,7 @@ def test_create_portfolio_assigns_uuid_id() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -371,7 +371,7 @@ def test_create_returns_uuid_in_response() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["--format", "json", "portfolio", "create", "MY_PORTFOLIO", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -389,7 +389,7 @@ def test_status_resolves_by_name_and_user() -> None:
|
||||
|
||||
create_result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["--format", "json", "portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
assert create_result.exit_code == 0, create_result.output
|
||||
|
||||
@@ -437,7 +437,7 @@ def test_create_without_user_fails() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
@@ -450,7 +450,7 @@ def test_create_with_unknown_user_fails() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "ghost", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "ghost", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -464,7 +464,7 @@ def test_name_validation_rejects_invalid_characters() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "invalid name!", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "invalid name!", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -478,7 +478,7 @@ def test_name_validation_rejects_empty_name() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
@@ -492,11 +492,11 @@ def test_alice_and_bob_can_have_same_portfolio_name() -> None:
|
||||
|
||||
alice_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
bob_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert alice_result.exit_code == 0, alice_result.output
|
||||
@@ -513,7 +513,7 @@ def test_buy_resolves_portfolio_by_name_and_user() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--user", "alice", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--cash-balance", "1000000", "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
buy_result = runner.invoke(
|
||||
runner.app,
|
||||
@@ -532,7 +532,7 @@ def test_delete_resolves_portfolio_by_name_and_user() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
delete_result = runner.invoke(
|
||||
runner.app,
|
||||
@@ -552,7 +552,7 @@ def test_alice_cannot_delete_bobs_portfolio() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--force"],
|
||||
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
delete_result = runner.invoke(
|
||||
runner.app,
|
||||
@@ -575,7 +575,7 @@ def test_name_validation_accepts_korean() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "삼성전자 포트폴리오", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "삼성전자 포트폴리오", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -589,7 +589,7 @@ def test_name_validation_accepts_ascii_unchanged() -> None:
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "my-portfolio_v1:test.01", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "my-portfolio_v1:test.01", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -602,7 +602,7 @@ def test_name_validation_rejects_over_100_chars() -> None:
|
||||
|
||||
long_name = "가" * 101
|
||||
with pytest.raises(Exception):
|
||||
CreatePortfolioRequest(name=long_name, asset_id="some-uuid")
|
||||
CreatePortfolioRequest(name=long_name, asset_id="some-uuid", strategy_id="set-1")
|
||||
|
||||
|
||||
def test_update_new_name_renames_portfolio() -> None:
|
||||
@@ -612,7 +612,7 @@ def test_update_new_name_renames_portfolio() -> None:
|
||||
|
||||
runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "create", "OLD_NAME", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--force"],
|
||||
["portfolio", "create", "OLD_NAME", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
rename_result = runner.invoke(
|
||||
runner.app,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Idempotency contract for the Issue #26 strategy-catalog migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).parents[1]
|
||||
/ "migrations/versions/20260814_0029_strategy_catalog_v1.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration():
|
||||
spec = importlib.util.spec_from_file_location("strategy_catalog_0029", _MIGRATION_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class _Bind:
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[str] = []
|
||||
self.params: list[dict | None] = []
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
self.executed.append(str(statement))
|
||||
self.params.append(params)
|
||||
return SimpleNamespace()
|
||||
|
||||
|
||||
class _Inspector:
|
||||
def __init__(self, columns: set[str]) -> None:
|
||||
self.columns = columns
|
||||
|
||||
def get_table_names(self) -> list[str]:
|
||||
return ["strategy_sets"]
|
||||
|
||||
def get_columns(self, table_name: str) -> list[dict[str, str]]:
|
||||
assert table_name == "strategy_sets"
|
||||
return [{"name": name} for name in sorted(self.columns)]
|
||||
|
||||
|
||||
def test_upgrade_is_repeatable_and_uses_data_and_constraint_guards() -> None:
|
||||
migration = _load_migration()
|
||||
columns = {"id", "name", "config", "nick", "created_at"}
|
||||
inspector = _Inspector(columns)
|
||||
bind = _Bind()
|
||||
added: list[str] = []
|
||||
|
||||
def add_column(table_name: str, column) -> None:
|
||||
assert table_name == "strategy_sets"
|
||||
columns.add(column.name)
|
||||
added.append(column.name)
|
||||
|
||||
with (
|
||||
patch.object(migration.op, "get_bind", return_value=bind),
|
||||
patch.object(migration.op, "add_column", side_effect=add_column),
|
||||
patch.object(migration, "inspect", return_value=inspector),
|
||||
):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
assert added == ["status", "revision", "updated_at"]
|
||||
sql = "\n".join(bind.executed)
|
||||
assert "WHERE NOT EXISTS" in sql
|
||||
assert "IF NOT EXISTS" in sql
|
||||
assert "ck_strategy_sets_status" in sql
|
||||
assert "ck_strategy_sets_revision_positive" in sql
|
||||
assert any(params and params.get("nick") == "target_value:bf21a9e8fbc5" for params in bind.params)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""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 hashlib
|
||||
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_keeps_derived_nick_consistent() -> None:
|
||||
owner = _repo()
|
||||
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
|
||||
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
|
||||
expected_nick = f"{final.name}:{hashlib.sha1(final.config.encode()).hexdigest()[:12]}"
|
||||
assert final.nick == expected_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/python"), "-m", "alembic", "upgrade", "head"],
|
||||
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()
|
||||
@@ -79,6 +79,9 @@ class _FakeRepo:
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
return self._strategy_set
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self._strategy_set if id == self._strategy_set.id else None
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
@@ -190,7 +193,7 @@ def test_post_admin_creates_for_target_user() -> None:
|
||||
with _with_repo(repo):
|
||||
resp = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "user_id": _OTHER_USER_ID},
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "user_id": _OTHER_USER_ID},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -209,7 +212,7 @@ def test_post_user_creates_for_self() -> None:
|
||||
with _with_repo(repo):
|
||||
resp = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
|
||||
headers=_auth("user-tok"),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -230,7 +233,7 @@ def test_post_service_token_returns_403() -> None:
|
||||
with _with_repo(repo, db_auth=True):
|
||||
resp = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
|
||||
headers=_auth("svc-raw-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -244,7 +247,7 @@ def test_post_inactive_asset_returns_422() -> None:
|
||||
with _with_repo(repo):
|
||||
resp = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -259,7 +262,7 @@ def test_post_missing_asset_returns_422() -> None:
|
||||
with _with_repo(repo):
|
||||
resp = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": "no-such-uuid", "broker": "kiwoom"},
|
||||
json={"asset_id": "no-such-uuid", "broker": "kiwoom", "strategy_id": "strat-001"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -268,7 +271,7 @@ def test_post_missing_asset_returns_422() -> None:
|
||||
def test_post_no_bearer_returns_401() -> None:
|
||||
"""REQ-025 AC-6: no token → HTTP 401."""
|
||||
client = _make_client()
|
||||
resp = client.post("/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom"})
|
||||
resp = client.post("/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@@ -280,7 +283,7 @@ def test_post_non_admin_user_id_ignored() -> None:
|
||||
with _with_repo(repo):
|
||||
resp = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "user_id": _OTHER_USER_ID},
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001", "user_id": _OTHER_USER_ID},
|
||||
headers=_auth("user-tok"),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
@@ -499,3 +502,48 @@ def test_delete_already_disabled_is_idempotent() -> None:
|
||||
resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "disabled"
|
||||
|
||||
|
||||
# Issue #26: strategy catalog references on portfolio mutations.
|
||||
def test_post_strategy_reference_errors_and_required_contract() -> None:
|
||||
repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active"))
|
||||
client = _make_client()
|
||||
with _with_repo(repo):
|
||||
missing = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "missing"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
invalid = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
repo._strategy_set.status = "inactive"
|
||||
inactive = client.post(
|
||||
"/v1/portfolios",
|
||||
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
assert invalid.status_code == 422
|
||||
assert inactive.status_code == 409
|
||||
|
||||
|
||||
def test_patch_strategy_reference_errors_are_404_and_409() -> None:
|
||||
repo = _repo_with_portfolio(owner=_OTHER_USER_ID)
|
||||
client = _make_client()
|
||||
with _with_repo(repo):
|
||||
missing = client.patch(
|
||||
f"/v1/portfolios/{_PORTFOLIO_UUID}",
|
||||
json={"strategy_id": "missing"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
repo._strategy_set.status = "inactive"
|
||||
inactive = client.patch(
|
||||
f"/v1/portfolios/{_PORTFOLIO_UUID}",
|
||||
json={"strategy_id": "strat-001"},
|
||||
headers=_auth("admin-tok"),
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
assert inactive.status_code == 409
|
||||
|
||||
@@ -49,6 +49,7 @@ def _make_portfolio(
|
||||
active_strategy_id: str | None = None,
|
||||
strategy_name: str | None = None,
|
||||
strategy_params: dict | None = None,
|
||||
strategy_revision: int | None = None,
|
||||
markets: list[str] | None = None,
|
||||
) -> Portfolio:
|
||||
return Portfolio(
|
||||
@@ -69,6 +70,7 @@ def _make_portfolio(
|
||||
active_strategy_id=active_strategy_id,
|
||||
strategy_name=strategy_name,
|
||||
strategy_params=strategy_params if strategy_params is not None else {},
|
||||
strategy_revision=strategy_revision,
|
||||
markets=markets if markets is not None else [],
|
||||
)
|
||||
|
||||
@@ -375,6 +377,7 @@ def test_req055_ac1_bound_portfolio_returns_strategy_fields() -> None:
|
||||
active_strategy_id=_STRATEGY_ID,
|
||||
strategy_name="tvr_v1",
|
||||
strategy_params={"target_weight": 0.5, "rebalance_band_pct": 0.05},
|
||||
strategy_revision=7,
|
||||
markets=["KRX"],
|
||||
)
|
||||
repo = _FakeListRepo(portfolios=[p])
|
||||
@@ -387,6 +390,7 @@ def test_req055_ac1_bound_portfolio_returns_strategy_fields() -> None:
|
||||
assert item["active_strategy_id"] == _STRATEGY_ID
|
||||
assert item["strategy_name"] == "tvr_v1"
|
||||
assert item["strategy_params"] == {"target_weight": 0.5, "rebalance_band_pct": 0.05}
|
||||
assert item["strategy_revision"] == 7
|
||||
|
||||
|
||||
def test_req055_ac2_unbound_portfolio_returns_null_strategy() -> None:
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Contract tests for the versioned strategy catalog API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import StrategySet
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class FakeStrategyRepo:
|
||||
def __init__(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self.strategies = {
|
||||
"active-1": StrategySet(
|
||||
id="active-1", name="target_value", config="{}", nick="target_value:base",
|
||||
status="active", revision=1, created_at=now, updated_at=now,
|
||||
),
|
||||
"inactive-1": StrategySet(
|
||||
id="inactive-1", name="archived", config="{}", nick="archived:base",
|
||||
status="inactive", revision=3, created_at=now, updated_at=now,
|
||||
),
|
||||
}
|
||||
self.referenced: set[str] = set()
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def list_strategy_sets(self, *, include_inactive: bool = True) -> list[StrategySet]:
|
||||
values = list(self.strategies.values())
|
||||
return values if include_inactive else [item for item in values if item.status == "active"]
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self.strategies.get(id)
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
return self.create_strategy_set(name, config)
|
||||
|
||||
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
|
||||
existing = next(
|
||||
(item for item in self.strategies.values() if item.name == name and item.config == config),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
now = datetime.now(timezone.utc)
|
||||
item = StrategySet(
|
||||
id=f"created-{len(self.strategies)}",
|
||||
name=name,
|
||||
config=config,
|
||||
nick=f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}",
|
||||
status=status,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.strategies[item.id] = item
|
||||
return item
|
||||
|
||||
def update_strategy_set(
|
||||
self,
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet:
|
||||
item = self.strategies.get(id)
|
||||
if item is None:
|
||||
raise ValueError("전략을 찾을 수 없습니다")
|
||||
final_name = name if name is not None else item.name
|
||||
final_config = config if config is not None else item.config
|
||||
if any(
|
||||
other.id != id and other.name == final_name and other.config == final_config
|
||||
for other in self.strategies.values()
|
||||
):
|
||||
raise ValueError("동일한 name/config 전략이 이미 존재합니다")
|
||||
final_status = status if status is not None else item.status
|
||||
if (final_name, final_config, final_status) == (item.name, item.config, item.status):
|
||||
return item
|
||||
item.name = final_name
|
||||
item.config = final_config
|
||||
item.status = final_status
|
||||
item.nick = f"{final_name}:{hashlib.sha1(final_config.encode()).hexdigest()[:12]}"
|
||||
item.revision += 1
|
||||
item.updated_at = datetime.now(timezone.utc)
|
||||
return item
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None:
|
||||
if id in self.referenced:
|
||||
raise ValueError("Strategy set is in use by 1 portfolio(s) and cannot be deleted")
|
||||
if id not in self.strategies:
|
||||
raise ValueError("전략을 찾을 수 없습니다")
|
||||
del self.strategies[id]
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
settings = Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
admin_tokens_raw="admin-token",
|
||||
read_tokens_raw="read-token",
|
||||
)
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _repos(repo: FakeStrategyRepo) -> ExitStack:
|
||||
stack = ExitStack()
|
||||
stack.enter_context(patch("app.api.v1_strategies.get_portfolio_repository_from_settings", return_value=repo))
|
||||
stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo))
|
||||
return stack
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_public_catalog_excludes_inactive_and_inactive_lookup_conflicts() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
response = _client().get("/v1/strategies", headers=_auth("read-token"))
|
||||
inactive = _client().get("/v1/strategies/inactive-1", headers=_auth("read-token"))
|
||||
assert response.status_code == 200
|
||||
assert [item["strategy_id"] for item in response.json()] == ["active-1"]
|
||||
assert inactive.status_code == 409
|
||||
|
||||
|
||||
def test_admin_catalog_requires_admin_and_reports_missing_strategy() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
forbidden = _client().get("/v1/admin/strategies", headers=_auth("read-token"))
|
||||
missing = _client().get("/v1/admin/strategies/missing", headers=_auth("admin-token"))
|
||||
assert forbidden.status_code == 403
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_admin_create_and_patch_preserve_id_and_revision_semantics() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "momentum", "config": {"window": 20}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
strategy_id = created.json()["strategy_id"]
|
||||
changed = _client().patch(
|
||||
f"/v1/admin/strategies/{strategy_id}",
|
||||
json={"name": "momentum-v2"},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
no_op = _client().patch(
|
||||
f"/v1/admin/strategies/{strategy_id}",
|
||||
json={"name": "momentum-v2"},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["revision"] == 1
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["strategy_id"] == strategy_id
|
||||
assert changed.json()["revision"] == 2
|
||||
assert no_op.json()["revision"] == 2
|
||||
assert repo.strategies[strategy_id].nick.startswith("momentum-v2:")
|
||||
|
||||
|
||||
def test_admin_create_inactive_starts_at_revision_one() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "retired", "config": {}, "status": "inactive"},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["status"] == "inactive"
|
||||
assert created.json()["revision"] == 1
|
||||
|
||||
|
||||
def test_admin_patch_validation_conflict_and_reference_safe_delete() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
repo.referenced.add("active-1")
|
||||
with _repos(repo):
|
||||
empty = _client().patch("/v1/admin/strategies/active-1", json={}, headers=_auth("admin-token"))
|
||||
conflict = _client().patch(
|
||||
"/v1/admin/strategies/inactive-1",
|
||||
json={"name": "target_value"},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
referenced = _client().delete("/v1/admin/strategies/active-1", headers=_auth("admin-token"))
|
||||
deleted = _client().delete("/v1/admin/strategies/inactive-1", headers=_auth("admin-token"))
|
||||
assert empty.status_code == 422
|
||||
assert conflict.status_code == 409
|
||||
assert referenced.status_code == 409
|
||||
assert deleted.status_code == 204
|
||||
|
||||
|
||||
def test_legacy_strategy_set_routes_are_removed() -> None:
|
||||
response = _client().get("/strategy-sets", headers=_auth("admin-token"))
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user