"""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"], ) assert result.exit_code == 2 assert repo._portfolios == [] 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"