feat(REQ-001): add portfolio comment history API, CLI, repository, migration, and tests
Agent-Logs-Url: https://github.com/genichin/portfolio-hub/sessions/64fc2cec-78f5-41d1-99a4-45f868781edd Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
4684597353
commit
e8f782bec6
+155
-2
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import app.cli.commands.health as health_commands
|
||||
@@ -8,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, StrategySet, User
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -19,6 +20,7 @@ class FakeRepo:
|
||||
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:
|
||||
@@ -262,6 +264,47 @@ class FakeRepo:
|
||||
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")
|
||||
@@ -525,4 +568,114 @@ def test_cli_user_update_and_delete_flow() -> None:
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user