Files
portfolio-hub/tests/test_portfolio_uuid.py
T

627 lines
21 KiB
Python

"""Tests for BAT-006: portfolio UUID migration and name+user identification."""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.domain.models import Asset, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
class FakeRepo:
def __init__(self) -> None:
self._portfolios: list[Portfolio] = []
self._users: dict[str, User] = {}
self._user_seq = 1
self._strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:abc")
self._strategy_sets: dict[str, StrategySet] = {self._strategy_set.id: self._strategy_set}
self._entries: list[LedgerEntry] = []
self._comments: dict[str, PortfolioComment] = {}
self._tags: dict[str, PortfolioTag] = {}
self.closed = False
def close(self) -> None:
self.closed = True
def ensure_schema(self) -> None:
return None
def ping(self) -> None:
return None
def create_user(
self,
username: str,
*,
email: str | None = None,
password_hash: str | None = None,
permission: str = "read",
is_active: bool = True,
) -> User:
now = datetime.now(timezone.utc)
user = User(
id=f"user-{self._user_seq}",
username=username,
email=email,
permission=permission,
is_active=is_active,
password_hash=password_hash,
created_at=now,
updated_at=now,
)
self._user_seq += 1
self._users[user.id] = user
return user
def get_user(self, user_id: str) -> User:
user = self._users.get(user_id)
if user is None:
raise ValueError("사용자를 찾을 수 없습니다")
return user
def get_user_by_username(self, username: str) -> User | None:
return next((u for u in self._users.values() if u.username == username), None)
def list_users(self) -> list[User]:
return list(self._users.values())
def create_portfolio(
self,
portfolio_id: str,
*,
name: str,
owner_user_id: str,
broker: str,
status: str,
asset_id: str,
cash_balance: float,
asset_quantity: float,
asset_price: float,
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio:
if any(
p.owner_user_id == owner_user_id and p.name == name
for p in self._portfolios
):
raise ValueError(f"이미 사용 중인 이름입니다: {name}")
portfolio = Portfolio(
portfolio_id=portfolio_id,
broker=broker,
status=status,
name=name,
symbol=asset_id,
asset_id=asset_id,
owner_user_id=owner_user_id,
cash_balance=cash_balance,
asset_quantity=asset_quantity,
price=asset_price,
profit_buffer=profit_buffer,
active_strategy_id=active_strategy_id,
avabile_strategies=avabile_strategies,
)
self._portfolios.append(portfolio)
return portfolio
def get_asset_by_id(self, asset_id: str):
return Asset(asset_id=asset_id, symbol="TEST", markets=[], status="active")
def get_asset_by_symbol(self, symbol: str):
return Asset(asset_id=_TEST_ASSET_ID, symbol=symbol, markets=[], status="active")
def list_assets(self):
return []
def insert_asset(self, *, symbol: str, markets, display_name=None, status: str = "active"):
return None
def update_asset(self, asset_id: str, **kwargs):
return None
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | None:
return next(
(p for p in self._portfolios if p.owner_user_id == owner_user_id and p.name == name),
None,
)
def get(self, portfolio_id: str) -> Portfolio:
portfolio = next((p for p in self._portfolios if p.portfolio_id == portfolio_id), None)
if portfolio is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
return portfolio
def get_or_create(
self,
portfolio_id: str,
symbol: str,
*,
name: str | None = None,
broker: str = "kis",
status: str = "enabled",
active_strategy_id: str | None = None,
avabile_strategies: list[str] | None = None,
owner_user_id: str | None = None,
initial_cash: float = 0.0,
) -> Portfolio:
existing = next((p for p in self._portfolios if p.portfolio_id == portfolio_id), None)
if existing:
return existing
portfolio = Portfolio(
portfolio_id=portfolio_id,
broker=broker,
status=status,
name=name or symbol,
symbol=symbol,
owner_user_id=owner_user_id,
cash_balance=initial_cash,
active_strategy_id=active_strategy_id,
avabile_strategies=avabile_strategies,
)
self._portfolios.append(portfolio)
return portfolio
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: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
) -> list[Portfolio]:
results = self._portfolios
if broker is not None:
results = [p for p in results if p.broker == broker]
if status is not None:
results = [p for p in results if p.status == status]
if owner_user_id is not None:
results = [p for p in results if p.owner_user_id == owner_user_id]
return results
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
s = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:abc")
self._strategy_set = s
self._strategy_sets[s.id] = s
return s
def get_strategy_set(self, id: str) -> StrategySet | None:
return self._strategy_sets.get(id)
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:
self._strategy_sets.pop(id, None)
def save(
self,
portfolio: Portfolio,
*,
current_price: float,
action: str,
trade_price: float,
quantity: float,
fee_amount: float,
tax_amount: float,
total_value_after: float,
strategy_set_id: str = "manual",
note: str = "",
) -> None:
idx = next((i for i, p in enumerate(self._portfolios) if p.portfolio_id == portfolio.portfolio_id), None)
if idx is not None:
self._portfolios[idx] = portfolio
self._entries.insert(
0,
LedgerEntry(
portfolio_id=portfolio.portfolio_id,
recorded_at=datetime.now(timezone.utc),
action=action,
price=trade_price,
quantity=quantity,
fee_amount=fee_amount,
tax_amount=tax_amount,
cash_balance_after=portfolio.cash_balance,
asset_quantity_after=portfolio.asset_quantity,
total_value_after=total_value_after,
profit_buffer_after=portfolio.profit_buffer,
strategy_set_id=strategy_set_id,
note=note,
),
)
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
return [e for e in self._entries if e.portfolio_id == portfolio_id][:limit]
def reset(self, portfolio_id: str) -> Portfolio:
portfolio = self.get(portfolio_id)
portfolio.cash_balance = 0.0
portfolio.asset_quantity = 0.0
portfolio.price = 0.0
portfolio.profit_buffer = 0.0
return portfolio
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
portfolio = self.get(portfolio_id)
if action == "INVEST":
portfolio.profit_buffer += amount
else:
portfolio.profit_buffer -= amount
return portfolio
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
portfolio = self.get(portfolio_id)
if direction == "to-buffer":
portfolio.cash_balance -= amount
portfolio.profit_buffer += amount
else:
portfolio.cash_balance += amount
portfolio.profit_buffer -= amount
return portfolio
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None:
self._portfolios = [p for p in self._portfolios if p.portfolio_id != portfolio_id]
if clear_ledger:
self._entries = [e for e in self._entries if e.portfolio_id != portfolio_id]
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
key = f"{portfolio_id}:{tag}"
if key in self._tags:
raise ValueError(f"이미 연결된 태그입니다: {tag}")
item = PortfolioTag(portfolio_id=portfolio_id, tag=tag, created_at=datetime.now(timezone.utc))
self._tags[key] = item
return item
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
return [t for t in self._tags.values() if t.portfolio_id == portfolio_id]
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
key = f"{portfolio_id}:{tag}"
if key not in self._tags:
raise ValueError("태그를 찾을 수 없습니다")
del self._tags[key]
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
self.get(portfolio_id)
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]:
self.get(portfolio_id)
return [c for c in self._comments.values() if c.portfolio_id == portfolio_id]
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
self.get(portfolio_id)
comment = self._comments.get(comment_id)
if comment is None:
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:
self.get(portfolio_id)
if comment_id not in self._comments:
raise ValueError("댓글을 찾을 수 없습니다")
del self._comments[comment_id]
_TEST_ASSET_ID = "00000000-0000-0000-0000-000000000001"
def _settings() -> Settings:
return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000")
def build_runner(repo: FakeRepo) -> CliRunner:
app = create_app(settings_factory=_settings, repository_factory=lambda settings: repo)
runner = CliRunner()
runner.app = app
return runner
# --- REQ-016: portfolio_id is a server-generated UUID ---
def test_create_portfolio_assigns_uuid_id() -> None:
repo = FakeRepo()
alice = repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 0, result.output
assert len(repo._portfolios) == 1
portfolio = repo._portfolios[0]
assert portfolio.name == "SAMSUNG"
assert portfolio.owner_user_id == alice.id
try:
uuid.UUID(portfolio.portfolio_id)
except ValueError:
raise AssertionError(f"portfolio_id is not a valid UUID: {portfolio.portfolio_id!r}")
def test_create_returns_uuid_in_response() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--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
payload = json.loads(result.stdout)
portfolio_id = payload["portfolio"]["portfolio_id"]
uuid.UUID(portfolio_id) # must not raise
# --- REQ-017: CLI name+user identification ---
def test_status_resolves_by_name_and_user() -> None:
repo = FakeRepo()
alice = repo.create_user("alice", permission="read")
runner = build_runner(repo)
create_result = runner.invoke(
runner.app,
["--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
status_result = runner.invoke(
runner.app,
["--format", "json", "portfolio", "status", "MY_PORT", "--user", "alice"],
)
assert status_result.exit_code == 0, status_result.output
payload = json.loads(status_result.stdout)
assert payload["portfolio"]["name"] == "MY_PORT"
assert payload["portfolio"]["owner_user_id"] == alice.id
def test_status_fails_when_user_not_found() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "status", "MY_PORT", "--user", "ghost"],
)
assert result.exit_code == 1
assert "ghost" in result.output
def test_status_fails_when_portfolio_name_not_found_for_user() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "status", "NONEXISTENT", "--user", "alice"],
)
assert result.exit_code == 1
assert "NONEXISTENT" in result.output
def test_create_without_user_fails() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--strategy-id", "set-1", "--force"],
color=False,
terminal_width=200,
)
assert result.exit_code == 2
assert "Missing option '--user'" in result.output
def test_create_with_unknown_user_fails() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "ghost", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 1
assert "ghost" in result.output
def test_name_validation_rejects_invalid_characters() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "invalid name!", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 1
assert "invalid name!" in result.output or "유효하지 않은" in result.output
def test_name_validation_rejects_empty_name() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code != 0
def test_alice_and_bob_can_have_same_portfolio_name() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
repo.create_user("bob", permission="read")
runner = build_runner(repo)
alice_result = runner.invoke(
runner.app,
["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", "--strategy-id", "set-1", "--force"],
)
assert alice_result.exit_code == 0, alice_result.output
assert bob_result.exit_code == 0, bob_result.output
assert len(repo._portfolios) == 2
portfolio_ids = {p.portfolio_id for p in repo._portfolios}
assert len(portfolio_ids) == 2, "Alice and Bob must have distinct portfolio UUIDs"
def test_buy_resolves_portfolio_by_name_and_user() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
runner.invoke(
runner.app,
["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,
["portfolio", "buy", "MY_PORT", "--user", "alice", "--quantity", "1", "--price", "70000", "--force"],
)
assert buy_result.exit_code == 0, buy_result.output
assert len(repo._entries) == 1
assert repo._entries[0].action == "BUY"
def test_delete_resolves_portfolio_by_name_and_user() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
runner.invoke(
runner.app,
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
delete_result = runner.invoke(
runner.app,
["portfolio", "delete", "MY_PORT", "--user", "alice", "--force"],
)
assert delete_result.exit_code == 0, delete_result.output
assert "MY_PORT" in delete_result.output
assert len(repo._portfolios) == 0
def test_alice_cannot_delete_bobs_portfolio() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
repo.create_user("bob", permission="read")
runner = build_runner(repo)
runner.invoke(
runner.app,
["portfolio", "create", "SAMSUNG", "--asset-id", _TEST_ASSET_ID, "--user", "bob", "--strategy-id", "set-1", "--force"],
)
delete_result = runner.invoke(
runner.app,
["portfolio", "delete", "SAMSUNG", "--user", "alice", "--force"],
)
assert delete_result.exit_code == 1
assert len(repo._portfolios) == 1, "Bob's portfolio must not be deleted"
# ---------------------------------------------------------------------------
# name 검증: 한글 허용 (change-req CHG-20260627-03 in REQ-025)
# ---------------------------------------------------------------------------
def test_name_validation_accepts_korean() -> None:
"""한글 이름이 포함된 포트폴리오를 허용한다."""
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "삼성전자 포트폴리오", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
assert result.exit_code == 0, result.output
def test_name_validation_accepts_ascii_unchanged() -> None:
"""기존 ASCII name은 완화 후에도 통과한다."""
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["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
def test_name_validation_rejects_over_100_chars() -> None:
"""101자 초과 이름은 거부한다."""
from app.schemas.portfolio import CreatePortfolioRequest
import pytest
long_name = "가" * 101
with pytest.raises(Exception):
CreatePortfolioRequest(name=long_name, asset_id="some-uuid", strategy_id="set-1")
def test_update_new_name_renames_portfolio() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
runner.invoke(
runner.app,
["portfolio", "create", "OLD_NAME", "--asset-id", _TEST_ASSET_ID, "--user", "alice", "--strategy-id", "set-1", "--force"],
)
rename_result = runner.invoke(
runner.app,
["portfolio", "update", "OLD_NAME", "--user", "alice", "--new-name", "NEW_NAME", "--force"],
)
assert rename_result.exit_code == 0, rename_result.output
portfolio = repo._portfolios[0]
assert portfolio.name == "NEW_NAME"