feat(req-002): implement portfolio tagging and tag filters
This commit is contained in:
+79
-1
@@ -9,7 +9,7 @@ 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
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -21,6 +21,7 @@ class FakeRepo:
|
||||
self._users: dict[str, User] = {}
|
||||
self._user_seq = 1
|
||||
self._comments: dict[str, PortfolioComment] = {}
|
||||
self._tags: dict[str, PortfolioTag] = {}
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -179,6 +180,9 @@ class FakeRepo:
|
||||
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 []
|
||||
@@ -188,8 +192,46 @@ class FakeRepo:
|
||||
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
|
||||
@@ -691,3 +733,39 @@ def test_cli_comment_list_nonexistent_portfolio() -> None:
|
||||
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(_portfolio())
|
||||
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"])
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user