Files
portfolio-hub/tests/test_cli.py
T
genichin db5f476a22 refactor: change portfolio list output format from table to line-based
- Replace Rich Table format with line-based output for portfolio list display
- Each portfolio item now displays as individual key-value pairs
- Simplified output structure with better readability
- Update test assertion to match new output format
2026-05-11 00:50:48 +00:00

1465 lines
46 KiB
Python

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.core.security import hash_token, token_prefix
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, 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._tags: dict[str, PortfolioTag] = {}
self._auth_tokens: dict[str, AuthToken] = {}
self._token_seq = 1
self._broker_credentials: dict[tuple[str, str], BrokerCredential] = {}
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_user_by_username(self, username: str):
return next((u for u in self._users.values() if u.username == username), None)
def get_service_token_by_name(self, name: str) -> AuthToken | None:
return next((t for t in self._auth_tokens.values() if t.name == name and t.token_type == "service"), None)
def create_auth_token(
self,
user_id: str,
*,
name: str,
token_hash: str,
token_prefix: str,
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
token = AuthToken(
id=f"token-{self._token_seq}",
user_id=user_id,
name=name,
token_prefix=token_prefix,
permission=permission,
token_type=token_type,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
runtime_id=runtime_id,
)
self._token_seq += 1
self._auth_tokens[token.id] = token
return token
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
token = self._auth_tokens.get(token_id)
if token is None or token.user_id != user_id:
raise ValueError("토큰을 찾을 수 없습니다")
token.revoked_at = datetime.now(timezone.utc)
def rotate_service_token(
self,
token_id: str,
token_hash: str,
token_prefix: str,
runtime_id: str,
) -> AuthToken:
token = self._auth_tokens.get(token_id)
if token is None or token.revoked_at is not None:
raise ValueError("토큰을 찾을 수 없습니다")
token.token_prefix = token_prefix
token.runtime_id = runtime_id
return token
def list_service_tokens(
self,
user_id: str | None = None,
*,
include_revoked: bool = False,
) -> list[AuthToken]:
results = [
t for t in self._auth_tokens.values()
if t.token_type == "service"
and (user_id is None or t.user_id == user_id)
and (include_revoked or t.revoked_at is None)
]
return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True)
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,
include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None,
match_mode: 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 []
tags = set(self._portfolio.tags or [])
if include_tags:
required = set(include_tags)
mode = (match_mode or "OR").upper()
if mode == "AND" and not required.issubset(tags):
return []
if mode != "AND" and tags.isdisjoint(required):
return []
if exclude_tags and tags.intersection(exclude_tags):
return []
return [self._portfolio]
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
portfolio = self.get(portfolio_id)
normalized = tag.strip()
key = f"{portfolio_id}:{normalized}"
if key in self._tags:
raise ValueError(f"이미 연결된 태그입니다: {normalized}")
created = datetime.now(timezone.utc)
item = PortfolioTag(portfolio_id=portfolio_id, tag=normalized, created_at=created)
self._tags[key] = item
portfolio.tags = sorted([*list(portfolio.tags or []), normalized])
return item
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
self.get(portfolio_id)
return sorted(
[tag for tag in self._tags.values() if tag.portfolio_id == portfolio_id],
key=lambda item: item.tag,
)
def remove_tag(self, portfolio_id: str, *, tag: str) -> None:
portfolio = self.get(portfolio_id)
normalized = tag.strip()
key = f"{portfolio_id}:{normalized}"
if key not in self._tags:
raise ValueError("태그를 찾을 수 없습니다")
del self._tags[key]
portfolio.tags = [item for item in (portfolio.tags or []) if item != normalized]
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]
# --- broker credentials ---
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
) -> BrokerCredential:
key = (user_id, broker)
existing = self._broker_credentials.get(key)
version = (existing.credential_version + 1) if existing else 1
credential = BrokerCredential(
user_id=user_id,
broker=broker,
account_ref=account_ref,
encrypted_payload=encrypted_payload,
credential_version=version,
last_rotated_at=datetime.now(timezone.utc),
)
self._broker_credentials[key] = credential
return credential
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
return [c for (uid, _), c in self._broker_credentials.items() if uid == user_id]
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
return self._broker_credentials.get((user_id, broker))
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
key = (user_id, broker)
if key in self._broker_credentials:
del self._broker_credentials[key]
return True
return False
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()
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
assert result.exit_code == 0
assert "005930:tv" in result.stdout
assert "id: 005930:tv" 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()
user = repo.create_user("alice", permission="read")
runner = build_runner(repo)
create_result = runner.invoke(
runner.app,
[
"portfolio",
"create",
"005930:tv",
"--symbol",
"005930",
"--user",
"alice",
"--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"
assert repo._portfolio.owner_user_id == user.id
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()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
[
"portfolio",
"create",
"005930:tv",
"--symbol",
"005930",
"--user",
"alice",
"--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()
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
[
"portfolio",
"create",
"005930:tv",
"--symbol",
"005930",
"--user",
"alice",
"--cash-balance",
"1000000",
],
input="n\n",
)
assert result.exit_code == 0
assert repo._portfolio is None
def test_cli_portfolio_create_without_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "005930:tv", "--symbol", "005930", "--force"],
)
assert result.exit_code == 1
assert "--user" in result.stdout
def test_cli_portfolio_create_with_valid_user_sets_owner() -> None:
repo = FakeRepo()
user = repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "alice", "--force"],
)
assert result.exit_code == 0
assert repo._portfolio is not None
assert repo._portfolio.owner_user_id == user.id
def test_cli_portfolio_create_with_unknown_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["portfolio", "create", "005930:tv", "--symbol", "005930", "--user", "nobody", "--force"],
)
assert result.exit_code == 1
assert "nobody" in result.stdout
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
def test_cli_tag_add_and_list() -> None:
repo = FakeRepo(_portfolio())
runner = build_runner(repo)
add_result = runner.invoke(runner.app, ["portfolio", "tag", "add", "005930:tv", "--tag", " grade:A "])
list_result = runner.invoke(runner.app, ["--format", "json", "portfolio", "tag", "list", "005930:tv"])
assert add_result.exit_code == 0
assert "grade:A" in add_result.stdout
assert list_result.exit_code == 0
payload = json.loads(list_result.stdout)
assert payload[0]["tag"] == "grade:A"
def test_cli_list_supports_tag_filters() -> None:
repo = FakeRepo()
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
repo.add_tag("005930:tv", tag="grade:A")
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND", "--user", "alice"])
assert result.exit_code == 0
assert "005930:tv" in result.stdout
def test_cli_tag_remove_flow() -> None:
repo = FakeRepo(_portfolio())
repo.add_tag("005930:tv", tag="grade:A")
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "tag", "remove", "005930:tv", "--tag", "grade:A", "--force"])
assert result.exit_code == 0
assert "Removed tag: grade:A" in result.stdout
def test_cli_help_exposes_bot_token_commands() -> None:
repo = FakeRepo(_portfolio())
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "--help"])
assert result.exit_code == 0
assert "issue" in result.stdout
assert "revoke" in result.stdout
def test_cli_service_token_issue_supports_json() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "issue", "nightly", "--user", "alice"],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["name"] == "nightly"
assert payload["revoked"] is False
assert payload["expires_at"] is not None
def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None:
repo = FakeRepo(_portfolio())
repo.create_user("alice", permission="read")
runner = build_runner(repo)
result = runner.invoke(
runner.app,
[
"service-token",
"issue",
"nightly",
"--user", "alice",
"--ttl-days",
"7",
"--no-expiry",
],
)
assert result.exit_code == 1
assert "동시에 사용할 수 없습니다" in result.stdout
def test_cli_service_token_revoke_can_abort() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="to-revoke",
token_hash=hash_token("token-value"),
token_prefix=token_prefix("token-value"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["service-token", "revoke", "to-revoke"],
input="n\n",
)
assert result.exit_code == 0
assert repo._auth_tokens[token.id].revoked_at is None
def test_cli_service_token_revoke_force_succeeds() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="to-revoke",
token_hash=hash_token("token-value"),
token_prefix=token_prefix("token-value"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app,
["--format", "json", "service-token", "revoke", "to-revoke", "--force"],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["name"] == "to-revoke"
assert payload["revoked"] is True
def test_cli_service_token_list_returns_active_tokens() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="active-token",
token_hash=hash_token("tok-1"),
token_prefix=token_prefix("tok-1"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["--format", "json", "service-token", "list"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert isinstance(payload, list)
assert len(payload) == 1
assert payload[0]["name"] == "active-token"
def test_cli_service_token_list_excludes_revoked_by_default() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-token",
token_hash=hash_token("tok-2"),
token_prefix=token_prefix("tok-2"),
permission="read",
token_type="service",
)
repo.revoke_auth_token(user.id, token.id)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["--format", "json", "service-token", "list"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload == []
def test_cli_service_token_list_show_revoked_includes_revoked() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-token",
token_hash=hash_token("tok-3"),
token_prefix=token_prefix("tok-3"),
permission="read",
token_type="service",
)
repo.revoke_auth_token(user.id, token.id)
runner = build_runner(repo)
result = runner.invoke(
runner.app, ["--format", "json", "service-token", "list", "--show-revoked"]
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert len(payload) == 1
assert payload[0]["name"] == "old-token"
assert payload[0]["revoked_at"] is not None
def test_cli_service_token_list_filter_by_user() -> None:
repo = FakeRepo(_portfolio())
alice = repo.create_user("alice", permission="read")
bob = repo.create_user("bob", permission="read")
repo.create_auth_token(
alice.id,
name="alice-token",
token_hash=hash_token("tok-a"),
token_prefix=token_prefix("tok-a"),
permission="read",
token_type="service",
)
repo.create_auth_token(
bob.id,
name="bob-token",
token_hash=hash_token("tok-b"),
token_prefix=token_prefix("tok-b"),
permission="read",
token_type="service",
)
runner = build_runner(repo)
result = runner.invoke(
runner.app, ["--format", "json", "service-token", "list", "--user", "alice"]
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert len(payload) == 1
assert payload[0]["name"] == "alice-token"
def test_cli_service_token_list_unknown_user_exits_error() -> None:
repo = FakeRepo(_portfolio())
runner = build_runner(repo)
result = runner.invoke(
runner.app, ["service-token", "list", "--user", "nobody"]
)
assert result.exit_code == 1
assert "nobody" in result.stdout
def test_cli_service_token_list_empty_message() -> None:
repo = FakeRepo(_portfolio())
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "list"])
assert result.exit_code == 0
assert "No service tokens found." in result.stdout
# ---------------------------------------------------------------------------
# service-token rotate
# ---------------------------------------------------------------------------
def test_cli_service_token_rotate_no_expiry_succeeds() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
# no-expiry 토큰 발급 (expires_at=None)
repo.create_auth_token(
user.id,
name="permanent",
token_hash="oldhash",
token_prefix="oldpref",
permission="read",
token_type="service",
expires_at=None,
runtime_id="old-runtime-id",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "permanent", "--force"])
assert result.exit_code == 0
assert "token:" in result.stdout
# token_prefix가 새 값으로 교체되었는지 확인
rotated = repo.get_service_token_by_name("permanent")
assert rotated is not None
assert rotated.token_prefix != "oldpref"
assert rotated.runtime_id != "old-runtime-id"
def test_cli_service_token_rotate_unknown_name_exits_error() -> None:
repo = FakeRepo(_portfolio())
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "nonexistent", "--force"])
assert result.exit_code == 1
assert "nonexistent" in result.stdout
def test_cli_service_token_rotate_revoked_token_exits_error() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
token = repo.create_auth_token(
user.id,
name="old-token",
token_hash="hash",
token_prefix="pref",
permission="read",
token_type="service",
expires_at=None,
runtime_id="rid",
)
repo.revoke_auth_token(user.id, token.id)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "old-token", "--force"])
assert result.exit_code == 1
assert "old-token" in result.stdout
def test_cli_service_token_rotate_with_expiry_exits_error() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="expiring",
token_hash="hash",
token_prefix="pref",
permission="read",
token_type="service",
expires_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
runtime_id="rid",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "expiring", "--force"])
assert result.exit_code == 1
assert "expiring" in result.stdout
def test_cli_service_token_rotate_can_abort() -> None:
repo = FakeRepo(_portfolio())
user = repo.create_user("alice", permission="read")
repo.create_auth_token(
user.id,
name="permanent",
token_hash="hash",
token_prefix="pref",
permission="read",
token_type="service",
expires_at=None,
runtime_id="rid",
)
runner = build_runner(repo)
result = runner.invoke(runner.app, ["service-token", "rotate", "permanent"], input="n\n")
assert result.exit_code == 0
# token_prefix 변경 없음
token = repo.get_service_token_by_name("permanent")
assert token is not None
assert token.token_prefix == "pref"
# ---------------------------------------------------------------------------
# portfolio list --user (REQ-013)
# ---------------------------------------------------------------------------
def test_cli_portfolio_list_without_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list"])
assert result.exit_code == 1
assert "--user" in result.stdout
def test_cli_portfolio_list_with_valid_user_filters_by_owner() -> None:
repo = FakeRepo()
alice = repo.create_user("alice", permission="read")
bob = repo.create_user("bob", permission="read")
alice_portfolio = _portfolio()
alice_portfolio.owner_user_id = alice.id
repo._portfolio = alice_portfolio
runner = build_runner(repo)
result_alice = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
result_bob = runner.invoke(runner.app, ["portfolio", "list", "--user", "bob"])
assert result_alice.exit_code == 0
assert "005930:tv" in result_alice.stdout
assert result_bob.exit_code == 0
assert "005930:tv" not in result_bob.stdout
def test_cli_portfolio_list_with_unknown_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "nobody"])
assert result.exit_code == 1
assert "nobody" in result.stdout
def test_cli_portfolio_list_in_memory_mode_filters_owner() -> None:
repo = FakeRepo()
user = repo.create_user("alice", permission="read")
p = _portfolio()
p.owner_user_id = user.id
repo._portfolio = p
runner = build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "list", "--user", "alice"])
assert result.exit_code == 0
assert "005930:tv" in result.stdout
# ---------------------------------------------------------------------------
# credential command tests
# ---------------------------------------------------------------------------
_TEST_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
def _settings_with_enc_key() -> Settings:
return Settings(
db_enabled=True,
allowed_origins_raw="http://localhost:3000",
credential_encryption_key=_TEST_ENC_KEY,
)
def build_runner_with_enc_key(repo: FakeRepo) -> CliRunner:
app = create_app(settings_factory=_settings_with_enc_key, repository_factory=lambda settings: repo)
runner = CliRunner()
runner.app = app
return runner
def test_cli_credential_set_creates_record() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="write")
runner = build_runner_with_enc_key(repo)
result = runner.invoke(
runner.app,
[
"credential", "set",
"--user", "alice",
"--broker", "kis",
"--account-ref", "12345678",
"--app-key", "PS_APP_KEY",
"--app-secret", "PS_APP_SECRET",
"--force",
],
)
assert result.exit_code == 0
assert "kis" in result.stdout
assert "12345678" in result.stdout
assert "PS_APP_KEY" not in result.stdout # encrypted_payload 미출력 확인
def test_cli_credential_list_hides_payload() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="write")
runner = build_runner_with_enc_key(repo)
runner.invoke(
runner.app,
[
"credential", "set",
"--user", "alice",
"--broker", "kis",
"--account-ref", "12345678",
"--app-key", "MYKEY",
"--app-secret", "MYSECRET",
"--force",
],
)
result = runner.invoke(runner.app, ["credential", "list", "--user", "alice"])
assert result.exit_code == 0
assert "kis" in result.stdout
assert "12345678" in result.stdout
assert "MYKEY" not in result.stdout
assert "MYSECRET" not in result.stdout
def test_cli_credential_set_with_unknown_user_exits_error() -> None:
repo = FakeRepo()
runner = build_runner_with_enc_key(repo)
result = runner.invoke(
runner.app,
[
"credential", "set",
"--user", "ghost",
"--broker", "kis",
"--account-ref", "REF",
"--app-key", "KEY",
"--app-secret", "SECRET",
"--force",
],
)
assert result.exit_code == 1
assert "ghost" in result.stdout
def test_cli_credential_remove_deletes_record() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="write")
runner = build_runner_with_enc_key(repo)
runner.invoke(
runner.app,
[
"credential", "set",
"--user", "alice",
"--broker", "kis",
"--account-ref", "REF",
"--app-key", "KEY",
"--app-secret", "SECRET",
"--force",
],
)
remove_result = runner.invoke(
runner.app,
["credential", "remove", "--user", "alice", "--broker", "kis", "--force"],
)
list_result = runner.invoke(runner.app, ["credential", "list", "--user", "alice"])
assert remove_result.exit_code == 0
assert list_result.exit_code == 0
assert "kis" not in list_result.stdout
def test_cli_credential_set_requires_encryption_key() -> None:
repo = FakeRepo()
repo.create_user("alice", permission="write")
runner = build_runner(repo) # CREDENTIAL_ENCRYPTION_KEY 미설정 runner
result = runner.invoke(
runner.app,
[
"credential", "set",
"--user", "alice",
"--broker", "kis",
"--account-ref", "REF",
"--app-key", "KEY",
"--app-secret", "SECRET",
"--force",
],
)
assert result.exit_code == 1
assert "CREDENTIAL_ENCRYPTION_KEY" in result.stdout