Files
portfolio-hub/tests/test_portfolio_comments.py
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

439 lines
14 KiB
Python

"""Tests for portfolio comment history API endpoints."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
import app.api.portfolios as portfolios_api
from app.core.config import get_settings
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet
from app.main import app
class FakeRepo:
def __init__(self, portfolio: Portfolio | None = None) -> None:
self._portfolio = portfolio
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
self._comments: dict[str, PortfolioComment] = {}
def close(self) -> None:
pass
def ensure_schema(self) -> None:
pass
def ping(self) -> None:
pass
def list_users(self):
return []
def get(self, portfolio_id: str) -> Portfolio:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
return self._portfolio
def get_or_create(self, portfolio_id: str, symbol: str, **kwargs) -> Portfolio:
raise NotImplementedError()
def create_portfolio(self, *args, **kwargs):
raise NotImplementedError()
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
portfolio = self.get(portfolio_id)
for key, value in fields.items():
if value is not None:
setattr(portfolio, key, value)
return portfolio
def list_portfolios(self, *, broker=None, status=None, owner_user_id=None, include_tags=None, exclude_tags=None, match_mode=None):
if owner_user_id is not None and self._portfolio and self._portfolio.owner_user_id != owner_user_id:
return []
return [self._portfolio] if self._portfolio else []
def add_tag(self, portfolio_id: str, *, tag: str):
raise NotImplementedError()
def list_tags(self, portfolio_id: str):
return []
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
raise NotImplementedError()
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 list_strategy_sets(self, *, include_inactive: bool = True):
return [self.strategy_set]
def delete_strategy_set(self, id: str) -> None:
pass
def save(self, portfolio, *, current_price, action, trade_price, quantity, fee_amount, tax_amount,
total_value_after, strategy_set_id="manual", note="") -> None:
pass
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
return []
def reset(self, portfolio_id: str) -> Portfolio:
return self.get(portfolio_id)
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
return self.get(portfolio_id)
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
return self.get(portfolio_id)
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None:
self._portfolio = None
def get_user(self, user_id: str):
raise ValueError("사용자를 찾을 수 없습니다")
def get_user_by_username(self, username: str):
return None
def create_user(self, *args, **kwargs):
raise NotImplementedError()
def update_user(self, *args, **kwargs):
raise NotImplementedError()
def delete_user(self, *args, **kwargs):
raise NotImplementedError()
def update_user_last_login(self, user_id: str) -> None:
pass
def create_auth_token(self, *args, **kwargs):
raise NotImplementedError()
def list_auth_tokens(self, user_id: str):
return []
def revoke_auth_token(self, *args, **kwargs):
raise NotImplementedError()
def create_bot_principal(self, *args, **kwargs):
raise NotImplementedError()
def get_bot_principal(self, bot_principal_id: str):
raise ValueError("봇을 찾을 수 없습니다")
def list_bot_principals(self, *, is_active=None, owner_user_id=None):
return []
def deactivate_bot_principal(self, *args, **kwargs):
raise NotImplementedError()
def list_bot_auth_tokens(self, bot_principal_id: str):
return []
def revoke_bot_auth_token(self, *args, **kwargs):
raise NotImplementedError()
def get_token_principal(self, token_hash: str):
return None
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
now = datetime.now(timezone.utc)
comment = PortfolioComment(
id=str(uuid.uuid4()),
portfolio_id=portfolio_id,
content=content,
created_at=now,
updated_at=now,
)
self._comments[comment.id] = comment
return comment
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
return sorted(
[c for c in self._comments.values() if c.portfolio_id == portfolio_id],
key=lambda c: c.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
comment = self._comments.get(comment_id)
if comment is None or comment.portfolio_id != portfolio_id:
raise ValueError("댓글을 찾을 수 없습니다")
comment.content = content
comment.updated_at = datetime.now(timezone.utc)
return comment
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
raise ValueError("포트폴리오를 찾을 수 없습니다")
comment = self._comments.get(comment_id)
if comment is None or comment.portfolio_id != portfolio_id:
raise ValueError("댓글을 찾을 수 없습니다")
del self._comments[comment_id]
def _portfolio() -> Portfolio:
return Portfolio(
portfolio_id="005930:tv",
broker="kis",
status="enabled",
name="Samsung Electronics",
symbol="005930",
owner_user_id="legacy-owner",
cash_balance=1_000_000.0,
asset_quantity=1.0,
price=70_000.0,
profit_buffer=50_000.0,
active_strategy_id="set-1",
avabile_strategies=["set-1"],
)
def _setup_client(monkeypatch, repo: FakeRepo) -> TestClient:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "write-token")
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
get_settings.cache_clear()
app.dependency_overrides.clear()
monkeypatch.setattr(portfolios_api, "get_portfolio_repository_from_settings", lambda settings: repo)
return TestClient(app)
def test_create_comment_returns_201(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
response = client.post(
"/portfolios/005930:tv/comments",
json={"content": "First comment"},
headers={"Authorization": "Bearer write-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 201
data = response.json()
assert data["content"] == "First comment"
assert data["portfolio_id"] == "005930:tv"
assert "id" in data
assert "created_at" in data
def test_list_comments_returns_200_sorted_by_created_desc(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
client.post(
"/portfolios/005930:tv/comments",
json={"content": "First"},
headers={"Authorization": "Bearer write-token"},
)
client.post(
"/portfolios/005930:tv/comments",
json={"content": "Second"},
headers={"Authorization": "Bearer write-token"},
)
response = client.get(
"/portfolios/005930:tv/comments",
headers={"Authorization": "Bearer read-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 200
items = response.json()
assert len(items) == 2
assert items[0]["content"] == "Second"
assert items[1]["content"] == "First"
def test_update_comment_returns_200(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
create_resp = client.post(
"/portfolios/005930:tv/comments",
json={"content": "Original"},
headers={"Authorization": "Bearer write-token"},
)
comment_id = create_resp.json()["id"]
update_resp = client.patch(
f"/portfolios/005930:tv/comments/{comment_id}",
json={"content": "Updated"},
headers={"Authorization": "Bearer write-token"},
)
app.dependency_overrides.clear()
assert update_resp.status_code == 200
data = update_resp.json()
assert data["content"] == "Updated"
assert data["id"] == comment_id
def test_delete_comment_returns_204(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
create_resp = client.post(
"/portfolios/005930:tv/comments",
json={"content": "To delete"},
headers={"Authorization": "Bearer write-token"},
)
comment_id = create_resp.json()["id"]
delete_resp = client.delete(
f"/portfolios/005930:tv/comments/{comment_id}",
headers={"Authorization": "Bearer write-token"},
)
list_resp = client.get(
"/portfolios/005930:tv/comments",
headers={"Authorization": "Bearer read-token"},
)
app.dependency_overrides.clear()
assert delete_resp.status_code == 204
assert all(c["id"] != comment_id for c in list_resp.json())
def test_create_comment_nonexistent_portfolio_returns_404(monkeypatch) -> None:
repo = FakeRepo()
client = _setup_client(monkeypatch, repo)
response = client.post(
"/portfolios/nonexistent/comments",
json={"content": "Should fail"},
headers={"Authorization": "Bearer write-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 404
def test_list_comments_nonexistent_portfolio_returns_404(monkeypatch) -> None:
repo = FakeRepo()
client = _setup_client(monkeypatch, repo)
response = client.get(
"/portfolios/nonexistent/comments",
headers={"Authorization": "Bearer read-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 404
def test_update_nonexistent_comment_returns_404(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
response = client.patch(
"/portfolios/005930:tv/comments/no-such-id",
json={"content": "Updated"},
headers={"Authorization": "Bearer write-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 404
def test_delete_nonexistent_comment_returns_404(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
response = client.delete(
"/portfolios/005930:tv/comments/no-such-id",
headers={"Authorization": "Bearer write-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 404
def test_create_comment_without_token_returns_401(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
response = client.post(
"/portfolios/005930:tv/comments",
json={"content": "No auth"},
)
app.dependency_overrides.clear()
assert response.status_code == 401
def test_create_comment_with_read_token_returns_403(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
response = client.post(
"/portfolios/005930:tv/comments",
json={"content": "Should be forbidden"},
headers={"Authorization": "Bearer read-token"},
)
app.dependency_overrides.clear()
assert response.status_code == 403
def test_comment_multiline_text_stored_and_retrieved(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
multiline = "Line 1\nLine 2\nLine 3"
create_resp = client.post(
"/portfolios/005930:tv/comments",
json={"content": multiline},
headers={"Authorization": "Bearer write-token"},
)
comment_id = create_resp.json()["id"]
list_resp = client.get(
"/portfolios/005930:tv/comments",
headers={"Authorization": "Bearer read-token"},
)
app.dependency_overrides.clear()
assert create_resp.status_code == 201
assert create_resp.json()["content"] == multiline
matching = [c for c in list_resp.json() if c["id"] == comment_id]
assert matching[0]["content"] == multiline
def test_update_comment_preserves_id_and_updates_updated_at(monkeypatch) -> None:
repo = FakeRepo(_portfolio())
client = _setup_client(monkeypatch, repo)
create_resp = client.post(
"/portfolios/005930:tv/comments",
json={"content": "Original"},
headers={"Authorization": "Bearer write-token"},
)
original = create_resp.json()
comment_id = original["id"]
update_resp = client.patch(
f"/portfolios/005930:tv/comments/{comment_id}",
json={"content": "Modified"},
headers={"Authorization": "Bearer write-token"},
)
updated = update_resp.json()
app.dependency_overrides.clear()
assert updated["id"] == comment_id
assert updated["content"] == "Modified"