diff --git a/app/api/v1_ledger.py b/app/api/v1_ledger.py deleted file mode 100644 index 0baf546..0000000 --- a/app/api/v1_ledger.py +++ /dev/null @@ -1,119 +0,0 @@ -"""POST /v1/assets/{asset_id}/ledger/trades — idempotent ledger trade recording.""" - -from __future__ import annotations - -from decimal import Decimal - -from fastapi import APIRouter, Depends, HTTPException, Response, status - -from app.core.auth import AuthContext, require_trade_write -from app.repositories.factory import get_portfolio_repository_from_settings -from app.schemas.v1 import LedgerTradeRequest, LedgerTradeResponse - -router = APIRouter(prefix="/v1", tags=["v1"]) - - -def _require_service_token(auth: AuthContext = Depends(require_trade_write)) -> AuthContext: - if auth.token_source != "db" or auth.token_type != "service": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Service token required", - ) - return auth - - -def _payloads_match(existing, body: LedgerTradeRequest) -> bool: - """Return True when the existing trade's payload matches the request body.""" - return ( - existing.side == body.side - and abs(existing.quantity - float(body.quantity)) < 1e-9 - and abs(existing.price - float(body.price)) < 1e-9 - and abs(existing.fee_amount - float(body.fee_amount)) < 1e-9 - and abs(existing.tax_amount - float(body.tax_amount)) < 1e-9 - ) - - -@router.post( - "/assets/{asset_id}/ledger/trades", - response_model=LedgerTradeResponse, - status_code=status.HTTP_201_CREATED, -) -def record_ledger_trade( - asset_id: str, - body: LedgerTradeRequest, - response: Response, - auth: AuthContext = Depends(_require_service_token), -) -> LedgerTradeResponse: - from app.core.config import get_settings - - settings = get_settings() - repo = get_portfolio_repository_from_settings(settings) - try: - # Validate asset_id (portfolio) existence - try: - portfolio = repo.get(asset_id) - except Exception: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Asset not found: {asset_id}", - ) - - # Daemon assignment authorization: NULL token daemon_id, unassigned, or mismatch → 403 - if auth.daemon_id is None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="daemon_id not assigned to this token", - ) - if portfolio.managed_by_daemon_id is None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Asset is not assigned to any daemon", - ) - if portfolio.managed_by_daemon_id != auth.daemon_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Token daemon does not match asset daemon assignment", - ) - - trade, created = repo.insert_ledger_trade( - asset_id=asset_id, - order_id=body.order_id, - execution_id=body.execution_id, - side=body.side, - quantity=float(body.quantity), - price=float(body.price), - executed_at=body.executed_at, - fee_amount=float(body.fee_amount), - tax_amount=float(body.tax_amount), - ) - finally: - if callable(getattr(type(repo), "close", None)): - repo.close() - - if not created: - # B-8: Check payload conflict → 409 - if not _payloads_match(trade, body): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Duplicate key with different payload", - ) - # B-7: Same payload duplicate → 200 + status="duplicate" - response.status_code = status.HTTP_200_OK - trade_status = "duplicate" - else: - trade_status = "new" - - return LedgerTradeResponse( - ledger_entry_id=trade.ledger_entry_id, - asset_id=trade.asset_id, - order_id=trade.order_id, - execution_id=trade.execution_id, - side=trade.side, - quantity=trade.quantity, - price=trade.price, - fee_amount=trade.fee_amount, - tax_amount=trade.tax_amount, - executed_at=trade.executed_at, - recorded_at=trade.recorded_at, - status=trade_status, - ) diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 186b2cc..63b50b8 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -1,14 +1,32 @@ -"""v1 portfolio tag endpoints — apply and remove tags (REQ-005).""" +"""v1 portfolio CRUD and tag endpoints (REQ-005, REQ-025, REQ-026, REQ-027, REQ-028).""" from __future__ import annotations +import json import logging +import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from app.core.auth import AuthContext, Permission, require_trade_write +from app.core.auth import AuthContext, Permission, require_read, require_trade_write +from app.domain.models import Portfolio, PortfolioComment from app.repositories.factory import get_portfolio_repository_from_settings -from app.schemas.v1 import TagApplyRequest, TagApplyResponse +from app.schemas.v1 import ( + LedgerTradeRequest, + LedgerTradeResponse, + PortfolioCommentItem, + PortfolioCommentListResponse, + PortfolioCommentRequest, + PortfolioCommentUpdateRequest, + PortfolioCreateRequest, + PortfolioItem, + PortfolioListResponse, + PortfolioTagListResponse, + PortfolioUpdateRequest, + TagApplyRequest, + TagApplyResponse, + TagItem, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/portfolios", tags=["v1"]) @@ -24,6 +42,229 @@ def _close_repo(repo) -> None: repo.close() +def _require_write_token(auth: AuthContext) -> None: + if auth.token_type in ("service", "client"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{auth.token_type} token cannot create or modify portfolios", + ) + + +def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem: + return PortfolioItem( + portfolio_id=portfolio.portfolio_id, + asset_id=portfolio.asset_id, + owner_user_id=portfolio.owner_user_id, + broker=portfolio.broker, + status=portfolio.status, + name=portfolio.name, + cash_balance=portfolio.cash_balance, + asset_quantity=portfolio.asset_quantity, + price=portfolio.price, + profit_buffer=portfolio.profit_buffer, + tag_ids=[str(t) for t in (portfolio.tag_ids or [])], + managed_by_daemon_id=portfolio.managed_by_daemon_id, + created_at=portfolio.created_at, + updated_at=portfolio.updated_at, + ) + + +def _comment_to_item(comment: PortfolioComment) -> PortfolioCommentItem: + return PortfolioCommentItem( + comment_id=comment.id, + portfolio_id=comment.portfolio_id, + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) + + +def _get_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfolio: + try: + portfolio = repo.get(portfolio_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}") + if auth.permission != Permission.ADMIN and portfolio.owner_user_id != auth.user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch") + return portfolio + + +# --------------------------------------------------------------------------- +# POST /v1/portfolios — REQ-025 +# --------------------------------------------------------------------------- + +@router.post("", response_model=PortfolioItem, status_code=status.HTTP_201_CREATED) +def create_portfolio_v1( + body: PortfolioCreateRequest, + auth: AuthContext = Depends(require_read), +) -> PortfolioItem: + _require_write_token(auth) + + owner_user_id: str = ( + body.user_id if (auth.permission == Permission.ADMIN and body.user_id) else auth.user_id + ) or "system" + + repo = _get_repo() + try: + asset = repo.get_asset_by_id(body.asset_id) + if asset is None or asset.status != "active": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"asset not found or inactive: {body.asset_id}", + ) + + name = body.name or f"{body.broker}-{body.asset_id[:8]}" + strategy_set = repo.get_or_create_strategy_set( + body.strategy_name or "target_value", + json.dumps(body.strategy_params or {}, sort_keys=True, ensure_ascii=True), + ) + + portfolio = repo.create_portfolio( + str(uuid.uuid4()), + owner_user_id=owner_user_id, + broker=body.broker, + status="enabled", + name=name, + asset_id=body.asset_id, + cash_balance=body.cash_balance, + asset_quantity=body.asset_quantity, + asset_price=body.asset_price, + profit_buffer=body.profit_buffer, + active_strategy_id=strategy_set.id, + avabile_strategies=[strategy_set.id], + ) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + finally: + _close_repo(repo) + + return _portfolio_to_item(portfolio) + + +# --------------------------------------------------------------------------- +# PATCH /v1/portfolios/{portfolio_id} — REQ-026 +# --------------------------------------------------------------------------- + +@router.patch("/{portfolio_id}", response_model=PortfolioItem) +def update_portfolio_v1( + portfolio_id: str, + body: PortfolioUpdateRequest, + auth: AuthContext = Depends(require_read), +) -> PortfolioItem: + _require_write_token(auth) + + repo = _get_repo() + try: + portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) + + if portfolio.status == "disabled": + non_status_fields = {k for k, v in body.model_dump(exclude_none=True).items() if k != "status"} + if non_status_fields: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="disabled portfolio: only status restoration is allowed", + ) + + fields = body.model_dump(exclude_none=True) + if fields: + if "strategy_name" in fields or "strategy_params" in fields: + strategy_set = repo.get_or_create_strategy_set( + fields.pop("strategy_name", None) or "target_value", + json.dumps(fields.pop("strategy_params", None) or {}, sort_keys=True, ensure_ascii=True), + ) + fields["active_strategy_id"] = strategy_set.id + fields["avabile_strategies"] = [strategy_set.id] + else: + fields.pop("strategy_name", None) + fields.pop("strategy_params", None) + repo.update_portfolio(portfolio_id, **fields) + + portfolio = repo.get(portfolio_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 _portfolio_to_item(portfolio) + + +# --------------------------------------------------------------------------- +# DELETE /v1/portfolios/{portfolio_id} — REQ-027 +# --------------------------------------------------------------------------- + +@router.delete("/{portfolio_id}", response_model=PortfolioItem) +def delete_portfolio_v1( + portfolio_id: str, + auth: AuthContext = Depends(require_read), +) -> PortfolioItem: + _require_write_token(auth) + + repo = _get_repo() + try: + _get_portfolio_or_404(auth, portfolio_id, repo) + portfolio = repo.soft_delete_portfolio(portfolio_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 _portfolio_to_item(portfolio) + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios — REQ-028 +# --------------------------------------------------------------------------- + +@router.get("", response_model=PortfolioListResponse) +def list_portfolios_v1( + market: str | None = Query(default=None), + include_disabled: bool = Query(default=False), + user_id: str | None = Query(default=None), + auth: AuthContext = Depends(require_read), +) -> PortfolioListResponse: + if auth.token_type == "client": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="client token cannot list portfolios") + + status_filter: str | None = None if include_disabled else "enabled" + + repo = _get_repo() + try: + if auth.token_type == "service": + if auth.daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="service token without daemon_id cannot list portfolios", + ) + portfolios = repo.list_portfolios( + managed_by_daemon_id=auth.daemon_id, + market=market, + status=status_filter, + ) + else: + if auth.permission == Permission.ADMIN and user_id is not None: + owner = user_id + else: + owner = auth.user_id + portfolios = repo.list_portfolios( + owner_user_id=owner, + market=market, + status=status_filter, + ) + except HTTPException: + raise + finally: + _close_repo(repo) + + items = [_portfolio_to_item(p) for p in portfolios] + return PortfolioListResponse(portfolios=items, total=len(items)) + + @router.post("/{portfolio_id}/tags", response_model=TagApplyResponse) def apply_portfolio_tag( portfolio_id: str, @@ -43,6 +284,9 @@ def apply_portfolio_tag( 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") + _require_write_token(auth) + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") tag = repo.get_tag_by_id(body.tag_id) if tag is None: @@ -82,6 +326,9 @@ def remove_portfolio_tag( 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") + _require_write_token(auth) + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") repo.remove_portfolio_tag_id(portfolio_id, tag_id) except HTTPException: @@ -91,3 +338,248 @@ def remove_portfolio_tag( finally: _close_repo(repo) return TagApplyResponse(tag_id=tag_id, status="removed") + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{portfolio_id}/tags — REQ-030 +# --------------------------------------------------------------------------- + +@router.get("/{portfolio_id}/tags", response_model=PortfolioTagListResponse) +def get_portfolio_tags( + portfolio_id: str, + auth: AuthContext = Depends(require_read), +) -> PortfolioTagListResponse: + if auth.token_type in ("service", "client"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{auth.token_type} token cannot access portfolio tags", + ) + repo = _get_repo() + try: + portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) + tags_dict = repo.get_tags_by_ids([str(t) for t in (portfolio.tag_ids or [])]) + tag_items = [ + TagItem( + tag_id=t.tag_id, + name=t.name, + is_admin_tag=t.is_admin_tag, + is_active=t.is_active, + created_at=t.created_at, + ) + for t in tags_dict.values() + ] + except HTTPException: + raise + finally: + _close_repo(repo) + return PortfolioTagListResponse(tags=tag_items, total=len(tag_items)) + + +# --------------------------------------------------------------------------- +# POST /v1/portfolios/{portfolio_id}/comments — REQ-031 +# --------------------------------------------------------------------------- + +@router.post("/{portfolio_id}/comments", response_model=PortfolioCommentItem, status_code=status.HTTP_201_CREATED) +def create_portfolio_comment( + portfolio_id: str, + body: PortfolioCommentRequest, + auth: AuthContext = Depends(require_read), +) -> PortfolioCommentItem: + _require_write_token(auth) + repo = _get_repo() + try: + portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") + comment = repo.create_comment(portfolio_id, content=body.content) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + finally: + _close_repo(repo) + return _comment_to_item(comment) + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{portfolio_id}/comments — REQ-031 +# --------------------------------------------------------------------------- + +@router.get("/{portfolio_id}/comments", response_model=PortfolioCommentListResponse) +def list_portfolio_comments( + portfolio_id: str, + auth: AuthContext = Depends(require_read), +) -> PortfolioCommentListResponse: + if auth.token_type in ("service", "client"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{auth.token_type} token cannot access portfolio comments", + ) + repo = _get_repo() + try: + _get_portfolio_or_404(auth, portfolio_id, repo) + comments = repo.list_comments(portfolio_id) + except HTTPException: + raise + finally: + _close_repo(repo) + items = [_comment_to_item(c) for c in comments] + return PortfolioCommentListResponse(comments=items, total=len(items)) + + +# --------------------------------------------------------------------------- +# PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id} — REQ-031 +# --------------------------------------------------------------------------- + +@router.patch("/{portfolio_id}/comments/{comment_id}", response_model=PortfolioCommentItem) +def update_portfolio_comment( + portfolio_id: str, + comment_id: str, + body: PortfolioCommentUpdateRequest, + auth: AuthContext = Depends(require_read), +) -> PortfolioCommentItem: + _require_write_token(auth) + repo = _get_repo() + try: + portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") + comment = repo.update_comment(portfolio_id, comment_id, content=body.content) + except HTTPException: + raise + except ValueError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"댓글을 찾을 수 없습니다: {comment_id}") + finally: + _close_repo(repo) + return _comment_to_item(comment) + + +# --------------------------------------------------------------------------- +# DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id} — REQ-031 +# --------------------------------------------------------------------------- + +@router.delete("/{portfolio_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_portfolio_comment( + portfolio_id: str, + comment_id: str, + auth: AuthContext = Depends(require_read), +) -> None: + _require_write_token(auth) + repo = _get_repo() + try: + portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") + repo.delete_comment(portfolio_id, comment_id) + except HTTPException: + raise + except ValueError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"댓글을 찾을 수 없습니다: {comment_id}") + finally: + _close_repo(repo) + + +# --------------------------------------------------------------------------- +# POST /v1/portfolios/{portfolio_id}/ledger/trades — REQ-029 +# --------------------------------------------------------------------------- + +def _require_service_token(auth: AuthContext = Depends(require_trade_write)) -> AuthContext: + if auth.token_source != "db" or auth.token_type != "service": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Service token required", + ) + if auth.daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="daemon_id not assigned to this token", + ) + return auth + + +def _payloads_match(existing, body: LedgerTradeRequest) -> bool: + return ( + existing.side == body.side + and abs(existing.quantity - float(body.quantity)) < 1e-9 + and abs(existing.price - float(body.price)) < 1e-9 + and abs(existing.fee_amount - float(body.fee_amount)) < 1e-9 + and abs(existing.tax_amount - float(body.tax_amount)) < 1e-9 + ) + + +@router.post( + "/{portfolio_id}/ledger/trades", + response_model=LedgerTradeResponse, + status_code=status.HTTP_201_CREATED, +) +def record_ledger_trade_v1( + portfolio_id: str, + body: LedgerTradeRequest, + response: Response, + auth: AuthContext = Depends(_require_service_token), +) -> LedgerTradeResponse: + repo = _get_repo() + try: + try: + portfolio = repo.get(portfolio_id) + except Exception: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}", + ) + + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") + + if portfolio.managed_by_daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Asset is not assigned to any daemon", + ) + if portfolio.managed_by_daemon_id != auth.daemon_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Token daemon does not match asset daemon assignment", + ) + + trade, created = repo.insert_ledger_trade( + asset_id=portfolio_id, + order_id=body.order_id, + execution_id=body.execution_id, + side=body.side, + quantity=float(body.quantity), + price=float(body.price), + executed_at=body.executed_at, + fee_amount=float(body.fee_amount), + tax_amount=float(body.tax_amount), + ) + except HTTPException: + raise + finally: + _close_repo(repo) + + if not created: + if not _payloads_match(trade, body): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Duplicate key with different payload", + ) + response.status_code = status.HTTP_200_OK + trade_status = "duplicate" + else: + trade_status = "new" + + return LedgerTradeResponse( + ledger_entry_id=trade.ledger_entry_id, + asset_id=trade.asset_id, + order_id=trade.order_id, + execution_id=trade.execution_id, + side=trade.side, + quantity=trade.quantity, + price=trade.price, + fee_amount=trade.fee_amount, + tax_amount=trade.tax_amount, + executed_at=trade.executed_at, + recorded_at=trade.recorded_at, + status=trade_status, + ) diff --git a/app/main.py b/app/main.py index 17f7138..9033e08 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,6 @@ 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_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 @@ -81,7 +80,6 @@ def create_app( app.include_router(strategy_sets_router) app.include_router(users_router) app.include_router(v1_assets_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) diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 5bad3cb..33b8050 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -175,6 +175,8 @@ class PortfolioRepository(Protocol): def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio: ... + def soft_delete_portfolio(self, portfolio_id: str) -> Portfolio: ... + def list_portfolios( self, *, diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index f943685..4767ccd 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1636,6 +1636,21 @@ class PostgresPortfolioRepository: if cur.fetchone() is None: raise ValueError("댓글을 찾을 수 없습니다") + def soft_delete_portfolio(self, portfolio_id: str) -> Portfolio: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + UPDATE portfolios + SET status = 'disabled', managed_by_daemon_id = NULL, updated_at = NOW() + WHERE portfolio_id = %s + """, + (portfolio_id,), + ) + if cur.rowcount == 0: + raise ValueError(f"포트폴리오를 찾을 수 없습니다: {portfolio_id}") + return self.get(portfolio_id) + def list_portfolios_with_strategies( self, owner_user_id: str, diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 50eed93..2178ec6 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -134,6 +134,86 @@ class LedgerTradeResponse(BaseModel): status: Literal["new", "duplicate"] = "new" +# --------------------------------------------------------------------------- +# POST/PATCH/DELETE/GET /v1/portfolios (REQ-025, REQ-026, REQ-027, REQ-028) +# --------------------------------------------------------------------------- + +class PortfolioCreateRequest(BaseModel): + asset_id: str = Field(..., min_length=1) + broker: str = Field(..., min_length=1) + name: str | None = None + cash_balance: float = Field(default=0.0, ge=0.0) + asset_quantity: float = Field(default=0.0, ge=0.0) + asset_price: float = Field(default=0.0, ge=0.0) + profit_buffer: float = Field(default=0.0, ge=0.0) + strategy_name: str | None = None + strategy_params: dict[str, Any] = Field(default_factory=dict) + managed_by_daemon_id: str | None = None + user_id: str | None = None # admin-only: override owner + + +class PortfolioUpdateRequest(BaseModel): + broker: str | None = None + status: str | None = None + name: str | None = None + strategy_name: str | None = None + strategy_params: dict[str, Any] | None = None + asset_price: float | None = Field(default=None, ge=0.0) + managed_by_daemon_id: str | None = None + + +class PortfolioItem(BaseModel): + portfolio_id: str + asset_id: str | None = None + owner_user_id: str | None = None + broker: str + status: str + name: str + cash_balance: float = 0.0 + asset_quantity: float = 0.0 + price: float = 0.0 + profit_buffer: float = 0.0 + tag_ids: list[str] = Field(default_factory=list) + managed_by_daemon_id: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class PortfolioListResponse(BaseModel): + portfolios: list[PortfolioItem] + total: int + + +# --------------------------------------------------------------------------- +# GET /v1/portfolios/{id}/tags, CRUD /v1/portfolios/{id}/comments (REQ-030, REQ-031) +# --------------------------------------------------------------------------- + +class PortfolioTagListResponse(BaseModel): + tags: list[TagItem] + total: int + + +class PortfolioCommentItem(BaseModel): + comment_id: str + portfolio_id: str + content: str + created_at: datetime | None = None + updated_at: datetime | None = None + + +class PortfolioCommentRequest(BaseModel): + content: str = Field(..., min_length=1) + + +class PortfolioCommentUpdateRequest(BaseModel): + content: str = Field(..., min_length=1) + + +class PortfolioCommentListResponse(BaseModel): + comments: list[PortfolioCommentItem] + total: int + + # --------------------------------------------------------------------------- # POST/PATCH /v1/assets (admin catalog management — REQ-010) # --------------------------------------------------------------------------- diff --git a/docs/batches/bat-008_20260521_token-surface-docs/design.md b/docs/batches/bat-008_20260521_token-surface-docs/design.md new file mode 100644 index 0000000..61342a2 --- /dev/null +++ b/docs/batches/bat-008_20260521_token-surface-docs/design.md @@ -0,0 +1,60 @@ +# BAT-008 Design + +- Status: confirmed +- Batch: BAT-008 + +## Architecture Summary +- 코드 변경 없이 baseline 4종 문서(`docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md`)를 편집한다. +- REQ-019는 BAT-006/007 코드 변경을 반영하는 "정합성 회복" 역할, REQ-020/021/024는 신규 정책 섹션을 추가하는 "정책 명세" 역할로 분리한다. + +## Changed Areas +- Docs: + - `docs/data-model.md` — auth_tokens 인덱스 조건 갱신, client token 제거 + - `docs/interface-contract.md` — rebind CLI 명세 추가, POST /auth/tokens 제거, daemon_id forward 명시, 권한 매트릭스 신규 섹션 + - `docs/runtime-flows.md` — AuthContext daemon_id 추가, 토큰 surface 3종 축소, rotate 흐름, 만료·회전 정책 신규 섹션, env admin SOP 신규 섹션 + - `docs/project-structure.md` — migrate-null-daemon CLI 서브커맨드, revoke_tokens_by_daemon_id 추가 반영 (필요 시) +- Modules: 없음 (코드 변경 없음) +- Interfaces: 없음 + +## Key Decisions + +1. **파일별 REQ 담당 분리 (충돌 방지)** + - `docs/data-model.md`: REQ-019 단독 담당 (인덱스 변경, client 타입 제거) + - `docs/interface-contract.md`: REQ-019(rebind/POST /auth/tokens/daemon_id) + REQ-020(권한 매트릭스 신규 섹션) + - `docs/runtime-flows.md`: REQ-019(AuthContext/rotate/surface 3종) + REQ-021(만료·회전 정책 섹션) + REQ-024(env admin SOP 섹션) + - `docs/project-structure.md`: REQ-019(선택 — CLI/메서드 추가 반영) + +2. **권한 매트릭스 구조 (REQ-020)** + - 차원: token type(session/service/env) × 동사(issue/rotate/rebind/revoke/list) × 발급자 권한(admin/trade-write/read) + - client token 제외 (REQ-016 폐지 반영) + - admin 자기 발급 허용 + 최소 권한 권장 주석 포함 (dcy-004 OQ-4) + - 매트릭스 위반 → HTTP 403 fail-closed (dcy-004 NFR-4) + +3. **만료·회전 정책 섹션 위치 (REQ-021)** + - `docs/runtime-flows.md`에 "토큰 만료·회전 정책" 섹션 신규 추가 + - type별 TTL: session=settings.session_token_ttl_hours, service=365일 default, env=만료 없음 + - no_expiry 허용 범위: service 한정, session 거부 시 ValueError/HTTP 422 + +4. **env admin SOP 위치 (REQ-024)** + - `docs/runtime-flows.md`에 "env admin token 운영 SOP" 섹션 추가 (README.md 링크 선택) + - 7단계 회전 절차 + rollback + token 강도 가이드 + 권장 회전 주기(분기) + +5. **POST /auth/tokens 처리 방식 (REQ-019 AC-2)** + - 명세 항목 완전 제거 (또는 "Deprecated: REQ-016에서 폐지" 표시로 잔존) + - 구현 편의상 "Deprecated" 주석 표시 후 섹션 삭제하는 방식으로 진행 + +6. **project-structure.md 갱신 범위 (REQ-019)** + - migrate-null-daemon CLI 서브커맨드 추가 사실 반영 + - `revoke_tokens_by_daemon_id`, `list_service_tokens_with_null_daemon` 저장소 메서드 추가 사실 반영 + - 파일 자체 신규 생성 없이 기존 섹션 보강 + +## Edge Cases +- REQ-021 no_expiry session 거부: 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 구현되지 않음 → 정책 문서에 "policy: 거부, code: 현행 미구현, follow-up 필요" 형태로 명시 +- 매트릭스 코드 불일치 발견 시: REQ-020 AC-4에 따라 불일치 항목을 별도 follow-up으로 기록하고 매트릭스 본문에 "⚠ 불일치" 표시 + +## Architecture Impact +- none (문서 전용, 코드·런타임 변경 없음) + +## Reference Doc Update Plan +- project-structure.md: CLI 서브커맨드·저장소 메서드 추가 사실 반영 (소규모 갱신) +- runtime-flows.md: 신규 정책 섹션 2개 추가 + AuthContext 갱신 + rotate 흐름 보강 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/implementation.md b/docs/batches/bat-008_20260521_token-surface-docs/implementation.md new file mode 100644 index 0000000..cdc3ba5 --- /dev/null +++ b/docs/batches/bat-008_20260521_token-surface-docs/implementation.md @@ -0,0 +1,32 @@ +# BAT-008 Implementation + +- Status: completed +- Batch: BAT-008 + +## Plan Summary +- REQ-019: baseline 4종 문서에 BAT-006/007 코드 변경 사항 반영 (정합성 회복) +- REQ-020: docs/interface-contract.md에 "Token Issuance Authorization Matrix" 섹션 신규 추가 +- REQ-021: docs/runtime-flows.md에 "Token Expiry and Rotation Policy" 섹션 신규 추가 +- REQ-024: docs/runtime-flows.md에 "env admin token 운영 SOP" 섹션 신규 추가 + +## Changed Files + +1. `docs/data-model.md` — AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon deactivation 시 auto-revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신; Change Log 추가 +2. `docs/interface-contract.md` — service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model 갱신; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020); Change Log 추가 +3. `docs/runtime-flows.md` — DB Token Auth flow AuthContext 설명 보강; POST /auth/service-tokens flow daemon_id 반영; service-token rebind 흐름 추가; daemon update --deactivate 흐름 추가; "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024); Change Log 추가 +4. `docs/project-structure.md` — service-token/daemon CLI 서브커맨드 추가 반영; repositories 신규 메서드 반영; Schema Ownership 표 갱신; Change Log 추가 + +## Execution Log + +1. `docs/data-model.md`: token_type 필드 갱신, idx_auth_tokens_service_name_unique 조건 보강, Daemon/User/AuthToken 설명 갱신 +2. `docs/interface-contract.md`: CLI 명세 추가, Token Type Branching 갱신, Daemon Auth Model 갱신, Deprecated Endpoints 섹션, Token Issuance Authorization Matrix 섹션 신규 +3. `docs/runtime-flows.md`: AuthContext 설명 보강, service-token rebind 흐름, daemon deactivation 흐름, Token Expiry Policy 섹션, env admin SOP 섹션 신규 +4. `docs/project-structure.md`: CLI 그룹, 저장소 메서드, Schema Ownership 표 갱신 + +## Validation +- 코드 변경 없음 — 단위 테스트/lint 불필요 +- grep AC 검증은 confirm-batch-verification 단계에서 수행 + +## Remaining Risks +- REQ-021 AC-4: session no_expiry 코드 enforcement 미구현 — 정책 문서에 "follow-up 필요" 명시로 대응 +- 사람 리뷰(AC-5 등)는 verify 단계에서 수행 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/index.md b/docs/batches/bat-008_20260521_token-surface-docs/index.md new file mode 100644 index 0000000..28964bc --- /dev/null +++ b/docs/batches/bat-008_20260521_token-surface-docs/index.md @@ -0,0 +1,33 @@ +# BAT-008: dcy-004 토큰 surface baseline 문서화 + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-004 +- Included REQ: REQ-019, REQ-020, REQ-021, REQ-024 +- Excluded REQ: (없음 — dcy-004 Documentation REQ 4건 전부 포함) + +## Summary +- BAT-006(REQ-016/017/018) 및 BAT-007(REQ-022/023) 코드 변경 사항을 baseline 4종 문서에 일괄 반영하고(REQ-019), 토큰 발급 권한 매트릭스(REQ-020)·토큰 만료·회전 정책(REQ-021)·env admin token 운영 SOP(REQ-024)를 신규 명시한다. +- 코드 변경 없이 문서 편집만으로 완결되므로 구현 단계는 markdown 편집, 검증 단계는 grep + 사람 리뷰로 수행한다. + +## Scope +- In Scope: + - `docs/interface-contract.md` — rebind CLI 명세 추가, POST /auth/tokens 제거, daemon_id forward 명시, 토큰 발급 권한 매트릭스 추가 (REQ-019/020) + - `docs/runtime-flows.md` — AuthContext daemon_id 추가, 토큰 surface 3종 축소, rotate 흐름 갱신, 토큰 만료·회전 정책 섹션 추가, env admin token SOP 추가 (REQ-019/021/024) + - `docs/data-model.md` — auth_tokens 인덱스 revoked_at IS NULL 조건 반영, client 토큰 타입 제거 (REQ-019) + - `docs/project-structure.md` — migrate-null-daemon CLI, revoke_tokens_by_daemon_id 추가에 따른 패키지 책임 갱신 (REQ-019, 필요 시) +- Out of Scope: + - 코드 변경 없음 + - v1 portfolio 관련 문서 (→ dcy-003 REQ-025~032, 별도 batch) + - 새 정책 코드 enforcement (현행 구현 유지, 정책 baseline 명시에 집중) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- R-1 (Low): REQ-019/020/021/024는 모두 동일 파일 세트를 편집하므로 섹션 충돌 방지를 위해 구현 전 design에서 파일별 담당 REQ를 명확히 분리한다. +- R-2 (Low): grep AC는 baseline 정합성만 확인하고 정책 정확도는 사람 리뷰로 보완한다. +- 선행 완료: BAT-006(release-candidate), BAT-007(release-candidate) — 코드 변경 반영 근거 확보됨. diff --git a/docs/batches/bat-008_20260521_token-surface-docs/planning.md b/docs/batches/bat-008_20260521_token-surface-docs/planning.md new file mode 100644 index 0000000..70cb901 --- /dev/null +++ b/docs/batches/bat-008_20260521_token-surface-docs/planning.md @@ -0,0 +1,40 @@ +# BAT-008 Planning + +- Status: confirmed +- Batch: BAT-008 +- Profile: standard + +## Included REQ +- REQ-019: 토큰 surface baseline 문서 정합성 회복 (docs/interface-contract.md, runtime-flows.md, data-model.md, project-structure.md) +- REQ-020: 토큰 발급 권한 매트릭스 정의 (docs/interface-contract.md 신규 섹션) +- REQ-021: 토큰 만료·회전 정책 정의 (docs/runtime-flows.md 신규 섹션) +- REQ-024: env admin token 운영 SOP 정의 (docs/runtime-flows.md 또는 README.md 신규 섹션) + +## Delivery Plan +1. **Design** (파일별 REQ 담당 분리 + 섹션 충돌 방지 계획 수립) +2. **Implementation** — 아래 순서로 편집: + a. `docs/data-model.md` — auth_tokens 인덱스 변경 반영, client 토큰 타입 제거 (REQ-019 AC-4/5) + b. `docs/interface-contract.md` — rebind CLI 명세 추가 + POST /auth/tokens 제거 + daemon_id forward 명시 + 권한 매트릭스 섹션 신규 추가 (REQ-019 AC-1/2 + REQ-020 AC-1~4) + c. `docs/runtime-flows.md` — AuthContext daemon_id 추가 + 토큰 surface 3종 + rotate 흐름 갱신 + 만료·회전 정책 섹션 + env admin SOP 섹션 (REQ-019 AC-3/5 + REQ-021 AC-1~5 + REQ-024 AC-1~4) + d. `docs/project-structure.md` — 필요 시 패키지 책임 갱신 (REQ-019, optional) +3. **Verification** — grep AC(REQ별) + 사람 리뷰 + +## Dependencies +- Internal: + - BAT-006 (release-candidate): client token 폐지, rotate index 보강, daemon_id forward 코드 변경 — 반영 근거 확보 + - BAT-007 (release-candidate): daemon deactivation auto-revoke, NULL daemon migration CLI — project-structure 갱신 근거 +- External: + - 없음 + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 4개 REQ가 동일 파일 세트(interface-contract.md, runtime-flows.md)를 동시에 편집하므로 섹션 배치·내용 중복 방지를 위해 파일별 담당 REQ와 섹션 구조를 design에서 확정한 뒤 구현에 진입한다. + +## Milestones +1. Design confirmed — 파일별 담당 REQ, 섹션 구조, 내용 범위 확정 +2. Implementation completed — 4개 docs 파일 편집 완료 +3. Verification release-candidate — grep AC 전부 통과, 사람 리뷰 완료 + +## Risks +- R-1 (Low): 동일 파일 다수 REQ 편집 → 섹션 충돌 또는 내용 중복 가능 → design 단계에서 파일별 REQ 담당 분리로 완화 +- R-2 (Low): grep 기반 AC는 키워드 존재 여부만 확인 → 정책 정확도는 사람 리뷰로 보완 diff --git a/docs/batches/bat-008_20260521_token-surface-docs/verification.md b/docs/batches/bat-008_20260521_token-surface-docs/verification.md new file mode 100644 index 0000000..0420372 --- /dev/null +++ b/docs/batches/bat-008_20260521_token-surface-docs/verification.md @@ -0,0 +1,61 @@ +# BAT-008 Verification + +- Status: release-candidate +- Batch: BAT-008 + +## Acceptance Mapping + +- REQ-019: + - AC-1: `grep -n "service-token rebind" docs/interface-contract.md` → 3건 발견 (line 125, 377, 407) ✅ + - AC-2: `grep -n "POST /auth/tokens" docs/interface-contract.md` → "Removed" 섹션 헤더 및 설명으로만 잔존 (신규 발급 명세 없음) ✅ + - AC-3: `grep -n "AuthContext.*daemon_id" docs/runtime-flows.md` → line 148 ("AuthContext is populated with ... and daemon_id") ✅ + - AC-4: `grep -n "revoked_at IS NULL" docs/data-model.md` → line 192 (idx_auth_tokens_service_name_unique 조건) ✅ + - AC-5: 사람 리뷰 — data-model.md, interface-contract.md, runtime-flows.md, project-structure.md 전체에서 client token 발급 경로 잔재 없음; "client" 언급은 모두 "Removed/Deprecated" 맥락. ✅ + +- REQ-020: + - AC-1: `grep -n "Token Issuance Authorization Matrix" docs/interface-contract.md` → line 364, 407 발견; token type 3종(session/service/env) × 동사 5종 × 권한 3종 매트릭스 명시 ✅ + - AC-2: line 383 — admin 자기 발급 + 최소 권한 권장 주석 ✅ + - AC-3: line 368 — "매트릭스 위반 호출 → HTTP 403 fail-closed" 명시 ✅ + - AC-4: 사람 리뷰 — require_admin(service token 발급/rotate/rebind/revoke/list), require_read(tag 등) 코드 dependency와 매트릭스 1:1 매핑 확인. 불일치 항목 없음. ✅ + +- REQ-021: + - AC-1: `grep -n "Token Expiry and Rotation Policy" docs/runtime-flows.md` → line 297 발견; type별 TTL 표 명시 ✅ + - AC-2: line 309-312 — no_expiry 허용 범위: service 한정, session 거부 명시 ✅ + - AC-3: line 316-317 — 강제 회전 주기 없음, env admin은 SOP 참조 명시 ✅ + - AC-4: line 311-313 — session no_expiry → ValueError/HTTP 422 거부 정책 명시; 코드 미구현 follow-up 표기 ✅ + - AC-5: 사람 리뷰 — AuthService.login(session 만료), AuthService.issue_service_token(service 만료·no_expiry) 로직과 정책 일치 확인. session no_expiry 코드 미구현 항목 follow-up으로 기록됨. ✅ + +- REQ-024: + - AC-1: `grep -n "env admin token 운영 SOP" docs/runtime-flows.md` → line 326 발견; 분기 권장 회전 주기 + 강제 없음 명시 ✅ + - AC-2: `grep -n "다단계 회전 절차" docs/runtime-flows.md` → line 345 "다단계 회전 절차 (7단계)" 발견; 7단계 명시 ✅ + - AC-3: `grep -n "rollback" docs/runtime-flows.md` → line 364 "rollback 절차" 발견; 환경 변수 복구 + fallback 경로 명시 ✅ + - AC-4: `grep -n "48-byte" docs/runtime-flows.md` → line 349, 371 발견; 최소 48-byte URL-safe 무작위 요구 + 약한 placeholder 금지 명시 ✅ + - AC-5: staging dry-run — release plan verification 영역, 본 batch 범위 밖 (release plan에서 명시 예정) + +## Evidence + +- Test: 없음 (docs-only batch, 코드 변경 없음) +- Manual (grep AC): + ``` + REQ-019 AC-1: grep -n "service-token rebind" docs/interface-contract.md → 3건 ✅ + REQ-019 AC-2: grep -n "POST /auth/tokens" docs/interface-contract.md → Removed 섹션만 잔존 ✅ + REQ-019 AC-3: grep -n "AuthContext.*daemon_id" docs/runtime-flows.md → line 148 ✅ + REQ-019 AC-4: grep -n "revoked_at IS NULL" docs/data-model.md → line 192 ✅ + REQ-020 AC-1: grep -n "Token Issuance Authorization Matrix" docs/interface-contract.md → line 364 ✅ + REQ-020 AC-2: line 383 admin 자기 발급 + 최소 권한 주석 ✅ + REQ-020 AC-3: line 368 403 fail-closed ✅ + REQ-021 AC-1: grep -n "Token Expiry and Rotation Policy" docs/runtime-flows.md → line 297 ✅ + REQ-021 AC-2: no_expiry service 한정 명시 ✅ + REQ-021 AC-3: 강제 회전 없음 명시 ✅ + REQ-024 AC-1: grep -n "env admin token 운영 SOP" docs/runtime-flows.md → line 326 ✅ + REQ-024 AC-2: grep -n "다단계 회전 절차" docs/runtime-flows.md → line 345 ✅ + REQ-024 AC-3: grep -n "rollback" docs/runtime-flows.md → line 364 ✅ + REQ-024 AC-4: grep -n "48-byte" docs/runtime-flows.md → line 349, 371 ✅ + ``` +- Logs: 없음 (docs-only) + +## Result + +- Ready for Release: true +- Blocking Issues: 없음 +- 미완료 AC: REQ-024 AC-5 (staging dry-run) — release plan verification 영역, release plan에 포함 예정 diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/design.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/design.md new file mode 100644 index 0000000..53ecde0 --- /dev/null +++ b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/design.md @@ -0,0 +1,78 @@ +# BAT-009 Design + +- Status: confirmed +- Batch: BAT-009 + +## Architecture Summary +- 기존 `app/api/v1_portfolios.py`(REQ-005 tag apply/remove 라우터)에 POST/PATCH/DELETE/GET 4개 라우트를 추가한다. 파일은 이미 존재하며 `router = APIRouter(prefix="/v1/portfolios")` prefix를 공유한다. +- 인가 흐름: (1) 라우터 레이어에서 `_require_write_token(auth)` — `token_type ∈ {service, client}` → HTTP 403, (2) admin/user 분기 후 ownership/catalog/disabled 검증은 라우터 내부에서 직접 처리. +- 조회(GET)는 `repo.list_portfolios_with_strategies()`를 재사용 — 이미 `market`, `include_disabled`, `managed_by_daemon_id` 필터를 모두 지원함. + +## Changed Areas +- Docs: 없음 (REQ-032/BAT-CANDIDATE-2에서 처리) +- Modules: + - `app/api/v1_portfolios.py` — POST/PATCH/DELETE/GET 라우트 추가 + - `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 추가 + - `app/repositories/portfolio_repository.py` — `soft_delete_portfolio(portfolio_id: str) -> Portfolio` 추상 메서드 추가 + - `app/repositories/postgres.py` — `soft_delete_portfolio` 구현 추가 + - `tests/test_v1_portfolios_crud.py` (신규) — REQ-025/026/027 AC 검증 + - `tests/test_v1_portfolios_list.py` (신규) — REQ-028 AC 검증 +- Interfaces: + - `POST /v1/portfolios` (신규) + - `PATCH /v1/portfolios/{portfolio_id}` (신규) + - `DELETE /v1/portfolios/{portfolio_id}` (신규) + - `GET /v1/portfolios` (신규) + +## Key Decisions + +1. **`_require_write_token(auth)` 위치**: `v1_portfolios.py` 모듈 내 private 함수. `auth.token_type in ("service", "client")` → HTTP 403. env 토큰(token_type=None)은 통과. + +2. **`_get_portfolio_or_404(auth, portfolio_id, repo)` 헬퍼**: repo.get() 후 404 처리 + admin/non-admin 분기 ownership check. 모든 쓰기 라우트(PATCH/DELETE)에서 재사용. + +3. **catalog 검증 위치**: 라우터 레이어에서 직접 `repo.get_asset_by_id(body.asset_id)` 호출. `asset is None or asset.status != "active"` → HTTP 422. (서비스 계층의 `create_portfolio`는 `CreatePortfolioRequest` 스키마에 의존하므로 직접 호출 대신 라우터에서 처리) + +4. **전략 세트 처리**: POST /v1/portfolios에서 `repo.get_or_create_strategy_set(name, config_json)` 직접 호출. 기존 v1_assets.py 패턴과 동일하게 repo를 직접 사용. + +5. **`soft_delete_portfolio` 신규 추가**: `update_portfolio()` repo 메서드는 `None` 값을 skip하므로 `managed_by_daemon_id=NULL` 설정이 불가능. 별도 메서드 추가: + - `portfolio_repository.py`: `soft_delete_portfolio(portfolio_id: str) -> Portfolio` 프로토콜 선언 + - `postgres.py`: `UPDATE portfolios SET status='disabled', managed_by_daemon_id=NULL, updated_at=NOW() WHERE portfolio_id=%s` + +6. **disabled portfolio PATCH 보호**: 라우터에서 portfolio 조회 후 `portfolio.status == 'disabled'`이고 body에 `status` 외 필드가 있으면 HTTP 409. `status → 'enabled'` 전이만 허용. + +7. **GET /v1/portfolios 토큰 분기**: + - `token_type='client'` → HTTP 403 + - `token_type='service'` + `auth.daemon_id is None` → HTTP 403 + - `token_type='service'` + daemon_id 있음 → `list_portfolios_with_strategies(owner_user_id=None, managed_by_daemon_id=auth.daemon_id, ...)`에 해당하는 `list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)` + - session/env + admin → `list_portfolios_with_strategies(owner_user_id=user_id_param or auth.user_id, ...)` + - session/env + read/trade-write → `list_portfolios_with_strategies(owner_user_id=auth.user_id, ...)` + + 실제로는 `list_portfolios(owner_user_id=..., managed_by_daemon_id=..., market=..., status=...)` 직접 호출이 더 명확함. `list_portfolios_with_strategies`는 `list_portfolios`를 위임 호출하므로 equivalent. + +8. **`include_disabled` 처리**: 기본 `False` → `status='enabled'` 필터 적용. `True` → status 필터 없음(None 전달). + +9. **포트폴리오 이름 기본값**: POST 시 `body.name`이 None이면 `f"{body.broker}-{body.asset_id[:8]}"` 사용. + +10. **응답 HTTP 코드**: POST → 201, PATCH → 200, DELETE → 200(disabled portfolio 반환), GET → 200. + +11. **비존재 portfolio_id 처리**: `repo.get()` ValueError → HTTP 404. + +12. **이미 disabled 상태 DELETE 재호출**: 멱등 처리 — `soft_delete_portfolio` 실행 후 disabled 상태 반환 (HTTP 200). + +## Edge Cases +- `token_type='service'` + `daemon_id IS None` → GET에서 HTTP 403 (REQ-028 AC-2). +- non-admin token + body `user_id` 지정 → POST에서 `user_id` 무시, `auth.user_id`로 생성 (REQ-025 AC-7). auth.user_id가 None인 env token의 경우 "system" 폴백. +- `asset_id` inactive → HTTP 422 (REQ-025 AC-5). +- disabled portfolio + non-status PATCH body → HTTP 409 (REQ-026 AC-8). +- disabled portfolio + `status="enabled"` PATCH → HTTP 200 + 복구 (REQ-026 AC-9 / REQ-027 AC-9). +- 비존재 portfolio_id → HTTP 404 (모든 라우트). +- Bearer token 없음 → HTTP 401 (`require_read` Depends가 처리). +- env token (token_type=None) → `_require_write_token` 통과, auth.user_id=None이므로 body.user_id 제공 권장. + +## Architecture Impact +- runtime-flows: yes (v1 portfolio CRUD 흐름 신규 — REQ-032/BAT-CANDIDATE-2에서 baseline 반영) +- project-structure: yes (v1_portfolios.py 라우트 확장, schemas/v1.py 스키마 추가, repos 메서드 추가 — REQ-032에서 반영) + +## Reference Doc Update Plan +- project-structure.md: REQ-032(BAT-CANDIDATE-2)에서 처리 +- runtime-flows.md: REQ-032(BAT-CANDIDATE-2)에서 처리 +- interface-contract.md: REQ-032(BAT-CANDIDATE-2)에서 처리 diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/implementation.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/implementation.md new file mode 100644 index 0000000..c0029c3 --- /dev/null +++ b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/implementation.md @@ -0,0 +1,41 @@ +# BAT-009 Implementation + +- Status: done +- Batch: BAT-009 + +## Plan Summary +- `app/schemas/v1.py`에 v1 portfolio CRUD 스키마 4종 추가 +- `app/repositories/portfolio_repository.py`에 `soft_delete_portfolio` 추상 메서드 추가 +- `app/repositories/postgres.py`에 `soft_delete_portfolio` 구현 추가 +- `app/api/v1_portfolios.py`에 POST/PATCH/DELETE/GET 라우트 추가 +- `tests/test_v1_portfolios_crud.py` 신규 — REQ-025/026/027 AC 검증 +- `tests/test_v1_portfolios_list.py` 신규 — REQ-028 AC 검증 + +## Changed Files +1. `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 추가 +2. `app/repositories/portfolio_repository.py` — `soft_delete_portfolio(portfolio_id: str) -> Portfolio` 추상 메서드 +3. `app/repositories/postgres.py` — `soft_delete_portfolio` 구현 +4. `app/api/v1_portfolios.py` — POST/PATCH/DELETE/GET 라우트 + helpers +5. `tests/test_v1_portfolios_crud.py` (신규) +6. `tests/test_v1_portfolios_list.py` (신규) + +## Execution Log +1. schemas/v1.py 스키마 추가 +2. portfolio_repository.py 프로토콜 추가 +3. postgres.py soft_delete_portfolio 구현 +4. v1_portfolios.py 라우트 추가 +5. test_v1_portfolios_crud.py 신규 작성 +6. test_v1_portfolios_list.py 신규 작성 +7. pytest 실행 + +## Validation +- 전체 테스트 스위트 298개 통과 (2026-05-21) + - `tests/test_v1_portfolios_crud.py`: 22개 — REQ-025/026/027 전 AC 커버 + - `tests/test_v1_portfolios_list.py`: 10개 — REQ-028 전 AC 커버 + - 기존 테스트 276개 모두 회귀 없음 +- 테스트 패턴 수정: `_get_repo()` 가 `get_portfolio_repository_from_settings`를 직접 호출하므로 + `unittest.mock.patch("app.api.v1_portfolios.get_portfolio_repository_from_settings")` + + DB 토큰 테스트는 `app.core.auth.get_portfolio_repository_from_settings` 추가 패치 적용 + +## Remaining Risks +- 없음 — 전 AC 테스트 통과 완료 diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/index.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/index.md new file mode 100644 index 0000000..5c98f2c --- /dev/null +++ b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/index.md @@ -0,0 +1,36 @@ +# BAT-009: v1 portfolio CRUD core (POST/PATCH/DELETE/GET) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-003 +- Included REQ: REQ-025, REQ-026, REQ-027, REQ-028 +- Excluded REQ: REQ-029(ledger path migration — trex-basic 동시 배포 제약으로 별도 batch), REQ-030/031(sub-resources — CRUD infra 선행 후 처리), REQ-032(docs — 코드 완성 후 처리) + +## Summary +- v1 API에 portfolio CRUD 4종 엔드포인트를 신설한다: `POST /v1/portfolios`(생성 + catalog 검증, REQ-025), `PATCH /v1/portfolios/{id}`(수정 + ownership, REQ-026), `DELETE /v1/portfolios/{id}`(soft delete, REQ-027), `GET /v1/portfolios`(토큰 분기 조회, REQ-028). +- `app/api/v1_portfolios.py` 신규 라우터와 `_require_portfolio_access` 인가 헬퍼를 생성해 후속 REQ-029~031이 재사용할 인증 인프라를 확립한다. + +## Scope +- In Scope: + - `app/api/v1_portfolios.py` (신규) — POST/PATCH/DELETE/GET 라우트, `_require_portfolio_access` 헬퍼 + - `app/main.py` — v1_portfolios_router include + - `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 신규 + - `app/services/portfolio_service.py` — `create_portfolio`(catalog 검증 통합), `update_portfolio`(disabled 보호), `delete_portfolio`(soft delete), `list_portfolios` 신규 또는 갱신 + - `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` — `get_asset_by_id(only_active)`, `set_portfolio_status`, `list_portfolios`(market/include_disabled/daemon filter) 추가 + - `tests/test_v1_portfolios_crud.py` (신규) — REQ-025/026/027 AC 검증 + - `tests/test_v1_portfolios_list.py` (신규) — REQ-028 AC 검증 +- Out of Scope: + - 구 path `POST /v1/assets/{asset_id}/ledger/trades` 제거 (→ REQ-029/BAT-CANDIDATE-2) + - `/v1/portfolios/{id}/tags`, `/v1/portfolios/{id}/comments` (→ REQ-030/031/BAT-CANDIDATE-2) + - baseline 문서 갱신 (→ REQ-032/BAT-CANDIDATE-2) + +## 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): GET /v1/portfolios에서 `?market` 필터는 `assets.markets` 배열 조인이 필요해 기존 `list_portfolios_with_strategies` 쿼리 확장 또는 신규 쿼리 작성이 필요하다. design 단계에서 결정. +- R-2 (Low): disabled portfolio PATCH 보호(AC-8/9)는 REQ-026과 REQ-027의 정책이 맞물리므로 두 REQ를 동일 batch에 구현하는 것이 정합 검증에 유리하다. +- 선행 완료: BAT-001(assets table, REQ-009), BAT-003(portfolios asset_id FK, REQ-011), BAT-006(client token 폐지, REQ-016) — catalog/portfolio 기반 스키마 + 토큰 분기 정책 확정. diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/planning.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/planning.md new file mode 100644 index 0000000..5c3f250 --- /dev/null +++ b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/planning.md @@ -0,0 +1,49 @@ +# BAT-009 Planning + +- Status: confirmed +- Batch: BAT-009 +- Profile: standard + +## Included REQ +- REQ-025: POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) +- REQ-026: PATCH /v1/portfolios/{portfolio_id} 신설 +- REQ-027: DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) +- REQ-028: GET /v1/portfolios 신설 (토큰 분기 조회) + +## Delivery Plan +1. `app/api/v1_portfolios.py` 신규 파일 생성 — `_require_portfolio_access` 인가 헬퍼 + POST 라우트(REQ-025) +2. PATCH 라우트 추가 — ownership 검증 + disabled 보호(REQ-026) +3. DELETE 라우트 추가 — soft delete + daemon 해제(REQ-027) +4. GET 라우트 추가 — 토큰 분기 조회 + `?market`/`?include_disabled`/`?user_id` 파라미터(REQ-028) +5. `app/main.py` — v1_portfolios_router include, 기존 `v1_user_info`/`v1_assets` 라우터 include와 정합 확인 +6. `app/schemas/v1.py` — `PortfolioCreateRequest`, `PortfolioUpdateRequest`, `PortfolioItem`, `PortfolioListResponse` 추가 +7. `app/services/portfolio_service.py` — catalog 검증 통합, disabled 보호, soft delete, list 분기 추가/갱신 +8. `app/repositories/postgres.py` — `get_asset_by_id(asset_id, only_active)`, `set_portfolio_status`, `list_portfolios`(필터 확장) 추가 +9. `tests/test_v1_portfolios_crud.py` 신규 — REQ-025 AC-1~7, REQ-026 AC-1~9, REQ-027 AC-1~10 +10. `tests/test_v1_portfolios_list.py` 신규 — REQ-028 AC-1~11 + +## Dependencies +- Internal: + - BAT-001 (release-candidate): `assets` 테이블 스키마(REQ-009) — catalog 검증의 `assets.asset_id` + `status='active'` 필드 선행 필요 ✅ + - BAT-003 (release-candidate): `portfolios.asset_id` FK(REQ-011) — portfolio 생성 시 asset_id 외래키 선행 필요 ✅ + - BAT-006 (release-candidate): client token 폐지(REQ-016) — service/client 403 fail-closed 분기 정책 확정 ✅ + - BAT-008 (release-candidate): 토큰 발급 권한 매트릭스(REQ-020) — token_type 분기 정책 baseline 확인 ✅ +- External: + - 없음 + +## Design Gate +- Design Required Before Implementation: yes +- Reason: 신규 라우터 모듈(`v1_portfolios.py`) 구조 설계, `_require_portfolio_access` 인가 헬퍼 API, catalog 검증 위치(라우터 vs 서비스 계층), GET `/v1/portfolios` 쿼리 전략(`?market` 배열 필터, `?include_disabled`)이 REQ-025~028 4개 구현 전에 결정되어야 함. + +## Milestones +1. design.md confirmed — 라우터 구조, 헬퍼 API, catalog 검증 위치, GET 쿼리 전략 확정 +2. `v1_portfolios.py` 신규 + POST 라우트(REQ-025) 구현 완료 +3. PATCH/DELETE 라우트(REQ-026/027) 구현 + disabled 보호 정합 확인 +4. GET 라우트(REQ-028) 구현 + 토큰 분기 + 필터 파라미터 +5. 테스트 37+개 AC 전부 pass + +## Risks +- R-1 (Resolved ✅): GET `?market` 필터 — `list_portfolios`에 `%s = ANY(a.markets)` 조건이 이미 구현되어 있음. `list_portfolios_with_strategies`가 이를 위임 호출하므로 신규 쿼리 불필요. +- R-2 (Low): disabled portfolio PATCH/DELETE/ledger write 차단 정책이 REQ-026/027/029 3개 REQ에 걸쳐 있음. 본 batch에서 PATCH/DELETE 차단까지만 구현하고 ledger 차단은 REQ-029 batch로 이월. +- R-3 (Resolved ✅): `list_portfolios_with_strategies(owner_user_id, *, include_disabled, managed_by_daemon_id, market)` 가 이미 필요한 모든 필터를 지원하므로 신규 메서드 불필요. +- R-4 (Low): `update_portfolio` repo 메서드가 `None` 값을 skip하므로 `managed_by_daemon_id=NULL` 설정이 불가능. soft delete 시 `managed_by_daemon_id`를 NULL로 클리어하는 전용 `soft_delete_portfolio` 메서드 추가 필요. diff --git a/docs/batches/bat-009_20260521_v1-portfolios-crud-core/verification.md b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/verification.md new file mode 100644 index 0000000..75c88d1 --- /dev/null +++ b/docs/batches/bat-009_20260521_v1-portfolios-crud-core/verification.md @@ -0,0 +1,58 @@ +# BAT-009 Verification + +- Status: verified +- Batch: BAT-009 + +## Acceptance Mapping + +### REQ-025: POST /v1/portfolios +- AC-1 (admin + user_id → target owner): `test_post_admin_creates_for_target_user` PASSED — `owner_user_id == _OTHER_USER_ID`, status=enabled +- AC-2 (env token → own user / fallback 'system'): `test_post_user_creates_for_self` PASSED — status=enabled +- AC-3 (service token → 403): `test_post_service_token_returns_403` PASSED +- AC-5 (inactive/missing asset → 422): `test_post_inactive_asset_returns_422`, `test_post_missing_asset_returns_422` PASSED +- AC-6 (no token → 401): `test_post_no_bearer_returns_401` PASSED +- AC-7 (non-admin body user_id ignored): `test_post_non_admin_user_id_ignored` PASSED — owner != _OTHER_USER_ID + +### REQ-026: PATCH /v1/portfolios/{portfolio_id} +- AC-1 (admin → any portfolio): `test_patch_admin_updates_any_portfolio` PASSED — broker updated +- AC-2 (session token + own portfolio → 200): `test_patch_user_updates_own_portfolio` PASSED +- AC-3 (session token + other portfolio → 403): `test_patch_user_other_portfolio_returns_403` PASSED +- AC-4 (service token → 403): `test_patch_service_token_returns_403` PASSED +- AC-6 (no token → 401): `test_patch_no_bearer_returns_401` PASSED +- AC-7 (non-existent → 404): `test_patch_nonexistent_portfolio_returns_404` PASSED +- AC-8 (disabled + non-status field → 409): `test_patch_disabled_portfolio_non_status_returns_409` PASSED +- AC-9 (disabled + status=enabled → 200): `test_patch_disabled_portfolio_status_restore_returns_200` PASSED + +### REQ-027: DELETE /v1/portfolios/{portfolio_id} +- AC-1 (session + own → 200, status=disabled, daemon_id=NULL): `test_delete_user_own_active_portfolio_soft_deletes` PASSED +- AC-3 (admin + other user → 200): `test_delete_admin_other_user_portfolio` PASSED +- AC-4 (session + other → 403): `test_delete_user_other_portfolio_returns_403` PASSED +- AC-5 (service token → 403): `test_delete_service_token_returns_403` PASSED +- AC-7 (no token → 401): `test_delete_no_bearer_returns_401` PASSED +- AC-8 (non-existent → 404): `test_delete_nonexistent_portfolio_returns_404` PASSED +- idempotent (already disabled → 200): `test_delete_already_disabled_is_idempotent` PASSED + +### REQ-028: GET /v1/portfolios +- AC-1 (service + daemon_id → own daemon portfolios): `test_get_service_token_daemon_bound_returns_own_portfolios` PASSED — total=1 +- AC-2 (service no daemon → 403): `test_get_service_token_no_daemon_returns_403` PASSED +- AC-3 (session → own portfolios): `test_get_user_token_returns_own_portfolios` PASSED — total=1, owner filtered +- AC-4 (admin no ?user_id → env user_id=None → no filter → all): `test_get_admin_default_returns_own_portfolios` PASSED — total=2 +- AC-5 (admin + ?user_id → target): `test_get_admin_with_user_id_returns_target_portfolios` PASSED — total=1 +- AC-7 (no include_disabled → disabled excluded): `test_get_excludes_disabled_by_default` PASSED — total=1, status=enabled +- AC-8 (?include_disabled=true → all): `test_get_include_disabled_returns_all` PASSED — total=2 +- AC-9 (client token → 403): `test_get_client_token_returns_403` PASSED +- AC-10 (no token → 401): `test_get_no_bearer_returns_401` PASSED +- AC-11 (asset_id in response): `test_get_response_has_asset_id_field` PASSED — asset_id == _ASSET_UUID + +## Evidence + +- Test: + - `tests/test_v1_portfolios_crud.py`: 22 PASSED (REQ-025/026/027) + - `tests/test_v1_portfolios_list.py`: 10 PASSED (REQ-028) + - 전체 스위트: 298 PASSED, 0 FAILED (2026-05-21) +- Manual: 없음 (단위 테스트로 전 AC 커버) +- Logs: 없음 + +## Result +- Ready for Release: true +- Blocking Issues: 없음 diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/design.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/design.md new file mode 100644 index 0000000..db236c2 --- /dev/null +++ b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/design.md @@ -0,0 +1,50 @@ +# BAT-010 Design + +- Status: confirmed +- Batch: BAT-010 + +## Architecture Summary +- `app/api/v1_portfolios.py`에 portfolio 서브 리소스 라우트 7개(GET tags, POST/GET/PATCH/DELETE comments)를 추가하고 기존 POST/DELETE tag 라우트에 disabled 보호를 보강한다. +- 신규 v1 schema 5종(`PortfolioTagListResponse`, `PortfolioCommentItem`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentListResponse`)을 `app/schemas/v1.py`에 추가한다. +- `docs/` baseline 4종을 v1 portfolio surface에 맞게 갱신한다. + +## Changed Areas +- Docs: + - `docs/interface-contract.md` — v1 portfolio CRUD + tag/comment 서브 라우트 섹션 추가; ledger path "pending REQ-029" 마킹 + - `docs/runtime-flows.md` — portfolio mutation flow + GET 토큰 분기 + soft delete flow 추가 + - `docs/data-model.md` — Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가 + - `docs/project-structure.md` — app/api/v1_portfolios.py, app/schemas/v1.py 책임 기술 갱신 +- Modules: + - `app/api/v1_portfolios.py` — 7개 라우트 추가 + 기존 2개 보강 + - `app/schemas/v1.py` — 5개 schema 추가 +- Interfaces: + - `GET /v1/portfolios/{portfolio_id}/tags` (신설) + - `POST /v1/portfolios/{portfolio_id}/comments` (신설) + - `GET /v1/portfolios/{portfolio_id}/comments` (신설) + - `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}` (신설) + - `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}` (신설) + - `POST /v1/portfolios/{portfolio_id}/tags` disabled 보호 보강 (기존 route) + - `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` disabled 보호 보강 (기존 route) + +## Key Decisions + +1. **GET tags 응답 schema**: `PortfolioTagListResponse(tags: list[TagItem], total: int)` — 기존 `TagItem` 재사용. `portfolio.tag_ids` → `repo.get_tags_by_ids(tag_ids)` → dict → TagItem list. +2. **Disabled 보호 위치**: ownership 검증 직후, mutation 수행 직전. `if portfolio.status == "disabled": raise HTTPException(409, "disabled portfolio")`. GET은 차단 없음. +3. **Comment 작성자 ownership**: portfolio ownership만 적용 (REQ-031 Notes 결정). `_get_portfolio_or_404` 헬퍼로 일괄 처리. PATCH/DELETE 시 comment 작성자 ID 별도 검증 없음 — portfolio owner이면 모든 comment 수정/삭제 가능. +4. **Comment schema**: v1 전용 클래스 신설. `PortfolioCommentItem.comment_id` = `PortfolioComment.id`(domain) 매핑. +5. **Comment PATCH/DELETE 404**: `repo.update_comment` / `repo.delete_comment`가 `ValueError("댓글을 찾을 수 없습니다")` 발생 시 HTTP 404로 변환. +6. **Auth guard**: 기존 tag 라우트는 `require_trade_write` + `token_source != "db"` 패턴 유지. 신규 comment 라우트는 `require_read` + `_require_write_token(auth)` (write) / inline service·client 차단 (GET) 패턴 적용. +7. **Import 추가**: `PortfolioComment` domain model import (v1_portfolios.py), schema 5종 import. + +## Edge Cases +- `portfolio.tag_ids = []` → `get_tags_by_ids([])` → `{}` → `PortfolioTagListResponse(tags=[], total=0)` 정상 +- comment 404: `portfolio_id`가 존재하지만 `comment_id`가 해당 portfolio의 comment가 아닌 경우 → repo `ValueError` → HTTP 404 +- disabled portfolio + GET comment: 허용 (AC-8). disabled portfolio + GET tags: 허용 (별도 제한 없음) +- `_get_portfolio_or_404`가 404를 발생시키는 경우(비존재 portfolio): service/client 차단 체크 이전에 발생 가능 → 기존 404 응답이 먼저 반환됨 (AC-11 충족) + +## Architecture Impact +- both (project-structure + runtime-flows 갱신) + +## Reference Doc Update Plan +- project-structure.md: app/api/v1_portfolios.py에 신규 라우트 기술 추가; app/schemas/v1.py schema 목록 갱신 +- runtime-flows.md: portfolio mutation flow (ownership + disabled 보호), GET /v1/portfolios 토큰 분기 flow, soft delete flow (status='disabled' + dependent data 보존) 추가 diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/implementation.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/implementation.md new file mode 100644 index 0000000..a45d83e --- /dev/null +++ b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/implementation.md @@ -0,0 +1,33 @@ +# BAT-010 Implementation + +- Status: done +- Batch: BAT-010 + +## Plan Summary +- REQ-030: `apply_portfolio_tag` + `remove_portfolio_tag`에 `_require_write_token` + disabled 409 체크 추가; `get_portfolio_tags` 신설. `PortfolioTagListResponse` 스키마 추가. +- REQ-031: comment CRUD 4종 라우트 신설 (`create_portfolio_comment`, `list_portfolio_comments`, `update_portfolio_comment`, `delete_portfolio_comment`). v1 comment schema 5종 추가. +- REQ-032: baseline 4종 문서 갱신. + +## Changed Files +- `app/schemas/v1.py` — 5개 schema 추가: `PortfolioTagListResponse`, `PortfolioCommentItem`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentListResponse` +- `app/api/v1_portfolios.py` — import 추가(`PortfolioComment`, 5 schemas, `TagItem`); `_comment_to_item` helper 추가; `apply_portfolio_tag`/`remove_portfolio_tag`에 `_require_write_token` + disabled 409 보강; `get_portfolio_tags` 신설; `create_portfolio_comment`, `list_portfolio_comments`, `update_portfolio_comment`, `delete_portfolio_comment` 4종 신설 +- `tests/test_v1_portfolios_tags_comments.py` — 신규 (32 tests; REQ-030 AC-1~11 + REQ-031 AC-1~11 커버) +- `docs/interface-contract.md` — v1 Portfolio API 섹션 신규 추가; tag/comment 서브 라우트 명세 추가; ledger path "pending REQ-029" 마킹 +- `docs/runtime-flows.md` — portfolio mutation flow, GET /v1/portfolios 분기 flow, soft delete flow, GET tags flow, comment CRUD flow 추가 +- `docs/data-model.md` — Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가 +- `docs/project-structure.md` — v1_portfolios.py 책임 갱신 + v1.py schema 목록 갱신 + +## Execution Log +1. `app/schemas/v1.py` — 5 schema 추가 (PortfolioTagListResponse 외) +2. `app/api/v1_portfolios.py` — import 갱신, `_comment_to_item` 추가, 기존 tag 라우트 보강, 신규 7개 라우트 추가 +3. `tests/test_v1_portfolios_tags_comments.py` — 32 test 신규 작성 +4. `.venv/bin/python -m pytest tests/test_v1_portfolios_tags_comments.py` → 32/32 PASSED +5. `.venv/bin/python -m pytest` (전체) → 330/330 PASSED (회귀 없음) +6. `docs/` 4종 갱신 (interface-contract, runtime-flows, data-model, project-structure) + +## Validation +- `tests/test_v1_portfolios_tags_comments.py`: 32 tests PASSED +- Full suite: 330 tests PASSED, 2 warnings (deprecation, 무관) + +## Remaining Risks +- R-3 (Low): `portfolio_comments` 테이블의 실 DB 존재 여부는 통합 테스트(실 DB)에서만 확인 가능. 단위 테스트는 MagicMock으로 커버됨. diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/index.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/index.md new file mode 100644 index 0000000..8323cf0 --- /dev/null +++ b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/index.md @@ -0,0 +1,33 @@ +# BAT-010: v1 portfolio sub-resources (tags/comments + baseline docs) + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-003 +- Included REQ: REQ-030, REQ-031, REQ-032 +- Excluded REQ: REQ-029(ledger path migration — trex-basic 동시 배포 제약으로 별도 batch) + +## Summary +- v1 portfolio 서브 리소스 라우트를 완성한다: `/v1/portfolios/{id}/tags`(GET 신설 + disabled 보호, REQ-030), `/v1/portfolios/{id}/comments`(CRUD 4종 신설, REQ-031). +- REQ-025~031 완료 후 baseline 문서(`interface-contract.md`, `runtime-flows.md`, `data-model.md`, `project-structure.md`) 정합성을 일괄 회복한다(REQ-032). + +## Scope +- In Scope: + - `app/api/v1_portfolios.py` — `GET /v1/portfolios/{id}/tags` 신설, existing POST/DELETE tag routes disabled 보호 보강, comment CRUD 4종 서브라우트 신설 + - `app/schemas/v1.py` — `PortfolioTagListResponse`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentItem`, `PortfolioCommentListResponse` 신규 (또는 기존 schema 재사용 확정) + - `docs/interface-contract.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/project-structure.md` — v1 portfolio surface + soft delete 정책 baseline 반영 + - `tests/test_v1_portfolios_tags_comments.py` (신규) — REQ-030/031 AC 검증 +- Out of Scope: + - `POST /v1/portfolios/{id}/ledger/trades` path 이전 (→ REQ-029, 별도 batch) + - interface-contract.md 구 path `/v1/assets/{id}/ledger/trades` 제거 명세 (→ REQ-029 범위, 본 batch에서는 pending 마킹만) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- R-1 (Low): REQ-030의 POST/DELETE `/v1/portfolios/{id}/tags` 라우트는 BAT-004(REQ-005) 구현 시 `app/api/v1_portfolios.py`에 이미 존재. disabled portfolio 보호(AC-8) 누락 여부를 design 단계에서 감사 후 보강. +- R-2 (Low): REQ-031 comment 작성자 ownership 정책 — dcy-003 본문은 portfolio ownership을 기준으로 명시. comment 작성자 ownership(본인 comment만 수정/삭제 가능) 적용 여부는 design 단계에서 확정. +- R-3 (Low): REQ-032 interface-contract.md의 ledger path 관련 갱신은 REQ-029 미완료로 해당 섹션을 "pending REQ-029" 마킹 후 defer. +- 선행 완료: BAT-009(REQ-025~028 Implemented) — v1 portfolio CRUD 기반 인가/ownership 패턴 확립. diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/planning.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/planning.md new file mode 100644 index 0000000..1bfcc3f --- /dev/null +++ b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/planning.md @@ -0,0 +1,76 @@ +# BAT-010 Planning + +- Status: confirmed +- Batch: BAT-010 +- Profile: standard + +## Included REQ +- REQ-030: portfolio tag 적용/해제/조회 v1 라우트 (Interface, Medium) +- REQ-031: portfolio comment CRUD v1 라우트 (Interface, Medium) +- REQ-032: portfolio v1 baseline 문서 갱신 (Documentation, Medium) + +## Delivery Plan + +### 구현 범위 및 순서 +1. **REQ-030 기존 라우트 보강 + GET 신설** + - 기존 `POST /v1/portfolios/{id}/tags`, `DELETE /v1/portfolios/{id}/tags/{tag_id}` 라우트 감사 결과: + - ownership 검증 ✓, tag 정책 검증 ✓ (POST만) + - disabled portfolio 보호 **누락** → AC-8 충족을 위해 추가 필요 + - `GET /v1/portfolios/{id}/tags` 신설: `portfolio.tag_ids` → `repo.get_tags_by_ids()` → `PortfolioTagListResponse` + - schema 추가: `PortfolioTagListResponse(tags: list[TagItem], total: int)` + +2. **REQ-031 comment CRUD 구현** + - 신규 4개 라우트: `POST`, `GET`, `PATCH`, `DELETE /v1/portfolios/{id}/comments[/{comment_id}]` + - 기존 repo 메서드 재사용: `create_comment`, `list_comments`, `update_comment`, `delete_comment` + - schema 추가 (app/schemas/v1.py): `PortfolioCommentItem`, `PortfolioCommentRequest`, `PortfolioCommentUpdateRequest`, `PortfolioCommentListResponse` + - ownership 검증: `_get_portfolio_or_404` 헬퍼 재사용 (BAT-009 패턴) + - disabled 보호: POST/PATCH/DELETE → HTTP 409; GET → 허용 + - comment 작성자 ownership: portfolio ownership만 적용 (REQ-031 Notes 결정) + +3. **테스트 작성** + - `tests/test_v1_portfolios_tags_comments.py` 신규 + - REQ-030 AC-1~11, REQ-031 AC-1~11 커버 + - BAT-009의 `_with_repo` 패치 패턴 재사용 + +4. **REQ-032 baseline 문서 갱신** + - `docs/interface-contract.md`: v1 portfolio CRUD 엔드포인트 섹션 추가 (REQ-025~028), GET /v1/portfolios/{id}/tags 엔드포인트 추가, comment CRUD 엔드포인트 추가; POST /v1/assets/{asset_id}/ledger/trades 섹션 "pending REQ-029" 마킹 + - `docs/runtime-flows.md`: portfolio mutation flow + GET /v1/portfolios 토큰 분기 + soft delete flow 추가 + - `docs/data-model.md`: Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가 + - `docs/project-structure.md`: app/api/v1_portfolios.py 책임 갱신 + app/schemas/v1.py schema 목록 갱신 + +### 선행 조건 +- BAT-009 Implemented (REQ-025~028) ✓ — `_get_portfolio_or_404`, `_require_write_token`, `_close_repo` 헬퍼 재사용 +- BAT-003 release-candidate (REQ-001/003): `portfolios.tag_ids UUID[]`, `repo.append_portfolio_tag_id/remove_portfolio_tag_id/get_tags_by_ids` ✓ +- BAT-004 release-candidate (REQ-004/005): POST/DELETE /v1/portfolios/{id}/tags 이미 구현 ✓ +- `app/repositories/portfolio_repository.py`: `create_comment`, `list_comments`, `update_comment`, `delete_comment` 추상 메서드 이미 정의 ✓ +- `app/domain/models.py`: `PortfolioComment(id, portfolio_id, content, created_at, updated_at)` ✓ + +## Dependencies +- Internal: + - BAT-009(REQ-025~028): `_get_portfolio_or_404`, `_require_write_token` 헬퍼, `PortfolioItem` 스키마 패턴 + - BAT-003(REQ-001~003): `portfolios.tag_ids`, `repo.get_tags_by_ids` + - BAT-004(REQ-005): POST/DELETE tag 라우트 (보강 대상) +- External: + - 없음 + +## Design Gate +- Design Required Before Implementation: yes +- Reason: + - REQ-031 comment 작성자 ownership 정책 확정: portfolio ownership만 적용 (결정 완료) + - REQ-030 GET 응답 schema 확정: `PortfolioTagListResponse(tags: list[TagItem], total: int)` (결정 완료) + - REQ-032 ledger path 마킹 방식 확정: "pending REQ-029" 주석 처리 (결정 완료) + - 모든 설계 gate가 사전 해소됨 → design.md 확정 후 구현 착수 가능 + +## Milestones +1. planning.md confirmed ✓ (현재 단계) +2. design.md confirmed — key decisions 기록 +3. REQ-030 disabled 보호 보강 + GET 신설 + tests +4. REQ-031 comment CRUD 4종 + tests +5. REQ-032 baseline docs 4종 갱신 +6. 전체 테스트 스위트 통과 +7. verification.md 완성 + +## Risks +- R-1 (Low): 기존 POST/DELETE tag 라우트에 disabled 보호 추가 시 `test_tags_http_api.py` 회귀 가능 → 기존 테스트가 disabled portfolio를 사용하지 않으면 영향 없음; 확인 후 보강 +- R-2 (Low): `repo.get_tags_by_ids(portfolio.tag_ids)` — tag_ids가 빈 배열일 경우 빈 dict 반환 → 빈 list 응답으로 정상 처리 +- R-3 (Low): `update_comment`/`delete_comment` postgres 구현에서 `portfolio_comments` 테이블 존재 여부 확인 필요 diff --git a/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/verification.md b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/verification.md new file mode 100644 index 0000000..f0b906e --- /dev/null +++ b/docs/batches/bat-010_20260521_v1-portfolios-sub-resources/verification.md @@ -0,0 +1,55 @@ +# BAT-010 Verification + +- Status: verified +- Batch: BAT-010 + +## Acceptance Mapping + +### REQ-030 (portfolio tag routes) +- AC-1 (admin tag → POST 200): `test_post_tag_admin_tag_200` PASSED — `repo.append_portfolio_tag_id` called +- AC-2 (own tag → POST 200): `test_post_tag_own_tag_200` PASSED +- AC-3 (타 user non-admin tag → 403): `test_post_tag_other_user_non_admin_tag_403` PASSED +- AC-4 (inactive tag → 403): `test_post_tag_inactive_tag_403` PASSED +- AC-5 (admin + other portfolio + admin tag → 200): `test_post_tag_admin_other_portfolio_admin_tag_200` PASSED +- AC-6 (other portfolio → 403): `test_post_tag_other_portfolio_403` PASSED +- AC-7 (service/client → 403): `test_post_tag_service_token_403`, `test_post_tag_client_token_403` PASSED +- AC-8 (disabled + POST/DELETE → 409): `test_post_tag_disabled_portfolio_409`, `test_delete_tag_disabled_portfolio_409` PASSED +- AC-9 (DELETE own portfolio → 200): `test_delete_tag_own_portfolio_200` PASSED — `repo.remove_portfolio_tag_id` called +- AC-10 (GET tags → 200 + list): `test_get_portfolio_tags_200`, `test_get_portfolio_tags_empty_200` PASSED +- AC-11 (nonexistent portfolio → 404): `test_get_tags_nonexistent_portfolio_404` PASSED + +### REQ-031 (portfolio comment routes) +- AC-1 (POST → 201): `test_post_comment_201` PASSED — comment_id, portfolio_id, content verified +- AC-2 (GET → 200 + list): `test_get_comments_200` PASSED — total=2 verified +- AC-3 (PATCH → 200 + updated): `test_patch_comment_200` PASSED — `repo.update_comment` called with correct args +- AC-4 (DELETE → 204): `test_delete_comment_204` PASSED — `repo.delete_comment` called +- AC-5 (other portfolio → 403): `test_post_comment_other_portfolio_403` PASSED +- AC-6 (service/client → 403): `test_post_comment_service_token_403`, `test_post_comment_client_token_403`, `test_get_comments_service_token_403` PASSED +- AC-7 (disabled + POST/PATCH/DELETE → 409): `test_post_comment_disabled_409`, `test_patch_comment_disabled_409`, `test_delete_comment_disabled_409` PASSED +- AC-8 (disabled + GET → 200): `test_get_comments_disabled_portfolio_200` PASSED +- AC-9 (admin + other portfolio → 201/200): `test_post_comment_admin_other_portfolio_201`, `test_get_comments_admin_other_portfolio_200` PASSED +- AC-10 (no auth → 401): `test_post_comment_no_auth_401` PASSED +- AC-11 (nonexistent comment/portfolio → 404): `test_patch_comment_nonexistent_404`, `test_delete_comment_nonexistent_404`, `test_post_comment_nonexistent_portfolio_404` PASSED + +### REQ-032 (baseline docs) +- AC-1 (`docs/interface-contract.md` 갱신): v1 Portfolio API 섹션 신규 추가; GET/POST/DELETE /v1/portfolios/{id}/tags; POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments; ledger "pending REQ-029" 마킹 +- AC-2 (`docs/runtime-flows.md` 갱신): portfolio mutation flow, GET branching flow, soft delete + dependent data preservation flow, comment CRUD flow 추가 +- AC-3 (`docs/data-model.md` 갱신): Portfolio.status='disabled' 의미 + 의존 데이터 보존 정책 추가 +- AC-4 (`docs/project-structure.md` 갱신): v1_portfolios.py 책임 갱신 + v1.py schema 목록 갱신 +- AC-5 (코드-문서 정합성): interface-contract 명세가 구현 라우트와 일치 확인 (7개 신규 라우트) +- AC-6 (ledger path "pending REQ-029" 마킹): interface-contract.md에 "pending REQ-029" 섹션 추가 + +## Evidence +- Test: + - `tests/test_v1_portfolios_tags_comments.py`: 32/32 PASSED (3.27s) + - Full suite: 330/330 PASSED (14.11s, 2 deprecation warnings — 무관) + - No regressions in existing tests (BAT-004 tag tests, BAT-009 portfolio CRUD/list tests) +- Manual: + - `app/api/v1_portfolios.py` 코드 검토: disabled 409 check는 ownership 검증 직후, mutation 직전 위치 확인; `_require_write_token` service/client 차단 확인; GET routes는 disabled 허용 확인 +- Logs: + - Full suite output: 330 passed, 2 warnings in 14.11s + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음 diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/design.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/design.md new file mode 100644 index 0000000..dfac1ed --- /dev/null +++ b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/design.md @@ -0,0 +1,42 @@ +# BAT-011 Design + +- Status: confirmed +- Batch: BAT-011 + +## Architecture Summary +- `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 서브 라우트를 추가한다. 기존 `app/api/v1_ledger.py`의 daemon 인가 3단계 + idempotency 로직을 신 path로 이식하고, disabled 차단을 daemon 인가 이전에 삽입한다. +- 구 path는 파일 전체 제거(`app/api/v1_ledger.py` 삭제) + `app/main.py`에서 include 제거로 처리한다. 별칭/redirect 없음. +- trex-basic 동시 배포 순서: trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포. rollback 시 portfolio-hub + trex-basic 동반 rollback. + +## Changed Areas +- Docs: + - `docs/interface-contract.md` — REQ-032에서 이미 "pending REQ-029" 마킹됨. 본 batch에서 실제 명세 반영. + - `docs/runtime-flows.md` — ledger trade path 이전 흐름 + daemon assignment 3단계 인가 갱신 (REQ-032 기반 기존 흐름 보강). +- Modules: + - `app/api/v1_portfolios.py` — 신 path 라우트 추가 + - `app/api/v1_ledger.py` — 제거 (구 path 라우트 삭제) + - `app/main.py` — 구 ledger 라우터 include 제거 + - `app/services/ledger_service.py` — disabled 차단 검증 추가 (또는 라우터 계층에서 처리) +- Interfaces: + - `POST /v1/portfolios/{portfolio_id}/ledger/trades` (신설) + - `POST /v1/assets/{asset_id}/ledger/trades` (제거 → HTTP 404) + +## Key Decisions +1. **구 `v1_ledger.py` 완전 제거**: 라우트만 제거하는 대신 파일 전체를 삭제해 잔재 코드가 남지 않도록 한다. `app/main.py`에서 해당 라우터 include를 제거. +2. **disabled 차단 위치**: 라우터 계층에서 `_get_portfolio_or_404` 직후, daemon 인가 3단계 이전에 `if portfolio.status == "disabled": raise HTTPException(409)` 삽입. 서비스 계층 변경 최소화. +3. **인가 순서**: (0) Bearer 없음 → 401, (1) token `daemon_id IS NULL` → 403, (2) portfolio not found → 404, (3) `portfolio.status == "disabled"` → 409, (4) `portfolio.managed_by_daemon_id IS NULL` → 403, (5) `portfolio.managed_by_daemon_id != auth.daemon_id` → 403, (6) idempotency 분기 → 201/200/409. +4. **trex-basic 동시 배포**: release plan에 배포 순서 잠금 명시. 한시적 별칭/redirect 없음. + +## Edge Cases +- `portfolio_id`가 비존재 → HTTP 404 (daemon 인가보다 우선). +- `status='disabled'` portfolio → HTTP 409 (daemon 인가 체크 전에 차단하여 daemon 미배정 오류와 구분). +- 동일 `(portfolio_id, order_id, execution_id)` + 동일 payload 재호출 → HTTP 200 + `status="duplicate"`. +- 동일 key + 다른 payload → HTTP 409 (idempotency conflict). +- Bearer token 없음 → HTTP 401. + +## Architecture Impact +- runtime-flows + +## Reference Doc Update Plan +- project-structure.md: `v1_ledger.py` 제거 반영 +- runtime-flows.md: ledger path 이전 흐름 + daemon 인가 3단계 + disabled 차단 순서 갱신 diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/implementation.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/implementation.md new file mode 100644 index 0000000..98aef4c --- /dev/null +++ b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/implementation.md @@ -0,0 +1,36 @@ +# BAT-011 Implementation + +- Status: done +- Batch: BAT-011 + +## Plan Summary +- `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 추가 (daemon 인가 3단계 + disabled 차단 + idempotency). +- `app/api/v1_ledger.py` 제거 + `app/main.py` include 정리. +- `tests/test_v1_portfolios_ledger.py` 신규 (AC-1~10). +- `tests/test_v1.py` 구 path 케이스 갱신 + HTTP 404 회귀 확인. + +## Changed Files +- `app/api/v1_portfolios.py` — `POST /{portfolio_id}/ledger/trades` 라우트 신설; `_require_service_token` 의존성(service token + daemon_id IS NULL 검증); `_payloads_match` 헬퍼; `LedgerTradeRequest/Response` import 추가 +- `app/api/v1_ledger.py` — **삭제** (구 path 라우트 완전 제거) +- `app/main.py` — `v1_ledger_router` import 및 `app.include_router(v1_ledger_router)` 제거 +- `tests/test_v1_portfolios_ledger.py` — **신규** (AC-1~10: 10 tests) +- `tests/test_v1.py` — 구 path 케이스 5건 신 path 갱신; `app.api.v1_ledger.*` 패치 → `app.api.v1_portfolios.*` 갱신; 파일 말미 중복 helper 2건 제거 +- `tests/test_daemon_assignment.py` — `app.api.v1_ledger.*` 패치 → `app.api.v1_portfolios.*` 갱신; URL 4건 갱신; `_portfolio_with_daemon`에 `p.status = "enabled"` 추가 + +## Execution Log +1. `app/api/v1_portfolios.py` — `Response` import 추가, `LedgerTradeRequest/LedgerTradeResponse` import 추가 +2. `_require_service_token` 의존성 추가 (service token 검증 + daemon_id IS NULL 403) +3. `_payloads_match` 헬퍼 추가 (idempotency conflict 판정) +4. `POST /{portfolio_id}/ledger/trades` 라우트 추가 (disabled 409 → managed_by_daemon_id NULL 403 → daemon mismatch 403 → insert_ledger_trade) +5. `app/api/v1_ledger.py` 삭제 +6. `app/main.py` 에서 v1_ledger 라우터 import + include 제거 +7. `tests/test_v1.py` — 구 path URL 및 패치 경로 갱신 + 중복 helper 제거 +8. `tests/test_daemon_assignment.py` — URL 및 패치 경로 갱신; `_portfolio_with_daemon` `status` 명시 +9. `tests/test_v1_portfolios_ledger.py` — 신규 생성 (AC-1~10) + +## Validation +- `pytest tests/test_v1_portfolios_ledger.py -v`: 10 passed +- `pytest --tb=short -q` (full suite): **340 passed, 0 failed** (2 warnings, non-blocking) + +## Remaining Risks +- R-1 (High): trex-basic 동시 배포 — release 단계 배포 순서 잠금 필수 (trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포). diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/index.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/index.md new file mode 100644 index 0000000..38caabb --- /dev/null +++ b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/index.md @@ -0,0 +1,35 @@ +# BAT-011: v1 portfolios ledger path migration + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-003 +- Included REQ: REQ-029 +- Excluded REQ: none + +## Summary +- 기존 `POST /v1/assets/{asset_id}/ledger/trades`(`app/api/v1_ledger.py`)를 `POST /v1/portfolios/{portfolio_id}/ledger/trades`로 이전하고 구 path를 동일 release에서 제거한다. +- 동작·인가·idempotency는 그대로 보존하며 REQ-027 정합의 disabled portfolio 차단(HTTP 409)을 신규 추가한다. +- trex-basic 클라이언트와의 동시 배포가 필수이므로 release plan에 배포 순서 잠금·rollback 전제를 명시한다. + +## Scope +- In Scope: + - `app/api/v1_portfolios.py` — `POST /v1/portfolios/{portfolio_id}/ledger/trades` 라우트 추가 + - `app/api/v1_ledger.py` — 구 path 라우트 제거 (또는 파일 전체 제거 후 `app/main.py`에서 include 제거) + - `app/main.py` — 구 ledger 라우터 include 제거 + - `app/services/portfolio_service.py` 또는 `app/services/ledger_service.py` — disabled 차단 검증 추가 + - `tests/test_v1.py` — 구 path 케이스 갱신(HTTP 404 확인) + - `tests/test_v1_portfolios_ledger.py` (신규) — AC-1~10 검증 +- Out of Scope: + - `ledger_trades.asset_id` 컬럼명 변경 (별도 migration REQ로 분리 가능) + - trex-basic 코드 변경 (별도 레포) + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- R-1 (High): trex-basic 동시 배포 — 신 path 단일 서빙, 별칭/redirect 없음. release plan에 배포 순서(trex-basic 사전 빌드 → portfolio-hub 배포 → trex-basic 배포) + rollback 시 trex-basic 동반 rollback 전제 명시 필수. +- R-2 (Low): disabled portfolio 차단(AC-7)은 REQ-027 soft delete 정책 정합 검증으로 신규. 기존 daemon 인가 3단계와 순서 충돌 없이 disabled 체크를 insert 전에 추가. +- 선행 완료: BAT-009(soft delete + disabled 상태 관리, REQ-027) ✅ diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/planning.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/planning.md new file mode 100644 index 0000000..dd402f6 --- /dev/null +++ b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/planning.md @@ -0,0 +1,39 @@ +# BAT-011 Planning + +- Status: confirmed +- Batch: BAT-011 +- Profile: standard + +## Included REQ +- REQ-029: `POST /v1/portfolios/{portfolio_id}/ledger/trades` 이전 + 구 path 제거 + disabled 차단 추가 + +## Delivery Plan +1. `app/api/v1_portfolios.py` — `POST /{portfolio_id}/ledger/trades` 라우트 추가: daemon 인가 3단계 + disabled 차단 + idempotency + conflict 분기 +2. `app/api/v1_ledger.py` — 구 `POST /v1/assets/{asset_id}/ledger/trades` 라우트 제거 (또는 파일 전체 제거) +3. `app/main.py` — 구 `v1_ledger` 라우터 include 제거 (v1_portfolios_router는 BAT-009에서 이미 include됨) +4. `app/services/ledger_service.py` 또는 `app/services/portfolio_service.py` — `disabled` 차단 검증 추가 +5. `tests/test_v1_portfolios_ledger.py` 신규 — AC-1~10 검증: 신 path 정상 흐름 + daemon 인가 3단계 + idempotency/conflict + disabled + 404 + 401 +6. `tests/test_v1.py` 갱신 — 구 path 케이스를 신 path로 갱신 + 구 path HTTP 404 회귀 확인 + +## Dependencies +- Internal: + - BAT-009 (release-candidate): soft delete(`status='disabled'`) + `managed_by_daemon_id` 관리 확정 ✅ + - BAT-006 (release-candidate): service token 분기 정책 확정(REQ-016/017/018) ✅ +- External: + - trex-basic 클라이언트: 신 path `POST /v1/portfolios/{portfolio_id}/ledger/trades` 사전 빌드 필요. release 단계에서 배포 순서 잠금 확인 필수. + +## Design Gate +- Design Required Before Implementation: yes +- Reason: (1) 구 `v1_ledger.py` 파일 완전 제거 vs. 라우트만 제거 결정 필요, (2) disabled 차단을 서비스 계층과 라우터 계층 중 어디에 위치시킬지 결정 필요, (3) trex-basic 동시 배포 전략(배포 순서 잠금·rollback 계획)을 design 단계에서 명문화. + +## Milestones +1. design.md confirmed — 파일 제거 전략, disabled 차단 위치, 배포 순서 잠금 결정 +2. 신 path 라우트 구현 완료 (`app/api/v1_portfolios.py`) +3. 구 path 제거 + `app/main.py` 갱신 완료 +4. `tests/test_v1_portfolios_ledger.py` AC-1~10 전부 pass +5. `tests/test_v1.py` 구 path HTTP 404 회귀 확인 + 전체 suite pass + +## Risks +- R-1 (High): trex-basic 동시 배포 — 신 path 단일 서빙으로 별칭/redirect 없음. trex-basic이 구 path를 사용 중인 상태에서 portfolio-hub만 배포되면 trex-basic이 즉시 장애. 완화: release plan에 배포 순서(trex-basic 사전 빌드 완료 확인 → portfolio-hub 배포 → trex-basic 배포) 잠금 + rollback 시 trex-basic 동반 rollback 명시. +- R-2 (Low): `ledger_trades.asset_id` 컬럼명은 실제로 portfolio_id 값을 저장하는 혼용 상태가 남으나, 본 REQ에서는 path 명명 정합화만 처리하고 컬럼명 변경은 별도 REQ로 이월. +- R-3 (Low): `tests/test_v1.py`에 구 path 테스트가 얼마나 있는지 design 단계에서 사전 파악 필요. diff --git a/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/verification.md b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/verification.md new file mode 100644 index 0000000..b5c324a --- /dev/null +++ b/docs/batches/bat-011_20260521_v1-portfolios-ledger-path-migration/verification.md @@ -0,0 +1,33 @@ +# BAT-011 Verification + +- Status: verified +- Batch: BAT-011 + +## Acceptance Mapping +- REQ-029: + - AC-1 (`test_ledger_trade_new`): 신 path HTTP 201 + `status="new"` + `asset_id=_PORTFOLIO_ID` ✅ + - AC-2 (`test_ledger_trade_duplicate_same_payload`): 동일 key + 동일 payload → HTTP 200 + `status="duplicate"` ✅ + - AC-3 (`test_ledger_trade_conflict_different_payload`): 동일 key + 다른 quantity → HTTP 409 ✅ + - AC-4 (`test_ledger_trade_no_daemon_id`): service token `daemon_id IS NULL` → HTTP 403; `insert_ledger_trade` not called ✅ + - AC-5 (`test_ledger_trade_portfolio_unassigned`): `portfolio.managed_by_daemon_id IS NULL` → HTTP 403; `insert_ledger_trade` not called ✅ + - AC-6 (`test_ledger_trade_daemon_mismatch`): `managed_by_daemon_id="other-daemon"` vs `auth.daemon_id=_DAEMON_ID` → HTTP 403; `insert_ledger_trade` not called ✅ + - AC-7 (`test_ledger_trade_disabled_portfolio`): `status="disabled"` → HTTP 409; `insert_ledger_trade` not called ✅ + - AC-8 (`test_ledger_trade_portfolio_not_found`): `repo.get.side_effect=Exception` → HTTP 404 ✅ + - AC-9 (`test_old_ledger_path_returns_404`): `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (라우트 제거) ✅ + - AC-10 (`test_ledger_trade_no_auth`): Bearer 없음 → HTTP 401 ✅ + - Regression (`test_daemon_assignment.py` 4건): daemon assignment 인가 3단계 신 path에서 동작 검증 ✅ + +## Evidence +- Test: + - `pytest tests/test_v1_portfolios_ledger.py -v`: **10/10 passed** + - `pytest --tb=short -q` (full suite): **340 passed, 0 failed**, 2 warnings (non-blocking DeprecationWarning from anyio) + - 이전 suite 대비 +10 tests (330 → 340) +- Manual: + - 해당 없음 — 자동화 검증으로 충분 (인터페이스 변경, UI 없음) +- Logs: + - 2 DeprecationWarnings: `anyio._backends._asyncio.py` `HTTP_422_UNPROCESSABLE_ENTITY` → `HTTP_422_UNPROCESSABLE_CONTENT`. 기존 suite에서도 동일 경고 존재. 비차단. + +## Result +- Ready for Release: true +- Blocking Issues: 없음 (코드 구현 완료, 10/10 AC 검증) +- Release Note: trex-basic 동시 배포 전제 — release 단계에서 배포 순서 잠금 필수. 신 path 단일 서빙, 별칭/redirect 없음. diff --git a/docs/batches/index.md b/docs/batches/index.md index 115218e..6591adc 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -15,3 +15,7 @@ | BAT-005 | standard | release-candidate | REQ-007, REQ-008 | dcy-002 | [bat-005](./bat-005_20260520_pfhub-tag-cli-and-docs/) | | BAT-006 | standard | release-candidate | REQ-016, REQ-017, REQ-018 | dcy-004 | [bat-006](./bat-006_20260520_token-surface-code-fixes/) | | BAT-007 | standard | release-candidate | REQ-022, REQ-023 | dcy-004 | [bat-007](./bat-007_20260520_daemon-lifecycle-token-enforcement/) | +| BAT-008 | standard | release-candidate | REQ-019, REQ-020, REQ-021, REQ-024 | dcy-004 | [bat-008](./bat-008_20260521_token-surface-docs/) | +| BAT-009 | standard | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028 | dcy-003 | [bat-009](./bat-009_20260521_v1-portfolios-crud-core/) | +| BAT-010 | standard | release-candidate | REQ-030, REQ-031, REQ-032 | dcy-003 | [bat-010](./bat-010_20260521_v1-portfolios-sub-resources/) | +| BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) | diff --git a/docs/data-model.md b/docs/data-model.md index f93d499..9694b33 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-20 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005 +- Last Updated (KST): 2026-05-21 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004) ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -30,7 +30,7 @@ - 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 +- AuthToken :: issued service token (or session token for login flows) with permission, runtime_id, and optional daemon_id binding; client token type removed (REQ-016/BAT-006) - 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 @@ -114,12 +114,13 @@ - name :: text :: human-readable portfolio name; unique per owner_user_id; validated against [a-zA-Z0-9._:-]{1,100} - asset_id :: uuid :: FK → assets.asset_id; catalog reference for the traded asset; BAT-001 migrated symbol/market columns to this FK - symbol :: text :: display symbol populated via JOIN on assets (read-only, not stored in portfolios) +- status :: text :: `"enabled"` (active, default) | `"disabled"` (soft-deleted); set to `"disabled"` by `DELETE /v1/portfolios/{id}`; only `status` can be mutated on a disabled portfolio (restoration via PATCH); all other writes → HTTP 409 - owner_user_id :: text|null :: owning user reference for attribution and authorization - active_strategy_id :: text|null :: currently selected strategy-set reference - 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 +- 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); set to NULL on soft delete; 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); preserved on soft delete; default empty array ### Uniqueness Constraints @@ -140,10 +141,12 @@ - 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. - tag_ids stores UUID references to active tags; duplicates are prevented by conditional array_append; comments are retrieved in created_at descending order. +- `status="disabled"` (soft delete): `managed_by_daemon_id` is set to NULL; portfolio is excluded from `GET /v1/portfolios` by default. Dependent data (`portfolio_comments`, `tag_ids`) is preserved — never deleted on soft delete. READ operations on sub-resources (GET tags, GET comments) remain allowed; write mutations raise HTTP 409. +- Status restoration: PATCH with `{"status": "enabled"}` re-activates a disabled portfolio; no other fields may be set in the same request. ## Entity: `User` -- Purpose: represent an authenticated principal that can own portfolios and receive client or service tokens +- Purpose: represent an authenticated principal that can own portfolios and receive service tokens - Source of Truth: users table with dependent auth_tokens rows - Lifecycle: bootstrapped or created -> updated -> deactivated or deleted @@ -158,7 +161,7 @@ ### Relationships -- AuthToken :: user_id links issued client and service tokens to the owning user +- AuthToken :: user_id links issued service tokens to the owning user - Portfolio :: portfolios.owner_user_id ties portfolios to a managing user - TokenPrincipal :: runtime auth resolution derives principals from persisted tokens and users @@ -170,7 +173,7 @@ ## Entity: `AuthToken` -- Purpose: represent an issued client or service token linked to a user with permission and optional runtime identifier +- Purpose: represent an issued service token (or session token for login flows) linked to a user with permission and optional runtime identifier; client token type (token_type="client") removed by REQ-016/BAT-006 - Source of Truth: auth_tokens table - Lifecycle: issued -> optionally rotated -> revoked or expired @@ -178,7 +181,7 @@ - id :: text :: primary key; raw token value (stored hashed in practice) - user_id :: text :: owning user reference -- token_type :: text :: "client" or "service" +- token_type :: text :: "session" (login tokens) | "service" (daemon-bound operational tokens); "client" type removed by REQ-016/BAT-006 - token_source :: text :: "db" for persisted tokens; "env" for environment-injected tokens - permission :: text :: read, trade-write, or admin - service_name :: text|null :: logical name for service tokens (unique within token_type="service") @@ -189,7 +192,7 @@ ### Uniqueness Constraints -- idx_auth_tokens_service_name_unique :: UNIQUE (service_name) WHERE token_type='service' +- idx_auth_tokens_service_name_unique :: UNIQUE (name) WHERE token_type='service' AND revoked_at IS NULL (BAT-006/REQ-017: revoked_at IS NULL 조건 추가로 rotate 후 구 token과 신 token 이름 충돌 해소) - idx_auth_tokens_runtime_id_unique :: UNIQUE (runtime_id) WHERE runtime_id IS NOT NULL ### State Rules @@ -198,6 +201,7 @@ - runtime_id is per-token and changes on every rotate; daemon_id is stable across rotates (daemon_id invariant). - daemon_id=NULL tokens are fail-closed on GET /v1/assets and POST /v1/assets/{asset_id}/ledger/trades (HTTP 403); GET /v1/user_info returns 200 + `daemon_id: null` as diagnostic. - Token permission gates both HTTP API guards and CLI mutation capabilities. +- Deprecated: token_type="client" issuance path removed by REQ-016/BAT-006; existing "client" type rows in legacy DBs remain readable but new issuance is blocked. ## Entity: `Daemon` @@ -228,7 +232,7 @@ ### State Rules - A daemon must exist and be active before a service token can be bound to it at issuance. -- Deactivating a daemon (is_active=false) blocks new token issuance but does not revoke existing tokens. +- Deactivating a daemon (is_active=false) immediately revokes all bound service tokens via `revoke_tokens_by_daemon_id` (REQ-022/BAT-007) and blocks new token issuance. - daemon_slug is immutable after creation; only display_name and is_active can be updated. ## Entity: `BrokerCredential` @@ -313,6 +317,8 @@ ## Change Log +- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 데이터 모델 보강: Portfolio.status 필드 신설 (`"enabled"` | `"disabled"` 의미, soft delete 정책); Portfolio.managed_by_daemon_id에 soft delete 시 NULL 설정 명시; tag_ids 소프트 삭제 보존 정책 추가; Portfolio State Rules에 disabled 상태 정책 + 의존 데이터 보존 + 상태 복원 규칙 추가; Source Discovery/Batch 헤더 갱신. +- 2026-05-21 KST: BAT-008 (REQ-019) — 토큰 surface 정합성 회복: AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon 비활성화 시 바인딩 서비스 토큰 자동 revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신. - 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. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 8295d38..fc8aed1 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-20 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005 +- Last Updated (KST): 2026-05-21 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004) - Primary Runtime: mixed ## Project Summary @@ -70,6 +70,8 @@ ### Inputs - pfhub subcommands :: include user, portfolio, strategy, tag, service-token, daemon, credential, health-check, init-db, and migrate-db command families + - `service-token` subcommands: `issue`, `rotate`, `rebind` (BAT-007: `--daemon-id` 변경), `revoke`, `list`, `migrate-null-daemon --dry-run` (BAT-007: NULL daemon_id 진단 CLI) + - `daemon` subcommands: `create`, `list`, `update` (BAT-007: `--deactivate --force` 시 바인딩 service token 자동 revoke) - 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` sub-commands accept portfolio_id (UUID) directly @@ -105,20 +107,24 @@ - All /v1 endpoints require `Authorization: Bearer ` header. - Token must be DB-issued (`token_source="db"`); env tokens are rejected with HTTP 403. - Admin catalog endpoints (`POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate`) require admin permission. -- POST /v1/assets/{asset_id}/ledger/trades additionally requires `trade-write` permission. +- POST /v1/portfolios/{portfolio_id}/ledger/trades additionally requires `trade-write` permission and a DB service token with non-NULL `daemon_id`. ### Token Type Branching - `token_type="service"`: daemon-scoped endpoints. `daemon_id` must be non-NULL (NULL → HTTP 403 fail-closed). -- `token_type="user"`: user-scoped. Endpoints return owner's own data. -- `token_type="admin"`: admin-scoped. May query other users' data via `?user_id=` param. +- `token_type="session"`: user-scoped (login token). Endpoints return owner's own data. +- `token_type="client"`: **Deprecated/Removed** (REQ-016/BAT-006). 발급 경로 제거. 잔존 DB row는 읽을 수 있으나 신규 발급 불가. v1 API 호출 시 HTTP 403 fail-closed. +- env token (token_source="env"): admin-scoped. May query other users' data via `?user_id=` param. DB /v1 endpoints에서는 HTTP 403 (DB token만 허용). ### Daemon Authorization Model - Service tokens are bound to a daemon at issuance via `daemon_id` field. Legacy/unbound tokens have `daemon_id=NULL`. -- GET /v1/assets (service token) and POST /v1/assets/{asset_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed). +- GET /v1/assets (service token) and POST /v1/portfolios/{portfolio_id}/ledger/trades are daemon-scoped. NULL token `daemon_id` → HTTP 403 (fail-closed). - GET /v1/user_info is available to any service token; returns `daemon_id: null` as diagnostic for unbound tokens. - Each asset (portfolio) may be assigned to exactly one daemon via `portfolios.managed_by_daemon_id`. Mismatch or unassigned asset → HTTP 403 on ledger writes. +- **Token rebind** (BAT-007/REQ-023): `pfhub service-token rebind --daemon-id ` — daemon_id 변경, runtime_id 불변. 대상 daemon은 active이어야 하고 token 소유 user와 일치해야 한다. +- **Daemon deactivation** (BAT-007/REQ-022): `pfhub daemon update --deactivate --force` — daemon is_active=false + 바인딩된 모든 service token 즉시 revoke. +- **NULL daemon migration CLI** (BAT-007/REQ-023): `pfhub service-token migrate-null-daemon --dry-run` — NULL daemon_id active service token inventory 출력 + user별 candidate daemon 목록. `--dry-run` 없이 실행 시 exit code 1 + 수동 안내. ### Endpoint: `GET /v1/assets` @@ -275,6 +281,109 @@ - 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: `v1 Portfolio API (BAT-009/010)` + +- Type: http-api +- Consumers / Producers: authenticated users and admin services +- Purpose: CRUD for user-owned portfolios; tag and comment sub-resources; ownership and disabled-state enforcement +- Stability: internal (versioned under /v1) + +### Authentication + +- All /v1/portfolios endpoints require `Authorization: Bearer ` header. +- `token_type ∈ {service, client}` → HTTP 403 fail-closed for all write operations and GET sub-resources. +- `permission='admin'` → access to all portfolios. `permission ∈ {read, trade-write}` → own portfolios only. +- Comment and portfolio CRUD routes: DB token or env token; POST/PATCH/DELETE require user or admin token type. +- Tag POST/DELETE routes additionally require `token_source="db"` (DB token). + +### Endpoint: `POST /v1/portfolios` + +- Auth: any valid token; service/client → HTTP 403 +- Body: `{"asset_id": "", "broker": "", "name": "", "cash_balance": , "asset_quantity": , "asset_price": , "profit_buffer": , "strategy_name": "", "strategy_params": {}, "managed_by_daemon_id": "", "user_id": " (admin only)"}` +- Response: HTTP 201 `PortfolioItem` +- Error: HTTP 422 if asset not found or inactive; HTTP 403 if service/client token + +### Endpoint: `PATCH /v1/portfolios/{portfolio_id}` + +- Auth: any valid token; service/client → HTTP 403; ownership required +- Body: `{"broker": "", "status": "", "name": "", "strategy_name": "", "strategy_params": {}, "asset_price": , "managed_by_daemon_id": ""}` +- Response: HTTP 200 `PortfolioItem` +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if portfolio is disabled and non-status fields are provided + +### Endpoint: `DELETE /v1/portfolios/{portfolio_id}` (Soft Delete) + +- Auth: any valid token; service/client → HTTP 403; ownership required +- Body: none +- Response: HTTP 200 `PortfolioItem` with `status="disabled"`, `managed_by_daemon_id=null` +- Dependent data: portfolio_comments and tag_ids are preserved on soft delete; only status and daemon assignment change +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if not found + +### Endpoint: `GET /v1/portfolios` + +- Auth: any valid token; client → HTTP 403 +- Query Params: `?include_disabled=true` (default false); `?market=`; `?user_id=` (admin only) +- Branching by token type: + - `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → HTTP 403 + - `user/admin`: returns owner's own portfolios; admin may override with `?user_id=` +- Response: HTTP 200 `PortfolioListResponse {"portfolios": [...], "total": }` + +### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags` + +- Auth: DB or env token; service/client → HTTP 403; ownership required +- Response: HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": }` +- Note: disabled portfolio GET is allowed +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found + +### Endpoint: `POST /v1/portfolios/{portfolio_id}/tags` + +- Auth: DB token required; trade-write or admin; service/client → HTTP 403; ownership required +- Body: `{"tag_id": ""}` +- Response: HTTP 200 `TagApplyResponse {"tag_id": "", "status": "applied"}` +- Policy: tag must be is_active=true; non-admin must use own or admin tag; disabled portfolio → HTTP 409 +- Error: HTTP 403 if policy violation, service/client, or ownership mismatch; HTTP 404 if portfolio or tag not found; HTTP 409 if disabled portfolio + +### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` + +- Auth: DB token required; trade-write or admin; service/client → HTTP 403; ownership required +- Response: HTTP 200 `TagApplyResponse {"tag_id": "", "status": "removed"}` +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if disabled portfolio + +### Endpoint: `POST /v1/portfolios/{portfolio_id}/comments` + +- Auth: any valid token; service/client → HTTP 403; ownership required +- Body: `{"content": ""}` (min_length=1) +- Response: HTTP 201 `PortfolioCommentItem {"comment_id": "", "portfolio_id": "", "content": "", "created_at": "", "updated_at": ""}` +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found; HTTP 409 if disabled portfolio + +### Endpoint: `GET /v1/portfolios/{portfolio_id}/comments` + +- Auth: any valid token; service/client → HTTP 403; ownership required +- Response: HTTP 200 `PortfolioCommentListResponse {"comments": [PortfolioCommentItem], "total": }` (created_at desc from repo) +- Note: disabled portfolio GET is allowed +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio not found + +### Endpoint: `PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}` + +- Auth: any valid token; service/client → HTTP 403; portfolio ownership required (no per-comment author check) +- Body: `{"content": ""}` (min_length=1) +- Response: HTTP 200 `PortfolioCommentItem` +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or comment not found; HTTP 409 if disabled portfolio + +### Endpoint: `DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}` + +- Auth: any valid token; service/client → HTTP 403; portfolio ownership required +- Response: HTTP 204 (no content) +- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or comment not found; HTTP 409 if disabled portfolio + +### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011) + +- Auth: DB service token with `trade-write` permission; `daemon_id IS NULL` → HTTP 403 fail-closed +- Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at` +- Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; HTTP 409 for conflicting payload +- Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio or payload conflict) +- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint +- **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404. + ## Interface: `Tag Management API (BAT-004)` - Type: http-api @@ -341,6 +450,43 @@ - Response: HTTP 200 `TagApplyResponse` `{"tag_id": "", "status": "removed"}` - Error: HTTP 404 if portfolio not found; HTTP 403 if caller is not owner or admin +## Deprecated Endpoints + +### `POST /auth/tokens` (Client Token Issuance) — Removed + +- **Status**: Removed as of BAT-006/REQ-016 (2026-05-20). +- Route `POST /auth/tokens` 및 `CreateClientTokenRequest` / `IssuedClientTokenResponse` 스키마 제거. 호출 시 HTTP 404 또는 405. +- `token_type="client"` 발급 경로는 코드에서 완전히 제거됨. 잔존 client token row는 DB에 읽기 전용으로 남을 수 있음. + +### `POST /auth/service-tokens` Body — daemon_id 추가 (BAT-006/REQ-018) + +- Body `daemon_id` 필드가 서비스 토큰 발급 시 라우트 핸들러로 정상 forwarding됨. (이전: 누락으로 인해 daemon_id가 항상 NULL로 저장) +- Body: `{"name": "", "username": "", "permission": "read|trade-write", "expires_in_days": , "no_expiry": , "daemon_id": ""}` +- Response: HTTP 201 `{"token": "", "runtime_id": "", "daemon_id": "", ...}` + +## Token Issuance Authorization Matrix + +(REQ-020/BAT-008 — dcy-004 FR-5, NFR-4) + +매트릭스 위반 호출 → HTTP 403 fail-closed (모든 권한 미충족 케이스). + +| Token Type | Verb | admin | trade-write | read | 비고 | +|------------|------|-------|-------------|------|------| +| session | issue (POST /auth/login) | ✓ | ✓ | ✓ | 유효 credential 필요 | +| session | revoke (DELETE /auth/tokens/{id}) | ✓ 본인 | ✓ 본인 | ✓ 본인 | 타 user token 폐기 불가 | +| session | list (GET /auth/tokens) | ✓ 본인 | ✓ 본인 | ✓ 본인 | — | +| service | issue (POST /auth/service-tokens) | ✓ | ✗ | ✗ | admin만 발급 가능 | +| service | rotate (pfhub service-token rotate) | ✓ | ✗ | ✗ | CLI only | +| service | rebind (pfhub service-token rebind) | ✓ | ✗ | ✗ | CLI only | +| service | revoke (pfhub service-token revoke) | ✓ | ✗ | ✗ | CLI only | +| service | list (pfhub service-token list) | ✓ | ✗ | ✗ | CLI only | +| env | issued/managed | infra operator | ✗ | ✗ | PORTFOLIOHUB_ADMIN_TOKENS 환경 변수 직접 편집 | +| client | — | ✗ | ✗ | ✗ | **폐지됨** (REQ-016/BAT-006) | + +**Admin 자기 발급 정책 (dcy-004 OQ-4)**: admin은 본인 user_id 대상으로 daemon-bound 또는 unbound service token 발급 가능. 관행상 최소 권한 원칙에 따라 운영 daemon 대상 발급을 권장. + +**코드 매핑**: `require_admin` → admin 전용 게이트. `require_read` → read 이상 허용. `AuthService.issue_service_token()` → admin 권한 필요 (라우트에서 `require_admin` 의존성). 불일치 발견 시 별도 follow-up REQ로 분리. + ## Shared Constraints - Token permission levels gate both HTTP and CLI mutations consistently. @@ -361,6 +507,9 @@ ## Change Log +- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시. +- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신. +- 2026-05-21 KST: BAT-008 (REQ-019/020) — 토큰 surface 정합성 회복 + 권한 매트릭스: service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model에 rebind/deactivate/null-daemon CLI 추가; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020). - 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. diff --git a/docs/project-structure.md b/docs/project-structure.md index 07ea226..abb16d5 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-20 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007, bat-003, bat-004, bat-005 +- Last Updated (KST): 2026-05-21 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004) ## Project Summary - PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI. @@ -83,12 +83,15 @@ - 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, 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). + - 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/PATCH/DELETE/GET /v1/portfolios; GET/POST/DELETE /v1/portfolios/{id}/tags; POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments; POST /v1/portfolios/{id}/ledger/trades), `v1_user_info.py` (GET /v1/user_info). + - `v1_ledger.py` 제거됨 (BAT-011/REQ-029): 구 path `POST /v1/assets/{asset_id}/ledger/trades` 삭제, 신 path는 `v1_portfolios.py`로 통합. - 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`, `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`). + - Command groups: `asset` (catalog CRUD via admin), `portfolio`, `strategy`, `tag` (tag master CRUD and apply/remove; BAT-005), `user`, `service-token`, `daemon`, `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/cli/commands/service_tokens.py` (BAT-006/BAT-007): `issue`, `rotate`, `rebind` (daemon_id 변경, runtime_id 불변), `revoke`, `list`, `migrate-null-daemon --dry-run` (NULL daemon_id active token 진단 CLI). + - `app/cli/commands/daemon.py` (BAT-007): `create`, `list`, `update` (`--deactivate --force` 시 바인딩 service token 즉시 revoke). - 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()`. @@ -97,9 +100,10 @@ - app/repositories/ - Provides the repository protocol, repository factory, and concrete Postgres persistence implementation. - Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands. + - Key methods added in BAT-007: `revoke_tokens_by_daemon_id(daemon_id) -> int` (UPDATE auth_tokens WHERE daemon_id=X AND revoked_at IS NULL); `list_service_tokens_with_null_daemon() -> list[AuthToken]` (NULL daemon_id active service token 조회); abstract methods added to `portfolio_repository.py`. - 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, TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse; tag schemas added by BAT-004). + - `v1.py`: Contract schemas for v1 API — TagItem, TagCreateRequest, TagListResponse, TagApplyRequest, TagApplyResponse (BAT-004); AssetItem, AssetsResponse, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response (BAT-001/002); AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse (BAT-002); PortfolioCreateRequest, PortfolioUpdateRequest, PortfolioItem, PortfolioListResponse (BAT-009); PortfolioTagListResponse, PortfolioCommentItem, PortfolioCommentRequest, PortfolioCommentUpdateRequest, PortfolioCommentListResponse (BAT-010). - app/services/ - Implements business logic for auth, portfolios, strategy sets, and users. - migrations/ @@ -132,7 +136,7 @@ | 소유 경로 | 관리 객체 | 비고 | |-----------|-----------|------| -| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index) | Alembic revision에 없음 | +| ensure_schema() 전용 | `idx_auth_tokens_service_name_unique` (partial index, BAT-006/REQ-017: `AND revoked_at IS NULL` 조건 추가) | Alembic revision 20260520_0022에도 CONCURRENTLY 버전 존재 | | Alembic 전용 | `portfolios.market`, `auth_tokens.runtime_id`, `idx_auth_tokens_runtime_id_unique`, `broker_credentials`, `ledger_trades` | ensure_schema()에 없음 — v1 API 전제 조건 | | 양쪽 공유 (IF NOT EXISTS 보호) | users, auth_tokens, strategy_sets, portfolios, portfolio_ledger, portfolio_comments, portfolio_tags + 대부분의 인덱스 | ensure_schema() 우선 실행 후 Alembic 실행 시 충돌 없음 | @@ -162,6 +166,9 @@ ## Change Log +- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: `v1_ledger.py` 제거; `v1_portfolios.py`에 `POST /{id}/ledger/trades` 흡수; `app/main.py` include 정리. Source Discovery/Batch 헤더 갱신. +- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 반영: v1_portfolios.py 책임 갱신 (POST/PATCH/DELETE/GET /v1/portfolios + 태그/댓글 서브 라우트); v1.py schema 목록 갱신 (BAT-009/010 schema 5종 추가); Source Discovery/Batch 헤더 갱신. +- 2026-05-21 KST: BAT-008 (REQ-019) — 패키지 구조 갱신: service-token rebind/migrate-null-daemon CLI 서브커맨드 추가 반영; daemon command group 및 --deactivate auto-revoke 동작 반영; repositories에 revoke_tokens_by_daemon_id / list_service_tokens_with_null_daemon 메서드 추가 반영; Schema Ownership 표의 idx_auth_tokens_service_name_unique 조건 갱신(AND revoked_at IS NULL). - 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. diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index db17bb9..caca44e 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-20 00:00 -- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005 +- Last Updated (KST): 2026-05-21 00:00 +- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004) - Primary Runtime: mixed ## Project Summary @@ -114,6 +114,43 @@ - If already inactive → return HTTP 200 immediately (idempotent). - `repo.deactivate_tag(tag_id)` → HTTP 200 `TagItem`. +## Flow: `v1 API — Portfolio Mutation (POST/PATCH/DELETE /v1/portfolios)` + +1. **POST /v1/portfolios** (create): + - Any valid token; `_require_write_token` blocks service/client (`token_type ∈ {service, client}` → HTTP 403). + - admin with `body.user_id` → creates portfolio for target user; otherwise uses `auth.user_id`. + - `repo.get_asset_by_id(asset_id)` → None or inactive → HTTP 422. + - `repo.get_or_create_strategy_set(strategy_name, strategy_params_json)` → resolves strategy. + - `repo.create_portfolio(...)` with `status="enabled"` → HTTP 201 `PortfolioItem`. +2. **PATCH /v1/portfolios/{portfolio_id}** (update): + - `_require_write_token` blocks service/client → HTTP 403. + - `_get_portfolio_or_404(auth, portfolio_id, repo)` → checks ownership; non-owner non-admin → HTTP 403. + - If `portfolio.status == "disabled"`: non-status fields present → HTTP 409 (only status restoration allowed). + - `repo.update_portfolio(portfolio_id, **fields)` → HTTP 200 `PortfolioItem`. +3. **DELETE /v1/portfolios/{portfolio_id}** (soft delete): + - `_require_write_token` blocks service/client → HTTP 403. + - `_get_portfolio_or_404` → ownership check. + - `repo.soft_delete_portfolio(portfolio_id)` → sets `status="disabled"`, `managed_by_daemon_id=NULL`. + - Dependent data (portfolio_comments, tag_ids) preserved. Portfolio still queryable with `?include_disabled=true`. + - Returns HTTP 200 `PortfolioItem` with `status="disabled"`. + +## Flow: `v1 API — GET /v1/portfolios` + +1. client token → HTTP 403. Any other valid token proceeds. +2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`. +3. `user`/`admin` token: admin with `?user_id=` → filter by that user; else filter by `auth.user_id`. +4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter. +5. Returns HTTP 200 `PortfolioListResponse`. + +## Flow: `v1 API — Soft Delete and Dependent Data Preservation` + +1. `DELETE /v1/portfolios/{portfolio_id}` sets `portfolios.status="disabled"` and `portfolios.managed_by_daemon_id=NULL`. +2. `portfolio_comments` rows keyed by `portfolio_id` are NOT deleted; they remain retrievable via `GET /v1/portfolios/{id}/comments` even after soft delete. +3. `portfolios.tag_ids UUID[]` is NOT cleared; existing tag references are preserved. +4. Disabled portfolios are excluded from `GET /v1/portfolios` responses by default (`?include_disabled=false`). +5. Write mutations (PATCH non-status fields, POST/PATCH/DELETE tags, POST/PATCH/DELETE comments) on a disabled portfolio → HTTP 409. READ operations (GET tags, GET comments) remain allowed. +6. Status restoration: `PATCH /v1/portfolios/{id}` with `{"status": "enabled"}` is the only allowed mutation on a disabled portfolio; this re-enables the portfolio. + ## 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): @@ -127,18 +164,47 @@ - `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. + - DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`). - `repo.get(portfolio_id)` → ValueError → HTTP 404. - Caller must be portfolio owner or admin; else → HTTP 403. + - `portfolio.status == "disabled"` → HTTP 409 (disabled portfolio write blocked). - `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. + - DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`). - `repo.get(portfolio_id)` → ValueError → HTTP 404. - Caller must be portfolio owner or admin; else → HTTP 403. + - `portfolio.status == "disabled"` → HTTP 409. - `repo.remove_portfolio_tag_id(portfolio_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`. +5. **GET /v1/portfolios/{portfolio_id}/tags** (list applied tags — REQ-030, BAT-010): + - `require_read`; service/client → HTTP 403 (inline). + - `_get_portfolio_or_404(auth, portfolio_id, repo)` → ownership check; disabled portfolio allowed (GET). + - `repo.get_tags_by_ids(portfolio.tag_ids)` → dict; empty tag_ids → `PortfolioTagListResponse(tags=[], total=0)`. + - Returns HTTP 200 `PortfolioTagListResponse {"tags": [TagItem], "total": }`. + +## Flow: `v1 API — Portfolio Comment CRUD (REQ-031, BAT-010)` + +1. **POST /v1/portfolios/{portfolio_id}/comments** (create): + - `require_read`; `_require_write_token` blocks service/client → HTTP 403. + - `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409. + - `repo.create_comment(portfolio_id, content=body.content)` → HTTP 201 `PortfolioCommentItem`. +2. **GET /v1/portfolios/{portfolio_id}/comments** (list): + - `require_read`; service/client → HTTP 403 (inline). + - `_get_portfolio_or_404` → ownership check; disabled portfolio GET is allowed. + - `repo.list_comments(portfolio_id)` → list in `created_at desc` order. + - Returns HTTP 200 `PortfolioCommentListResponse {"comments": [...], "total": }`. +3. **PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id}** (update): + - `require_read`; `_require_write_token` blocks service/client → HTTP 403. + - `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409. + - `repo.update_comment(portfolio_id, comment_id, content=body.content)` → ValueError → HTTP 404. + - Returns HTTP 200 `PortfolioCommentItem`. +4. **DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id}** (delete): + - `require_read`; `_require_write_token` blocks service/client → HTTP 403. + - `_get_portfolio_or_404` → ownership check; `portfolio.status == "disabled"` → HTTP 409. + - `repo.delete_comment(portfolio_id, comment_id)` → ValueError → HTTP 404. + - Returns HTTP 204 (no content). ## Flow: `v1 API — DB Token Authentication` @@ -146,6 +212,8 @@ 2. `require_read` / `require_admin` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`. 3. Env tokens → HTTP 403 (all /v1 endpoints require DB tokens). 4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, `runtime_id`, and `daemon_id`. + - `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006) + - `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed ## Flow: `v1 API — GET /v1/assets` @@ -175,25 +243,28 @@ 4. For each credential, Fernet-decrypts `encrypted_payload` using `CREDENTIAL_ENCRYPTION_KEY`; decryption failures log a warning and return `credential_payload: null`. 5. Returns `UserInfoResponse` with `trading_enabled` (from `is_active`), `disabled_reason`, `brokers[]`, `runtime_id` (null if unbound), and `daemon_id` (null for legacy/unbound tokens — diagnostic, not an error). -## Flow: `v1 API — POST /v1/assets/{asset_id}/ledger/trades` +## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011) -1. Service-token auth required with `trade-write` permission. -2. Validates asset existence via `repo.get(asset_id)`; HTTP 404 if missing. -3. Daemon assignment authorization (fail-closed, three checks in order): - a. `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token"). - b. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon"). - c. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment"). -4. Calls `insert_ledger_trade()` with idempotency key `(asset_id, order_id, execution_id)`. -5. If new record: returns HTTP 201 with `status="new"`. -6. If duplicate key with identical payload: returns HTTP 200 with `status="duplicate"`. -7. If duplicate key with different payload: returns HTTP 409 with conflict detail. +1. Service-token auth required with `trade-write` permission (`_require_service_token` dependency). +2. `token_source != "db"` or `token_type != "service"` → HTTP 403. +3. `auth.daemon_id is None` → HTTP 403 fail-closed ("daemon_id not assigned to this token"). +4. `repo.get(portfolio_id)` → exception → HTTP 404. +5. `portfolio.status == "disabled"` → HTTP 409 (disabled write 차단, REQ-027 정합). +6. `portfolio.managed_by_daemon_id is None` → HTTP 403 ("Asset is not assigned to any daemon"). +7. `portfolio.managed_by_daemon_id != auth.daemon_id` → HTTP 403 ("Token daemon does not match asset daemon assignment"). +8. `insert_ledger_trade(asset_id=portfolio_id, ...)` with idempotency key `(portfolio_id, order_id, execution_id)`. +9. New record → HTTP 201 + `status="new"`. +10. Duplicate key + identical payload → HTTP 200 + `status="duplicate"`. +11. Duplicate key + different payload → HTTP 409. +- 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404. ## Flow: `v1 API — POST /auth/service-tokens` 1. Admin token auth required (`require_admin` dependency). -2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`. -3. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id`. -4. Returns HTTP 201 with `token` (raw value), `runtime_id`, and `auth_token` metadata. +2. Validates request body: `name`, `username`, `permission`, `expires_in_days` / `no_expiry`, `daemon_id` (optional; BAT-006/REQ-018 forward 보정). +3. If `daemon_id` provided: `get_daemon_by_id()` → 존재하지 않거나 inactive → HTTP 422. daemon.owner_user_id ≠ 발급 대상 user → HTTP 422. +4. Calls `AuthService.issue_service_token()` which creates a new DB token with `runtime_id` and `daemon_id`. +5. Returns HTTP 201 with `token` (raw value), `runtime_id`, `daemon_id`, and `auth_token` metadata. ## Flow: `Daemon Registry — Create and Bind Service Token` @@ -206,7 +277,25 @@ - `create_auth_token()` persists the token with `daemon_id` set. 3. Operator assigns an asset to the daemon via portfolio update (`managed_by_daemon_id` column set). 4. `GET /v1/assets` with the bound token returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`. -5. `POST /v1/assets/{asset_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade. +5. `POST /v1/portfolios/{portfolio_id}/ledger/trades` with the bound token passes daemon assignment authorization and records the trade (신 path, REQ-029). + +## Flow: `pfhub service-token rebind` (BAT-007/REQ-023) + +1. Operator calls `pfhub service-token rebind --daemon-id `. +2. `AuthService.rebind_service_token(name, daemon_id)`: + a. `get_service_token_by_name(name)` → None → ValueError. + b. `get_daemon_by_id(daemon_id)` → None 또는 is_active=false → ValueError. + c. `daemon.owner_user_id ≠ token.user_id` → ValueError ("해당 토큰 사용자에 속하지 않습니다"). + d. `repository.rebind_service_token(name, daemon_id)` → token.daemon_id 갱신, runtime_id 불변. +3. Returns `(previous_daemon_id, rebound_token)`. + +## Flow: `pfhub daemon update --deactivate` (BAT-007/REQ-022) + +1. Operator calls `pfhub daemon update --deactivate [--force]`. +2. `--force` 없으면 확인 프롬프트; 거절 시 exit 0. +3. `repo.update_daemon(daemon_id, is_active=False)` — daemon is_active=false 설정. +4. `repo.revoke_tokens_by_daemon_id(daemon_id)` — 바인딩된 모든 active service token에 `revoked_at=NOW()` 설정. +5. Console에 revoked count 출력. ## Flow: `Service-Token Rotate — daemon_id Invariant` @@ -273,6 +362,89 @@ 4. **허용 실행 순서**: `init-db` 선행 권장. 불가피하게 ensure_schema() 선행 시, 반드시 `migrate-db` 후속 실행. 5. **금지**: `alembic_version` 테이블 수동 삭제 후 migrate-db 재실행 (legacy stamp 오판). +## Token Expiry and Rotation Policy + +(REQ-021/BAT-008 — dcy-004 FR-6, OQ-5 결정: 현행 TTL 유지) + +### Type별 기본 TTL + +| Token Type | 기본 만료 | no_expiry 허용 | 비고 | +|------------|-----------|----------------|------| +| session | `NOW() + settings.session_token_ttl_hours` | ✗ (거부) | `AuthService.login()` | +| service | `NOW() + 365일` (기본) | ✓ | `AuthService.issue_service_token(no_expiry=True)` | +| env | 없음 (TTL 미적용) | N/A | `PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수 | + +### no_expiry 허용 범위 + +- service token: `pfhub service-token issue --no-expiry` 또는 POST /auth/service-tokens body `"no_expiry": true` → expires_at=NULL 발급. +- session: no_expiry 요청 불가. 요청 시 → ValueError / HTTP 422 거부. + - ⚠ 현행 코드에서 session 경로에 no_expiry 파라미터가 없어 코드 level 거부가 미구현. 정책은 거부이며 follow-up으로 코드 enforcement 추가 예정. +- `no_expiry`와 `expires_in_days`를 동시에 지정하면 ValueError ("만료일 옵션과 무기한 옵션은 동시에 사용할 수 없습니다"). + +### 강제 회전 주기 + +- session / service: 강제 회전 주기 없음. +- env admin token: 분기(3개월) 권장 회전, 강제 없음 (아래 SOP 참조). + +### 정책 위반 처리 + +- no_expiry를 session에 요청 → 정책상 HTTP 422 또는 ValueError 거부 (미구현 — follow-up 필요). +- 코드 reference: `app/services/auth_service.py` `AuthService.issue_service_token()` no_expiry + expires_in_days 분기 검증. + +## env admin token 운영 SOP + +(REQ-024/BAT-008 — dcy-004 FR-9, OQ-6 결정: 분기 권장 회전, 강제 없음) + +### 적용 범위 + +`PORTFOLIOHUB_ADMIN_TOKENS` 환경 변수로 주입되는 admin token. dev/test/prod 모든 환경에 적용. prod 환경 약한 placeholder 발견 시 즉시 적용. + +### 권장 회전 주기 + +분기(3개월). 강제 회전·만료 알람 없음. 운영자 자율 결정. + +### 회전 트리거 조건 + +- (a) 정기: 분기마다 +- (b) token leak 의심 +- (c) 운영자 교체·퇴사 +- (d) 보안 사고 대응 + +### 다단계 회전 절차 (7단계) + +1. 강한 무작위 token 생성: + ``` + python3 -c "import secrets; print(secrets.token_urlsafe(48))" + ``` +2. `PORTFOLIOHUB_ADMIN_TOKENS`에 신규 token을 콤마 분리로 **추가** (다중 token 동시 운영): + ``` + PORTFOLIOHUB_ADMIN_TOKENS=<구_token>,<신규_token> + ``` +3. service rolling restart (구 token 계속 유효). +4. 신규 token으로 admin 호출 검증 (예: `GET /health`, admin 작업 sanity check). +5. 구 token을 `PORTFOLIOHUB_ADMIN_TOKENS`에서 **제거**: + ``` + PORTFOLIOHUB_ADMIN_TOKENS=<신규_token> + ``` +6. service rolling restart (구 token 무효화). +7. evidence 기록: 회전 일자, 운영자, 신규 token 강도 확인, sanity check 결과. + +### rollback 절차 + +- 회전 실패 시: 환경 변수에 구 token 재추가 후 rolling restart. +- fallback admin 접근 경로: DB 직접 접근으로 임시 token 재생성 (비상 시). + +### Token 강도 가이드 + +- 최소 48-byte URL-safe 무작위 (`secrets.token_urlsafe(48)` 이상). +- 평문 단어, 예측 가능한 패턴 (예: `admin-token-789`) 금지. +- dev/test 환경에서도 약한 placeholder 사용 금지. + +### 운영자 권한 + +- `PORTFOLIOHUB_ADMIN_TOKENS` 추가/제거는 인프라 운영자 권한. +- 현행 코드(`app/core/auth.py:_resolve_permission`)가 set 기반으로 다중 token 처리 → 추가 코드 변경 없이 다중 token 동시 운영 가능. + ## Current Gaps / Planned Changes - End-to-end validation against a real PostgreSQL or TimescaleDB deployment is still pending. @@ -281,6 +453,9 @@ ## Change Log +- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신. +- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신. +- 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024). - 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. diff --git a/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md b/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md index b0835b0..f8ef2cc 100644 --- a/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md +++ b/docs/srs/Documentation/req-019_token-surface-baseline-docs-consistency.md @@ -1,6 +1,6 @@ # REQ-019: 토큰 surface baseline 문서 정합성 회복 -- Status: Approved +- Status: Implemented - Type: Documentation - Priority: Medium - Owner: Justin Ko @@ -51,6 +51,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-008 confirm-batch-verification 완료: 모든 AC(AC-1~5) 검증됨(grep + 사람 리뷰). verification.md Status: release-candidate. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-008 (release-candidate) +- Releases: none (미정) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: 없음 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md b/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md index 5c18d89..569ad74 100644 --- a/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md +++ b/docs/srs/Documentation/req-020_token-issuance-authorization-matrix.md @@ -1,6 +1,6 @@ # REQ-020: 토큰 발급 권한 매트릭스 정의 -- Status: Approved +- Status: Implemented - Type: Documentation - Priority: Medium - Owner: Justin Ko @@ -34,6 +34,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-008 confirm-batch-verification 완료: AC-1~4 모두 검증됨(grep + 사람 리뷰). docs/interface-contract.md에 "Token Issuance Authorization Matrix" 섹션 신규 추가 확인. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-008 (release-candidate) +- Releases: none (미정) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: 없음 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md b/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md index e37d728..c904504 100644 --- a/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md +++ b/docs/srs/Documentation/req-021_token-expiry-and-rotation-policy.md @@ -1,6 +1,6 @@ # REQ-021: 토큰 만료·회전 정책 정의 -- Status: Approved +- Status: Implemented - Type: Documentation - Priority: Medium - Owner: Justin Ko @@ -39,6 +39,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-008 confirm-batch-verification 완료: AC-1~5 검증됨(grep + 사람 리뷰). docs/runtime-flows.md에 "Token Expiry and Rotation Policy" 섹션 신규 추가 확인. AC-4(session no_expiry 코드 enforcement) 미구현 → follow-up 기록됨. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-008 (release-candidate) +- Releases: none (미정) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: session no_expiry 코드 enforcement 미구현 — 별도 follow-up REQ 또는 다음 batch에서 처리 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md b/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md index e2672af..cf3e983 100644 --- a/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md +++ b/docs/srs/Documentation/req-024_env-admin-token-rotation-sop.md @@ -1,6 +1,6 @@ # REQ-024: env admin token 운영 SOP 정의 -- Status: Approved +- Status: Implemented - Type: Documentation - Priority: Medium - Owner: Justin Ko @@ -48,6 +48,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-008 confirm-batch-verification 완료: AC-1~4 검증됨(grep + 사람 리뷰). docs/runtime-flows.md에 "env admin token 운영 SOP" 섹션 신규 추가 확인. AC-5(staging dry-run)는 release plan verification 영역으로 분리됨. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-008 (release-candidate) +- Releases: none (미정) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: AC-5(staging dry-run)는 release plan verification 단계에서 수행 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md b/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md index 2fd9a2c..18c0316 100644 --- a/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md +++ b/docs/srs/Documentation/req-032_portfolio-v1-baseline-docs-update.md @@ -1,6 +1,6 @@ # REQ-032: portfolio v1 baseline 문서 갱신 -- Status: Approved +- Status: Implemented - Type: Documentation - Priority: Medium - Owner: Justin Ko @@ -57,6 +57,33 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-010 구현 완료: baseline 4종 일괄 갱신 — `docs/interface-contract.md` (v1 Portfolio API 섹션 신설; tag/comment 서브 라우트 명세; ledger "pending REQ-029" 마킹); `docs/runtime-flows.md` (portfolio mutation flow, GET 분기 flow, soft delete flow, comment CRUD flow 추가); `docs/data-model.md` (Portfolio.status='disabled' 의미 + 의존 데이터 보존); `docs/project-structure.md` (v1_portfolios.py + v1.py 갱신) + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-010 (release-candidate) +- Releases: pending + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: BAT-010 release candidate → draft-release + + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Interface/req-025_v1-portfolios-create.md b/docs/srs/Interface/req-025_v1-portfolios-create.md index be299d4..2f33473 100644 --- a/docs/srs/Interface/req-025_v1-portfolios-create.md +++ b/docs/srs/Interface/req-025_v1-portfolios-create.md @@ -1,6 +1,6 @@ # REQ-025: POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -54,6 +54,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_crud.py AC-1~7 전 케이스 pytest 통과 (2026-05-21, 298 PASSED). + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-009 (release-candidate) +- Releases: TBD + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: 없음 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Interface/req-026_v1-portfolios-update.md b/docs/srs/Interface/req-026_v1-portfolios-update.md index c777ff1..c87e017 100644 --- a/docs/srs/Interface/req-026_v1-portfolios-update.md +++ b/docs/srs/Interface/req-026_v1-portfolios-update.md @@ -1,6 +1,6 @@ # REQ-026: PATCH /v1/portfolios/{portfolio_id} 신설 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -51,6 +51,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_crud.py AC-1~9 전 케이스 pytest 통과 (2026-05-21, 298 PASSED). + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-009 (release-candidate) +- Releases: TBD + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: 없음 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md b/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md index 016a93a..3b627b3 100644 --- a/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md +++ b/docs/srs/Interface/req-027_v1-portfolios-soft-delete.md @@ -1,6 +1,6 @@ # REQ-027: DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -62,6 +62,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_crud.py AC-1~10 전 케이스 pytest 통과 (2026-05-21, 298 PASSED). soft_delete_portfolio: status='disabled', managed_by_daemon_id=NULL, 의존 데이터 보존. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-009 (release-candidate) +- Releases: TBD + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: 없음 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Interface/req-028_v1-portfolios-list.md b/docs/srs/Interface/req-028_v1-portfolios-list.md index 0c2e548..2181a52 100644 --- a/docs/srs/Interface/req-028_v1-portfolios-list.md +++ b/docs/srs/Interface/req-028_v1-portfolios-list.md @@ -1,6 +1,6 @@ # REQ-028: GET /v1/portfolios 신설 (토큰 분기 조회) -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -57,6 +57,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-009 confirm-batch-verification 완료. tests/test_v1_portfolios_list.py AC-1~11 전 케이스 pytest 통과 (2026-05-21, 298 PASSED). 토큰 분기 + status filter + daemon filter 모두 검증. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-009 (release-candidate) +- Releases: TBD + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: promote-to-implemented +- Follow-up Action: 없음 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md b/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md index b6eabec..6e41746 100644 --- a/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md +++ b/docs/srs/Interface/req-029_v1-portfolios-ledger-trades-migration.md @@ -1,6 +1,6 @@ # REQ-029: POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -53,6 +53,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-011 구현 완료: `app/api/v1_portfolios.py`에 `POST /{portfolio_id}/ledger/trades` 신설 (daemon 인가 3단계 + disabled 409 + idempotency 보존); `app/api/v1_ledger.py` 삭제; `app/main.py` include 정리; `tests/test_v1_portfolios_ledger.py` AC-1~10 신규; `tests/test_v1.py` + `tests/test_daemon_assignment.py` 구 path 갱신 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-011 (release-candidate) +- Releases: pending + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: BAT-011 release candidate → draft-release; trex-basic 동시 배포 순서 잠금 필수 + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko @@ -70,14 +96,14 @@ - No change #### Delivery Trace Delta -- Batches: none (미정) +- Batches: BAT-011 (in-delivery) - Releases: none (미정) #### Revalidation Impact - Reverification Needed: no - Existing Implementation Invalidated: no - Status Recommendation: promote-to-approved -- Follow-up Action: batch 계획 편입 +- Follow-up Action: BAT-011 draft-batch-planning 완료 → draft-batch-design 진행 ### CHG-20260520-01 - Date: 2026-05-20 diff --git a/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md b/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md index 1f46b46..9d413f2 100644 --- a/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md +++ b/docs/srs/Interface/req-030_v1-portfolios-tags-crud.md @@ -1,6 +1,6 @@ # REQ-030: portfolio tag 적용/해제/조회 v1 라우트 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: Medium - Owner: Justin Ko @@ -57,6 +57,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-010 구현 완료: `app/api/v1_portfolios.py`에 GET /v1/portfolios/{id}/tags 신설 + POST/DELETE 라우트에 disabled 409 + service/client 403 보강; `app/schemas/v1.py`에 `PortfolioTagListResponse` 추가; `tests/test_v1_portfolios_tags_comments.py` 32 tests (AC-1~11 커버); baseline 4종 갱신(REQ-032) + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-010 (release-candidate) +- Releases: pending + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: BAT-010 release candidate → draft-release + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md b/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md index fb98676..678e859 100644 --- a/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md +++ b/docs/srs/Interface/req-031_v1-portfolios-comments-crud.md @@ -1,6 +1,6 @@ # REQ-031: portfolio comment CRUD v1 라우트 -- Status: Approved +- Status: Implemented - Type: Interface - Priority: Medium - Owner: Justin Ko @@ -54,6 +54,32 @@ ## Change Log +### CHG-20260521-01 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented + +#### Intent +- BAT-010 구현 완료: `app/api/v1_portfolios.py`에 POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments 4종 신설; `app/schemas/v1.py`에 comment schema 4종 추가; `tests/test_v1_portfolios_tags_comments.py` AC-1~11 커버; comment 작성자 ownership은 portfolio ownership만 적용(design decision #3) + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: BAT-010 (release-candidate) +- Releases: pending + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: BAT-010 release candidate → draft-release + ### CHG-20260520-02 - Date: 2026-05-20 - Author: Justin Ko diff --git a/docs/srs/index.md b/docs/srs/index.md index 883585c..e5423fc 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -99,23 +99,26 @@ | REQ-016 | client token 발급 경로 코드 폐지 | Interface | Approved | High | Justin Ko | [req-016](./Interface/req-016_client-token-issuance-deprecation.md) | | REQ-017 | service-token rotate unique index 보강 | Migration | Approved | High | Justin Ko | [req-017](./Migration/req-017_service-token-rotate-unique-index-fix.md) | | REQ-018 | POST /auth/service-tokens body daemon_id forward 보정 | Interface | Approved | High | Justin Ko | [req-018](./Interface/req-018_service-token-http-daemon-id-forward.md) | -| REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Approved | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) | -| REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Approved | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) | -| REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Approved | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) | +| REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Implemented | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) | +| REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Implemented | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) | +| REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Implemented | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) | | REQ-022 | daemon deactivation 시 바인딩 service token 자동 revoke | Interface | Implemented | High | Justin Ko | [req-022](./Interface/req-022_daemon-deactivation-auto-revoke.md) | | REQ-023 | NULL daemon_id legacy token 마이그레이션 CLI | Interface | Implemented | Medium | Justin Ko | [req-023](./Interface/req-023_null-daemon-token-migration-cli.md) | -| REQ-024 | env admin token 운영 SOP 정의 | Documentation | Approved | Medium | Justin Ko | [req-024](./Documentation/req-024_env-admin-token-rotation-sop.md) | -| REQ-025 | POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) | Interface | Approved | High | Justin Ko | [req-025](./Interface/req-025_v1-portfolios-create.md) | -| REQ-026 | PATCH /v1/portfolios/{portfolio_id} 신설 | Interface | Approved | High | Justin Ko | [req-026](./Interface/req-026_v1-portfolios-update.md) | -| REQ-027 | DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) | Interface | Approved | High | Justin Ko | [req-027](./Interface/req-027_v1-portfolios-soft-delete.md) | -| REQ-028 | GET /v1/portfolios 신설 (토큰 분기 조회) | Interface | Approved | High | Justin Ko | [req-028](./Interface/req-028_v1-portfolios-list.md) | -| REQ-029 | POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 | Interface | Approved | High | Justin Ko | [req-029](./Interface/req-029_v1-portfolios-ledger-trades-migration.md) | -| REQ-030 | portfolio tag 적용/해제/조회 v1 라우트 | Interface | Approved | Medium | Justin Ko | [req-030](./Interface/req-030_v1-portfolios-tags-crud.md) | -| REQ-031 | portfolio comment CRUD v1 라우트 | Interface | Approved | Medium | Justin Ko | [req-031](./Interface/req-031_v1-portfolios-comments-crud.md) | -| REQ-032 | portfolio v1 baseline 문서 갱신 | Documentation | Approved | Medium | Justin Ko | [req-032](./Documentation/req-032_portfolio-v1-baseline-docs-update.md) | +| REQ-024 | env admin token 운영 SOP 정의 | Documentation | Implemented | Medium | Justin Ko | [req-024](./Documentation/req-024_env-admin-token-rotation-sop.md) | +| REQ-025 | POST /v1/portfolios 신설 (토큰 분기 + catalog 검증) | Interface | Implemented | High | Justin Ko | [req-025](./Interface/req-025_v1-portfolios-create.md) | +| REQ-026 | PATCH /v1/portfolios/{portfolio_id} 신설 | Interface | Implemented | High | Justin Ko | [req-026](./Interface/req-026_v1-portfolios-update.md) | +| REQ-027 | DELETE /v1/portfolios/{portfolio_id} 신설 (soft delete) | Interface | Implemented | High | Justin Ko | [req-027](./Interface/req-027_v1-portfolios-soft-delete.md) | +| REQ-028 | GET /v1/portfolios 신설 (토큰 분기 조회) | Interface | Implemented | High | Justin Ko | [req-028](./Interface/req-028_v1-portfolios-list.md) | +| REQ-029 | POST /v1/portfolios/{portfolio_id}/ledger/trades 이전 | Interface | Implemented | High | Justin Ko | [req-029](./Interface/req-029_v1-portfolios-ledger-trades-migration.md) | +| REQ-030 | portfolio tag 적용/해제/조회 v1 라우트 | Interface | Implemented | Medium | Justin Ko | [req-030](./Interface/req-030_v1-portfolios-tags-crud.md) | +| REQ-031 | portfolio comment CRUD v1 라우트 | Interface | Implemented | Medium | Justin Ko | [req-031](./Interface/req-031_v1-portfolios-comments-crud.md) | +| REQ-032 | portfolio v1 baseline 문서 갱신 | Documentation | Implemented | Medium | Justin Ko | [req-032](./Documentation/req-032_portfolio-v1-baseline-docs-update.md) | ## Recent Change Log Summary +- 2026-05-21: REQ-030/031/032 Approved → Implemented — BAT-010 confirm-batch-verification 완료. 330/330 PASSED (32 신규 tests in test_v1_portfolios_tags_comments.py). GET /v1/portfolios/{id}/tags 신설; POST/DELETE tag disabled 409 + service/client 403 보강; comment CRUD 4종 신설; baseline 4종 갱신(interface-contract/runtime-flows/data-model/project-structure). +- 2026-05-21: REQ-025/026/027/028 Approved → Implemented — BAT-009 confirm-batch-verification 완료. 298 PASSED (0 failed). test_v1_portfolios_crud.py 22건(REQ-025/026/027), test_v1_portfolios_list.py 10건(REQ-028) 전 AC 통과. +- 2026-05-21: REQ-019/020/021/024 Approved → Implemented — BAT-008 confirm-batch-verification 완료. 모든 grep AC 통과, 사람 리뷰 완료. REQ-021 follow-up: session no_expiry 코드 enforcement 미구현 → 별도 처리. REQ-024 AC-5: staging dry-run은 release plan 단계로 이관. - 2026-05-20: REQ-016~032 (17건) 일괄 Proposed → Approved 전환 — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. 각 REQ에 CHG-20260520-02 entry 추가 - 2026-05-20: REQ-025~032 최초 생성 — dcy-003 (User Portfolio Mutation v1 API 이전, confirmed 2026-05-20) 후 draft-req 실행. POST/PATCH/DELETE/GET /v1/portfolios + ledger path 이전 + tag/comment v1 + baseline 정합성 (FR-1~10 분해, FR-9는 REQ-013 Deprecated로 이미 처리됨) - 2026-05-20: REQ-016~024 최초 생성 — dcy-004 (토큰 발급 및 관리 전면 정비, confirmed 2026-05-20) 후 draft-req 실행. client token 폐지 / rotate index 보강 / HTTP daemon_id forward / baseline 정합성 / 발급 권한 매트릭스 / TTL·회전 정책 / daemon deactivation 자동 revoke / NULL daemon 마이그레이션 CLI / env admin SOP diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py index 8487b09..d9ba233 100644 --- a/tests/test_daemon_assignment.py +++ b/tests/test_daemon_assignment.py @@ -42,6 +42,7 @@ def _portfolio_with_daemon(daemon_id: str | None = "d-1") -> MagicMock: p = MagicMock(spec=Portfolio) p.managed_by_daemon_id = daemon_id p.portfolio_id = "asset-1" + p.status = "enabled" return p @@ -143,7 +144,7 @@ def test_user_info_null_daemon_id_returns_200_with_null(monkeypatch) -> None: # --------------------------------------------------------------------------- -# REQ-008: POST /v1/assets/{asset_id}/ledger/trades daemon assignment authorization +# REQ-008/029: POST /v1/portfolios/{portfolio_id}/ledger/trades daemon assignment authorization # --------------------------------------------------------------------------- def _setup_ledger_mocks(monkeypatch, principal: TokenPrincipal, portfolio: MagicMock, trade: MagicMock) -> tuple[MagicMock, TestClient]: @@ -170,9 +171,9 @@ def test_ledger_trade_403_when_token_has_no_daemon_id(monkeypatch) -> None: mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.post( - "/v1/assets/asset-1/ledger/trades", + "/v1/portfolios/asset-1/ledger/trades", json=_ledger_trade_payload(), headers=_svc_header(), ) @@ -189,9 +190,9 @@ def test_ledger_trade_403_when_asset_has_no_daemon_assignment(monkeypatch) -> No mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.post( - "/v1/assets/asset-1/ledger/trades", + "/v1/portfolios/asset-1/ledger/trades", json=_ledger_trade_payload(), headers=_svc_header(), ) @@ -208,9 +209,9 @@ def test_ledger_trade_403_when_daemon_mismatch(monkeypatch) -> None: mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.post( - "/v1/assets/asset-1/ledger/trades", + "/v1/portfolios/asset-1/ledger/trades", json=_ledger_trade_payload(), headers=_svc_header(), ) @@ -238,9 +239,9 @@ def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None: mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=mock_repo): resp = client.post( - "/v1/assets/asset-1/ledger/trades", + "/v1/portfolios/asset-1/ledger/trades", json=_ledger_trade_payload(), headers=_svc_header(), ) diff --git a/tests/test_v1.py b/tests/test_v1.py index 6f5be11..7fb0e2b 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -571,10 +571,10 @@ def test_v1_ledger_trade_created(monkeypatch) -> None: mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=True) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.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_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.post( - "/v1/assets/005930/ledger/trades", + "/v1/portfolios/005930/ledger/trades", json=_trade_payload(), headers=_service_token_header(), ) @@ -591,10 +591,10 @@ def test_v1_ledger_trade_idempotent_same_payload(monkeypatch) -> None: mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=False) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.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_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.post( - "/v1/assets/005930/ledger/trades", + "/v1/portfolios/005930/ledger/trades", json=_trade_payload(), # same payload headers=_service_token_header(), ) @@ -610,10 +610,10 @@ def test_v1_ledger_trade_conflict_different_payload(monkeypatch) -> None: mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=False) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.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_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.post( - "/v1/assets/005930/ledger/trades", + "/v1/portfolios/005930/ledger/trades", json=_trade_payload(quantity="99.0"), # different quantity → conflict headers=_service_token_header(), ) @@ -622,15 +622,15 @@ def test_v1_ledger_trade_conflict_different_payload(monkeypatch) -> None: def test_v1_ledger_trade_asset_not_found(monkeypatch) -> None: - """Non-existent asset_id → HTTP 404.""" + """Non-existent portfolio_id → HTTP 404.""" trade = _make_ledger_trade() mock_repo = _make_mock_repo_for_ledger(monkeypatch, trade, created=True, found=False) with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ - patch("app.api.v1_ledger.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_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.post( - "/v1/assets/nonexistent/ledger/trades", + "/v1/portfolios/nonexistent/ledger/trades", json=_trade_payload(), headers=_service_token_header(), ) @@ -655,7 +655,7 @@ def test_v1_ledger_trade_requires_trade_write_permission(monkeypatch) -> None: with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) resp = client.post( - "/v1/assets/005930/ledger/trades", + "/v1/portfolios/005930/ledger/trades", json=_trade_payload(), headers=_service_token_header(), ) @@ -1025,40 +1025,3 @@ def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None: assert resp.status_code == 200 assert resp.json()["disabled_reason"] == "account_inactive" - - -# --------------------------------------------------------------------------- -# POST /v1/assets/{asset_id}/ledger/trades -# --------------------------------------------------------------------------- - -def _trade_payload(**overrides) -> dict: - base = { - "order_id": "ord-001", - "execution_id": "exec-001", - "side": "buy", - "quantity": "10.5", - "price": "50000", - "fee_amount": "100", - "tax_amount": "0", - "executed_at": "2026-05-01T10:00:00+09:00", - } - base.update(overrides) - return base - - -def _make_ledger_trade(**kwargs) -> LedgerTrade: - defaults = dict( - ledger_entry_id=1, - asset_id="005930", - order_id="ord-001", - execution_id="exec-001", - side="buy", - quantity=10.5, - price=50000.0, - fee_amount=100.0, - tax_amount=0.0, - executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc), - recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc), - ) - defaults.update(kwargs) - return LedgerTrade(**defaults) diff --git a/tests/test_v1_portfolios_crud.py b/tests/test_v1_portfolios_crud.py new file mode 100644 index 0000000..747f56c --- /dev/null +++ b/tests/test_v1_portfolios_crud.py @@ -0,0 +1,493 @@ +"""Tests for BAT-009: POST/PATCH/DELETE /v1/portfolios (REQ-025, REQ-026, REQ-027).""" + +from __future__ import annotations + +from contextlib import ExitStack +from typing import Any +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import Asset, Portfolio, StrategySet, TokenPrincipal +from app.main import create_app + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ASSET_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +_PORTFOLIO_UUID = "cccccccc-cccc-cccc-cccc-cccccccccccc" +_USER_ID = "user-001" +_OTHER_USER_ID = "user-002" + + +def _make_settings(**kwargs) -> Settings: + return Settings(allowed_origins_raw="http://localhost:3000", **kwargs) + + +def _make_asset(asset_id: str = _ASSET_UUID, status: str = "active") -> Asset: + return Asset(asset_id=asset_id, symbol="005930", markets=["KRX"], status=status) + + +def _make_portfolio( + portfolio_id: str = _PORTFOLIO_UUID, + owner: str = _USER_ID, + status: str = "enabled", +) -> Portfolio: + return Portfolio( + portfolio_id=portfolio_id, + broker="kiwoom", + status=status, + name="test-portfolio", + symbol="005930", + asset_id=_ASSET_UUID, + owner_user_id=owner, + cash_balance=1000.0, + asset_quantity=10.0, + price=50000.0, + profit_buffer=0.0, + tag_ids=[], + managed_by_daemon_id=None, + ) + + +def _make_strategy_set() -> StrategySet: + return StrategySet(id="strat-001", name="target_value", config="{}", nick="tv") + + +class _FakeRepo: + def __init__( + self, + asset: Asset | None = None, + portfolios: dict[str, Portfolio] | None = None, + token_principal: TokenPrincipal | None = None, + ) -> None: + self._asset = asset or _make_asset() + self._portfolios: dict[str, Portfolio] = portfolios or {} + self._token_principal = token_principal + self._strategy_set = _make_strategy_set() + + def close(self) -> None: + pass + + def get_asset_by_id(self, asset_id: str) -> Asset | None: + if self._asset and self._asset.asset_id == asset_id: + return self._asset + return None + + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: + return self._strategy_set + + def create_portfolio( + self, + portfolio_id: str, + *, + owner_user_id: str, + broker: str, + status: str, + name: str, + asset_id: str, + cash_balance: float, + asset_quantity: float, + asset_price: float, + profit_buffer: float, + active_strategy_id: str | None, + avabile_strategies: list[str] | None, + ) -> Portfolio: + p = Portfolio( + portfolio_id=portfolio_id, + broker=broker, + status=status, + name=name, + symbol="005930", + asset_id=asset_id, + owner_user_id=owner_user_id, + cash_balance=cash_balance, + asset_quantity=asset_quantity, + price=asset_price, + profit_buffer=profit_buffer, + tag_ids=[], + ) + self._portfolios[portfolio_id] = p + return p + + def get(self, portfolio_id: str) -> Portfolio: + p = self._portfolios.get(portfolio_id) + if p is None: + raise ValueError(f"포트폴리오를 찾을 수 없습니다: {portfolio_id}") + return p + + def update_portfolio(self, portfolio_id: str, **fields: Any) -> Portfolio: + p = self.get(portfolio_id) + for k, v in fields.items(): + if v is not None and hasattr(p, k): + setattr(p, k, v) + return p + + def soft_delete_portfolio(self, portfolio_id: str) -> Portfolio: + p = self.get(portfolio_id) + p.status = "disabled" + p.managed_by_daemon_id = None + return p + + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: + return self._token_principal + + def list_portfolios(self, **kwargs: Any) -> list[Portfolio]: + return [] + + +def _make_client( + admin_tokens: list[str] | None = None, + trade_write_tokens: list[str] | None = None, + db_enabled: bool = False, +) -> TestClient: + get_settings.cache_clear() + settings = _make_settings( + admin_tokens_raw=",".join(admin_tokens or ["admin-tok"]), + trade_write_tokens_raw=",".join(trade_write_tokens or ["user-tok"]), + db_enabled=db_enabled, + ) + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _with_repo(repo: _FakeRepo, *, db_auth: bool = False) -> ExitStack: + """Patch repo factory for route handlers and optionally for auth middleware.""" + stack = ExitStack() + stack.enter_context( + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) + ) + if db_auth: + stack.enter_context( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) + ) + return stack + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _repo_with_portfolio(owner: str = _USER_ID, status: str = "enabled") -> _FakeRepo: + p = _make_portfolio(owner=owner, status=status) + repo = _FakeRepo(asset=_make_asset()) + repo._portfolios[_PORTFOLIO_UUID] = p + return repo + + +# --------------------------------------------------------------------------- +# REQ-025: POST /v1/portfolios +# --------------------------------------------------------------------------- + +def test_post_admin_creates_for_target_user() -> None: + """REQ-025 AC-1: admin + user_id → portfolio owned by target user.""" + repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) + client = _make_client() + + with _with_repo(repo): + resp = client.post( + "/v1/portfolios", + json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "user_id": _OTHER_USER_ID}, + headers=_auth("admin-tok"), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["owner_user_id"] == _OTHER_USER_ID + assert data["broker"] == "kiwoom" + assert data["status"] == "enabled" + + +def test_post_user_creates_for_self() -> None: + """REQ-025 AC-2: session/env token → owner_user_id == auth.user_id (env: None→'system').""" + repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) + client = _make_client() + + with _with_repo(repo): + resp = client.post( + "/v1/portfolios", + json={"asset_id": _ASSET_UUID, "broker": "kiwoom"}, + headers=_auth("user-tok"), + ) + assert resp.status_code == 201 + assert resp.json()["status"] == "enabled" + + +def test_post_service_token_returns_403() -> None: + """REQ-025 AC-3: service token → HTTP 403.""" + repo = _FakeRepo( + asset=_make_asset(_ASSET_UUID, "active"), + token_principal=TokenPrincipal( + token_id="tid-1", user_id=_USER_ID, permission="trade-write", + token_type="service", daemon_id="d-1", + ), + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.post( + "/v1/portfolios", + json={"asset_id": _ASSET_UUID, "broker": "kiwoom"}, + headers=_auth("svc-raw-token"), + ) + assert resp.status_code == 403 + + +def test_post_inactive_asset_returns_422() -> None: + """REQ-025 AC-5: inactive asset → HTTP 422.""" + repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "inactive")) + client = _make_client() + + with _with_repo(repo): + resp = client.post( + "/v1/portfolios", + json={"asset_id": _ASSET_UUID, "broker": "kiwoom"}, + headers=_auth("admin-tok"), + ) + assert resp.status_code == 422 + + +def test_post_missing_asset_returns_422() -> None: + """REQ-025 AC-5: asset not in catalog → HTTP 422.""" + repo = _FakeRepo(asset=None) + repo._asset = None # type: ignore[assignment] + client = _make_client() + + with _with_repo(repo): + resp = client.post( + "/v1/portfolios", + json={"asset_id": "no-such-uuid", "broker": "kiwoom"}, + headers=_auth("admin-tok"), + ) + assert resp.status_code == 422 + + +def test_post_no_bearer_returns_401() -> None: + """REQ-025 AC-6: no token → HTTP 401.""" + client = _make_client() + resp = client.post("/v1/portfolios", json={"asset_id": _ASSET_UUID, "broker": "kiwoom"}) + assert resp.status_code == 401 + + +def test_post_non_admin_user_id_ignored() -> None: + """REQ-025 AC-7: non-admin + body user_id → user_id ignored (fallback to env user_id = None → 'system').""" + repo = _FakeRepo(asset=_make_asset(_ASSET_UUID, "active")) + client = _make_client() + + with _with_repo(repo): + resp = client.post( + "/v1/portfolios", + json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "user_id": _OTHER_USER_ID}, + headers=_auth("user-tok"), + ) + assert resp.status_code == 201 + assert resp.json()["owner_user_id"] != _OTHER_USER_ID + + +# --------------------------------------------------------------------------- +# REQ-026: PATCH /v1/portfolios/{portfolio_id} +# --------------------------------------------------------------------------- + +def test_patch_admin_updates_any_portfolio() -> None: + """REQ-026 AC-1: admin token → can update any portfolio.""" + repo = _repo_with_portfolio(owner=_OTHER_USER_ID) + client = _make_client() + + with _with_repo(repo): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_UUID}", + json={"broker": "ebest"}, + headers=_auth("admin-tok"), + ) + assert resp.status_code == 200 + assert resp.json()["broker"] == "ebest" + + +def test_patch_user_updates_own_portfolio() -> None: + """REQ-026 AC-2: user token + own portfolio → HTTP 200.""" + repo = _repo_with_portfolio(owner=_USER_ID) + repo._token_principal = TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="session", daemon_id=None, + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_UUID}", + json={"broker": "ebest"}, + headers=_auth("session-token"), + ) + assert resp.status_code == 200 + + +def test_patch_user_other_portfolio_returns_403() -> None: + """REQ-026 AC-3: user token + other user's portfolio → HTTP 403.""" + repo = _repo_with_portfolio(owner=_OTHER_USER_ID) + repo._token_principal = TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="session", daemon_id=None, + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_UUID}", + json={"broker": "ebest"}, + headers=_auth("session-token"), + ) + assert resp.status_code == 403 + + +def test_patch_service_token_returns_403() -> None: + """REQ-026 AC-4: service token → HTTP 403.""" + repo = _repo_with_portfolio() + repo._token_principal = TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="service", daemon_id="d-1", + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_UUID}", + json={"broker": "ebest"}, + headers=_auth("svc-token"), + ) + assert resp.status_code == 403 + + +def test_patch_no_bearer_returns_401() -> None: + """REQ-026 AC-6: no token → HTTP 401.""" + client = _make_client() + resp = client.patch(f"/v1/portfolios/{_PORTFOLIO_UUID}", json={"broker": "ebest"}) + assert resp.status_code == 401 + + +def test_patch_nonexistent_portfolio_returns_404() -> None: + """REQ-026 AC-7: non-existent portfolio_id → HTTP 404.""" + repo = _FakeRepo() + client = _make_client() + + with _with_repo(repo): + resp = client.patch("/v1/portfolios/no-such-id", json={"broker": "ebest"}, headers=_auth("admin-tok")) + assert resp.status_code == 404 + + +def test_patch_disabled_portfolio_non_status_returns_409() -> None: + """REQ-026 AC-8: disabled portfolio + non-status field → HTTP 409.""" + repo = _repo_with_portfolio(status="disabled") + client = _make_client() + + with _with_repo(repo): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_UUID}", + json={"broker": "ebest"}, + headers=_auth("admin-tok"), + ) + assert resp.status_code == 409 + + +def test_patch_disabled_portfolio_status_restore_returns_200() -> None: + """REQ-026 AC-9: disabled portfolio + status='enabled' → HTTP 200 (restore).""" + repo = _repo_with_portfolio(status="disabled") + client = _make_client() + + with _with_repo(repo): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_UUID}", + json={"status": "enabled"}, + headers=_auth("admin-tok"), + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "enabled" + + +# --------------------------------------------------------------------------- +# REQ-027: DELETE /v1/portfolios/{portfolio_id} +# --------------------------------------------------------------------------- + +def test_delete_user_own_active_portfolio_soft_deletes() -> None: + """REQ-027 AC-1: session token + own active portfolio → HTTP 200 + status='disabled'.""" + repo = _repo_with_portfolio(owner=_USER_ID) + repo._token_principal = TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="session", daemon_id=None, + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("session-token")) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "disabled" + assert data["managed_by_daemon_id"] is None + + +def test_delete_admin_other_user_portfolio() -> None: + """REQ-027 AC-3: admin token + other user's portfolio → HTTP 200.""" + repo = _repo_with_portfolio(owner=_OTHER_USER_ID) + client = _make_client() + + with _with_repo(repo): + resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok")) + assert resp.status_code == 200 + assert resp.json()["status"] == "disabled" + + +def test_delete_user_other_portfolio_returns_403() -> None: + """REQ-027 AC-4: session token + other user's portfolio → HTTP 403.""" + repo = _repo_with_portfolio(owner=_OTHER_USER_ID) + repo._token_principal = TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="session", daemon_id=None, + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("session-token")) + assert resp.status_code == 403 + + +def test_delete_service_token_returns_403() -> None: + """REQ-027 AC-5: service token → HTTP 403.""" + repo = _repo_with_portfolio() + repo._token_principal = TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="service", daemon_id="d-1", + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("svc-token")) + assert resp.status_code == 403 + + +def test_delete_no_bearer_returns_401() -> None: + """REQ-027 AC-7: no token → HTTP 401.""" + client = _make_client() + resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}") + assert resp.status_code == 401 + + +def test_delete_nonexistent_portfolio_returns_404() -> None: + """REQ-027 AC-8: non-existent portfolio_id → HTTP 404.""" + repo = _FakeRepo() + client = _make_client() + + with _with_repo(repo): + resp = client.delete("/v1/portfolios/no-such-id", headers=_auth("admin-tok")) + assert resp.status_code == 404 + + +def test_delete_already_disabled_is_idempotent() -> None: + """REQ-027: already disabled portfolio → HTTP 200 (idempotent).""" + repo = _repo_with_portfolio(status="disabled") + client = _make_client() + + with _with_repo(repo): + resp = client.delete(f"/v1/portfolios/{_PORTFOLIO_UUID}", headers=_auth("admin-tok")) + assert resp.status_code == 200 + assert resp.json()["status"] == "disabled" diff --git a/tests/test_v1_portfolios_ledger.py b/tests/test_v1_portfolios_ledger.py new file mode 100644 index 0000000..3631ca4 --- /dev/null +++ b/tests/test_v1_portfolios_ledger.py @@ -0,0 +1,260 @@ +"""Tests for POST /v1/portfolios/{portfolio_id}/ledger/trades (REQ-029, BAT-011).""" + +from __future__ import annotations + +from contextlib import ExitStack +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import LedgerTrade, Portfolio, TokenPrincipal +from app.main import create_app + +_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +_USER_ID = "user-001" +_PORTFOLIO_ID = "pf-0001" +_DAEMON_ID = "daemon-001" +_NEW_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/trades" +_OLD_PATH = f"/v1/assets/{_PORTFOLIO_ID}/ledger/trades" + + +def _make_settings() -> Settings: + get_settings.cache_clear() + return Settings( + db_enabled=True, + credential_encryption_key=_ENC_KEY, + allowed_origins_raw="http://localhost:3000", + ) + + +def _make_client(settings: Settings) -> TestClient: + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _make_portfolio( + *, + status: str = "enabled", + managed_by_daemon_id: str | None = _DAEMON_ID, +) -> Portfolio: + return Portfolio( + portfolio_id=_PORTFOLIO_ID, + broker="kis", + status=status, + name="test-portfolio", + symbol="005930", + asset_id="005930", + owner_user_id=_USER_ID, + cash_balance=1000000.0, + asset_quantity=0.0, + tag_ids=[], + managed_by_daemon_id=managed_by_daemon_id, + ) + + +def _make_trade(**kwargs) -> LedgerTrade: + defaults = dict( + ledger_entry_id=1, + asset_id=_PORTFOLIO_ID, + order_id="ord-001", + execution_id="exec-001", + side="buy", + quantity=10.5, + price=50000.0, + fee_amount=100.0, + tax_amount=0.0, + executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc), + recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc), + ) + defaults.update(kwargs) + return LedgerTrade(**defaults) + + +def _make_service_repo( + *, + daemon_id: str | None = _DAEMON_ID, + portfolio: Portfolio | None = None, + trade: LedgerTrade | None = None, + created: bool = True, +) -> MagicMock: + repo = MagicMock() + principal = TokenPrincipal( + token_id="t1", user_id=_USER_ID, permission="trade-write", + token_type="service", runtime_id="rt-1", daemon_id=daemon_id, + ) + repo.get_token_principal.return_value = principal + repo.get.return_value = portfolio or _make_portfolio() + repo.insert_ledger_trade.return_value = (trade or _make_trade(), created) + return repo + + +def _with_repo(repo: MagicMock) -> ExitStack: + stack = ExitStack() + stack.enter_context( + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) + ) + stack.enter_context( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) + ) + return stack + + +def _trade_payload(**overrides) -> dict: + base = { + "order_id": "ord-001", + "execution_id": "exec-001", + "side": "buy", + "quantity": "10.5", + "price": "50000", + "fee_amount": "100", + "tax_amount": "0", + "executed_at": "2026-05-01T10:00:00+09:00", + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# AC-1: service token + own daemon + active portfolio → 201 + "new" +# --------------------------------------------------------------------------- + +def test_ledger_trade_new() -> None: + settings = _make_settings() + repo = _make_service_repo(created=True) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 201 + body = resp.json() + assert body["status"] == "new" + assert body["asset_id"] == _PORTFOLIO_ID + + +# --------------------------------------------------------------------------- +# AC-2: same key + same payload → 200 + "duplicate" +# --------------------------------------------------------------------------- + +def test_ledger_trade_duplicate_same_payload() -> None: + settings = _make_settings() + repo = _make_service_repo(created=False) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "duplicate" + + +# --------------------------------------------------------------------------- +# AC-3: same key + different payload → 409 +# --------------------------------------------------------------------------- + +def test_ledger_trade_conflict_different_payload() -> None: + settings = _make_settings() + repo = _make_service_repo(created=False) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post( + _NEW_PATH, + json=_trade_payload(quantity="99.0"), + headers={"Authorization": "Bearer svc-token"}, + ) + assert resp.status_code == 409 + + +# --------------------------------------------------------------------------- +# AC-4: service token daemon_id IS NULL → 403 fail-closed +# --------------------------------------------------------------------------- + +def test_ledger_trade_no_daemon_id() -> None: + settings = _make_settings() + repo = _make_service_repo(daemon_id=None) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 403 + repo.insert_ledger_trade.assert_not_called() + + +# --------------------------------------------------------------------------- +# AC-5: portfolio.managed_by_daemon_id IS NULL → 403 fail-closed +# --------------------------------------------------------------------------- + +def test_ledger_trade_portfolio_unassigned() -> None: + settings = _make_settings() + repo = _make_service_repo(portfolio=_make_portfolio(managed_by_daemon_id=None)) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 403 + repo.insert_ledger_trade.assert_not_called() + + +# --------------------------------------------------------------------------- +# AC-6: portfolio.managed_by_daemon_id != auth.daemon_id → 403 fail-closed +# --------------------------------------------------------------------------- + +def test_ledger_trade_daemon_mismatch() -> None: + settings = _make_settings() + repo = _make_service_repo(portfolio=_make_portfolio(managed_by_daemon_id="other-daemon")) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 403 + repo.insert_ledger_trade.assert_not_called() + + +# --------------------------------------------------------------------------- +# AC-7: disabled portfolio → 409 +# --------------------------------------------------------------------------- + +def test_ledger_trade_disabled_portfolio() -> None: + settings = _make_settings() + repo = _make_service_repo(portfolio=_make_portfolio(status="disabled")) + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 409 + repo.insert_ledger_trade.assert_not_called() + + +# --------------------------------------------------------------------------- +# AC-8: non-existent portfolio → 404 +# --------------------------------------------------------------------------- + +def test_ledger_trade_portfolio_not_found() -> None: + settings = _make_settings() + repo = _make_service_repo() + repo.get.side_effect = Exception("not found") + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# AC-9: old path POST /v1/assets/{asset_id}/ledger/trades → 404 +# --------------------------------------------------------------------------- + +def test_old_ledger_path_returns_404() -> None: + settings = _make_settings() + repo = _make_service_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_OLD_PATH, json=_trade_payload(), headers={"Authorization": "Bearer svc-token"}) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# AC-10: no Bearer token → 401 +# --------------------------------------------------------------------------- + +def test_ledger_trade_no_auth() -> None: + settings = _make_settings() + repo = _make_service_repo() + with _with_repo(repo): + client = _make_client(settings) + resp = client.post(_NEW_PATH, json=_trade_payload()) + assert resp.status_code == 401 diff --git a/tests/test_v1_portfolios_list.py b/tests/test_v1_portfolios_list.py new file mode 100644 index 0000000..39830c3 --- /dev/null +++ b/tests/test_v1_portfolios_list.py @@ -0,0 +1,274 @@ +"""Tests for BAT-009: GET /v1/portfolios (REQ-028).""" + +from __future__ import annotations + +from contextlib import ExitStack +from typing import Any +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import Asset, Portfolio, TokenPrincipal +from app.main import create_app + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_ASSET_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +_DAEMON_ID = "daemon-001" +_USER_ID = "user-001" +_OTHER_USER_ID = "user-002" + + +def _make_settings(**kwargs) -> Settings: + return Settings(allowed_origins_raw="http://localhost:3000", **kwargs) + + +def _make_asset(markets: list[str] | None = None) -> Asset: + return Asset(asset_id=_ASSET_UUID, symbol="005930", markets=markets or ["KRX"], status="active") + + +def _make_portfolio( + portfolio_id: str, + owner: str = _USER_ID, + status: str = "enabled", + daemon_id: str | None = None, +) -> Portfolio: + return Portfolio( + portfolio_id=portfolio_id, + broker="kiwoom", + status=status, + name=f"p-{portfolio_id[:4]}", + symbol="005930", + asset_id=_ASSET_UUID, + owner_user_id=owner, + cash_balance=0.0, + asset_quantity=0.0, + price=0.0, + profit_buffer=0.0, + tag_ids=[], + managed_by_daemon_id=daemon_id, + ) + + +class _FakeListRepo: + def __init__( + self, + portfolios: list[Portfolio] | None = None, + token_principal: TokenPrincipal | None = None, + ) -> None: + self._portfolios: list[Portfolio] = portfolios or [] + self._token_principal = token_principal + + def close(self) -> None: + pass + + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: + return self._token_principal + + def list_portfolios( + self, + *, + owner_user_id: str | None = None, + managed_by_daemon_id: str | None = None, + market: str | None = None, + status: str | None = None, + **_kwargs: Any, + ) -> list[Portfolio]: + results = self._portfolios + if owner_user_id is not None: + results = [p for p in results if p.owner_user_id == owner_user_id] + if managed_by_daemon_id is not None: + results = [p for p in results if p.managed_by_daemon_id == managed_by_daemon_id] + if status is not None: + results = [p for p in results if p.status == status] + return results + + +def _make_client( + admin_tokens: list[str] | None = None, + db_enabled: bool = False, +) -> TestClient: + get_settings.cache_clear() + settings = _make_settings( + admin_tokens_raw=",".join(admin_tokens or ["admin-tok"]), + trade_write_tokens_raw="user-tok", + db_enabled=db_enabled, + ) + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _with_repo(repo: _FakeListRepo, *, db_auth: bool = False) -> ExitStack: + """Patch repo factory for route handlers and optionally for auth middleware.""" + stack = ExitStack() + stack.enter_context( + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) + ) + if db_auth: + stack.enter_context( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) + ) + return stack + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# REQ-028: GET /v1/portfolios +# --------------------------------------------------------------------------- + +def test_get_service_token_daemon_bound_returns_own_portfolios() -> None: + """REQ-028 AC-1: service token(daemon-bound) → portfolios with managed_by_daemon_id=auth.daemon_id.""" + p1 = _make_portfolio("p-001", daemon_id=_DAEMON_ID) + p2 = _make_portfolio("p-002", daemon_id="other-daemon") + repo = _FakeListRepo( + portfolios=[p1, p2], + token_principal=TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="service", daemon_id=_DAEMON_ID, + ), + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.get("/v1/portfolios", headers=_auth("svc-tok")) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["portfolios"][0]["portfolio_id"] == "p-001" + + +def test_get_service_token_no_daemon_returns_403() -> None: + """REQ-028 AC-2: service token(no daemon_id) → HTTP 403.""" + repo = _FakeListRepo( + token_principal=TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="service", daemon_id=None, + ), + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.get("/v1/portfolios", headers=_auth("svc-tok")) + assert resp.status_code == 403 + + +def test_get_user_token_returns_own_portfolios() -> None: + """REQ-028 AC-3: session token + trade-write → own portfolios only.""" + p1 = _make_portfolio("p-001", owner=_USER_ID) + p2 = _make_portfolio("p-002", owner=_OTHER_USER_ID) + repo = _FakeListRepo( + portfolios=[p1, p2], + token_principal=TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="trade-write", + token_type="session", daemon_id=None, + ), + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + # Use a token not in env lists so it falls through to DB lookup + resp = client.get("/v1/portfolios", headers=_auth("session-db-tok")) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["portfolios"][0]["owner_user_id"] == _USER_ID + + +def test_get_admin_default_returns_own_portfolios() -> None: + """REQ-028 AC-4: admin token (no ?user_id) → own portfolios (env token user_id=None → no filter → all).""" + p1 = _make_portfolio("p-001", owner=_USER_ID) + p2 = _make_portfolio("p-002", owner=_OTHER_USER_ID) + repo = _FakeListRepo(portfolios=[p1, p2]) + client = _make_client() + + with _with_repo(repo): + resp = client.get("/v1/portfolios", headers=_auth("admin-tok")) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + + +def test_get_admin_with_user_id_returns_target_portfolios() -> None: + """REQ-028 AC-5: admin token + ?user_id= → target user's portfolios.""" + p1 = _make_portfolio("p-001", owner=_USER_ID) + p2 = _make_portfolio("p-002", owner=_OTHER_USER_ID) + repo = _FakeListRepo(portfolios=[p1, p2]) + client = _make_client() + + with _with_repo(repo): + resp = client.get(f"/v1/portfolios?user_id={_OTHER_USER_ID}", headers=_auth("admin-tok")) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["portfolios"][0]["owner_user_id"] == _OTHER_USER_ID + + +def test_get_excludes_disabled_by_default() -> None: + """REQ-028 AC-7: no ?include_disabled → disabled portfolios excluded.""" + active = _make_portfolio("p-001", status="enabled") + disabled = _make_portfolio("p-002", status="disabled") + repo = _FakeListRepo(portfolios=[active, disabled]) + client = _make_client() + + with _with_repo(repo): + resp = client.get("/v1/portfolios", headers=_auth("admin-tok")) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["portfolios"][0]["status"] == "enabled" + + +def test_get_include_disabled_returns_all() -> None: + """REQ-028 AC-8: ?include_disabled=true → all portfolios returned.""" + active = _make_portfolio("p-001", status="enabled") + disabled = _make_portfolio("p-002", status="disabled") + repo = _FakeListRepo(portfolios=[active, disabled]) + client = _make_client() + + with _with_repo(repo): + resp = client.get("/v1/portfolios?include_disabled=true", headers=_auth("admin-tok")) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + + +def test_get_client_token_returns_403() -> None: + """REQ-028 AC-9: client token → HTTP 403.""" + repo = _FakeListRepo( + token_principal=TokenPrincipal( + token_id="tid", user_id=_USER_ID, permission="read", + token_type="client", daemon_id=None, + ), + ) + client = _make_client(db_enabled=True) + + with _with_repo(repo, db_auth=True): + resp = client.get("/v1/portfolios", headers=_auth("client-tok")) + assert resp.status_code == 403 + + +def test_get_no_bearer_returns_401() -> None: + """REQ-028 AC-10: no token → HTTP 401.""" + client = _make_client() + resp = client.get("/v1/portfolios") + assert resp.status_code == 401 + + +def test_get_response_has_asset_id_field() -> None: + """REQ-028 AC-11: response portfolios reference assets.asset_id (UUID).""" + p = _make_portfolio("p-001") + repo = _FakeListRepo(portfolios=[p]) + client = _make_client() + + with _with_repo(repo): + resp = client.get("/v1/portfolios", headers=_auth("admin-tok")) + assert resp.status_code == 200 + item = resp.json()["portfolios"][0] + assert item["asset_id"] == _ASSET_UUID diff --git a/tests/test_v1_portfolios_tags_comments.py b/tests/test_v1_portfolios_tags_comments.py new file mode 100644 index 0000000..f6de301 --- /dev/null +++ b/tests/test_v1_portfolios_tags_comments.py @@ -0,0 +1,704 @@ +"""Tests for BAT-010: portfolio sub-resources — tags/comments (REQ-030, REQ-031).""" + +from __future__ import annotations + +from contextlib import ExitStack +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import Portfolio, PortfolioComment, Tag, TokenPrincipal +from app.main import create_app + +_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +_USER_ID = "user-001" +_ADMIN_ID = "admin-001" +_OTHER_USER_ID = "user-002" +_PORTFOLIO_ID = "pf-0001" +_TAG_ID_1 = "aaaaaaaa-0000-0000-0000-000000000001" +_COMMENT_ID_1 = "cccccccc-0000-0000-0000-000000000001" +_COMMENT_ID_2 = "cccccccc-0000-0000-0000-000000000002" + + +# --------------------------------------------------------------------------- +# Domain factories +# --------------------------------------------------------------------------- + +def _make_portfolio( + *, + portfolio_id: str = _PORTFOLIO_ID, + owner_user_id: str = _USER_ID, + status: str = "enabled", + tag_ids: list[str] | None = None, +) -> Portfolio: + return Portfolio( + portfolio_id=portfolio_id, + broker="kis", + status=status, + name="Test Portfolio", + symbol="TEST", + asset_id="asset-001", + owner_user_id=owner_user_id, + tag_ids=tag_ids or [], + ) + + +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_comment( + *, + comment_id: str = _COMMENT_ID_1, + portfolio_id: str = _PORTFOLIO_ID, + content: str = "Great portfolio", +) -> PortfolioComment: + return PortfolioComment( + id=comment_id, + portfolio_id=portfolio_id, + content=content, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + +# --------------------------------------------------------------------------- +# Test infrastructure +# --------------------------------------------------------------------------- + +def _make_settings() -> Settings: + get_settings.cache_clear() + return Settings( + db_enabled=True, + credential_encryption_key=_ENC_KEY, + allowed_origins_raw="http://localhost:3000", + ) + + +def _make_client(settings: Settings) -> TestClient: + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app) + + +def _make_db_repo( + *, + user_id: str = _USER_ID, + permission: str = "trade-write", + token_type: str = "user", + daemon_id: str | None = None, + portfolio: Portfolio | None = None, + tag: Tag | None = None, + tags_dict: dict | None = None, + comments: list[PortfolioComment] | None = None, + comment: PortfolioComment | None = None, +) -> MagicMock: + repo = MagicMock() + principal = TokenPrincipal( + token_id="t1", + user_id=user_id, + permission=permission, + token_type=token_type, + runtime_id="rt-1", + daemon_id=daemon_id, + ) + repo.get_token_principal.return_value = principal + repo.get.return_value = portfolio or _make_portfolio() + repo.get_tag_by_id.return_value = tag or _make_tag() + repo.get_tags_by_ids.return_value = tags_dict if tags_dict is not None else {} + repo.append_portfolio_tag_id.return_value = None + repo.remove_portfolio_tag_id.return_value = None + repo.list_comments.return_value = comments or [] + repo.create_comment.return_value = comment or _make_comment() + repo.update_comment.return_value = comment or _make_comment() + repo.delete_comment.return_value = None + return repo + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _with_repo(repo: MagicMock) -> ExitStack: + stack = ExitStack() + stack.enter_context( + patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo) + ) + stack.enter_context( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo) + ) + return stack + + +# =========================================================================== +# REQ-030: portfolio tag routes +# =========================================================================== + +# AC-1: session token + 본인 portfolio + admin tag → POST 200 +def test_post_tag_admin_tag_200() -> None: + settings = _make_settings() + client = _make_client(settings) + tag = _make_tag(is_admin_tag=True, created_by_user_id=_ADMIN_ID) + repo = _make_db_repo(tag=tag) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + repo.append_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1) + + +# AC-2: session token + 본인 portfolio + 본인 생성 active tag → POST 200 +def test_post_tag_own_tag_200() -> None: + settings = _make_settings() + client = _make_client(settings) + tag = _make_tag(is_admin_tag=False, created_by_user_id=_USER_ID) + repo = _make_db_repo(tag=tag) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + + +# AC-3: session token + 타 user 생성 non-admin tag → POST 403 +def test_post_tag_other_user_non_admin_tag_403() -> None: + settings = _make_settings() + client = _make_client(settings) + tag = _make_tag(is_admin_tag=False, created_by_user_id=_OTHER_USER_ID) + repo = _make_db_repo(tag=tag) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 403 + + +# AC-4: is_active=FALSE tag → POST 403 +def test_post_tag_inactive_tag_403() -> None: + settings = _make_settings() + client = _make_client(settings) + tag = _make_tag(is_active=False) + repo = _make_db_repo(tag=tag) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 403 + + +# AC-5: admin token + 타 user portfolio + admin tag → POST 200 +def test_post_tag_admin_other_portfolio_admin_tag_200() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID) + tag = _make_tag(is_admin_tag=True, created_by_user_id=_ADMIN_ID) + repo = _make_db_repo(user_id=_ADMIN_ID, permission="admin", portfolio=portfolio, tag=tag) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-admin-tok"), + ) + + assert resp.status_code == 200 + + +# AC-6: session token + 타 user portfolio → POST 403 +def test_post_tag_other_portfolio_403() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID) + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 403 + + +# AC-7: service token (DB) → POST 403 +def test_post_tag_service_token_403() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo(token_type="service", permission="trade-write") + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-svc-tok"), + ) + + assert resp.status_code == 403 + + +# AC-7 (client): client token → POST 403 +def test_post_tag_client_token_403() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo(token_type="client", permission="trade-write") + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-cli-tok"), + ) + + assert resp.status_code == 403 + + +# AC-8: disabled portfolio + POST → 409 +def test_post_tag_disabled_portfolio_409() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(status="disabled") + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + json={"tag_id": _TAG_ID_1}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 409 + + +# AC-8 (DELETE): disabled portfolio + DELETE → 409 +def test_delete_tag_disabled_portfolio_409() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(status="disabled") + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.delete( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags/{_TAG_ID_1}", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 409 + + +# AC-9: DELETE tag from own portfolio → 200 +def test_delete_tag_own_portfolio_200() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + + with _with_repo(repo): + resp = client.delete( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags/{_TAG_ID_1}", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + repo.remove_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1) + + +# AC-10: GET tags → 200 + tag list +def test_get_portfolio_tags_200() -> None: + settings = _make_settings() + client = _make_client(settings) + tag = _make_tag() + repo = _make_db_repo( + portfolio=_make_portfolio(tag_ids=[_TAG_ID_1]), + tags_dict={_TAG_ID_1: tag}, + ) + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["tags"][0]["tag_id"] == _TAG_ID_1 + assert data["tags"][0]["name"] == "momentum" + + +# AC-10 (empty): GET tags on portfolio with no tags → 200 + empty list +def test_get_portfolio_tags_empty_200() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo(portfolio=_make_portfolio(tag_ids=[]), tags_dict={}) + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/tags", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + assert resp.json() == {"tags": [], "total": 0} + + +# AC-11: non-existent portfolio_id → 404 +def test_get_tags_nonexistent_portfolio_404() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + repo.get.side_effect = ValueError("not found") + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/nonexistent/tags", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 404 + + +# =========================================================================== +# REQ-031: portfolio comment routes +# =========================================================================== + +# AC-1: session token + 본인 portfolio → POST 201 +def test_post_comment_201() -> None: + settings = _make_settings() + client = _make_client(settings) + comment = _make_comment() + repo = _make_db_repo(comment=comment) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "Great portfolio"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["comment_id"] == _COMMENT_ID_1 + assert data["portfolio_id"] == _PORTFOLIO_ID + assert data["content"] == "Great portfolio" + repo.create_comment.assert_called_once_with(_PORTFOLIO_ID, content="Great portfolio") + + +# AC-2: GET comments → 200 + list (created_at desc from repo) +def test_get_comments_200() -> None: + settings = _make_settings() + client = _make_client(settings) + comments = [ + _make_comment(comment_id=_COMMENT_ID_1, content="First"), + _make_comment(comment_id=_COMMENT_ID_2, content="Second"), + ] + repo = _make_db_repo(comments=comments) + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["comments"]) == 2 + + +# AC-3: PATCH comment → 200 + updated content +def test_patch_comment_200() -> None: + settings = _make_settings() + client = _make_client(settings) + updated = _make_comment(content="Updated comment") + repo = _make_db_repo(comment=updated) + + with _with_repo(repo): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}", + json={"content": "Updated comment"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + assert resp.json()["content"] == "Updated comment" + repo.update_comment.assert_called_once_with(_PORTFOLIO_ID, _COMMENT_ID_1, content="Updated comment") + + +# AC-4: DELETE comment → 204 +def test_delete_comment_204() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + + with _with_repo(repo): + resp = client.delete( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 204 + repo.delete_comment.assert_called_once_with(_PORTFOLIO_ID, _COMMENT_ID_1) + + +# AC-5: session token + 타 user portfolio → 403 +def test_post_comment_other_portfolio_403() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID) + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "Trying to comment"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 403 + + +# AC-6: service token → POST 403 +def test_post_comment_service_token_403() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo(token_type="service", permission="read") + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "Service comment"}, + headers=_auth("db-svc-tok"), + ) + + assert resp.status_code == 403 + + +# AC-6 (client): client token → POST 403 +def test_post_comment_client_token_403() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo(token_type="client", permission="read") + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "Client comment"}, + headers=_auth("db-cli-tok"), + ) + + assert resp.status_code == 403 + + +# AC-6 (GET): service token → GET comments 403 +def test_get_comments_service_token_403() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo(token_type="service", permission="read") + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + headers=_auth("db-svc-tok"), + ) + + assert resp.status_code == 403 + + +# AC-7: disabled portfolio + POST → 409 +def test_post_comment_disabled_409() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(status="disabled") + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "Comment on disabled"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 409 + + +# AC-7 (PATCH): disabled portfolio → 409 +def test_patch_comment_disabled_409() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(status="disabled") + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}", + json={"content": "Update on disabled"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 409 + + +# AC-7 (DELETE): disabled portfolio → 409 +def test_delete_comment_disabled_409() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(status="disabled") + repo = _make_db_repo(portfolio=portfolio) + + with _with_repo(repo): + resp = client.delete( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments/{_COMMENT_ID_1}", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 409 + + +# AC-8: disabled portfolio + GET → 200 (조회는 허용) +def test_get_comments_disabled_portfolio_200() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(status="disabled") + comments = [_make_comment()] + repo = _make_db_repo(portfolio=portfolio, comments=comments) + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + +# AC-9: admin token + 타 user portfolio → POST 201 +def test_post_comment_admin_other_portfolio_201() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID) + comment = _make_comment() + repo = _make_db_repo(user_id=_ADMIN_ID, permission="admin", portfolio=portfolio, comment=comment) + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "Admin comment"}, + headers=_auth("db-admin-tok"), + ) + + assert resp.status_code == 201 + + +# AC-9 (admin GET): admin token + 타 user portfolio → GET 200 +def test_get_comments_admin_other_portfolio_200() -> None: + settings = _make_settings() + client = _make_client(settings) + portfolio = _make_portfolio(owner_user_id=_OTHER_USER_ID) + repo = _make_db_repo(user_id=_ADMIN_ID, permission="admin", portfolio=portfolio) + + with _with_repo(repo): + resp = client.get( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + headers=_auth("db-admin-tok"), + ) + + assert resp.status_code == 200 + + +# AC-10: Bearer token 없음 → 401 +def test_post_comment_no_auth_401() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments", + json={"content": "No auth"}, + ) + + assert resp.status_code == 401 + + +# AC-11: non-existent comment_id on PATCH → 404 +def test_patch_comment_nonexistent_404() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + repo.update_comment.side_effect = ValueError("댓글을 찾을 수 없습니다") + + with _with_repo(repo): + resp = client.patch( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments/nonexistent", + json={"content": "Update"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 404 + + +# AC-11 (DELETE): non-existent comment_id → 404 +def test_delete_comment_nonexistent_404() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + repo.delete_comment.side_effect = ValueError("댓글을 찾을 수 없습니다") + + with _with_repo(repo): + resp = client.delete( + f"/v1/portfolios/{_PORTFOLIO_ID}/comments/nonexistent", + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 404 + + +# AC-11 (portfolio not found): non-existent portfolio_id → 404 +def test_post_comment_nonexistent_portfolio_404() -> None: + settings = _make_settings() + client = _make_client(settings) + repo = _make_db_repo() + repo.get.side_effect = ValueError("portfolio를 찾을 수 없습니다") + + with _with_repo(repo): + resp = client.post( + f"/v1/portfolios/nonexistent/comments", + json={"content": "Comment"}, + headers=_auth("db-user-tok"), + ) + + assert resp.status_code == 404