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