from __future__ import annotations import json import uuid from datetime import datetime, timezone import app.cli.commands.health as health_commands from typer.testing import CliRunner from app.cli.main import create_app from app.core.config import Settings from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet, User class FakeRepo: def __init__(self, portfolio: Portfolio | None = None) -> None: self._portfolio = portfolio self.entries: list[LedgerEntry] = [] self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set} self._users: dict[str, User] = {} self._user_seq = 1 self._comments: dict[str, PortfolioComment] = {} self.closed = False def close(self) -> None: self.closed = True def ensure_schema(self) -> None: return None def ping(self) -> None: return None def get_user(self, user_id: str) -> User: user = self._users.get(user_id) if user is None: raise ValueError("사용자를 찾을 수 없습니다") return user def list_users(self) -> list[User]: return sorted(self._users.values(), key=lambda user: user.username) def create_user( self, username: str, *, email: str | None = None, password_hash: str | None = None, permission: str = "read", is_active: bool = True, ) -> User: if any(user.username == username for user in self._users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(user.email == email for user in self._users.values()): raise ValueError(f"이미 존재하는 email입니다: {email}") 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 update_user( self, user_id: str, *, username: str | None = None, email: str | None = None, password_hash: str | None = None, permission: str | None = None, is_active: bool | None = None, ) -> User: user = self.get_user(user_id) if username is not None and any(item.id != user_id and item.username == username for item in self._users.values()): raise ValueError(f"이미 존재하는 username입니다: {username}") if email is not None and any(item.id != user_id and item.email == email for item in self._users.values()): raise ValueError(f"이미 존재하는 email입니다: {email}") if username is not None: user.username = username if email is not None: user.email = email if password_hash is not None: user.password_hash = password_hash if permission is not None: user.permission = permission if is_active is not None: user.is_active = is_active user.updated_at = datetime.now(timezone.utc) return user def delete_user(self, user_id: str) -> None: if user_id not in self._users: raise ValueError("사용자를 찾을 수 없습니다") del self._users[user_id] 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, *, 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: if self._portfolio is None: self._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, ) return self._portfolio def create_portfolio( self, portfolio_id: str, *, owner_user_id: str, broker: str, status: str, name: str, symbol: str, cash_balance: float, asset_quantity: float, asset_price: float, profit_buffer: float, active_strategy_id: str | None, avabile_strategies: list[str] | None, ) -> Portfolio: self._portfolio = Portfolio( portfolio_id=portfolio_id, broker=broker, status=status, name=name, symbol=symbol, 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, ) return self._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, ) -> list[Portfolio]: if self._portfolio is None: return [] if broker is not None and self._portfolio.broker != broker: return [] if status is not None and self._portfolio.status != status: return [] if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id: return [] return [self._portfolio] def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3") self._strategy_sets[self.strategy_set.id] = self.strategy_set return self.strategy_set 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]: from datetime import datetime, timezone 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: if id in self._strategy_sets: del self._strategy_sets[id] 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: self._portfolio = 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 self.entries[: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 portfolio.active_strategy_id = None self.entries = [] 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._portfolio = None if clear_ledger: self.entries = [] 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 _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 def _portfolio() -> Portfolio: return Portfolio( portfolio_id="005930:tv", broker="kis", status="enabled", name="Samsung Electronics", symbol="005930", 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 test_cli_list_renders_table() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "list"]) assert result.exit_code == 0 assert "005930:tv" in result.stdout assert "Portfolios" in result.stdout def test_cli_status_supports_json_output() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["--format", "json", "portfolio", "status", "005930:tv"]) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["portfolio"]["portfolio_id"] == "005930:tv" def test_cli_create_buy_and_delete_flow() -> None: repo = FakeRepo() runner = build_runner(repo) create_result = runner.invoke( runner.app, [ "portfolio", "create", "005930:tv", "--symbol", "005930", "--cash-balance", "1000000", "--strategy-params", '{"target_asset_ratio": 0.5}', "--force", ], ) buy_result = runner.invoke( runner.app, [ "portfolio", "buy", "005930:tv", "--quantity", "2", "--price", "50000", "--fee", "100", "--force", ], ) assert repo.entries[0].action == "BUY" delete_result = runner.invoke(runner.app, ["portfolio", "delete", "005930:tv", "--keep-ledger", "--force"]) assert create_result.exit_code == 0 assert buy_result.exit_code == 0 assert delete_result.exit_code == 0 assert "Deleted portfolio: 005930:tv" in delete_result.stdout def test_cli_write_command_prompts_before_create() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke( runner.app, [ "portfolio", "create", "005930:tv", "--symbol", "005930", "--cash-balance", "1000000", ], input="y\n", ) assert result.exit_code == 0 assert "Create portfolio 005930:tv for symbol 005930?" in result.stdout def test_cli_write_command_can_abort() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke( runner.app, [ "portfolio", "create", "005930:tv", "--symbol", "005930", "--cash-balance", "1000000", ], input="n\n", ) assert result.exit_code == 0 assert repo._portfolio is None def test_cli_health_check_reports_repository() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke(runner.app, ["health-check"]) assert result.exit_code == 0 assert "repository: FakeRepo" in result.stdout def test_cli_init_db_reports_created(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) def fake_initialize_database(*, settings, maintenance_db: str = "postgres") -> dict[str, object]: return { "database": settings.active_db_name, "host": settings.active_db_host, "port": settings.active_db_port, "created": True, "schema_initialized": True, "migration_applied": "head", } monkeypatch.setattr(health_commands, "initialize_database", fake_initialize_database) result = runner.invoke(runner.app, ["init-db"]) assert result.exit_code == 0 assert "created: True" in result.stdout assert "schema_initialized: True" in result.stdout assert "migration_applied: head" in result.stdout def test_cli_init_db_supports_json(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) def fake_initialize_database(*, settings, maintenance_db: str = "postgres") -> dict[str, object]: return { "database": "portfoliohub", "host": "localhost", "port": 5432, "created": False, "schema_initialized": True, "migration_applied": "head", } monkeypatch.setattr(health_commands, "initialize_database", fake_initialize_database) result = runner.invoke(runner.app, ["--format", "json", "init-db"]) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["database"] == "portfoliohub" assert payload["schema_initialized"] is True def test_cli_migrate_db_reports_head(monkeypatch) -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) def fake_bootstrap_or_upgrade_to_head(settings) -> None: return None monkeypatch.setattr(health_commands, "bootstrap_or_upgrade_to_head", fake_bootstrap_or_upgrade_to_head) result = runner.invoke(runner.app, ["migrate-db"]) assert result.exit_code == 0 assert "migration_applied: head" in result.stdout def test_cli_user_list_renders_table() -> None: repo = FakeRepo() repo.create_user("alice", email="alice@example.com") runner = build_runner(repo) result = runner.invoke(runner.app, ["user", "list"]) assert result.exit_code == 0 assert "Users" in result.stdout assert "alice" in result.stdout def test_cli_user_create_supports_json() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke( runner.app, [ "--format", "json", "user", "create", "--username", "alice", "--password", "password123", "--email", "alice@example.com", "--force", ], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["username"] == "alice" assert payload["email"] == "alice@example.com" def test_cli_user_update_and_delete_flow() -> None: repo = FakeRepo() user = repo.create_user("alice", email="alice@example.com") runner = build_runner(repo) update_result = runner.invoke( runner.app, ["user", "update", user.id, "--username", "alice-updated", "--force"], ) delete_result = runner.invoke(runner.app, ["user", "delete", user.id, "--force"]) assert update_result.exit_code == 0 assert "alice-updated" in update_result.stdout assert delete_result.exit_code == 0 assert f"Deleted user: {user.id}" in delete_result.stdout def test_cli_comment_add_and_list() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke( runner.app, ["portfolio", "comment", "add", "005930:tv", "--content", "First comment"], ) list_result = runner.invoke(runner.app, ["portfolio", "comment", "list", "005930:tv"]) assert add_result.exit_code == 0 assert "First comment" in add_result.stdout assert list_result.exit_code == 0 assert "First comment" in list_result.stdout def test_cli_comment_add_supports_json_output() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "JSON comment"], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["content"] == "JSON comment" assert payload["portfolio_id"] == "005930:tv" assert "id" in payload def test_cli_comment_update_flow() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "Original"], ) assert add_result.exit_code == 0 comment_id = json.loads(add_result.stdout)["id"] update_result = runner.invoke( runner.app, [ "--format", "json", "portfolio", "comment", "update", "005930:tv", comment_id, "--content", "Updated", "--force", ], ) assert update_result.exit_code == 0 updated = json.loads(update_result.stdout) assert updated["content"] == "Updated" assert updated["id"] == comment_id def test_cli_comment_delete_flow() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) add_result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "To delete"], ) assert add_result.exit_code == 0 comment_id = json.loads(add_result.stdout)["id"] delete_result = runner.invoke( runner.app, ["portfolio", "comment", "delete", "005930:tv", comment_id, "--force"], ) list_result = runner.invoke(runner.app, ["portfolio", "comment", "list", "005930:tv"]) assert delete_result.exit_code == 0 assert comment_id in delete_result.stdout assert "To delete" not in list_result.stdout def test_cli_comment_multiline_content() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) multiline = "Line 1\nLine 2\nLine 3" result = runner.invoke( runner.app, ["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", multiline], ) assert result.exit_code == 0 payload = json.loads(result.stdout) assert payload["content"] == multiline def test_cli_comment_list_nonexistent_portfolio() -> None: repo = FakeRepo() runner = build_runner(repo) result = runner.invoke(runner.app, ["portfolio", "comment", "list", "nonexistent:id"]) assert result.exit_code == 1