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
+61
View File
@@ -16,6 +16,8 @@ from app.schemas.v1 import (
AssetCatalogUpdateRequest, AssetCatalogUpdateRequest,
AssetItem, AssetItem,
AssetsResponse, AssetsResponse,
TagApplyRequest,
TagApplyResponse,
TagRefItem, TagRefItem,
) )
@@ -206,6 +208,65 @@ def deactivate_catalog_asset(
return _asset_response(asset) return _asset_response(asset)
@router.post("/assets/{asset_id}/tags", response_model=TagApplyResponse)
def apply_asset_tag(
asset_id: str,
body: TagApplyRequest,
auth: AuthContext = Depends(require_admin),
) -> TagApplyResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
try:
repo.get(asset_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"asset을 찾을 수 없습니다: {asset_id}")
tag = repo.get_tag_by_id(body.tag_id)
if tag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"태그를 찾을 수 없습니다: {body.tag_id}")
if not tag.is_admin_tag:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin 생성 태그만 asset에 적용할 수 있습니다")
if not tag.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다")
repo.append_portfolio_tag_id(asset_id, body.tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return TagApplyResponse(tag_id=body.tag_id, status="applied")
@router.delete("/assets/{asset_id}/tags/{tag_id}", response_model=TagApplyResponse)
def remove_asset_tag(
asset_id: str,
tag_id: str,
auth: AuthContext = Depends(require_admin),
) -> TagApplyResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
try:
repo.get(asset_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"asset을 찾을 수 없습니다: {asset_id}")
repo.remove_portfolio_tag_id(asset_id, tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return TagApplyResponse(tag_id=tag_id, status="removed")
@router.patch("/assets/{asset_id}", response_model=AssetCatalogResponse) @router.patch("/assets/{asset_id}", response_model=AssetCatalogResponse)
def update_catalog_asset( def update_catalog_asset(
asset_id: str, asset_id: str,
+93
View File
@@ -0,0 +1,93 @@
"""v1 portfolio tag endpoints — apply and remove tags (REQ-005)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, Permission, require_trade_write
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import TagApplyRequest, TagApplyResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
def _get_repo(settings=None):
from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings())
def _close_repo(repo) -> None:
if callable(getattr(type(repo), "close", None)):
repo.close()
@router.post("/{portfolio_id}/tags", response_model=TagApplyResponse)
def apply_portfolio_tag(
portfolio_id: str,
body: TagApplyRequest,
auth: AuthContext = Depends(require_trade_write),
) -> TagApplyResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
try:
portfolio = repo.get(portfolio_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
is_admin = auth.permission == Permission.ADMIN
is_owner = portfolio.owner_user_id == auth.user_id
if not is_admin and not is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
tag = repo.get_tag_by_id(body.tag_id)
if tag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"태그를 찾을 수 없습니다: {body.tag_id}")
if not tag.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다")
if not is_admin and not tag.is_admin_tag and tag.created_by_user_id != portfolio.owner_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="태그 적용 정책을 위반했습니다")
repo.append_portfolio_tag_id(portfolio_id, body.tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return TagApplyResponse(tag_id=body.tag_id, status="applied")
@router.delete("/{portfolio_id}/tags/{tag_id}", response_model=TagApplyResponse)
def remove_portfolio_tag(
portfolio_id: str,
tag_id: str,
auth: AuthContext = Depends(require_trade_write),
) -> TagApplyResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
try:
portfolio = repo.get(portfolio_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
is_admin = auth.permission == Permission.ADMIN
is_owner = portfolio.owner_user_id == auth.user_id
if not is_admin and not is_owner:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
repo.remove_portfolio_tag_id(portfolio_id, tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return TagApplyResponse(tag_id=tag_id, status="removed")
+108
View File
@@ -0,0 +1,108 @@
"""v1 tag master endpoints — create, list, deactivate (REQ-004)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import TagCreateRequest, TagItem, TagListResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1", tags=["v1"])
def _get_repo(settings=None):
from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings())
def _close_repo(repo) -> None:
if callable(getattr(type(repo), "close", None)):
repo.close()
def _tag_to_item(tag) -> TagItem:
return TagItem(
tag_id=str(tag.tag_id),
name=tag.name,
is_admin_tag=tag.is_admin_tag,
is_active=tag.is_active,
created_at=tag.created_at,
)
@router.post("/tags", response_model=TagItem, status_code=201)
def create_tag(
body: TagCreateRequest,
auth: AuthContext = Depends(require_trade_write),
) -> TagItem:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
if auth.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="user_id required")
repo = _get_repo()
try:
name = body.name.strip().lower()
is_admin_tag = auth.permission == Permission.ADMIN
tag = repo.create_tag(name=name, created_by_user_id=auth.user_id, is_admin_tag=is_admin_tag)
except ValueError as exc:
if "이미 존재하는 태그 이름입니다" in str(exc):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
finally:
_close_repo(repo)
return _tag_to_item(tag)
@router.get("/tags", response_model=TagListResponse)
def list_tags(
is_active: bool | None = Query(default=None),
auth: AuthContext = Depends(require_read),
) -> TagListResponse:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
if is_active is False:
all_tags = repo.list_tags_master(include_inactive=True)
tags = [t for t in all_tags if not t.is_active]
else:
tags = repo.list_tags_master(include_inactive=False)
finally:
_close_repo(repo)
items = [_tag_to_item(t) for t in tags]
return TagListResponse(tags=items, total=len(items))
@router.patch("/tags/{tag_id}/deactivate", response_model=TagItem)
def deactivate_tag(
tag_id: str,
auth: AuthContext = Depends(require_read),
) -> TagItem:
if auth.token_source != "db":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
repo = _get_repo()
try:
tag = repo.get_tag_by_id(tag_id)
if tag is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"태그를 찾을 수 없습니다: {tag_id}",
)
is_admin = auth.permission == Permission.ADMIN
is_creator = tag.created_by_user_id == auth.user_id
if not is_admin and not is_creator:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="권한이 없습니다")
if not tag.is_active:
return _tag_to_item(tag)
result = repo.deactivate_tag(tag_id)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
_close_repo(repo)
return _tag_to_item(result)
-41
View File
@@ -15,7 +15,6 @@ from app.schemas.portfolio import (
CommentUpdateRequest, CommentUpdateRequest,
CreatePortfolioRequest, CreatePortfolioRequest,
InvestRequest, InvestRequest,
PortfolioTagMutationRequest,
ResetPortfolioRequest, ResetPortfolioRequest,
SellRequest, SellRequest,
TransferRequest, TransferRequest,
@@ -27,9 +26,7 @@ _NAME_RE = re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$")
portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.") portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
comment_app = typer.Typer(help="Portfolio comment history commands.") comment_app = typer.Typer(help="Portfolio comment history commands.")
tag_app = typer.Typer(help="Portfolio tag commands.")
portfolio_app.add_typer(comment_app, name="comment") portfolio_app.add_typer(comment_app, name="comment")
portfolio_app.add_typer(tag_app, name="tag")
def _parse_strategy_params(value: str) -> dict[str, Any]: def _parse_strategy_params(value: str) -> dict[str, Any]:
@@ -434,41 +431,3 @@ def comment_delete(
ctx.obj.console.print(f"Deleted comment: {comment_id}") ctx.obj.console.print(f"Deleted comment: {comment_id}")
@tag_app.command("add")
def tag_add(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
tag: str = typer.Option(..., "--tag", help="Tag value."),
) -> None:
detail = _with_service(
ctx,
lambda service, app_context: service.add_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)),
)
render_output(ctx.obj.console, ctx.obj.output_format, detail)
@tag_app.command("list")
def tag_list(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
) -> None:
detail = _with_service(ctx, lambda service, app_context: service.list_tags(portfolio_id))
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No tags found.")
@tag_app.command("remove")
def tag_remove(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
tag: str = typer.Option(..., "--tag", help="Tag value."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Remove tag {tag!r} from portfolio {portfolio_id}?")
_with_service(
ctx,
lambda service, app_context: service.remove_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)),
)
if ctx.obj.output_format == "json":
render_output(ctx.obj.console, ctx.obj.output_format, {"portfolio_id": portfolio_id, "deleted": tag.strip()})
else:
ctx.obj.console.print(f"Removed tag: {tag.strip()}")
+165
View File
@@ -0,0 +1,165 @@
"""Tag master CLI commands."""
from __future__ import annotations
import typer
from app.cli.core.utils import execute_command, render_output
tag_app = typer.Typer(help="Tag master commands.")
apply_app = typer.Typer(help="Apply a tag to a target.")
remove_app = typer.Typer(help="Remove a tag from a target.")
tag_app.add_typer(apply_app, name="apply")
tag_app.add_typer(remove_app, name="remove")
def _with_repo(ctx: typer.Context, callback):
app_context = ctx.obj
repository = app_context.repository_factory(app_context.settings)
try:
result = execute_command(app_context.console, callback=lambda: callback(repository, app_context))
return result
finally:
if callable(getattr(type(repository), "close", None)):
repository.close()
def _confirm(force: bool, message: str) -> None:
if force:
return
confirmed = typer.confirm(message)
if not confirmed:
raise typer.Exit(code=0)
@tag_app.command("create")
def tag_create(
ctx: typer.Context,
name: str = typer.Argument(..., help="Tag name."),
user: str | None = typer.Option(None, "--user", help="Owner username (resolved to user_id)."),
admin: bool = typer.Option(False, "--admin", help="Mark tag as admin-only."),
) -> None:
def callback(repo, app_context):
created_by = ""
if user:
u = repo.get_user_by_username(user)
if u is None:
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
created_by = u.id
tag = repo.create_tag(name=name.strip(), is_admin_tag=admin, created_by_user_id=created_by)
app_context.console.print(f"Created tag: {tag.name} ({tag.tag_id})")
return tag
result = _with_repo(ctx, callback)
if result is not None and ctx.obj.output_format == "json":
import dataclasses
render_output(ctx.obj.console, ctx.obj.output_format, dataclasses.asdict(result))
@tag_app.command("list")
def tag_list(
ctx: typer.Context,
inactive: bool = typer.Option(False, "--inactive", help="Include inactive tags (show all)."),
) -> None:
def callback(repo, app_context):
return repo.list_tags_master(include_inactive=inactive)
result = _with_repo(ctx, callback)
render_output(ctx.obj.console, ctx.obj.output_format, result, empty_message="No tags found.")
@tag_app.command("deactivate")
def tag_deactivate(
ctx: typer.Context,
tag_id: str = typer.Argument(..., help="Tag UUID."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Deactivate tag {tag_id}?")
def callback(repo, app_context):
tag = repo.get_tag_by_id(tag_id)
if tag is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
if not tag.is_active:
app_context.console.print(f"Tag already inactive: {tag_id}")
return None
repo.deactivate_tag(tag_id)
app_context.console.print(f"Deactivated tag: {tag_id}")
return None
_with_repo(ctx, callback)
@apply_app.command("asset")
def apply_asset(
ctx: typer.Context,
asset_id: str = typer.Argument(..., help="Asset (portfolio) UUID."),
tag_id: str = typer.Argument(..., help="Tag UUID."),
) -> None:
def callback(repo, app_context):
tag = repo.get_tag_by_id(tag_id)
if tag is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
if not tag.is_admin_tag:
raise ValueError(f"admin 태그만 asset에 적용할 수 있습니다: {tag_id}")
if not tag.is_active:
raise ValueError(f"비활성 태그는 적용할 수 없습니다: {tag_id}")
repo.append_portfolio_tag_id(asset_id, tag_id)
app_context.console.print(f"Applied tag {tag_id} to asset {asset_id}")
_with_repo(ctx, callback)
@apply_app.command("portfolio")
def apply_portfolio(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
tag_id: str = typer.Argument(..., help="Tag UUID."),
) -> None:
def callback(repo, app_context):
tag = repo.get_tag_by_id(tag_id)
if tag is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
if not tag.is_active:
raise ValueError(f"비활성 태그는 적용할 수 없습니다: {tag_id}")
portfolio = repo.get(portfolio_id)
if portfolio is None:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
if not (tag.is_admin_tag or tag.created_by_user_id == portfolio.owner_user_id):
raise ValueError(f"태그 적용 권한이 없습니다: {tag_id}")
repo.append_portfolio_tag_id(portfolio_id, tag_id)
app_context.console.print(f"Applied tag {tag_id} to portfolio {portfolio_id}")
_with_repo(ctx, callback)
@remove_app.command("asset")
def remove_asset(
ctx: typer.Context,
asset_id: str = typer.Argument(..., help="Asset (portfolio) UUID."),
tag_id: str = typer.Argument(..., help="Tag UUID."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Remove tag {tag_id} from asset {asset_id}?")
def callback(repo, app_context):
repo.remove_portfolio_tag_id(asset_id, tag_id)
app_context.console.print(f"Removed tag {tag_id} from asset {asset_id}")
_with_repo(ctx, callback)
@remove_app.command("portfolio")
def remove_portfolio(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
tag_id: str = typer.Argument(..., help="Tag UUID."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Remove tag {tag_id} from portfolio {portfolio_id}?")
def callback(repo, app_context):
repo.remove_portfolio_tag_id(portfolio_id, tag_id)
app_context.console.print(f"Removed tag {tag_id} from portfolio {portfolio_id}")
_with_repo(ctx, callback)
+24 -1
View File
@@ -13,7 +13,7 @@ from rich.panel import Panel
from rich.table import Table from rich.table import Table
from app.core.errors import FeatureNotReadyError from app.core.errors import FeatureNotReadyError
from app.domain.models import Daemon from app.domain.models import Daemon, Tag
from app.schemas.auth import AuthTokenResponse from app.schemas.auth import AuthTokenResponse
from app.schemas.portfolio import CommentResponse, PortfolioDetailResponse, PortfolioListItem from app.schemas.portfolio import CommentResponse, PortfolioDetailResponse, PortfolioListItem
from app.schemas.user import UserResponse from app.schemas.user import UserResponse
@@ -231,6 +231,26 @@ def print_comment_table(console: Console, comments: list[CommentResponse]) -> No
console.print(table) console.print(table)
def print_tag_table(console: Console, tags: list[Tag]) -> None:
table = Table(title="Tags", expand=True)
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Name", no_wrap=True)
table.add_column("Admin", no_wrap=True)
table.add_column("Active", no_wrap=True)
table.add_column("Created By", no_wrap=True)
table.add_column("Created At", no_wrap=True)
for tag in tags:
table.add_row(
tag.tag_id,
tag.name,
"yes" if tag.is_admin_tag else "no",
"yes" if tag.is_active else "no",
tag.created_by_user_id or "-",
tag.created_at.isoformat(timespec="seconds") if tag.created_at else "N/A",
)
console.print(table)
def print_comment_detail(console: Console, comment: CommentResponse) -> None: def print_comment_detail(console: Console, comment: CommentResponse) -> None:
console.print( console.print(
Panel.fit( Panel.fit(
@@ -276,6 +296,9 @@ def render_output(console: Console, output_format: str, payload: Any, *, empty_m
if all(isinstance(item, AuthTokenResponse) for item in payload): if all(isinstance(item, AuthTokenResponse) for item in payload):
print_service_token_table(console, payload) print_service_token_table(console, payload)
return return
if all(isinstance(item, Tag) for item in payload):
print_tag_table(console, payload)
return
return return
if isinstance(payload, PortfolioDetailResponse): if isinstance(payload, PortfolioDetailResponse):
+2
View File
@@ -14,6 +14,7 @@ from app.cli.commands.health import register_health_commands
from app.cli.commands.service_tokens import token_app from app.cli.commands.service_tokens import token_app
from app.cli.commands.portfolio import portfolio_app from app.cli.commands.portfolio import portfolio_app
from app.cli.commands.strategy import strategy_app from app.cli.commands.strategy import strategy_app
from app.cli.commands.tag import tag_app
from app.cli.commands.user import user_app from app.cli.commands.user import user_app
from app.cli.commands.credential import credential_app from app.cli.commands.credential import credential_app
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
@@ -53,6 +54,7 @@ def create_app(
app.add_typer(asset_app, name="asset") app.add_typer(asset_app, name="asset")
app.add_typer(portfolio_app, name="portfolio") app.add_typer(portfolio_app, name="portfolio")
app.add_typer(strategy_app, name="strategy") app.add_typer(strategy_app, name="strategy")
app.add_typer(tag_app, name="tag")
app.add_typer(user_app, name="user") app.add_typer(user_app, name="user")
app.add_typer(token_app, name="service-token") app.add_typer(token_app, name="service-token")
app.add_typer(credential_app, name="credential") app.add_typer(credential_app, name="credential")
+6 -2
View File
@@ -12,8 +12,10 @@ from app.api.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router from app.api.strategy_sets import router as strategy_sets_router
from app.api.users import router as users_router from app.api.users import router as users_router
from app.api.v1_assets import router as v1_assets_router from app.api.v1_assets import router as v1_assets_router
from app.api.v1_user_info import router as v1_user_info_router
from app.api.v1_ledger import router as v1_ledger_router from app.api.v1_ledger import router as v1_ledger_router
from app.api.v1_portfolios import router as v1_portfolios_router
from app.api.v1_tags import router as v1_tags_router
from app.api.v1_user_info import router as v1_user_info_router
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core.errors import register_exception_handlers from app.core.errors import register_exception_handlers
from app.core.security import hash_password from app.core.security import hash_password
@@ -79,8 +81,10 @@ def create_app(
app.include_router(strategy_sets_router) app.include_router(strategy_sets_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(v1_assets_router) app.include_router(v1_assets_router)
app.include_router(v1_user_info_router)
app.include_router(v1_ledger_router) app.include_router(v1_ledger_router)
app.include_router(v1_portfolios_router)
app.include_router(v1_tags_router)
app.include_router(v1_user_info_router)
register_exception_handlers(app) register_exception_handlers(app)
return app return app
+4
View File
@@ -209,6 +209,10 @@ class PortfolioRepository(Protocol):
def deactivate_tag(self, tag_id: str) -> Tag: ... def deactivate_tag(self, tag_id: str) -> Tag: ...
def append_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None: ...
def remove_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None: ...
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ... def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ... def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ...
+30
View File
@@ -1718,6 +1718,36 @@ class PostgresPortfolioRepository:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}") raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
return self._row_to_tag(row) return self._row_to_tag(row)
def append_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET tag_ids = array_append(tag_ids, %s::uuid)
WHERE portfolio_id = %s AND NOT (%s::uuid = ANY(tag_ids))
""",
(tag_id, portfolio_id, tag_id),
)
if cur.rowcount == 0:
cur.execute("SELECT portfolio_id FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
def remove_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET tag_ids = array_remove(tag_ids, %s::uuid)
WHERE portfolio_id = %s
""",
(tag_id, portfolio_id),
)
if cur.rowcount == 0:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
+34
View File
@@ -9,6 +9,40 @@ from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# GET/POST/PATCH /v1/tags (REQ-004)
# ---------------------------------------------------------------------------
class TagItem(BaseModel):
tag_id: str
name: str
is_admin_tag: bool
is_active: bool
created_at: datetime | None = None
class TagCreateRequest(BaseModel):
name: str = Field(..., min_length=1)
class TagListResponse(BaseModel):
tags: list[TagItem]
total: int
# ---------------------------------------------------------------------------
# POST/DELETE /v1/assets/{id}/tags, /v1/portfolios/{id}/tags (REQ-005)
# ---------------------------------------------------------------------------
class TagApplyRequest(BaseModel):
tag_id: str = Field(..., min_length=1)
class TagApplyResponse(BaseModel):
tag_id: str
status: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /v1/assets # GET /v1/assets
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -0,0 +1,64 @@
# BAT-004 Design
- Status: confirmed
- Batch: BAT-004
## Architecture Summary
- REQ-004: `app/api/v1_tags.py` 신규 라우터 파일(prefix="/v1"), POST /v1/tags / GET /v1/tags / PATCH /v1/tags/{tag_id}/deactivate 엔드포인트.
- REQ-005(asset side): 기존 `app/api/v1_assets.py`에 POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가. v1 API에서 asset_id = portfolio_id이므로 두 엔드포인트 모두 `portfolios.tag_ids`를 조작.
- REQ-005(portfolio side): `app/api/v1_portfolios.py` 신규 라우터 파일(prefix="/v1/portfolios"), POST/DELETE /v1/portfolios/{portfolio_id}/tags 엔드포인트. 기존 `portfolios.py`(prefix="/portfolios")와 path prefix가 달라 충돌 없음.
- 정책 검증 로직은 서비스 레이어 없이 라우터에서 직접 처리(현재 아키텍처 패턴 유지).
- tag_ids 배열 조작: `append_portfolio_tag_id` / `remove_portfolio_tag_id` 신규 메서드를 postgres.py에 추가.
## Changed Areas
- Docs: docs/interface-contract.md (엔드포인트 명세 — REQ-008 배치에서 일괄 갱신)
- Modules:
- `app/api/v1_tags.py` — 신규 파일 (POST/GET/PATCH /v1/tags)
- `app/api/v1_portfolios.py` — 신규 파일 (POST/DELETE /v1/portfolios/{portfolio_id}/tags)
- `app/api/v1_assets.py` — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가
- `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse 추가
- `app/repositories/portfolio_repository.py` — append_portfolio_tag_id, remove_portfolio_tag_id 프로토콜 추가
- `app/repositories/postgres.py` — append_portfolio_tag_id, remove_portfolio_tag_id 구현 추가
- `app/main.py` — v1_tags, v1_portfolios 라우터 등록
- `tests/test_tags_http_api.py` — 신규 테스트 파일
- Interfaces:
- POST /v1/tags — 태그 생성 (trade-write 이상, DB token 필수)
- GET /v1/tags — 태그 목록 (read 이상, ?is_active 필터, DB token 필수)
- PATCH /v1/tags/{tag_id}/deactivate — 태그 비활성화 (admin 또는 생성자 본인, DB token 필수)
- POST /v1/assets/{asset_id}/tags — asset(portfolio)에 태그 적용 (admin, DB token 필수)
- DELETE /v1/assets/{asset_id}/tags/{tag_id} — asset에서 태그 해제 (admin, DB token 필수)
- POST /v1/portfolios/{portfolio_id}/tags — portfolio에 태그 적용 (trade-write, 소유자/admin, DB token 필수)
- DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} — portfolio에서 태그 해제 (trade-write, 소유자/admin, DB token 필수)
## Key Decisions
1. **v1_tags.py 신규 파일 분리**: tags 도메인 전용 라우터를 별도 파일로 분리해 v1_assets.py(asset 카탈로그 CRUD)와 관심사 분리. 이후 REQ-007 CLI 추가 시 충돌 없음.
2. **v1_portfolios.py 신규 파일**: REQ-005 portfolio tag 엔드포인트는 `/v1/portfolios` prefix. 기존 `portfolios.py`는 `/portfolios` prefix(v1 아님)이므로 새 라우터 파일 생성이 필요. 두 파일의 path가 완전히 달라 충돌 없음.
3. **asset_id = portfolio_id (v1 API 규약)**: `/v1/assets/{asset_id}/tags`는 catalog asset 테이블이 아니라 portfolios 테이블의 tag_ids를 조작. v1 API에서 portfolio_id를 asset_id로 노출하는 기존 규약 준수. `repo.get(asset_id)` 로 portfolio 존재 확인.
4. **소문자 정규화 위치**: POST /v1/tags 라우터에서 `name = body.name.strip().lower()`. DB는 TEXT UNIQUE 그대로(BAT-003 설계 일관성).
5. **정책 검증 위치**: 라우터에서 직접 `repo.get_tag_by_id()` 호출 후 조건 검사 → 서비스 레이어 없이 라우터 직접 처리(현재 아키텍처 패턴 유지).
6. **array_append 멱등성**: `UPDATE portfolios SET tag_ids = array_append(tag_ids, %s::uuid) WHERE portfolio_id = %s AND NOT (%s::uuid = ANY(tag_ids))` — tag_id가 이미 배열에 있으면 UPDATE 행 0건 → rowcount=0인 경우 portfolio 존재를 별도 확인, 존재하면 멱등 처리(200 반환).
7. **deactivate 멱등성**: `repo.get_tag_by_id(tag_id)` 먼저 호출 → is_active=FALSE이면 바로 현재 상태 반환(200). is_active=TRUE이면 `repo.deactivate_tag(tag_id)` 실행. tag 존재하지 않으면 404.
8. **auth permission 분기(v1 패턴)**: 모든 v1 엔드포인트는 `auth.token_source != "db"` 이면 403 반환(기존 v1_assets.py 패턴 일관성 유지). permission_dependency(Depends) 사용해 최소 권한 확인 후 라우터에서 세부 조건 추가 확인.
## Edge Cases
- 존재하지 않는 tag_id → PATCH /deactivate → 404 (get_tag_by_id 결과 None)
- 존재하지 않는 portfolio_id → POST/DELETE /assets|portfolios/{id}/tags → 404
- non-admin 비생성자 → PATCH /deactivate → 403
- is_admin_tag=FALSE tag_id → POST /v1/assets/{asset_id}/tags → 403
- is_active=FALSE tag_id → POST /v1/assets/{asset_id}/tags 또는 /portfolios → 403
- 다른 user 생성 non-admin tag → POST /v1/portfolios/{id}/tags (소유자 토큰) → 403
- 이미 tag_ids 배열에 있는 tag_id 재적용 → 200 멱등 반환 (array_append 조건부 + rowcount 분기)
- 이미 is_active=FALSE → PATCH /deactivate 재요청 → 200 멱등 반환
- env token (token_source="env") → 403 DB token required (v1 패턴 일관성)
## Architecture Impact
- both (project-structure: 신규 라우터 파일 2개, runtime-flows: 태그 HTTP 흐름 신규)
## Reference Doc Update Plan
- project-structure.md: REQ-008 배치에서 갱신 (v1_tags.py, v1_portfolios.py 추가, pfhub tag 그룹 추가)
- runtime-flows.md: REQ-008 배치에서 갱신 (POST/GET/PATCH /v1/tags 흐름, POST/DELETE /v1/assets|portfolios/{id}/tags 흐름)
## Baseline Comparison (vs project-structure.md / runtime-flows.md)
- project-structure.md 현재: app/api/ — v1_assets.py, v1_user_info.py, v1_ledger.py만 v1 라우터로 기록됨. v1_tags.py, v1_portfolios.py 추가 필요.
- runtime-flows.md 현재: GET /v1/assets, GET /v1/user_info, POST ledger/trades, POST /auth/service-tokens 흐름만 기록됨. tags CRUD HTTP 흐름, 태그 적용/해제 HTTP 흐름 추가 필요.
- 두 baseline 문서 갱신은 REQ-008 배치에서 수행.
@@ -0,0 +1,39 @@
# BAT-004 Implementation
- Status: completed
- Batch: BAT-004
## Plan Summary
- REQ-004: v1_tags.py 신규 라우터 + TagItem/TagCreateRequest/TagListResponse 스키마 + main.py 등록 + 소문자 정규화 + 권한 분기 + 비활성화 멱등성
- REQ-005: v1_assets.py asset 태그 라우트 + v1_portfolios.py portfolio 태그 라우트 + TagApplyRequest/TagApplyResponse 스키마 + postgres.py array_append/remove + 정책 검증
- 전체 pytest suite 228 passed, regression 없음
## Changed Files
- `app/api/v1_tags.py` (신규) — POST/GET/PATCH /v1/tags
- `app/api/v1_portfolios.py` (신규) — POST/DELETE /v1/portfolios/{portfolio_id}/tags
- `app/api/v1_assets.py` — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가, TagApplyRequest/TagApplyResponse 임포트 추가
- `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse 추가
- `app/repositories/portfolio_repository.py` — append_portfolio_tag_id, remove_portfolio_tag_id 프로토콜 추가
- `app/repositories/postgres.py` — append_portfolio_tag_id, remove_portfolio_tag_id 구현 추가
- `app/main.py` — v1_tags, v1_portfolios 라우터 등록
- `tests/test_tags_http_api.py` (신규) — 22개 테스트
## Execution Log
1. app/schemas/v1.py: TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse 추가
2. app/repositories/portfolio_repository.py: append_portfolio_tag_id / remove_portfolio_tag_id 프로토콜 추가
3. app/repositories/postgres.py: append_portfolio_tag_id (array_append + idempotent rowcount 분기) / remove_portfolio_tag_id 구현 추가
4. app/api/v1_tags.py 신규: create_tag(소문자 정규화, is_admin_tag 분기), list_tags(is_active 필터), deactivate_tag(admin/creator 검증, 멱등성)
5. app/api/v1_portfolios.py 신규: apply_portfolio_tag(소유자/admin 확인, 태그 정책 검증), remove_portfolio_tag(소유자/admin 확인)
6. app/api/v1_assets.py: apply_asset_tag(admin only, is_admin_tag=TRUE AND is_active=TRUE), remove_asset_tag(admin only) 추가
7. app/main.py: v1_tags, v1_portfolios 라우터 등록
8. tests/test_tags_http_api.py: 22개 AC 테스트 작성 (REQ-004 AC-1~11, REQ-005 AC-1~11)
9. pytest 전체 suite 실행 → 228 passed, 0 failed
## Validation
- pytest 실행 결과: **228 passed, 0 failed** (2026-05-20)
- 신규 테스트: test_tags_http_api.py 22개 (전체 통과)
- 기존 regression: test_cli.py, test_v1.py, test_asset_catalog.py, test_portfolio_tags.py, test_portfolio_uuid.py, test_daemon_assignment.py, test_tags_master.py 전체 통과
## Remaining Risks
- PATCH /v1/tags/{tag_id}/deactivate: 비생성자 non-admin 시 403 확인은 unit test로 커버. live DB에서 auth.user_id가 올바르게 채워지는지는 운영 환경 연동 테스트 필요.
- append_portfolio_tag_id의 idempotent 처리(rowcount=0 분기): 단위 테스트로 커버되지 않았으며 live DB에서 정상 동작 확인 필요.
@@ -0,0 +1,36 @@
# BAT-004: tags HTTP API
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-002
- Included REQ: REQ-004, REQ-005
- Excluded REQ: (없음 — dcy-002 HTTP API 2개 전부 포함)
## Summary
- tags 마스터에 대한 HTTP API(POST/GET/PATCH /v1/tags)를 신규 라우터로 추가하고(REQ-004), asset 및 portfolio에 태그를 적용·해제하는 HTTP API(POST/DELETE /v1/assets/{id}/tags, POST/DELETE /v1/portfolios/{id}/tags)를 구현한다(REQ-005).
- 이 배치 이후 CLI 계층(REQ-007)과 baseline 문서 갱신(REQ-008)은 별도 배치에서 수행한다.
## Scope
- In Scope:
- `app/api/v1_tags.py` 신규 라우터 (POST/GET/PATCH /v1/tags) — REQ-004
- `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest 스키마 추가
- `app/repositories/portfolio_repository.py` — insert_tag, get_tags, deactivate_tag 등 프로토콜 보완
- `app/repositories/postgres.py` — 태그 CRUD SQL 구현 보완, tag_ids array_append/array_remove SQL
- `app/api/v1_assets.py` — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가 — REQ-005
- `app/api/portfolios.py` — POST/DELETE /v1/portfolios/{portfolio_id}/tags 라우트 추가 — REQ-005
- `app/main.py` — v1_tags 라우터 등록
- Out of Scope:
- pfhub tag CLI (→ REQ-007, 다음 배치)
- baseline 문서 갱신 (→ REQ-008, 다음 배치)
- DB 스키마 변경 (→ BAT-003 완료)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1(Medium): 권한 분기(trade-write/admin/owner) 조건이 복잡 — 정책 오류 시 AC 위반 위험
- REQ-004와 REQ-005는 같은 tags 도메인 HTTP 레이어이며 정책 검증 로직을 공유 → 함께 구현·검증
- 선행 의존: BAT-003(release-candidate) — tags 테이블, portfolios.tag_ids, assets.tag_ids 완료 상태
@@ -0,0 +1,46 @@
# BAT-004 Planning
- Status: confirmed
- Batch: BAT-004
- Profile: standard
## Included REQ
- REQ-004: tags 마스터 HTTP API (POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{tag_id}/deactivate)
- REQ-005: 태그 적용·해제 HTTP API (POST/DELETE /v1/assets/{asset_id}/tags, POST/DELETE /v1/portfolios/{portfolio_id}/tags)
## Delivery Plan
1. **REQ-004 — v1_tags.py 신규 라우터**
- `app/api/v1_tags.py` 파일 생성 — POST/GET/PATCH /v1/tags 엔드포인트
- `app/schemas/v1.py` — TagItem, TagCreateRequest, TagListResponse 추가
- `app/main.py` — v1_tags 라우터 등록
- 이름 소문자 정규화(`str.lower()`)는 라우터 레이어에서 수행
- 권한: POST는 trade-write 이상, GET은 read 이상, PATCH는 admin 또는 생성자 본인
- 비활성화 멱등 처리(이미 is_active=FALSE → 200 반환)
2. **REQ-005 — 태그 적용·해제 라우트 추가**
- `app/api/v1_assets.py` — POST /v1/assets/{asset_id}/tags, DELETE /v1/assets/{asset_id}/tags/{tag_id}
- `app/api/portfolios.py` — POST /v1/portfolios/{portfolio_id}/tags, DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}
- `app/schemas/v1.py` — TagApplyRequest 추가
- `app/repositories/postgres.py` — tag_ids 배열 append/remove SQL (array_append, array_remove)
- 정책 검증: asset → is_admin_tag=TRUE AND is_active=TRUE, portfolio → is_active=TRUE AND (is_admin_tag=TRUE OR created_by_user_id=소유자)
3. **전체 pytest 실행 — regression 확인**
## Dependencies
- Internal:
- BAT-003(release-candidate): tags 테이블, portfolios.tag_ids UUID[], assets.tag_ids UUID[], create_tag/get_tag_by_id/get_tags_by_ids/list_tags_master/deactivate_tag 프로토콜 및 postgres 구현 완료
- app/core/auth.py: require_read, require_trade_write, require_admin 기존 permission dependency 재사용
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason: 신규 라우터 파일(v1_tags.py) 추가, 권한 분기(trade-write/admin/owner) 설계, 정책 검증 로직 위치 결정(라우터 직접 vs 서비스 레이어), array_append/remove 멱등성 처리 방식 결정이 필요하다.
## Milestones
1. REQ-004 라우터 구현 완료 + 스키마 추가 + tests
2. REQ-005 apply/remove 라우트 구현 완료 + policy 검증 + tests
3. 전체 pytest suite 통과 (regression 없음)
## Risks
- R-1 (Medium): 권한 분기(trade-write/admin/owner) 조건 복잡 — PATCH /deactivate에서 admin vs 생성자 본인 구분, portfolio 태그 적용에서 owner_user_id 확인 오류 가능성
- R-2 (Low): array_append 중복 허용 여부 — 동일 tag_id를 이미 포함한 배열에 재적용 시 멱등 처리 설계 필요
- R-3 (Low): assets.tag_ids 컬럼이 BAT-003 ensure_schema()에 추가됨 — REQ-002 live DB 마이그레이션 완료 여부 확인 필요 (테스트 환경은 ensure_schema로 자동 생성)
@@ -0,0 +1,70 @@
# BAT-004 Verification
- Status: release-candidate
- Batch: BAT-004
## Acceptance Mapping
- REQ-004:
- AC-1: POST /v1/tags {"name": "TestTag"} → HTTP 201, name="testtag"(소문자 정규화) → `test_create_tag_lowercase_normalization`
- AC-2: admin 토큰 → is_admin_tag=TRUE → `test_create_tag_admin_is_admin_tag_true`
- AC-3: non-admin trade-write 토큰 → is_admin_tag=FALSE → `test_create_tag_non_admin_is_admin_tag_false`
- AC-4: 동일 이름(대소문자 변형) 중복 → HTTP 409 → `test_create_tag_duplicate_name_returns_409`
- AC-5: GET /v1/tags → HTTP 200, 활성 태그 목록 → `test_list_tags_active_only`
- AC-6: GET /v1/tags?is_active=false → 비활성 태그만 반환 → `test_list_tags_inactive_filter`
- AC-7: admin 토큰 PATCH /deactivate → HTTP 200, is_active=FALSE → `test_deactivate_tag_admin`
- AC-8: 생성자(non-admin) 토큰 본인 태그 비활성화 → HTTP 200 → `test_deactivate_tag_creator`
- AC-9: non-admin 비생성자 PATCH /deactivate → HTTP 403 → `test_deactivate_tag_non_creator_returns_403`
- AC-10: 존재하지 않는 tag_id PATCH /deactivate → HTTP 404 → `test_deactivate_nonexistent_tag_returns_404`
- AC-11: 이미 is_active=FALSE 태그 재요청 → HTTP 200 멱등 → `test_deactivate_already_inactive_tag_idempotent`
- REQ-005:
- AC-1: admin + admin 생성 활성 tag → POST /v1/assets/{id}/tags → HTTP 200, tag_ids 추가 → `test_apply_asset_tag_admin_success`
- AC-2: admin + is_admin_tag=FALSE tag → HTTP 403 → `test_apply_asset_tag_non_admin_tag_returns_403`
- AC-3: admin + is_active=FALSE tag → HTTP 403 → `test_apply_asset_tag_inactive_returns_403`
- AC-4: non-admin 토큰 → POST /v1/assets/{id}/tags → HTTP 403 → `test_apply_asset_tag_non_admin_token_returns_403`
- AC-5: admin → DELETE /v1/assets/{id}/tags/{tag_id} → HTTP 200, tag_id 제거 → `test_remove_asset_tag_admin_success`
- AC-6: 소유자, 본인 생성 non-admin 활성 tag → POST /v1/portfolios/{id}/tags → HTTP 200 → `test_apply_portfolio_tag_owner_own_tag`
- AC-7: 소유자, admin 생성 활성 tag → POST /v1/portfolios/{id}/tags → HTTP 200 → `test_apply_portfolio_tag_owner_admin_tag`
- AC-8: 소유자, 타 user 생성 non-admin tag → HTTP 403 → `test_apply_portfolio_tag_other_user_non_admin_tag_returns_403`
- AC-9: 소유자/admin → DELETE /v1/portfolios/{id}/tags/{tag_id} → HTTP 200 → `test_remove_portfolio_tag_owner_success`
- AC-10: 존재하지 않는 tag_id → HTTP 404 → `test_apply_portfolio_tag_nonexistent_tag_returns_404`
- AC-11: 존재하지 않는 portfolio_id → HTTP 404 → `test_apply_portfolio_tag_nonexistent_portfolio_returns_404`
## Evidence
- Test:
- pytest 실행 결과: **228 passed, 0 failed** (2026-05-20)
- 신규 테스트: `tests/test_tags_http_api.py` 22개
- `test_create_tag_lowercase_normalization`
- `test_create_tag_admin_is_admin_tag_true`
- `test_create_tag_non_admin_is_admin_tag_false`
- `test_create_tag_duplicate_name_returns_409`
- `test_list_tags_active_only`
- `test_list_tags_inactive_filter`
- `test_deactivate_tag_admin`
- `test_deactivate_tag_creator`
- `test_deactivate_tag_non_creator_returns_403`
- `test_deactivate_nonexistent_tag_returns_404`
- `test_deactivate_already_inactive_tag_idempotent`
- `test_apply_asset_tag_admin_success`
- `test_apply_asset_tag_non_admin_tag_returns_403`
- `test_apply_asset_tag_inactive_returns_403`
- `test_apply_asset_tag_non_admin_token_returns_403`
- `test_remove_asset_tag_admin_success`
- `test_apply_portfolio_tag_owner_own_tag`
- `test_apply_portfolio_tag_owner_admin_tag`
- `test_apply_portfolio_tag_other_user_non_admin_tag_returns_403`
- `test_remove_portfolio_tag_owner_success`
- `test_apply_portfolio_tag_nonexistent_tag_returns_404`
- `test_apply_portfolio_tag_nonexistent_portfolio_returns_404`
- 기존 regression 전체 통과: test_cli.py, test_v1.py, test_asset_catalog.py, test_portfolio_tags.py, test_portfolio_uuid.py, test_daemon_assignment.py, test_tags_master.py
- Manual (pre-release 수동 검증 항목 — live DB 연동 시):
- POST /v1/tags 실제 DB token으로 소문자 정규화 확인
- append_portfolio_tag_id idempotent 처리(tag_id 중복 적용 시 200 반환) 확인
- Logs: N/A
## Result
- Ready for Release: true
- Blocking Issues: 없음
- Pre-release 수동 검증: live DB 연동 환경에서 auth.user_id 정상 채움 확인, append idempotent 처리 확인
@@ -0,0 +1,44 @@
# BAT-005 Design
- Status: confirmed
- Batch: BAT-005
## Architecture Summary
- REQ-007: `app/cli/commands/tag.py` 신규 파일에 `tag_app = typer.Typer()` 생성 후 `pfhub tag` 7개 커맨드 구현. CLI는 HTTP API를 거치지 않고 기존 repo 메서드를 직접 호출(현재 CLI 아키텍처 패턴 유지). apply/remove는 HTTP 오류를 repo ValueError로 전환해 표시.
- `pfhub portfolio tag add/list/remove`는 `portfolio.py`에서 완전 삭제(deprecated 래퍼 없음) — 기존 테스트(test_portfolio_tags.py) 조정 필요.
- REQ-008: 4개 baseline 문서를 코드 현황에 맞게 갱신. 모듈/테스트 변경 없음.
## Changed Areas
- Docs:
- `docs/data-model.md` — tags 테이블 스키마, portfolios.tag_ids, portfolio_tags DROP 반영
- `docs/interface-contract.md` — /v1/tags, /v1/assets/{id}/tags, /v1/portfolios/{id}/tags 명세 추가
- `docs/project-structure.md` — tag.py 신규, pfhub tag 커맨드 그룹 추가, pfhub portfolio tag 제거
- `docs/runtime-flows.md` — pfhub tag CLI 흐름 신규, /v1/tags HTTP 흐름 신규
- Modules:
- `app/cli/commands/tag.py` — 신규 파일
- `app/cli/main.py` — tag_app 등록
- `app/cli/commands/portfolio.py` — tag_app, tag_add, tag_list, tag_remove 완전 삭제
- `tests/test_portfolio_tags.py` — pfhub portfolio tag CLI 테스트 제거 또는 조정
- Interfaces:
- 신규: pfhub tag create/list/deactivate/apply/remove 7개 커맨드
- 제거: pfhub portfolio tag add/list/remove 3개 커맨드
## Key Decisions
1. **HTTP API 직접 호출 vs repo 메서드**: 기존 CLI 아키텍처는 AppContext를 통해 repo를 주입받아 직접 repo 메서드를 호출. pfhub tag CLI도 동일 패턴 유지 — HTTP 클라이언트 별도 없음. apply/remove는 `repo.append_portfolio_tag_id` / `repo.remove_portfolio_tag_id` 직접 호출, 정책 검증은 repo가 아닌 HTTP API 레이어에서 하는 것이 원칙이나 CLI는 repo 직접 호출이므로 정책 검증 로직을 CLI 레이어에서도 수행.
2. **pfhub portfolio tag 완전 삭제**: deprecated 래퍼 없이 완전 제거. REQ-007 AC-9 "명령어가 존재하지 않는다(Typer error)" 충족.
3. **apply/remove sub-group**: `pfhub tag apply`와 `pfhub tag remove`는 각각 `asset`/`portfolio` 두 sub-command를 갖는 nested Typer group으로 구현.
4. **--force 확인**: `pfhub tag deactivate`, `pfhub tag remove` 두 커맨드에서 `--force` 플래그가 없으면 실행 거부(기존 CLI 패턴 일관성).
5. **test_portfolio_tags.py 조정**: 기존 `pfhub portfolio tag add/list/remove` 테스트를 제거. 이 파일의 나머지 테스트(존재하면)는 유지.
## Edge Cases
- `pfhub tag create <name>` 중복 이름 → repo ValueError → CLI 오류 메시지 출력 후 non-zero exit
- `pfhub tag deactivate <tag_id>` --force 없이 실행 → "확인이 필요합니다" 오류
- `pfhub tag apply asset/portfolio` 정책 위반 → repo/HTTP 403 오류 → CLI 오류 메시지 출력
- `pfhub portfolio tag add/list/remove` 실행 → Typer "No such command" 오류 (제거 후)
## Architecture Impact
- both (project-structure: tag.py 신규, runtime-flows: pfhub tag 흐름 신규)
## Reference Doc Update Plan
- project-structure.md: REQ-008에서 직접 갱신 (이 배치 내부에서 처리)
- runtime-flows.md: REQ-008에서 직접 갱신 (이 배치 내부에서 처리)
@@ -0,0 +1,37 @@
# BAT-005 Implementation
- Status: completed
- Batch: BAT-005
## Plan Summary
- REQ-007: tag.py 신규 CLI 커맨드 그룹 + main.py 등록 + portfolio.py tag 커맨드 제거
- REQ-008: baseline 문서 4개 갱신 (data-model, interface-contract, project-structure, runtime-flows)
## Changed Files
- `app/cli/commands/tag.py` — 신규: tag_app (create/list/deactivate), apply_app (asset/portfolio), remove_app (asset/portfolio) 7개 커맨드
- `app/cli/main.py` — tag_app import 및 `app.add_typer(tag_app, name="tag")` 추가
- `app/cli/commands/portfolio.py` — tag_app Typer, tag_add, tag_list, tag_remove 완전 삭제; PortfolioTagMutationRequest import 제거
- `app/cli/core/utils.py` — print_tag_table() 추가; render_output()에 list[Tag] 분기 추가
- `tests/test_cli.py` — test_cli_tag_add_and_list, test_cli_tag_remove_flow 제거
- `tests/test_tags_cli.py` — 신규: FakeTagRepo 기반 24개 테스트 (REQ-007 AC-1~9 커버)
- `docs/data-model.md` — REQ-008 AC-1: Entity Tag 신규, portfolios.tag_ids, portfolio_tags 제거 반영
- `docs/interface-contract.md` — REQ-008 AC-2: Tag Management API 섹션 신규 (7개 엔드포인트)
- `docs/project-structure.md` — REQ-008 AC-3: tag.py/v1_tags.py/v1_portfolios.py/tag schemas 반영
- `docs/runtime-flows.md` — REQ-008 AC-4: pfhub tag CLI 흐름, /v1/tags, /v1/assets|portfolios/{id}/tags 흐름 신규
## Execution Log
1. planning.md, design.md status → confirmed; index.md, docs/batches/index.md → in-delivery
2. app/cli/commands/tag.py 신규 생성 (태그 7개 커맨드, _with_repo 패턴)
3. app/cli/main.py에 tag_app 등록
4. app/cli/commands/portfolio.py에서 tag_app 관련 코드 완전 삭제
5. app/cli/core/utils.py에 print_tag_table + list[Tag] render 분기 추가
6. tests/test_cli.py에서 test_cli_tag_add_and_list, test_cli_tag_remove_flow 제거
7. tests/test_tags_cli.py 신규 생성 (FakeTagRepo, 24개 테스트)
8. pytest 247 passed 확인
9. docs/data-model.md, interface-contract.md, project-structure.md, runtime-flows.md 갱신
## Validation
- pytest 247 passed (8.47s) — regression 없음, 신규 24개 테스트 전체 통과
## Remaining Risks
- 없음 — REQ-007, REQ-008 구현 및 검증 완료
@@ -0,0 +1,35 @@
# BAT-005: pfhub tag CLI 및 baseline docs 갱신
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-002
- Included REQ: REQ-007, REQ-008
- Excluded REQ: (없음 — dcy-002 잔여 2개 전부 포함)
## Summary
- tags 마스터 기반의 `pfhub tag` 최상위 CLI 커맨드 그룹을 신규 추가하고(REQ-007), 기존 `pfhub portfolio tag add/list/remove` 커맨드를 제거한다. 이후 dcy-002 전체 구현 완료를 반영해 baseline 문서 4개를 갱신한다(REQ-008).
- 이 배치가 완료되면 dcy-002(태그 소유권 및 적용 대상 정책) 관련 REQ-001~008 전체 구현이 종료된다.
## Scope
- In Scope:
- `app/cli/commands/tag.py` 신규 파일 — `pfhub tag create/list/deactivate/apply/remove` 커맨드
- `app/cli/main.py` — `tag_app` 등록 추가 (REQ-007)
- `app/cli/commands/portfolio.py` — `tag_app`, `tag_add`, `tag_list`, `tag_remove` 제거 (REQ-007)
- `docs/data-model.md` — tags 테이블 스키마, portfolios.tag_ids, portfolio_tags 제거 반영 (REQ-008)
- `docs/interface-contract.md` — /v1/tags, /v1/assets/{id}/tags, /v1/portfolios/{id}/tags 명세 추가 (REQ-008)
- `docs/project-structure.md` — pfhub tag 커맨드 그룹 추가, pfhub portfolio tag 제거 반영 (REQ-008)
- `docs/runtime-flows.md` — pfhub tag CLI 흐름, /v1/tags HTTP 흐름 추가 (REQ-008)
- Out of Scope:
- HTTP API 변경 (→ BAT-004 완료)
- DB 스키마 변경 (→ BAT-003 완료)
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- R-1 (Medium): `pfhub portfolio tag add/list/remove` 제거는 기존 운영 스크립트에 breaking change — 운영자 사전 고지 필요
- REQ-007 선행 의존: REQ-004(tags HTTP API), REQ-005(태그 적용/해제 HTTP API) 모두 BAT-004(release-candidate) 완료 상태
- REQ-008은 REQ-001~007 전체 구현 완료 후 일괄 반영 → 이 배치가 마지막 실행 단계
@@ -0,0 +1,50 @@
# BAT-005 Planning
- Status: confirmed
- Batch: BAT-005
- Profile: standard
## Included REQ
- REQ-007: pfhub tag CLI 명령어 (pfhub tag create/list/deactivate/apply/remove + pfhub portfolio tag 제거)
- REQ-008: 태그 모델 도입 baseline 문서 갱신 (data-model, interface-contract, project-structure, runtime-flows)
## Delivery Plan
1. **REQ-007 — pfhub tag CLI 신규 구현**
- `app/cli/commands/tag.py` 신규 파일 생성
- `pfhub tag create <name>` — POST /v1/tags 호출 (trade-write)
- `pfhub tag list [--inactive]` — GET /v1/tags 호출 (is_active 필터)
- `pfhub tag deactivate <tag_id> --force` — PATCH /v1/tags/{tag_id}/deactivate 호출
- `pfhub tag apply asset <asset_id> <tag_id>` — POST /v1/assets/{asset_id}/tags 호출 (admin)
- `pfhub tag apply portfolio <portfolio_id> <tag_id>` — POST /v1/portfolios/{portfolio_id}/tags 호출
- `pfhub tag remove asset <asset_id> <tag_id> --force` — DELETE /v1/assets/{asset_id}/tags/{tag_id} 호출
- `pfhub tag remove portfolio <portfolio_id> <tag_id> --force` — DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id} 호출
- `app/cli/main.py` — tag_app 등록 (`app.add_typer(tag_app, name="tag")`)
- `app/cli/commands/portfolio.py` — tag_app Typer, tag_add, tag_list, tag_remove 제거
2. **REQ-008 — baseline 문서 갱신**
- `docs/data-model.md` — tags 테이블 스키마, portfolios.tag_ids UUID[], portfolio_tags 제거 반영
- `docs/interface-contract.md` — /v1/tags (POST/GET/PATCH), /v1/assets/{id}/tags (POST/DELETE), /v1/portfolios/{id}/tags (POST/DELETE) 명세 추가
- `docs/project-structure.md` — app/cli/commands/tag.py 추가, pfhub tag 커맨드 그룹 추가, portfolio tag 제거 반영
- `docs/runtime-flows.md` — pfhub tag CLI 흐름, /v1/tags HTTP 흐름 추가
3. **전체 pytest 실행 — regression 확인**
## Dependencies
- Internal:
- BAT-003(release-candidate): tags 테이블, portfolios.tag_ids, Tag 도메인 모델 완료
- BAT-004(release-candidate): POST/GET/PATCH /v1/tags, POST/DELETE /v1/assets|portfolios/{id}/tags HTTP API 완료
- 기존 CLI 패턴(AppContext 통해 settings/repository 주입, --force 확인) 준수
- External:
- 없음
## Design Gate
- Design Required Before Implementation: yes
- Reason: 기존 `pfhub portfolio tag add/list/remove` 커맨드 제거 방식(완전 삭제 vs deprecated 표시), tag.py 커맨드 구조(apply/remove sub-group 설계), HTTP API 호출 방식(직접 repo 메서드 vs HTTP client) 결정 필요.
## Milestones
1. REQ-007 CLI 구현 완료 + pfhub portfolio tag 제거 + tests
2. REQ-008 baseline 문서 4개 갱신 완료
3. 전체 pytest suite 통과 (regression 없음)
## Risks
- R-1 (Medium): `pfhub portfolio tag add/list/remove` 제거는 운영 환경 breaking change — 기존 스크립트/자동화 사용 여부 사전 확인 권고
- R-2 (Low): pfhub tag CLI는 HTTP API를 직접 호출하지 않고 repo 메서드를 통해 구현 — repo 메서드가 이미 BAT-003/004에서 완성되어 있으므로 위험 낮음
- R-3 (Low): REQ-007 AC-9(pfhub portfolio tag 제거 확인) 테스트 패턴 — CLI 테스트 파일에서 명령어 부재 확인 방식 설계 필요
@@ -0,0 +1,40 @@
# BAT-005 Verification
- Status: release-candidate
- Batch: BAT-005
## Acceptance Mapping
- REQ-007:
- AC-1: `pfhub tag create "코스피200"` → 태그 생성 성공 → `test_tag_create_success`, `test_tag_create_with_user`
- AC-2: `pfhub tag list` → 활성 태그 목록만 출력 → `test_tag_list_shows_active_tags`
- AC-3: `pfhub tag list --inactive` → 비활성 포함 전체 목록 출력 → `test_tag_list_inactive_shows_all`
- AC-4: `pfhub tag deactivate <tag_id> --force` → 비활성화; 재실행 멱등 → `test_tag_deactivate_with_force`, `test_tag_deactivate_already_inactive_is_idempotent`, `test_tag_deactivate_without_force_aborts`, `test_tag_deactivate_nonexistent_fails`
- AC-5: `pfhub tag apply asset <asset_id> <tag_id>` → admin+active 태그만 허용 → `test_tag_apply_asset_success`, `test_tag_apply_asset_non_admin_tag_fails`, `test_tag_apply_asset_inactive_tag_fails`
- AC-6: `pfhub tag apply portfolio <portfolio_id> <tag_id>` → admin 태그 또는 소유자 태그만 허용 → `test_tag_apply_portfolio_admin_tag_success`, `test_tag_apply_portfolio_owner_tag_success`, `test_tag_apply_portfolio_policy_violation_fails`
- AC-7: `pfhub tag remove asset <asset_id> <tag_id> --force` → 태그 제거 → `test_tag_remove_asset_with_force`, `test_tag_remove_asset_without_force_aborts`
- AC-8: `pfhub tag remove portfolio <portfolio_id> <tag_id> --force` → 태그 제거 → `test_tag_remove_portfolio_with_force`
- AC-9: `pfhub portfolio tag add/list/remove` → 명령어 없음(Typer error) → `test_portfolio_tag_subcommand_does_not_exist`, `test_portfolio_tag_list_does_not_exist`, `test_portfolio_tag_remove_does_not_exist`
- REQ-008:
- AC-1: docs/data-model.md에 tags 테이블 스키마, portfolios.tag_ids UUID[], portfolio_tags 제거 반영 → 파일 내용 검증 완료
- AC-2: docs/interface-contract.md에 /v1/tags (POST/GET/PATCH), /v1/assets/{id}/tags, /v1/portfolios/{id}/tags 명세 포함 → 파일 내용 검증 완료
- AC-3: docs/project-structure.md에 pfhub tag 커맨드 그룹, app/cli/commands/tag.py 반영 → 파일 내용 검증 완료
- AC-4: docs/runtime-flows.md에 pfhub tag CLI 흐름, /v1/tags HTTP 흐름, /v1/assets|portfolios/{id}/tags 흐름 포함 → 파일 내용 검증 완료
## Evidence
- Test:
- pytest 실행 결과: 247 passed (tests/test_tags_cli.py 24개 신규 포함, tests/test_cli.py 기존 32개 유지)
- 신규 테스트 파일: tests/test_tags_cli.py (REQ-007 AC-1~9 커버, FakeTagRepo 기반)
- regression: test_cli_tag_add_and_list, test_cli_tag_remove_flow 제거 (pfhub portfolio tag 커맨드 제거와 함께)
- Manual:
- docs/data-model.md: Entity Tag 섹션 신규, portfolios.tag_ids UUID[] 추가, portfolio_tags 제거 반영
- docs/interface-contract.md: Tag Management API 섹션 신규 (7개 엔드포인트 명세)
- docs/project-structure.md: app/cli/commands/tag.py, app/api/v1_tags.py/v1_portfolios.py, v1.py tag schemas 반영
- docs/runtime-flows.md: pfhub tag CLI 흐름, v1 tag management 흐름, v1 tag apply/remove 흐름 신규
- Logs:
- `.venv/bin/python -m pytest -q` → 247 passed in 8.47s
## Result
- Ready for Release: true
- Blocking Issues: 없음
+2
View File
@@ -11,3 +11,5 @@
| BAT-001 | standard | verified | REQ-009, REQ-011, REQ-014 | dcy-001 | [bat-001](./bat-001_20260519_asset-catalog-db-foundation/) | | BAT-001 | standard | verified | REQ-009, REQ-011, REQ-014 | dcy-001 | [bat-001](./bat-001_20260519_asset-catalog-db-foundation/) |
| BAT-002 | standard | release-candidate | REQ-010, REQ-012, REQ-013, REQ-015 | dcy-001 | [bat-002](./bat-002_20260519_asset-catalog-api-completion/) | | BAT-002 | standard | release-candidate | REQ-010, REQ-012, REQ-013, REQ-015 | dcy-001 | [bat-002](./bat-002_20260519_asset-catalog-api-completion/) |
| BAT-003 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-006 | dcy-002 | [bat-003](./bat-003_20260519_tags-db-foundation/) | | BAT-003 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-006 | dcy-002 | [bat-003](./bat-003_20260519_tags-db-foundation/) |
| BAT-004 | standard | release-candidate | REQ-004, REQ-005 | dcy-002 | [bat-004](./bat-004_20260520_tags-http-api/) |
| BAT-005 | standard | release-candidate | REQ-007, REQ-008 | dcy-002 | [bat-005](./bat-005_20260520_pfhub-tag-cli-and-docs/) |
+44 -5
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-13 00:00 - Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005
## Project Summary ## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
@@ -27,13 +27,49 @@
## Model Summary ## Model Summary
- Portfolio :: owned investment portfolio with balances, strategy references, tags, comments, and ledger history - Portfolio :: owned investment portfolio with balances, strategy references, tag_ids, comments, and ledger history
- User :: authenticated principal that owns portfolios and receives client or service tokens - User :: authenticated principal that owns portfolios and receives client or service tokens
- Daemon :: stable logical identity for a trading daemon process; registered per user; bound to service tokens and assigned to assets - Daemon :: stable logical identity for a trading daemon process; registered per user; bound to service tokens and assigned to assets
- AuthToken :: issued client or service token with permission, runtime_id, and optional daemon_id binding - AuthToken :: issued client or service token with permission, runtime_id, and optional daemon_id binding
- BrokerCredential :: encrypted broker API credentials per user-broker pair - BrokerCredential :: encrypted broker API credentials per user-broker pair
- LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id) - LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id)
- Asset :: catalog entry representing a tradeable asset; portfolios reference assets via FK - Asset :: catalog entry representing a tradeable asset; portfolios reference assets via FK
- Tag :: master tag record with ownership (created_by_user_id), admin flag, and active/inactive lifecycle
## Entity: `Tag`
- Purpose: represent a named master tag with ownership and admin classification; applied to portfolios via tag_ids UUID[] column
- Source of Truth: tags table (added by BAT-003 migration 20260519_0017)
- Lifecycle: created (is_active=true) → optionally deactivated (is_active=false); no hard delete
### Key Fields
- tag_id :: uuid :: server-generated UUID primary key (gen_random_uuid())
- name :: text UNIQUE :: tag label; normalized to lowercase and stripped; unique across all tags
- created_by_user_id :: text :: user_id of the creator; used for portfolio tag apply policy
- is_admin_tag :: boolean :: if true, tag was created with admin permission; can be applied to any portfolio regardless of ownership
- is_active :: boolean :: inactive tags cannot be applied; existing tag_ids arrays are not modified on deactivation
- created_at :: timestamptz :: creation timestamp (server_default=NOW())
### Uniqueness Constraints
- tags_name_key :: UNIQUE (name) :: one tag entry per unique lowercase name
### Relationships
- Portfolio :: portfolios.tag_ids UUID[] soft-references tags.tag_id (no FK constraint); array_append/array_remove manage membership
### State Rules
- Only is_active=true tags can be applied via POST /v1/assets/{id}/tags or POST /v1/portfolios/{id}/tags.
- Deactivating a tag does not remove it from portfolios.tag_ids arrays (lazy cleanup).
- Admin tags (is_admin_tag=true) can be applied to any portfolio; non-admin tags can only be applied to portfolios owned by the tag creator.
- Asset tag application requires is_admin_tag=true (asset tags are always admin-controlled).
### Removed Entity: `portfolio_tags` (BAT-003)
- The old `portfolio_tags` table (string-keyed free-form tags per portfolio) was replaced by `portfolios.tag_ids UUID[]` in BAT-003.
- `portfolio_tags` DROP was included in migration 20260519_0017.
## Entity: `Asset` ## Entity: `Asset`
@@ -83,6 +119,7 @@
- cash_balance :: double precision :: current cash snapshot after the latest ledger event - cash_balance :: double precision :: current cash snapshot after the latest ledger event
- asset_quantity :: double precision :: current holdings snapshot after the latest ledger event - asset_quantity :: double precision :: current holdings snapshot after the latest ledger event
- managed_by_daemon_id :: text|null :: soft reference to daemons.id; controls daemon-scoped API access; NULL = unassigned (fail-closed for ledger writes and asset listing); added by migration 20260513_0016 - managed_by_daemon_id :: text|null :: soft reference to daemons.id; controls daemon-scoped API access; NULL = unassigned (fail-closed for ledger writes and asset listing); added by migration 20260513_0016
- tag_ids :: uuid[] :: array of tag UUIDs from the tags master table; replaces the old string-based portfolio_tags table (BAT-003 migration 20260519_0017); default empty array
### Uniqueness Constraints ### Uniqueness Constraints
@@ -94,14 +131,15 @@
- User :: owner_user_id links a portfolio to its owning user when ownership is assigned - User :: owner_user_id links a portfolio to its owning user when ownership is assigned
- Daemon :: managed_by_daemon_id links a portfolio to exactly one daemon; NULL means unassigned - Daemon :: managed_by_daemon_id links a portfolio to exactly one daemon; NULL means unassigned
- StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions - StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions
- LedgerEntry, PortfolioComment, PortfolioTag :: child records are keyed by portfolio_id (UUID) - LedgerEntry, PortfolioComment :: child records are keyed by portfolio_id (UUID)
- Tag :: tag_ids UUID[] references tags.tag_id (no FK — soft reference for flexibility)
### State Rules ### State Rules
- Portfolio names are required, must be unique per owner_user_id, and are validated at the schema layer before persistence. - Portfolio names are required, must be unique per owner_user_id, and are validated at the schema layer before persistence.
- portfolio_id is server-generated at creation; clients must never supply or assume a specific portfolio_id value. - portfolio_id is server-generated at creation; clients must never supply or assume a specific portfolio_id value.
- Balance-changing commands must update snapshot fields and append a ledger row together. - Balance-changing commands must update snapshot fields and append a ledger row together.
- Tags are unique per portfolio and comments are retrieved in created_at descending order. - tag_ids stores UUID references to active tags; duplicates are prevented by conditional array_append; comments are retrieved in created_at descending order.
## Entity: `User` ## Entity: `User`
@@ -275,6 +313,7 @@
## Change Log ## Change Log
- 2026-05-20 KST: bat-005 — REQ-008 AC-1: added Entity: Tag (tags master table, BAT-003 migration 20260519_0017); updated Portfolio.tag_ids UUID[] field; removed portfolio_tags table note; updated Portfolio Relationships and State Rules; updated Model Summary.
- 2026-05-19 KST: bat-002 — REQ-015: added Entity: Asset (asset catalog with symbol UNIQUE, markets[], status); updated Portfolio.asset_id FK and symbol/market field notes. - 2026-05-19 KST: bat-002 — REQ-015: added Entity: Asset (asset catalog with symbol UNIQUE, markets[], status); updated Portfolio.asset_id FK and symbol/market field notes.
- 2026-05-13 KST: bat-010 — REQ-009/023/028: added Entity: Daemon; added portfolios.managed_by_daemon_id; added auth_tokens.daemon_id; updated AuthToken State Rules (runtime_id vs daemon_id distinction, fail-closed policy); updated Portfolio Relationships; updated Model Summary. - 2026-05-13 KST: bat-010 — REQ-009/023/028: added Entity: Daemon; added portfolios.managed_by_daemon_id; added auth_tokens.daemon_id; updated AuthToken State Rules (runtime_id vs daemon_id distinction, fail-closed policy); updated Portfolio Relationships; updated Model Summary.
- 2026-05-11 KST: bat-008 — REQ-011 AC-2: added portfolios.market column; added Entity: AuthToken (runtime_id field); added Entity: BrokerCredential; added Entity: LedgerTrade; updated Model Summary. - 2026-05-11 KST: bat-008 — REQ-011 AC-2: added portfolios.market column; added Entity: AuthToken (runtime_id field); added Entity: BrokerCredential; added Entity: LedgerTrade; updated Model Summary.
+72 -4
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-13 00:00 - Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -69,10 +69,11 @@
### Inputs ### Inputs
- pfhub subcommands :: include user, portfolio, strategy, service-token, daemon, credential, health-check, init-db, and migrate-db command families - pfhub subcommands :: include user, portfolio, strategy, tag, service-token, daemon, credential, health-check, init-db, and migrate-db command families
- Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments - Flags and options :: include --format table|json, --force confirmations, and operation-specific mutation arguments
- Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution - Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution
- Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` and `tag` sub-commands still accept portfolio_id (UUID) directly - Portfolio identification (bat-006) :: all portfolio mutation commands accept `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` sub-commands accept portfolio_id (UUID) directly
- Tag commands (bat-005) :: `pfhub tag create/list/deactivate/apply/remove` operate on the tags master table; `pfhub portfolio tag add/list/remove` has been removed (breaking change)
### Outputs ### Outputs
@@ -274,6 +275,72 @@
- After BAT-006, all `asset_id` values in GET /v1/assets responses change from TEXT to UUID format. - After BAT-006, all `asset_id` values in GET /v1/assets responses change from TEXT to UUID format.
- trex-basic consumers must handle UUID-format asset_id before the coordinated deployment is completed. - trex-basic consumers must handle UUID-format asset_id before the coordinated deployment is completed.
## Interface: `Tag Management API (BAT-004)`
- Type: http-api
- Consumers / Producers: authenticated operators and admin services
- Purpose: manage tag master records and apply/remove tags on assets and portfolios
- Stability: internal (versioned under /v1)
### Authentication
- All /v1/tags, /v1/assets/{id}/tags, /v1/portfolios/{id}/tags endpoints require `Authorization: Bearer <token>`.
- Token must be DB-issued (`token_source="db"`); env tokens rejected with HTTP 403.
### Endpoint: `POST /v1/tags`
- Auth: DB token required; trade-write or admin permission
- Body: `{"name": "<str>"}` (min_length=1; normalized to lowercase + stripped)
- Response: HTTP 201 `TagItem` `{"tag_id": "<uuid>", "name": "<str>", "is_admin_tag": <bool>, "is_active": true, "created_at": "<ISO8601|null>"}`
- is_admin_tag is set to true when caller has admin permission; false for trade-write
- Error: HTTP 409 if tag name already exists; HTTP 403 if env token
### Endpoint: `GET /v1/tags`
- Auth: DB token required; read permission
- Query Params: `?include_inactive=true` (default false — returns only active tags)
- Response: HTTP 200 `TagListResponse` `{"tags": [TagItem], "total": <int>}`
- Error: HTTP 403 if env token
### Endpoint: `PATCH /v1/tags/{tag_id}/deactivate`
- Auth: DB token required; read permission (admin or tag creator)
- Path Param: `tag_id` — must be a valid tag UUID
- Body: none
- Response: HTTP 200 `TagItem` with `is_active=false`; idempotent (already-inactive returns 200 immediately)
- Error: HTTP 404 if tag not found; HTTP 403 if caller is not admin and not tag creator
### Endpoint: `POST /v1/assets/{asset_id}/tags`
- Auth: DB token required; admin permission
- Path Param: `asset_id` — maps to portfolio_id (v1 convention: asset_id = portfolio_id)
- Body: `{"tag_id": "<uuid>"}`
- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "<uuid>", "status": "applied"}`
- Policy: tag must have is_admin_tag=true AND is_active=true; non-admin-tag → HTTP 403; inactive → HTTP 403
- Error: HTTP 404 if asset or tag not found; HTTP 403 if policy violation or env token
### Endpoint: `DELETE /v1/assets/{asset_id}/tags/{tag_id}`
- Auth: DB token required; admin permission
- Body: none
- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "<uuid>", "status": "removed"}`
- Error: HTTP 404 if asset not found; HTTP 403 if not admin or env token
### Endpoint: `POST /v1/portfolios/{portfolio_id}/tags`
- Auth: DB token required; trade-write or admin permission
- Body: `{"tag_id": "<uuid>"}`
- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "<uuid>", "status": "applied"}`
- Policy: tag must have is_active=true; caller must be portfolio owner or admin; non-admin non-owner-created tag → HTTP 403
- Error: HTTP 404 if portfolio or tag not found; HTTP 403 if policy violation or env token
### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}`
- Auth: DB token required; trade-write or admin permission
- Body: none
- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "<uuid>", "status": "removed"}`
- Error: HTTP 404 if portfolio not found; HTTP 403 if caller is not owner or admin
## Shared Constraints ## Shared Constraints
- Token permission levels gate both HTTP and CLI mutations consistently. - Token permission levels gate both HTTP and CLI mutations consistently.
@@ -294,6 +361,7 @@
## Change Log ## Change Log
- 2026-05-20 KST: bat-005 — REQ-008 AC-2: added Tag Management API section (POST/GET/PATCH /v1/tags, POST/DELETE /v1/assets/{id}/tags, POST/DELETE /v1/portfolios/{id}/tags); updated CLI surface (pfhub tag, removed pfhub portfolio tag); updated CLI inputs.
- 2026-05-19 KST: bat-002 — REQ-010/012/013/015: added admin catalog endpoints (POST/PATCH /v1/assets, PATCH /v1/assets/{id}/deactivate); rewrote GET /v1/assets authentication to support service/user/admin token branching; added market filter param; removed market field from AssetItem; added AssetCatalogResponse schema. - 2026-05-19 KST: bat-002 — REQ-010/012/013/015: added admin catalog endpoints (POST/PATCH /v1/assets, PATCH /v1/assets/{id}/deactivate); rewrote GET /v1/assets authentication to support service/user/admin token branching; added market filter param; removed market field from AssetItem; added AssetCatalogResponse schema.
- 2026-05-13 KST: bat-010 — REQ-006/007/008/011/023: added daemon authorization model section; updated GET /v1/assets (daemon-scoped filter, NULL 403); updated GET /v1/user_info (daemon_id field, NULL runtime_id → 200+null diagnostic); updated POST /v1/assets/{asset_id}/ledger/trades (daemon assignment authorization, fail-closed policy); added daemon CLI subcommand to CLI surface; updated v1 purpose and source discovery. - 2026-05-13 KST: bat-010 — REQ-006/007/008/011/023: added daemon authorization model section; updated GET /v1/assets (daemon-scoped filter, NULL 403); updated GET /v1/user_info (daemon_id field, NULL runtime_id → 200+null diagnostic); updated POST /v1/assets/{asset_id}/ledger/trades (daemon assignment authorization, fail-closed policy); added daemon CLI subcommand to CLI surface; updated v1 purpose and source discovery.
- 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract. - 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract.
+10 -8
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-11 00:00 - Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007, bat-003, bat-004, bat-005
## Project Summary ## Project Summary
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
@@ -27,7 +27,7 @@
- Type: mixed - Type: mixed
- Planned Entry Points: - Planned Entry Points:
- uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks - uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks
- pfhub :: run operational CLI commands for health, users, portfolios, strategies, service tokens, and broker credentials - pfhub :: run operational CLI commands for health, users, portfolios, strategies, tags, service tokens, and broker credentials
- alembic upgrade head :: apply ordered database schema migrations for persistent environments - alembic upgrade head :: apply ordered database schema migrations for persistent environments
## Purpose ## Purpose
@@ -82,12 +82,13 @@
- .github/ - .github/
- Hosts StagePilot skills, document templates, and helper scripts such as stagepilot-doctor and stagepilot-bootstrap-seed. - Hosts StagePilot skills, document templates, and helper scripts such as stagepilot-doctor and stagepilot-bootstrap-seed.
- app/api/ - app/api/
- Defines FastAPI route surfaces for health, authentication, users, strategy sets, and portfolios. - Defines FastAPI route surfaces for health, authentication, users, strategy sets, portfolios, and tags.
- v1 route surfaces: `v1_assets.py` (GET /v1/assets token-branched, POST /v1/assets admin catalog create, PATCH /v1/assets/{id} admin update, PATCH /v1/assets/{id}/deactivate admin deactivate), `v1_user_info.py` (GET /v1/user_info), `v1_ledger.py` (POST /v1/assets/{asset_id}/ledger/trades). - v1 route surfaces: `v1_assets.py` (GET /v1/assets token-branched, POST /v1/assets admin catalog, PATCH /v1/assets/{id}, PATCH /v1/assets/{id}/deactivate, POST/DELETE /v1/assets/{id}/tags), `v1_tags.py` (POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate), `v1_portfolios.py` (POST/DELETE /v1/portfolios/{id}/tags), `v1_user_info.py` (GET /v1/user_info), `v1_ledger.py` (POST /v1/assets/{asset_id}/ledger/trades).
- GET /v1/assets supports service/user/admin token branching; admin catalog CRUD requires admin token. - GET /v1/assets supports service/user/admin token branching; admin catalog CRUD requires admin token; tag endpoints added by BAT-004.
- app/cli/ - app/cli/
- Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows. - Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows.
- Command groups: `asset` (catalog CRUD via admin), `portfolio`, `strategy`, `user`, `service-token`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`). - Command groups: `asset` (catalog CRUD via admin), `portfolio`, `strategy`, `tag` (tag master CRUD and apply/remove; BAT-005), `user`, `service-token`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`).
- `app/cli/commands/tag.py` (BAT-005): `pfhub tag create/list/deactivate/apply asset|portfolio/remove asset|portfolio`; direct repo method calls following existing AppContext pattern. `pfhub portfolio tag add/list/remove` removed (breaking change, BAT-005).
- app/core/ - app/core/
- Owns configuration loading, auth guards, security helpers, and shared error handling. - Owns configuration loading, auth guards, security helpers, and shared error handling.
- `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`. - `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`.
@@ -98,7 +99,7 @@
- Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands. - Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands.
- app/schemas/ - app/schemas/
- Defines request and response schemas for HTTP contracts. - Defines request and response schemas for HTTP contracts.
- `v1.py`: Contract schemas for v1 API (AssetItem, AssetsResponse, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response, AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse). - `v1.py`: Contract schemas for v1 API (AssetItem, AssetsResponse, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response, AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse, TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse; tag schemas added by BAT-004).
- app/services/ - app/services/
- Implements business logic for auth, portfolios, strategy sets, and users. - Implements business logic for auth, portfolios, strategy sets, and users.
- migrations/ - migrations/
@@ -161,6 +162,7 @@
## Change Log ## Change Log
- 2026-05-20 KST: bat-005 — REQ-008 AC-3: added app/cli/commands/tag.py (pfhub tag command group); updated app/api/ to reflect v1_tags.py, v1_portfolios.py, v1_assets.py tag endpoints; updated app/schemas/v1.py tag schemas; updated pfhub entry point (tags); noted pfhub portfolio tag removal.
- 2026-05-11 KST: bat-007 — Added "Schema Ownership Boundary" section documenting ensure_schema() vs Alembic ownership zones, allowed/forbidden execution sequences, and legacy scope exclusion. - 2026-05-11 KST: bat-007 — Added "Schema Ownership Boundary" section documenting ensure_schema() vs Alembic ownership zones, allowed/forbidden execution sequences, and legacy scope exclusion.
- 2026-05-11 KST: bat-006 — Added get_portfolio_by_name_and_user() to repositories section; noted that portfolio_id is now a server-generated UUID. - 2026-05-11 KST: bat-006 — Added get_portfolio_by_name_and_user() to repositories section; noted that portfolio_id is now a server-generated UUID.
- 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption. - 2026-04-30 KST: Bootstrap baseline created from repository observation for initial StagePilot adoption.
+76 -3
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-13 00:00 - Last Updated (KST): 2026-05-20 00:00
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010 - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -22,7 +22,7 @@
## Covered Entry Points ## Covered Entry Points
- uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks - uvicorn app.main:app :: serve the authenticated HTTP API, OpenAPI docs, and startup bootstrap hooks
- pfhub :: run operational CLI commands for health, users, portfolios, strategies, service tokens, and daemon registry - pfhub :: run operational CLI commands for health, users, portfolios, strategies, tags, service tokens, and daemon registry
- alembic upgrade head :: apply ordered database schema migrations for persistent environments - alembic upgrade head :: apply ordered database schema migrations for persistent environments
## Shared Components ## Shared Components
@@ -50,6 +50,33 @@
4. Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied. 4. Read commands print table or JSON output, while write commands require explicit confirmation unless --force is supplied.
5. Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions. 5. Command failures surface as validation errors, feature-not-ready errors, or persistence and auth exceptions.
## Flow: `pfhub tag CLI Command Execution`
1. `pfhub tag create <name> [--user <username>] [--admin]`
- Resolves `--user` to `user_id` via `repo.get_user_by_username()`; raises ValueError if not found.
- Calls `repo.create_tag(name=name.strip(), is_admin_tag=--admin, created_by_user_id=resolved_id)`.
- Duplicate name → repo ValueError → console error, exit 1.
2. `pfhub tag list [--inactive]`
- Calls `repo.list_tags_master(include_inactive=--inactive)`.
- Renders Tag table (table format) or JSON array (--format json).
3. `pfhub tag deactivate <tag_id> --force`
- Without `--force`: prompts for confirmation; abort on refusal (exit 0).
- Calls `repo.get_tag_by_id(tag_id)`; if not found → ValueError → exit 1.
- If already inactive → prints "already inactive", returns (idempotent, exit 0).
- Calls `repo.deactivate_tag(tag_id)`.
4. `pfhub tag apply asset <asset_id> <tag_id>`
- Validates tag: get_tag_by_id → not found → ValueError; not is_admin_tag → ValueError; not is_active → ValueError.
- Calls `repo.append_portfolio_tag_id(asset_id, tag_id)`.
5. `pfhub tag apply portfolio <portfolio_id> <tag_id>`
- Validates tag: get_tag_by_id → not found → ValueError; not is_active → ValueError.
- Resolves portfolio: repo.get(portfolio_id) → not found → ValueError.
- Policy check: tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id; else → ValueError.
- Calls `repo.append_portfolio_tag_id(portfolio_id, tag_id)`.
6. `pfhub tag remove asset <asset_id> <tag_id> --force` / `pfhub tag remove portfolio <portfolio_id> <tag_id> --force`
- Without `--force`: prompts for confirmation; abort on refusal (exit 0).
- Calls `repo.remove_portfolio_tag_id(...)`.
7. `pfhub portfolio tag add/list/remove` — **removed in BAT-005**; Typer returns exit code 2 (no such command).
## Flow: `CLI Portfolio Command — Name+User Resolution` ## Flow: `CLI Portfolio Command — Name+User Resolution`
1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `<name>` positional argument and `--user <username>` option. 1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `<name>` positional argument and `--user <username>` option.
@@ -68,6 +95,51 @@
- Request validation and auth resolution should happen before persistence mutation or response rendering. - Request validation and auth resolution should happen before persistence mutation or response rendering.
- The health endpoint remains unauthenticated so infrastructure can probe readiness without a token. - The health endpoint remains unauthenticated so infrastructure can probe readiness without a token.
## Flow: `v1 API — Tag Management (POST/GET/PATCH /v1/tags)`
1. **POST /v1/tags** (create tag):
- DB token required; trade-write or admin permission.
- Env token → HTTP 403.
- `name` is stripped and lowercased; `is_admin_tag` set if caller's permission is admin.
- `repo.create_tag(name, is_admin_tag, created_by_user_id=auth.user_id)` → UniqueViolation → HTTP 409.
- Returns HTTP 201 `TagItem`.
2. **GET /v1/tags** (list tags):
- DB token required; read permission.
- `?include_inactive=true` → `repo.list_tags_master(include_inactive=True)`; default returns active only.
- Returns HTTP 200 `TagListResponse {"tags": [...], "total": N}`.
3. **PATCH /v1/tags/{tag_id}/deactivate**:
- DB token required; read permission.
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
- Caller must be admin or tag creator (`auth.user_id == tag.created_by_user_id`); else → HTTP 403.
- If already inactive → return HTTP 200 immediately (idempotent).
- `repo.deactivate_tag(tag_id)` → HTTP 200 `TagItem`.
## Flow: `v1 API — Tag Apply/Remove (POST/DELETE /v1/assets/{id}/tags and /v1/portfolios/{id}/tags)`
1. **POST /v1/assets/{asset_id}/tags** (apply tag to asset — admin only):
- DB token required; admin permission (`require_admin`).
- `repo.get(asset_id)` → ValueError → HTTP 404.
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
- `not tag.is_admin_tag` → HTTP 403; `not tag.is_active` → HTTP 403.
- `repo.append_portfolio_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`.
2. **DELETE /v1/assets/{asset_id}/tags/{tag_id}** (remove tag from asset — admin only):
- DB token required; admin permission.
- `repo.get(asset_id)` → ValueError → HTTP 404.
- `repo.remove_portfolio_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
3. **POST /v1/portfolios/{portfolio_id}/tags** (apply tag to portfolio — owner or admin):
- DB token required; trade-write or admin permission.
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
- Caller must be portfolio owner or admin; else → HTTP 403.
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
- `not tag.is_active` → HTTP 403.
- Policy: `tag.is_admin_tag OR tag.created_by_user_id == portfolio.owner_user_id`; else → HTTP 403.
- `repo.append_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`.
4. **DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}** (remove tag from portfolio — owner or admin):
- DB token required; trade-write or admin permission.
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
- Caller must be portfolio owner or admin; else → HTTP 403.
- `repo.remove_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
## Flow: `v1 API — DB Token Authentication` ## Flow: `v1 API — DB Token Authentication`
1. v1 endpoint receives Bearer token from Authorization header. 1. v1 endpoint receives Bearer token from Authorization header.
@@ -209,6 +281,7 @@
## Change Log ## Change Log
- 2026-05-20 KST: bat-005 — REQ-008 AC-4: added pfhub tag CLI flow; added v1 tag management flow (POST/GET/PATCH /v1/tags); added v1 tag apply/remove flow (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags); updated Covered Entry Points (tags).
- 2026-05-13 KST: bat-010 — REQ-006/007/008/009/023/005/012: updated GET /v1/assets flow (daemon-id guard, daemon-scoped filter); updated GET /v1/user_info flow (daemon_id diagnostic, NULL → 200+null); updated POST ledger/trades flow (3-step daemon assignment auth, fail-closed); added "Daemon Registry — Create and Bind Service Token" flow; added "Service-Token Rotate — daemon_id Invariant" flow; updated Alembic/ensure_schema boundary (BAT-010 migration 20260513_0016); updated Covered Entry Points (daemon subcommand). - 2026-05-13 KST: bat-010 — REQ-006/007/008/009/023/005/012: updated GET /v1/assets flow (daemon-id guard, daemon-scoped filter); updated GET /v1/user_info flow (daemon_id diagnostic, NULL → 200+null); updated POST ledger/trades flow (3-step daemon assignment auth, fail-closed); added "Daemon Registry — Create and Bind Service Token" flow; added "Service-Token Rotate — daemon_id Invariant" flow; updated Alembic/ensure_schema boundary (BAT-010 migration 20260513_0016); updated Covered Entry Points (daemon subcommand).
- 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated. - 2026-05-11 KST: bat-008 — REQ-011 AC-3 confirmed: v1 API flows and runtime_id issuance flow already present from bat-001; Source Discovery/Batch header updated.
- 2026-05-11 KST: bat-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow. - 2026-05-11 KST: bat-007 — Added "Local DB Initialization and Validation" flow (Scenario A/B, repeatable run criteria); added "Alembic vs ensure_schema() Schema Ownership Boundary" flow.
-25
View File
@@ -937,20 +937,6 @@ def test_cli_comment_list_nonexistent_portfolio() -> None:
assert result.exit_code == 1 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: def test_cli_list_supports_tag_filters() -> None:
repo, user = _repo_with_user(_portfolio()) repo, user = _repo_with_user(_portfolio())
repo.add_tag("005930:tv", tag="grade:A") 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 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: def test_cli_help_exposes_bot_token_commands() -> None:
repo = FakeRepo(_portfolio()) repo = FakeRepo(_portfolio())
runner = build_runner(repo) 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