feat: v1 portfolio mutation, sub-resources, ledger path migration (BAT-008~011)

dcy-003/004 기반 v1 API surface 완성 및 BAT-008~011 구현·검증 완료:

- BAT-008 (REQ-019~021,024): 토큰 surface baseline 문서 갱신
- BAT-009 (REQ-025~028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
  - _require_write_token, _get_portfolio_or_404, soft delete, daemon-scoped list
- BAT-010 (REQ-030~032): portfolio tag/comment 서브 라우트 신설 + baseline 갱신
  - GET/POST/DELETE /v1/portfolios/{id}/tags
  - POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments
- BAT-011 (REQ-029): ledger path migration
  - POST /v1/assets/{asset_id}/ledger/trades → /v1/portfolios/{portfolio_id}/ledger/trades
  - v1_ledger.py 제거, disabled 차단(409) 추가
  - test_v1.py / test_daemon_assignment.py 구 path 갱신
- 신규 tests: test_v1_portfolios_crud.py, test_v1_portfolios_list.py,
  test_v1_portfolios_tags_comments.py, test_v1_portfolios_ledger.py
- 전체 suite: 340 passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 07:25:39 +00:00
co-authored by Claude Sonnet 4.6
parent c815ce39b6
commit 4bf6b554a7
50 changed files with 3989 additions and 249 deletions
-119
View File
@@ -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,
)
+496 -4
View File
@@ -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,
)
-2
View File
@@ -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)
+2
View File
@@ -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,
*,
+15
View File
@@ -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,
+80
View File
@@ -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)
# ---------------------------------------------------------------------------
@@ -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 흐름 보강
@@ -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 단계에서 수행
@@ -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) — 코드 변경 반영 근거 확보됨.
@@ -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는 키워드 존재 여부만 확인 → 정책 정확도는 사람 리뷰로 보완
@@ -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에 포함 예정
@@ -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)에서 처리
@@ -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 테스트 통과 완료
@@ -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 기반 스키마 + 토큰 분기 정책 확정.
@@ -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` 메서드 추가 필요.
@@ -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: 없음
@@ -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 보존) 추가
@@ -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으로 커버됨.
@@ -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 패턴 확립.
@@ -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` 테이블 존재 여부 확인 필요
@@ -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:
- 없음
@@ -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 차단 순서 갱신
@@ -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 배포).
@@ -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) ✅
@@ -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 단계에서 사전 파악 필요.
@@ -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 없음.
+4
View File
@@ -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/) |
+17 -11
View File
@@ -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.
+155 -6
View File
@@ -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 `<name>` (positional) + `--user <username>` instead of raw portfolio_id; `comment` sub-commands accept portfolio_id (UUID) directly
@@ -105,20 +107,24 @@
- All /v1 endpoints require `Authorization: Bearer <token>` 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=<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=<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 <name> --daemon-id <id>` — daemon_id 변경, runtime_id 불변. 대상 daemon은 active이어야 하고 token 소유 user와 일치해야 한다.
- **Daemon deactivation** (BAT-007/REQ-022): `pfhub daemon update <daemon_id> --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 <token>` 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": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_name": "<str|null>", "strategy_params": {<dict>}, "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (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": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_name": "<str|null>", "strategy_params": {<dict|null>}, "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
- 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=<code>`; `?user_id=<str>` (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=<id>`
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [...], "total": <int>}`
### 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": <int>}`
- 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": "<uuid>"}`
- Response: HTTP 200 `TagApplyResponse {"tag_id": "<uuid>", "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": "<uuid>", "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": "<str>"}` (min_length=1)
- Response: HTTP 201 `PortfolioCommentItem {"comment_id": "<uuid>", "portfolio_id": "<uuid>", "content": "<str>", "created_at": "<ISO8601|null>", "updated_at": "<ISO8601|null>"}`
- 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": <int>}` (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": "<str>"}` (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": "<uuid>", "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": "<str>", "username": "<str>", "permission": "read|trade-write", "expires_in_days": <int|null>, "no_expiry": <bool>, "daemon_id": "<uuid|null>"}`
- Response: HTTP 201 `{"token": "<raw>", "runtime_id": "<str>", "daemon_id": "<str|null>", ...}`
## 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.
+13 -6
View File
@@ -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.
+194 -19
View File
@@ -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=<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": <int>}`.
## 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": <int>}`.
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 <name> --daemon-id <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 <daemon_id> --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 <name> --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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+15 -12
View File
@@ -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
+10 -9
View File
@@ -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(),
)
+10 -47
View File
@@ -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)
+493
View File
@@ -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"
+260
View File
@@ -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
+274
View File
@@ -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> → 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
+704
View File
@@ -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