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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user