Files
portfolio-hub/tests/test_v1_strategies.py
T
genichin 6e02d92282 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)
2026-08-15 11:18:31 +09:00

206 lines
7.7 KiB
Python

"""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