From d4b841bc3711efb4e1eb7649aab42e091173e4ce Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 20 May 2026 01:16:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=83=9C=EA=B7=B8=20HTTP=20API(BAT-004?= =?UTF-8?q?)=20=EB=B0=8F=20pfhub=20tag=20CLI=20+=20baseline=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B0=B1=EC=8B=A0(BAT-005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/v1_assets.py | 61 ++ app/api/v1_portfolios.py | 93 +++ app/api/v1_tags.py | 108 ++++ app/cli/commands/portfolio.py | 41 -- app/cli/commands/tag.py | 165 +++++ app/cli/core/utils.py | 25 +- app/cli/main.py | 2 + app/main.py | 8 +- app/repositories/portfolio_repository.py | 4 + app/repositories/postgres.py | 30 + app/schemas/v1.py | 34 + .../bat-004_20260520_tags-http-api/design.md | 64 ++ .../implementation.md | 39 ++ .../bat-004_20260520_tags-http-api/index.md | 36 ++ .../planning.md | 46 ++ .../verification.md | 70 +++ .../design.md | 44 ++ .../implementation.md | 37 ++ .../index.md | 35 ++ .../planning.md | 50 ++ .../verification.md | 40 ++ docs/batches/index.md | 2 + docs/data-model.md | 49 +- docs/interface-contract.md | 76 ++- docs/project-structure.md | 18 +- docs/runtime-flows.md | 79 ++- tests/test_cli.py | 25 - tests/test_tags_cli.py | 436 +++++++++++++ tests/test_tags_http_api.py | 588 ++++++++++++++++++ 29 files changed, 2216 insertions(+), 89 deletions(-) create mode 100644 app/api/v1_portfolios.py create mode 100644 app/api/v1_tags.py create mode 100644 app/cli/commands/tag.py create mode 100644 docs/batches/bat-004_20260520_tags-http-api/design.md create mode 100644 docs/batches/bat-004_20260520_tags-http-api/implementation.md create mode 100644 docs/batches/bat-004_20260520_tags-http-api/index.md create mode 100644 docs/batches/bat-004_20260520_tags-http-api/planning.md create mode 100644 docs/batches/bat-004_20260520_tags-http-api/verification.md create mode 100644 docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md create mode 100644 docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md create mode 100644 docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md create mode 100644 docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md create mode 100644 docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md create mode 100644 tests/test_tags_cli.py create mode 100644 tests/test_tags_http_api.py diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index eac019b..5afb9a1 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -16,6 +16,8 @@ from app.schemas.v1 import ( AssetCatalogUpdateRequest, AssetItem, AssetsResponse, + TagApplyRequest, + TagApplyResponse, TagRefItem, ) @@ -206,6 +208,65 @@ def deactivate_catalog_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) def update_catalog_asset( asset_id: str, diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py new file mode 100644 index 0000000..186b2cc --- /dev/null +++ b/app/api/v1_portfolios.py @@ -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") diff --git a/app/api/v1_tags.py b/app/api/v1_tags.py new file mode 100644 index 0000000..eacb9c6 --- /dev/null +++ b/app/api/v1_tags.py @@ -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) diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index ff80541..bf69c2c 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -15,7 +15,6 @@ from app.schemas.portfolio import ( CommentUpdateRequest, CreatePortfolioRequest, InvestRequest, - PortfolioTagMutationRequest, ResetPortfolioRequest, SellRequest, 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.") 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(tag_app, name="tag") 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}") -@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()}") diff --git a/app/cli/commands/tag.py b/app/cli/commands/tag.py new file mode 100644 index 0000000..0636556 --- /dev/null +++ b/app/cli/commands/tag.py @@ -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) diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index d369c83..73a254b 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -13,7 +13,7 @@ from rich.panel import Panel from rich.table import Table 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.portfolio import CommentResponse, PortfolioDetailResponse, PortfolioListItem from app.schemas.user import UserResponse @@ -231,6 +231,26 @@ def print_comment_table(console: Console, comments: list[CommentResponse]) -> No 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: console.print( 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): print_service_token_table(console, payload) return + if all(isinstance(item, Tag) for item in payload): + print_tag_table(console, payload) + return return if isinstance(payload, PortfolioDetailResponse): diff --git a/app/cli/main.py b/app/cli/main.py index 5cc9357..73a1585 100644 --- a/app/cli/main.py +++ b/app/cli/main.py @@ -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.portfolio import portfolio_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.credential import credential_app 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(portfolio_app, name="portfolio") app.add_typer(strategy_app, name="strategy") + app.add_typer(tag_app, name="tag") app.add_typer(user_app, name="user") app.add_typer(token_app, name="service-token") app.add_typer(credential_app, name="credential") diff --git a/app/main.py b/app/main.py index 14aa422..17f7138 100644 --- a/app/main.py +++ b/app/main.py @@ -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.users import router as users_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_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.errors import register_exception_handlers from app.core.security import hash_password @@ -79,8 +81,10 @@ def create_app( app.include_router(strategy_sets_router) app.include_router(users_router) app.include_router(v1_assets_router) - app.include_router(v1_user_info_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) return app diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 0f3229a..cd14a2c 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -209,6 +209,10 @@ class PortfolioRepository(Protocol): 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 list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 7f1beab..18e55cd 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1718,6 +1718,36 @@ class PostgresPortfolioRepository: raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}") 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]: from app.core.security import generate_token, hash_token, token_prefix as _token_prefix diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 7d2e07e..50eed93 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -9,6 +9,40 @@ from typing import Any, Literal 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 # --------------------------------------------------------------------------- diff --git a/docs/batches/bat-004_20260520_tags-http-api/design.md b/docs/batches/bat-004_20260520_tags-http-api/design.md new file mode 100644 index 0000000..43ec54b --- /dev/null +++ b/docs/batches/bat-004_20260520_tags-http-api/design.md @@ -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 배치에서 수행. diff --git a/docs/batches/bat-004_20260520_tags-http-api/implementation.md b/docs/batches/bat-004_20260520_tags-http-api/implementation.md new file mode 100644 index 0000000..ecdc1e8 --- /dev/null +++ b/docs/batches/bat-004_20260520_tags-http-api/implementation.md @@ -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에서 정상 동작 확인 필요. diff --git a/docs/batches/bat-004_20260520_tags-http-api/index.md b/docs/batches/bat-004_20260520_tags-http-api/index.md new file mode 100644 index 0000000..61466f1 --- /dev/null +++ b/docs/batches/bat-004_20260520_tags-http-api/index.md @@ -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 완료 상태 diff --git a/docs/batches/bat-004_20260520_tags-http-api/planning.md b/docs/batches/bat-004_20260520_tags-http-api/planning.md new file mode 100644 index 0000000..496fe91 --- /dev/null +++ b/docs/batches/bat-004_20260520_tags-http-api/planning.md @@ -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로 자동 생성) diff --git a/docs/batches/bat-004_20260520_tags-http-api/verification.md b/docs/batches/bat-004_20260520_tags-http-api/verification.md new file mode 100644 index 0000000..39ccccf --- /dev/null +++ b/docs/batches/bat-004_20260520_tags-http-api/verification.md @@ -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 처리 확인 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md new file mode 100644 index 0000000..c5929de --- /dev/null +++ b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/design.md @@ -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 ` 중복 이름 → repo ValueError → CLI 오류 메시지 출력 후 non-zero exit +- `pfhub tag deactivate ` --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에서 직접 갱신 (이 배치 내부에서 처리) diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md new file mode 100644 index 0000000..63ff610 --- /dev/null +++ b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/implementation.md @@ -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 구현 및 검증 완료 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md new file mode 100644 index 0000000..3eb6c40 --- /dev/null +++ b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/index.md @@ -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 전체 구현 완료 후 일괄 반영 → 이 배치가 마지막 실행 단계 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md new file mode 100644 index 0000000..a25327b --- /dev/null +++ b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/planning.md @@ -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 ` — POST /v1/tags 호출 (trade-write) + - `pfhub tag list [--inactive]` — GET /v1/tags 호출 (is_active 필터) + - `pfhub tag deactivate --force` — PATCH /v1/tags/{tag_id}/deactivate 호출 + - `pfhub tag apply asset ` — POST /v1/assets/{asset_id}/tags 호출 (admin) + - `pfhub tag apply portfolio ` — POST /v1/portfolios/{portfolio_id}/tags 호출 + - `pfhub tag remove asset --force` — DELETE /v1/assets/{asset_id}/tags/{tag_id} 호출 + - `pfhub tag remove portfolio --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 테스트 파일에서 명령어 부재 확인 방식 설계 필요 diff --git a/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md new file mode 100644 index 0000000..6f4d336 --- /dev/null +++ b/docs/batches/bat-005_20260520_pfhub-tag-cli-and-docs/verification.md @@ -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 --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 ` → 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 ` → 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 --force` → 태그 제거 → `test_tag_remove_asset_with_force`, `test_tag_remove_asset_without_force_aborts` + - AC-8: `pfhub tag remove portfolio --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: 없음 diff --git a/docs/batches/index.md b/docs/batches/index.md index b7ecfe3..48b0a8e 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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-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-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/) | diff --git a/docs/data-model.md b/docs/data-model.md index 1447498..f93d499 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-13 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010 +- Last Updated (KST): 2026-05-20 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005 ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -27,13 +27,49 @@ ## 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 - 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 - BrokerCredential :: encrypted broker API credentials per user-broker pair - 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 +- 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` @@ -83,6 +119,7 @@ - cash_balance :: double precision :: current cash 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 +- 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 @@ -94,14 +131,15 @@ - 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 - 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 - 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. - 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` @@ -275,6 +313,7 @@ ## 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-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. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index c7af02f..8295d38 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-13 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010 +- Last Updated (KST): 2026-05-20 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005 - Primary Runtime: mixed ## Project Summary @@ -69,10 +69,11 @@ ### 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 - Environment configuration :: app/core/config.py resolves PORTFOLIOHUB_* and TIMESCALEDB_* values before command execution -- Portfolio identification (bat-006) :: all portfolio mutation commands accept `` (positional) + `--user ` 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 `` (positional) + `--user ` 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 @@ -274,6 +275,72 @@ - 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. +## 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 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": ""}` (min_length=1; normalized to lowercase + stripped) +- Response: HTTP 201 `TagItem` `{"tag_id": "", "name": "", "is_admin_tag": , "is_active": true, "created_at": ""}` +- 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": }` +- 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": ""}` +- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "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": "", "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": ""}` +- Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "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": "", "status": "removed"}` +- Error: HTTP 404 if portfolio not found; HTTP 403 if caller is not owner or admin + ## Shared Constraints - Token permission levels gate both HTTP and CLI mutations consistently. @@ -294,6 +361,7 @@ ## 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-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. diff --git a/docs/project-structure.md b/docs/project-structure.md index f0accc6..07ea226 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-11 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007 +- Last Updated (KST): 2026-05-20 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007, bat-003, bat-004, bat-005 ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -27,7 +27,7 @@ - Type: mixed - Planned Entry Points: - 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 ## Purpose @@ -82,12 +82,13 @@ - .github/ - Hosts StagePilot skills, document templates, and helper scripts such as stagepilot-doctor and stagepilot-bootstrap-seed. - app/api/ - - Defines FastAPI route surfaces for health, authentication, users, strategy sets, and portfolios. - - 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). - - GET /v1/assets supports service/user/admin token branching; admin catalog CRUD requires admin token. + - 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, 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; tag endpoints added by BAT-004. - app/cli/ - 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/ - 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()`. @@ -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. - app/schemas/ - 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/ - Implements business logic for auth, portfolios, strategy sets, and users. - migrations/ @@ -161,6 +162,7 @@ ## 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-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. \ No newline at end of file diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 834bdbb..db17bb9 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,8 +4,8 @@ - Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Status: confirmed - Owner: repository-maintainers -- Last Updated (KST): 2026-05-13 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010 +- Last Updated (KST): 2026-05-20 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005 - Primary Runtime: mixed ## Project Summary @@ -22,7 +22,7 @@ ## Covered Entry Points - 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 ## Shared Components @@ -50,6 +50,33 @@ 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. +## Flow: `pfhub tag CLI Command Execution` + +1. `pfhub tag create [--user ] [--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 --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 ` + - 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 ` + - 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 --force` / `pfhub tag remove portfolio --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` 1. All portfolio mutation commands (`status`, `create`, `update`, `buy`, `sell`, `invest`, `transfer`, `reset`, `delete`) accept a `` positional argument and `--user ` option. @@ -68,6 +95,51 @@ - 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. +## 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` 1. v1 endpoint receives Bearer token from Authorization header. @@ -209,6 +281,7 @@ ## 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-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. diff --git a/tests/test_cli.py b/tests/test_cli.py index d3e9ff3..904cd2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -937,20 +937,6 @@ def test_cli_comment_list_nonexistent_portfolio() -> None: assert result.exit_code == 1 -def test_cli_tag_add_and_list() -> None: - repo = FakeRepo(_portfolio()) - runner = build_runner(repo) - - add_result = runner.invoke(runner.app, ["portfolio", "tag", "add", "005930:tv", "--tag", " grade:A "]) - list_result = runner.invoke(runner.app, ["--format", "json", "portfolio", "tag", "list", "005930:tv"]) - - assert add_result.exit_code == 0 - assert "grade:A" in add_result.stdout - assert list_result.exit_code == 0 - payload = json.loads(list_result.stdout) - assert payload[0]["tag"] == "grade:A" - - def test_cli_list_supports_tag_filters() -> None: repo, user = _repo_with_user(_portfolio()) repo.add_tag("005930:tv", tag="grade:A") @@ -962,17 +948,6 @@ def test_cli_list_supports_tag_filters() -> None: assert "Samsung_Electronics" in result.stdout -def test_cli_tag_remove_flow() -> None: - repo = FakeRepo(_portfolio()) - repo.add_tag("005930:tv", tag="grade:A") - runner = build_runner(repo) - - result = runner.invoke(runner.app, ["portfolio", "tag", "remove", "005930:tv", "--tag", "grade:A", "--force"]) - - assert result.exit_code == 0 - assert "Removed tag: grade:A" in result.stdout - - def test_cli_help_exposes_bot_token_commands() -> None: repo = FakeRepo(_portfolio()) runner = build_runner(repo) diff --git a/tests/test_tags_cli.py b/tests/test_tags_cli.py new file mode 100644 index 0000000..ac7de0d --- /dev/null +++ b/tests/test_tags_cli.py @@ -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 → 태그 생성 성공.""" + 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 --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 --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 --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 --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 diff --git a/tests/test_tags_http_api.py b/tests/test_tags_http_api.py new file mode 100644 index 0000000..1d15f00 --- /dev/null +++ b/tests/test_tags_http_api.py @@ -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