feat: 태그 HTTP API(BAT-004) 및 pfhub tag CLI + baseline 문서 갱신(BAT-005)

BAT-004 (REQ-004, REQ-005):
- app/api/v1_tags.py: POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{id}/deactivate
- app/api/v1_portfolios.py: POST/DELETE /v1/portfolios/{id}/tags
- app/api/v1_assets.py: POST/DELETE /v1/assets/{id}/tags 엔드포인트 추가
- app/schemas/v1.py: TagItem, TagCreateRequest, TagListResponse, TagApplyRequest/Response 추가
- app/repositories/portfolio_repository.py, postgres.py: append/remove_portfolio_tag_id 추가
- app/main.py: v1_portfolios_router, v1_tags_router 등록
- tests/test_tags_http_api.py: 22개 HTTP API 테스트

BAT-005 (REQ-007, REQ-008):
- app/cli/commands/tag.py: pfhub tag create/list/deactivate/apply/remove 7개 커맨드 신규
- app/cli/main.py: tag_app 등록
- app/cli/commands/portfolio.py: pfhub portfolio tag add/list/remove 완전 제거(breaking change)
- app/cli/core/utils.py: print_tag_table + list[Tag] render 분기 추가
- tests/test_tags_cli.py: REQ-007 AC-1~9 커버 24개 CLI 테스트 신규
- tests/test_cli.py: 제거된 portfolio tag 테스트 2개 삭제
- docs/data-model.md, interface-contract.md, project-structure.md, runtime-flows.md: 태그 모델 baseline 갱신(REQ-008)
- docs/batches/: BAT-004, BAT-005 전체 산출물(release-candidate)

247 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 01:16:13 +00:00
co-authored by Claude Sonnet 4.6
parent 158929b2d9
commit d4b841bc37
29 changed files with 2216 additions and 89 deletions
-25
View File
@@ -937,20 +937,6 @@ def test_cli_comment_list_nonexistent_portfolio() -> None:
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, user = _repo_with_user(_portfolio())
repo.add_tag("005930:tv", tag="grade:A")
@@ -962,17 +948,6 @@ def test_cli_list_supports_tag_filters() -> None:
assert "Samsung_Electronics" 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)
+436
View File
@@ -0,0 +1,436 @@
"""Tests for pfhub tag CLI commands (REQ-007)."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.domain.models import Portfolio, Tag, User
def _settings() -> Settings:
return Settings(
db_enabled=True,
allowed_origins_raw="http://localhost:3000",
credential_encryption_key="7ApeKhDwGrxiPdlfNbWw2pylyU-XvTk4v3CiRDnNdV0=",
)
class FakeTagRepo:
def __init__(self) -> None:
self._tags: dict[str, Tag] = {}
self._portfolios: dict[str, Portfolio] = {}
self._users: dict[str, User] = {}
self._tag_ids: dict[str, list[str]] = {} # portfolio_id -> [tag_id, ...]
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_by_username(self, username: str) -> User | None:
return next((u for u in self._users.values() if u.username == username), None)
def add_user(self, user: User) -> None:
self._users[user.id] = user
def add_portfolio(self, portfolio: Portfolio) -> None:
self._portfolios[portfolio.portfolio_id] = portfolio
self._tag_ids.setdefault(portfolio.portfolio_id, [])
def get(self, portfolio_id: str) -> Portfolio | None:
return self._portfolios.get(portfolio_id)
def create_tag(self, *, name: str, is_admin_tag: bool = False, created_by_user_id: str) -> Tag:
tag_id = str(uuid.uuid4())
tag = Tag(
tag_id=tag_id,
name=name,
is_admin_tag=is_admin_tag,
is_active=True,
created_by_user_id=created_by_user_id,
created_at=datetime.now(timezone.utc),
)
self._tags[tag_id] = tag
return tag
def get_tag_by_id(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
tags = list(self._tags.values())
if not include_inactive:
tags = [t for t in tags if t.is_active]
return sorted(tags, key=lambda t: t.name)
def deactivate_tag(self, tag_id: str) -> Tag:
tag = self._tags[tag_id]
self._tags[tag_id] = Tag(
tag_id=tag.tag_id,
name=tag.name,
is_admin_tag=tag.is_admin_tag,
is_active=False,
created_by_user_id=tag.created_by_user_id,
created_at=tag.created_at,
)
return self._tags[tag_id]
def append_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
if portfolio_id not in self._portfolios:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
ids = self._tag_ids.setdefault(portfolio_id, [])
if tag_id not in ids:
ids.append(tag_id)
def remove_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
if portfolio_id not in self._portfolios:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
ids = self._tag_ids.get(portfolio_id, [])
if tag_id in ids:
ids.remove(tag_id)
def _build_runner(repo: FakeTagRepo) -> CliRunner:
app = create_app(settings_factory=_settings, repository_factory=lambda settings: repo)
runner = CliRunner()
runner.app = app
return runner
def _make_tag(
*,
name: str = "코스피200",
is_admin_tag: bool = True,
is_active: bool = True,
created_by_user_id: str = "user-1",
) -> Tag:
return Tag(
tag_id=str(uuid.uuid4()),
name=name,
is_admin_tag=is_admin_tag,
is_active=is_active,
created_by_user_id=created_by_user_id,
created_at=datetime.now(timezone.utc),
)
def _portfolio_for(owner_user_id: str = "user-1") -> Portfolio:
return Portfolio(
portfolio_id=str(uuid.uuid4()),
broker="kis",
status="enabled",
name="테스트포트폴리오",
symbol="005930",
asset_id=str(uuid.uuid4()),
owner_user_id=owner_user_id,
cash_balance=1_000_000.0,
asset_quantity=1.0,
)
# --- REQ-007 AC-1: tag create ---
def test_tag_create_success() -> None:
"""AC-1: pfhub tag create <name> → 태그 생성 성공."""
repo = FakeTagRepo()
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "create", "코스피200"])
assert result.exit_code == 0, result.stdout
assert "코스피200" in result.stdout
assert len(repo._tags) == 1
def test_tag_create_with_user() -> None:
"""AC-1: pfhub tag create <name> --user alice → 사용자 해석 후 태그 생성."""
repo = FakeTagRepo()
repo.add_user(User(id="user-1", username="alice", permission="trade-write", is_active=True))
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "create", "테스트태그", "--user", "alice"])
assert result.exit_code == 0, result.stdout
tag = list(repo._tags.values())[0]
assert tag.created_by_user_id == "user-1"
def test_tag_create_with_nonexistent_user_fails() -> None:
"""AC-1 edge: --user 로 존재하지 않는 사용자 지정 시 exit code 1."""
repo = FakeTagRepo()
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "create", "테스트태그", "--user", "nobody"])
assert result.exit_code == 1
# --- REQ-007 AC-2: tag list (active only) ---
def test_tag_list_shows_active_tags() -> None:
"""AC-2: pfhub tag list → 활성 태그 목록 출력."""
repo = FakeTagRepo()
active = _make_tag(name="active-tag", is_active=True)
inactive = _make_tag(name="inactive-tag", is_active=False)
repo._tags[active.tag_id] = active
repo._tags[inactive.tag_id] = inactive
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "list"])
assert result.exit_code == 0, result.stdout
assert "active-tag" in result.stdout
assert "inactive-tag" not in result.stdout
# --- REQ-007 AC-3: tag list --inactive ---
def test_tag_list_inactive_shows_all() -> None:
"""AC-3: pfhub tag list --inactive → 비활성 포함 전체 목록 출력."""
repo = FakeTagRepo()
active = _make_tag(name="active-tag", is_active=True)
inactive = _make_tag(name="inactive-tag", is_active=False)
repo._tags[active.tag_id] = active
repo._tags[inactive.tag_id] = inactive
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "list", "--inactive"])
assert result.exit_code == 0, result.stdout
assert "active-tag" in result.stdout
assert "inactive-tag" in result.stdout
# --- REQ-007 AC-4: tag deactivate ---
def test_tag_deactivate_with_force() -> None:
"""AC-4: pfhub tag deactivate <tag_id> --force → 비활성화 성공."""
repo = FakeTagRepo()
tag = _make_tag(is_active=True)
repo._tags[tag.tag_id] = tag
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "deactivate", tag.tag_id, "--force"])
assert result.exit_code == 0, result.stdout
assert not repo._tags[tag.tag_id].is_active
def test_tag_deactivate_already_inactive_is_idempotent() -> None:
"""AC-4: 이미 비활성 태그 deactivate → 멱등 처리(오류 없음)."""
repo = FakeTagRepo()
tag = _make_tag(is_active=False)
repo._tags[tag.tag_id] = tag
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "deactivate", tag.tag_id, "--force"])
assert result.exit_code == 0, result.stdout
assert "already inactive" in result.stdout
def test_tag_deactivate_without_force_aborts() -> None:
"""AC-4: --force 없이 deactivate → 확인 거부 시 종료 코드 0 (중단)."""
repo = FakeTagRepo()
tag = _make_tag(is_active=True)
repo._tags[tag.tag_id] = tag
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "deactivate", tag.tag_id], input="n\n")
assert result.exit_code == 0
assert repo._tags[tag.tag_id].is_active
def test_tag_deactivate_nonexistent_fails() -> None:
"""AC-4: 존재하지 않는 tag_id deactivate → exit code 1."""
repo = FakeTagRepo()
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "deactivate", "nonexistent-id", "--force"])
assert result.exit_code == 1
# --- REQ-007 AC-5: tag apply asset ---
def test_tag_apply_asset_success() -> None:
"""AC-5: admin 생성 활성 태그 → asset에 tag 적용 성공."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=True, is_active=True)
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for()
repo.add_portfolio(portfolio)
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "apply", "asset", portfolio.portfolio_id, tag.tag_id])
assert result.exit_code == 0, result.stdout
assert tag.tag_id in repo._tag_ids[portfolio.portfolio_id]
def test_tag_apply_asset_non_admin_tag_fails() -> None:
"""AC-5: admin 태그가 아닌 경우 asset 적용 거부 → exit code 1."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=False, is_active=True)
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for()
repo.add_portfolio(portfolio)
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "apply", "asset", portfolio.portfolio_id, tag.tag_id])
assert result.exit_code == 1
def test_tag_apply_asset_inactive_tag_fails() -> None:
"""AC-5: 비활성 admin 태그 → asset 적용 거부 → exit code 1."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=True, is_active=False)
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for()
repo.add_portfolio(portfolio)
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "apply", "asset", portfolio.portfolio_id, tag.tag_id])
assert result.exit_code == 1
# --- REQ-007 AC-6: tag apply portfolio ---
def test_tag_apply_portfolio_admin_tag_success() -> None:
"""AC-6: admin 태그 → portfolio 적용 성공."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=True, is_active=True, created_by_user_id="admin-1")
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for(owner_user_id="user-1")
repo.add_portfolio(portfolio)
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "apply", "portfolio", portfolio.portfolio_id, tag.tag_id])
assert result.exit_code == 0, result.stdout
assert tag.tag_id in repo._tag_ids[portfolio.portfolio_id]
def test_tag_apply_portfolio_owner_tag_success() -> None:
"""AC-6: 소유자가 직접 생성한 비-admin 태그 → 소유자 portfolio에 적용 성공."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=False, is_active=True, created_by_user_id="user-1")
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for(owner_user_id="user-1")
repo.add_portfolio(portfolio)
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "apply", "portfolio", portfolio.portfolio_id, tag.tag_id])
assert result.exit_code == 0, result.stdout
def test_tag_apply_portfolio_policy_violation_fails() -> None:
"""AC-6: 비-admin 태그를 다른 사용자 portfolio에 적용 → 권한 없음 → exit code 1."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=False, is_active=True, created_by_user_id="user-2")
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for(owner_user_id="user-1")
repo.add_portfolio(portfolio)
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "apply", "portfolio", portfolio.portfolio_id, tag.tag_id])
assert result.exit_code == 1
# --- REQ-007 AC-7: tag remove asset ---
def test_tag_remove_asset_with_force() -> None:
"""AC-7: pfhub tag remove asset <asset_id> <tag_id> --force → 태그 제거 성공."""
repo = FakeTagRepo()
tag = _make_tag(is_admin_tag=True, is_active=True)
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for()
repo.add_portfolio(portfolio)
repo._tag_ids[portfolio.portfolio_id] = [tag.tag_id]
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "remove", "asset", portfolio.portfolio_id, tag.tag_id, "--force"])
assert result.exit_code == 0, result.stdout
assert tag.tag_id not in repo._tag_ids[portfolio.portfolio_id]
def test_tag_remove_asset_without_force_aborts() -> None:
"""AC-7: --force 없이 remove → 확인 거부 시 태그 유지."""
repo = FakeTagRepo()
tag = _make_tag()
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for()
repo.add_portfolio(portfolio)
repo._tag_ids[portfolio.portfolio_id] = [tag.tag_id]
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "remove", "asset", portfolio.portfolio_id, tag.tag_id], input="n\n")
assert result.exit_code == 0
assert tag.tag_id in repo._tag_ids[portfolio.portfolio_id]
# --- REQ-007 AC-8: tag remove portfolio ---
def test_tag_remove_portfolio_with_force() -> None:
"""AC-8: pfhub tag remove portfolio <portfolio_id> <tag_id> --force → 태그 제거 성공."""
repo = FakeTagRepo()
tag = _make_tag()
repo._tags[tag.tag_id] = tag
portfolio = _portfolio_for()
repo.add_portfolio(portfolio)
repo._tag_ids[portfolio.portfolio_id] = [tag.tag_id]
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["tag", "remove", "portfolio", portfolio.portfolio_id, tag.tag_id, "--force"])
assert result.exit_code == 0, result.stdout
assert tag.tag_id not in repo._tag_ids[portfolio.portfolio_id]
# --- REQ-007 AC-9: pfhub portfolio tag add/list/remove 존재하지 않음 ---
def test_portfolio_tag_subcommand_does_not_exist() -> None:
"""AC-9: pfhub portfolio tag add → 명령어 없음(Typer error)."""
repo = FakeTagRepo()
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "tag", "add", "some-id", "--tag", "test"])
assert result.exit_code != 0
def test_portfolio_tag_list_does_not_exist() -> None:
"""AC-9: pfhub portfolio tag list → 명령어 없음."""
repo = FakeTagRepo()
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "tag", "list", "some-id"])
assert result.exit_code != 0
def test_portfolio_tag_remove_does_not_exist() -> None:
"""AC-9: pfhub portfolio tag remove → 명령어 없음."""
repo = FakeTagRepo()
runner = _build_runner(repo)
result = runner.invoke(runner.app, ["portfolio", "tag", "remove", "some-id", "--tag", "test", "--force"])
assert result.exit_code != 0
+588
View File
@@ -0,0 +1,588 @@
"""Tests for BAT-004: tags HTTP API (REQ-004, REQ-005).
REQ-004: POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{tag_id}/deactivate
REQ-005: POST/DELETE /v1/assets/{asset_id}/tags, POST/DELETE /v1/portfolios/{portfolio_id}/tags
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import Portfolio, Tag, TokenPrincipal
from app.main import create_app
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
_USER_ID = "user-001"
_ADMIN_ID = "admin-001"
_TAG_ID_1 = "aaaaaaaa-0000-0000-0000-000000000001"
_TAG_ID_2 = "aaaaaaaa-0000-0000-0000-000000000002"
_PORTFOLIO_ID = "p-001"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tag(
*,
tag_id: str = _TAG_ID_1,
name: str = "momentum",
created_by_user_id: str = _USER_ID,
is_admin_tag: bool = False,
is_active: bool = True,
) -> Tag:
return Tag(
tag_id=tag_id,
name=name,
created_by_user_id=created_by_user_id,
is_admin_tag=is_admin_tag,
is_active=is_active,
created_at=datetime.now(timezone.utc),
)
def _make_portfolio(
*,
portfolio_id: str = _PORTFOLIO_ID,
owner_user_id: str = _USER_ID,
tag_ids: list[str] | None = None,
) -> Portfolio:
return Portfolio(
portfolio_id=portfolio_id,
broker="kis",
status="enabled",
name="Test Portfolio",
symbol="TEST",
asset_id="asset-001",
owner_user_id=owner_user_id,
tag_ids=tag_ids or [],
)
def _make_db_repo(
*,
token_type: str = "user",
permission: str = "trade-write",
user_id: str = _USER_ID,
tag: Tag | None = None,
portfolio: Portfolio | None = None,
tags: list[Tag] | None = None,
) -> MagicMock:
repo = MagicMock()
principal = TokenPrincipal(
token_id="t1",
user_id=user_id,
permission=permission,
token_type=token_type,
runtime_id="rt-1",
)
repo.get_token_principal.return_value = principal
_tags = tags or ([tag] if tag else [])
tags_by_id = {t.tag_id: t for t in _tags}
def _get_tag_by_id(tid):
return tags_by_id.get(tid)
repo.get_tag_by_id.side_effect = _get_tag_by_id
repo.list_tags_master.return_value = _tags
repo.get.return_value = portfolio or _make_portfolio()
repo.create_tag.side_effect = lambda *, name, created_by_user_id, is_admin_tag=False: _make_tag(
name=name,
created_by_user_id=created_by_user_id,
is_admin_tag=is_admin_tag,
)
repo.deactivate_tag.side_effect = lambda tid: _make_tag(tag_id=tid, is_active=False)
repo.append_portfolio_tag_id.return_value = None
repo.remove_portfolio_tag_id.return_value = None
repo.get_tags_by_ids.return_value = {}
repo.get_strategy_set.return_value = None
repo.list_portfolios_with_strategies.return_value = []
return repo
def _make_settings(monkeypatch) -> Settings:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _ENC_KEY)
return Settings(allowed_origins_raw="http://localhost:3000")
def _make_client(settings: Settings) -> TestClient:
get_settings.cache_clear()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
# ---------------------------------------------------------------------------
# REQ-004 AC-1: POST /v1/tags — 소문자 정규화 저장, HTTP 201
# ---------------------------------------------------------------------------
def test_create_tag_lowercase_normalization(monkeypatch) -> None:
"""REQ-004 AC-1: POST /v1/tags {"name": "TestTag"} → HTTP 201, name="testtag"."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo(permission="trade-write")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post("/v1/tags", json={"name": "TestTag"}, headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 201
mock_repo.create_tag.assert_called_once()
call_kwargs = mock_repo.create_tag.call_args.kwargs
assert call_kwargs["name"] == "testtag"
# ---------------------------------------------------------------------------
# REQ-004 AC-2: admin 토큰 → is_admin_tag=TRUE
# ---------------------------------------------------------------------------
def test_create_tag_admin_is_admin_tag_true(monkeypatch) -> None:
"""REQ-004 AC-2: admin 토큰으로 POST /v1/tags → is_admin_tag=TRUE."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post("/v1/tags", json={"name": "adminTag"}, headers={"Authorization": "Bearer admin-token"})
assert resp.status_code == 201
call_kwargs = mock_repo.create_tag.call_args.kwargs
assert call_kwargs["is_admin_tag"] is True
# ---------------------------------------------------------------------------
# REQ-004 AC-3: non-admin → is_admin_tag=FALSE
# ---------------------------------------------------------------------------
def test_create_tag_non_admin_is_admin_tag_false(monkeypatch) -> None:
"""REQ-004 AC-3: non-admin trade-write → is_admin_tag=FALSE."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo(permission="trade-write")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post("/v1/tags", json={"name": "userTag"}, headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 201
call_kwargs = mock_repo.create_tag.call_args.kwargs
assert call_kwargs["is_admin_tag"] is False
# ---------------------------------------------------------------------------
# REQ-004 AC-4: 중복 이름 → HTTP 409
# ---------------------------------------------------------------------------
def test_create_tag_duplicate_name_returns_409(monkeypatch) -> None:
"""REQ-004 AC-4: 이미 존재하는 이름(대소문자 변형) POST → HTTP 409."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo(permission="trade-write")
mock_repo.create_tag.side_effect = ValueError("이미 존재하는 태그 이름입니다: testtag")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post("/v1/tags", json={"name": "TestTag"}, headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# REQ-004 AC-5: GET /v1/tags → HTTP 200, 활성 태그 목록
# ---------------------------------------------------------------------------
def test_list_tags_active_only(monkeypatch) -> None:
"""REQ-004 AC-5: GET /v1/tags → HTTP 200, 활성 태그만 반환."""
settings = _make_settings(monkeypatch)
active_tag = _make_tag(is_active=True)
mock_repo = _make_db_repo(tags=[active_tag])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.get("/v1/tags", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
data = resp.json()
assert "tags" in data
assert data["total"] == 1
assert data["tags"][0]["is_active"] is True
# ---------------------------------------------------------------------------
# REQ-004 AC-6: GET /v1/tags?is_active=false → 비활성 태그만 반환
# ---------------------------------------------------------------------------
def test_list_tags_inactive_filter(monkeypatch) -> None:
"""REQ-004 AC-6: GET /v1/tags?is_active=false → 비활성 태그만 반환."""
settings = _make_settings(monkeypatch)
active_tag = _make_tag(tag_id=_TAG_ID_1, name="active", is_active=True)
inactive_tag = _make_tag(tag_id=_TAG_ID_2, name="inactive", is_active=False)
mock_repo = _make_db_repo(tags=[active_tag, inactive_tag])
mock_repo.list_tags_master.side_effect = lambda include_inactive=False: (
[active_tag, inactive_tag] if include_inactive else [active_tag]
)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.get("/v1/tags?is_active=false", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["tags"][0]["is_active"] is False
# ---------------------------------------------------------------------------
# REQ-004 AC-7: admin 토큰 → PATCH /deactivate → HTTP 200, is_active=FALSE
# ---------------------------------------------------------------------------
def test_deactivate_tag_admin(monkeypatch) -> None:
"""REQ-004 AC-7: admin 토큰으로 PATCH /deactivate → HTTP 200, is_active=FALSE."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id="other-user", is_active=True)
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID, tag=tag)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.patch(f"/v1/tags/{_TAG_ID_1}/deactivate", headers={"Authorization": "Bearer admin-token"})
assert resp.status_code == 200
assert resp.json()["is_active"] is False
# ---------------------------------------------------------------------------
# REQ-004 AC-8: 생성자 본인 → PATCH /deactivate → HTTP 200
# ---------------------------------------------------------------------------
def test_deactivate_tag_creator(monkeypatch) -> None:
"""REQ-004 AC-8: 태그 생성자(non-admin) 본인 토큰 → PATCH /deactivate → HTTP 200."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id=_USER_ID, is_active=True)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, tag=tag)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.patch(f"/v1/tags/{_TAG_ID_1}/deactivate", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# REQ-004 AC-9: non-admin 비생성자 → PATCH /deactivate → HTTP 403
# ---------------------------------------------------------------------------
def test_deactivate_tag_non_creator_returns_403(monkeypatch) -> None:
"""REQ-004 AC-9: non-admin 비생성자 → PATCH /deactivate → HTTP 403."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id="another-user", is_active=True)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, tag=tag)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.patch(f"/v1/tags/{_TAG_ID_1}/deactivate", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-004 AC-10: 존재하지 않는 tag_id → PATCH /deactivate → HTTP 404
# ---------------------------------------------------------------------------
def test_deactivate_nonexistent_tag_returns_404(monkeypatch) -> None:
"""REQ-004 AC-10: 존재하지 않는 tag_id → PATCH /deactivate → HTTP 404."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo()
mock_repo.get_tag_by_id.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.patch(f"/v1/tags/{_TAG_ID_1}/deactivate", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# REQ-004 AC-11: 이미 비활성 태그 → PATCH /deactivate 재요청 → HTTP 200 멱등
# ---------------------------------------------------------------------------
def test_deactivate_already_inactive_tag_idempotent(monkeypatch) -> None:
"""REQ-004 AC-11: 이미 is_active=FALSE 태그 → PATCH /deactivate → HTTP 200 멱등."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id=_USER_ID, is_active=False)
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID, tag=tag)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_tags.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.patch(f"/v1/tags/{_TAG_ID_1}/deactivate", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
assert resp.json()["is_active"] is False
mock_repo.deactivate_tag.assert_not_called()
# ---------------------------------------------------------------------------
# REQ-005 AC-1: admin + admin tag → POST /v1/assets/{id}/tags → HTTP 200
# ---------------------------------------------------------------------------
def test_apply_asset_tag_admin_success(monkeypatch) -> None:
"""REQ-005 AC-1: admin 토큰, admin 생성 활성 tag → POST /v1/assets/{id}/tags → HTTP 200."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, is_admin_tag=True, is_active=True)
portfolio = _make_portfolio()
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID, tag=tag, portfolio=portfolio)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/assets/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "applied"
mock_repo.append_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
# ---------------------------------------------------------------------------
# REQ-005 AC-2: admin + is_admin_tag=FALSE → HTTP 403
# ---------------------------------------------------------------------------
def test_apply_asset_tag_non_admin_tag_returns_403(monkeypatch) -> None:
"""REQ-005 AC-2: admin 토큰이지만 is_admin_tag=FALSE tag → HTTP 403."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, is_admin_tag=False, is_active=True)
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID, tag=tag)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/assets/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-005 AC-3: admin + is_active=FALSE tag → HTTP 403
# ---------------------------------------------------------------------------
def test_apply_asset_tag_inactive_returns_403(monkeypatch) -> None:
"""REQ-005 AC-3: admin 토큰, is_active=FALSE tag → HTTP 403."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, is_admin_tag=True, is_active=False)
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID, tag=tag)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/assets/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-005 AC-4: non-admin → POST /v1/assets/{id}/tags → HTTP 403
# ---------------------------------------------------------------------------
def test_apply_asset_tag_non_admin_token_returns_403(monkeypatch) -> None:
"""REQ-005 AC-4: non-admin 토큰 → POST /v1/assets/{id}/tags → HTTP 403."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID)
# 여기서 require_admin이 403을 발생시켜야 함
# mock_repo의 TokenPrincipal이 permission="trade-write"이면 require_admin이 거부
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/assets/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer user-token"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-005 AC-5: admin → DELETE /v1/assets/{id}/tags/{tag_id} → HTTP 200
# ---------------------------------------------------------------------------
def test_remove_asset_tag_admin_success(monkeypatch) -> None:
"""REQ-005 AC-5: admin 토큰 → DELETE /v1/assets/{id}/tags/{tag_id} → HTTP 200."""
settings = _make_settings(monkeypatch)
portfolio = _make_portfolio(tag_ids=[_TAG_ID_1])
mock_repo = _make_db_repo(permission="admin", user_id=_ADMIN_ID, portfolio=portfolio)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.delete(
f"/v1/assets/{_PORTFOLIO_ID}/tags/{_TAG_ID_1}",
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
mock_repo.remove_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
# ---------------------------------------------------------------------------
# REQ-005 AC-6: 소유자, 본인 생성 non-admin tag → POST /v1/portfolios/{id}/tags → HTTP 200
# ---------------------------------------------------------------------------
def test_apply_portfolio_tag_owner_own_tag(monkeypatch) -> None:
"""REQ-005 AC-6: 소유자 토큰, 소유자 생성 non-admin 활성 tag → HTTP 200."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id=_USER_ID, is_admin_tag=False, is_active=True)
portfolio = _make_portfolio(owner_user_id=_USER_ID)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, tag=tag, portfolio=portfolio)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "applied"
# ---------------------------------------------------------------------------
# REQ-005 AC-7: 소유자, admin 생성 활성 tag → POST /v1/portfolios/{id}/tags → HTTP 200
# ---------------------------------------------------------------------------
def test_apply_portfolio_tag_owner_admin_tag(monkeypatch) -> None:
"""REQ-005 AC-7: 소유자 토큰, admin 생성 활성 tag → HTTP 200."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id=_ADMIN_ID, is_admin_tag=True, is_active=True)
portfolio = _make_portfolio(owner_user_id=_USER_ID)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, tag=tag, portfolio=portfolio)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# REQ-005 AC-8: 소유자, 타인 생성 non-admin tag → HTTP 403
# ---------------------------------------------------------------------------
def test_apply_portfolio_tag_other_user_non_admin_tag_returns_403(monkeypatch) -> None:
"""REQ-005 AC-8: 소유자 토큰, 다른 user 생성 non-admin tag → HTTP 403."""
settings = _make_settings(monkeypatch)
tag = _make_tag(tag_id=_TAG_ID_1, created_by_user_id="user-B", is_admin_tag=False, is_active=True)
portfolio = _make_portfolio(owner_user_id=_USER_ID)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, tag=tag, portfolio=portfolio)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# REQ-005 AC-9: 소유자/admin → DELETE /v1/portfolios/{id}/tags/{tag_id} → HTTP 200
# ---------------------------------------------------------------------------
def test_remove_portfolio_tag_owner_success(monkeypatch) -> None:
"""REQ-005 AC-9: 소유자 토큰 → DELETE /v1/portfolios/{id}/tags/{tag_id} → HTTP 200."""
settings = _make_settings(monkeypatch)
portfolio = _make_portfolio(owner_user_id=_USER_ID, tag_ids=[_TAG_ID_1])
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, portfolio=portfolio)
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.delete(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags/{_TAG_ID_1}",
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
mock_repo.remove_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
# ---------------------------------------------------------------------------
# REQ-005 AC-10: 존재하지 않는 tag_id → HTTP 404
# ---------------------------------------------------------------------------
def test_apply_portfolio_tag_nonexistent_tag_returns_404(monkeypatch) -> None:
"""REQ-005 AC-10: 존재하지 않는 tag_id → POST /v1/portfolios/{id}/tags → HTTP 404."""
settings = _make_settings(monkeypatch)
portfolio = _make_portfolio()
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID, portfolio=portfolio)
mock_repo.get_tag_by_id.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# REQ-005 AC-11: 존재하지 않는 portfolio_id → HTTP 404
# ---------------------------------------------------------------------------
def test_apply_portfolio_tag_nonexistent_portfolio_returns_404(monkeypatch) -> None:
"""REQ-005 AC-11: 존재하지 않는 portfolio_id → POST /v1/portfolios/{id}/tags → HTTP 404."""
settings = _make_settings(monkeypatch)
mock_repo = _make_db_repo(permission="trade-write", user_id=_USER_ID)
mock_repo.get.side_effect = ValueError("portfolio를 찾을 수 없습니다")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_client(settings)
resp = client.post(
f"/v1/portfolios/nonexistent/tags",
json={"tag_id": _TAG_ID_1},
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 404