feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)
This commit is contained in:
+185
-63
@@ -1,4 +1,4 @@
|
||||
"""GET /v1/assets — service-token authenticated asset list endpoint."""
|
||||
"""v1 asset endpoints — portfolio list (service/user/admin) and admin catalog CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,24 +7,22 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.core.auth import AuthContext, require_admin, require_read
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
from app.schemas.v1 import AssetItem, AssetsResponse
|
||||
from app.schemas.v1 import (
|
||||
AssetCatalogCreateRequest,
|
||||
AssetCatalogResponse,
|
||||
AssetCatalogUpdateRequest,
|
||||
AssetItem,
|
||||
AssetsResponse,
|
||||
TagRefItem,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
def _require_service_token(auth: AuthContext = Depends(require_read)) -> 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 _parse_strategy_params(strategy_set_config: str | None) -> dict:
|
||||
if not strategy_set_config:
|
||||
return {}
|
||||
@@ -35,70 +33,194 @@ def _parse_strategy_params(strategy_set_config: str | None) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings
|
||||
return get_portfolio_repository_from_settings(settings or get_settings())
|
||||
|
||||
|
||||
def _asset_response(asset) -> AssetCatalogResponse:
|
||||
return AssetCatalogResponse(
|
||||
asset_id=asset.asset_id,
|
||||
symbol=asset.symbol,
|
||||
markets=asset.markets,
|
||||
display_name=asset.display_name,
|
||||
status=asset.status,
|
||||
created_at=asset.created_at,
|
||||
updated_at=asset.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_tag_refs(tag_ids: list[str] | None, active_map: dict[str, bool]) -> list[TagRefItem] | None:
|
||||
if not tag_ids:
|
||||
return None
|
||||
return [
|
||||
TagRefItem(tag_id=tid, archived=not active_map.get(tid, True))
|
||||
for tid in tag_ids
|
||||
]
|
||||
|
||||
|
||||
def _build_asset_items(portfolios, status_filter: str | None, repo) -> list[AssetItem]:
|
||||
# Batch-fetch tag active status for all tag_ids across all portfolios
|
||||
all_tag_ids: list[str] = [
|
||||
tid for p in portfolios for tid in (p.tag_ids or [])
|
||||
]
|
||||
tag_active_map: dict[str, bool] = {}
|
||||
if all_tag_ids:
|
||||
try:
|
||||
tags_by_id = repo.get_tags_by_ids(all_tag_ids)
|
||||
tag_active_map = {tid: t.is_active for tid, t in tags_by_id.items()}
|
||||
except Exception:
|
||||
logger.warning("tag active status lookup failed")
|
||||
|
||||
items = []
|
||||
for p in portfolios:
|
||||
if status_filter is not None and p.status != status_filter:
|
||||
continue
|
||||
strategy_params: dict = {}
|
||||
strategy_type: str | None = None
|
||||
if p.active_strategy_id:
|
||||
try:
|
||||
ss = repo.get_strategy_set(p.active_strategy_id)
|
||||
if ss is not None:
|
||||
strategy_params = _parse_strategy_params(ss.config)
|
||||
strategy_type = ss.name
|
||||
except Exception:
|
||||
logger.warning("strategy_set lookup failed for %s", p.active_strategy_id)
|
||||
items.append(
|
||||
AssetItem(
|
||||
asset_id=p.portfolio_id,
|
||||
symbol=p.symbol,
|
||||
status=p.status,
|
||||
cash=p.cash_balance,
|
||||
quantity=p.asset_quantity,
|
||||
broker_type=p.broker,
|
||||
strategy_id=p.active_strategy_id,
|
||||
strategy_type=strategy_type,
|
||||
strategy_params=strategy_params,
|
||||
updated_at=p.updated_at,
|
||||
name=p.name,
|
||||
price=p.price,
|
||||
profit_buffer=p.profit_buffer,
|
||||
tags=p.tags,
|
||||
tag_ids=_build_tag_refs(p.tag_ids, tag_active_map),
|
||||
owner_user_id=p.owner_user_id,
|
||||
created_at=p.created_at,
|
||||
last_trade_at=p.last_trade_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/assets — token-type-branched portfolio list (REQ-013)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/assets", response_model=AssetsResponse)
|
||||
def list_assets(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
market: str | None = Query(default=None),
|
||||
user_id: str | None = Query(default=None),
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> AssetsResponse:
|
||||
if auth.daemon_id is None:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="daemon_id not assigned to this token",
|
||||
detail="DB token required",
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
repo = _get_repo()
|
||||
try:
|
||||
portfolios = repo.list_portfolios_with_strategies(
|
||||
owner_user_id=auth.user_id,
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id=auth.daemon_id,
|
||||
)
|
||||
|
||||
# Fetch strategy_params per portfolio
|
||||
items = []
|
||||
for p in portfolios:
|
||||
# Optional status filter
|
||||
if status_filter is not None and p.status != status_filter:
|
||||
continue
|
||||
|
||||
strategy_params: dict = {}
|
||||
strategy_type: str | None = None
|
||||
if p.active_strategy_id:
|
||||
try:
|
||||
ss = repo.get_strategy_set(p.active_strategy_id)
|
||||
if ss is not None:
|
||||
strategy_params = _parse_strategy_params(ss.config)
|
||||
strategy_type = ss.name
|
||||
except Exception:
|
||||
logger.warning("strategy_set lookup failed for %s", p.active_strategy_id)
|
||||
strategy_params = {}
|
||||
|
||||
items.append(
|
||||
AssetItem(
|
||||
asset_id=p.portfolio_id,
|
||||
symbol=p.symbol,
|
||||
status=p.status,
|
||||
cash=p.cash_balance,
|
||||
quantity=p.asset_quantity,
|
||||
broker_type=p.broker,
|
||||
strategy_id=p.active_strategy_id,
|
||||
strategy_type=strategy_type,
|
||||
strategy_params=strategy_params,
|
||||
updated_at=p.updated_at,
|
||||
name=p.name,
|
||||
price=p.price,
|
||||
profit_buffer=p.profit_buffer,
|
||||
tags=p.tags,
|
||||
owner_user_id=p.owner_user_id,
|
||||
created_at=p.created_at,
|
||||
last_trade_at=p.last_trade_at,
|
||||
if auth.token_type == "service":
|
||||
if auth.daemon_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="daemon_id not assigned to this token",
|
||||
)
|
||||
portfolios = repo.list_portfolios_with_strategies(
|
||||
owner_user_id=auth.user_id,
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id=auth.daemon_id,
|
||||
market=market,
|
||||
)
|
||||
elif auth.token_type == "user":
|
||||
portfolios = repo.list_portfolios_with_strategies(
|
||||
owner_user_id=auth.user_id,
|
||||
include_disabled=True,
|
||||
market=market,
|
||||
)
|
||||
elif auth.token_type == "admin":
|
||||
filter_user_id = user_id if user_id is not None else auth.user_id
|
||||
portfolios = repo.list_portfolios_with_strategies(
|
||||
owner_user_id=filter_user_id,
|
||||
include_disabled=True,
|
||||
market=market,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Unsupported token type",
|
||||
)
|
||||
|
||||
items = _build_asset_items(portfolios, status_filter, repo)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
return AssetsResponse(assets=items, total=len(items))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin asset catalog CRUD (REQ-010)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/assets", response_model=AssetCatalogResponse, status_code=201)
|
||||
def create_catalog_asset(
|
||||
body: AssetCatalogCreateRequest,
|
||||
auth: AuthContext = Depends(require_admin),
|
||||
) -> AssetCatalogResponse:
|
||||
repo = _get_repo()
|
||||
try:
|
||||
asset = repo.insert_asset(
|
||||
symbol=body.symbol,
|
||||
markets=body.markets,
|
||||
display_name=body.display_name,
|
||||
status=body.status,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
return _asset_response(asset)
|
||||
|
||||
|
||||
@router.patch("/assets/{asset_id}/deactivate", response_model=AssetCatalogResponse)
|
||||
def deactivate_catalog_asset(
|
||||
asset_id: str,
|
||||
auth: AuthContext = Depends(require_admin),
|
||||
) -> AssetCatalogResponse:
|
||||
repo = _get_repo()
|
||||
try:
|
||||
asset = repo.update_asset(asset_id, status="inactive")
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
return _asset_response(asset)
|
||||
|
||||
|
||||
@router.patch("/assets/{asset_id}", response_model=AssetCatalogResponse)
|
||||
def update_catalog_asset(
|
||||
asset_id: str,
|
||||
body: AssetCatalogUpdateRequest,
|
||||
auth: AuthContext = Depends(require_admin),
|
||||
) -> AssetCatalogResponse:
|
||||
repo = _get_repo()
|
||||
try:
|
||||
asset = repo.update_asset(
|
||||
asset_id,
|
||||
markets=body.markets,
|
||||
display_name=body.display_name,
|
||||
status=body.status,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
return _asset_response(asset)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Asset catalog CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from app.cli.core.utils import execute_command
|
||||
|
||||
asset_app = typer.Typer(help="Asset catalog management commands.")
|
||||
|
||||
|
||||
def _with_repo(ctx: typer.Context, callback):
|
||||
app_context = ctx.obj
|
||||
repository = app_context.repository_factory(app_context.settings)
|
||||
try:
|
||||
return execute_command(app_context.console, callback=lambda: callback(repository, app_context))
|
||||
finally:
|
||||
if callable(getattr(type(repository), "close", None)):
|
||||
repository.close()
|
||||
|
||||
|
||||
def _confirm(force: bool, message: str) -> None:
|
||||
if force:
|
||||
return
|
||||
if not typer.confirm(message):
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
|
||||
def _asset_to_dict(asset) -> dict:
|
||||
return {
|
||||
"asset_id": asset.asset_id,
|
||||
"symbol": asset.symbol,
|
||||
"markets": asset.markets,
|
||||
"display_name": asset.display_name,
|
||||
"status": asset.status,
|
||||
"created_at": str(asset.created_at) if asset.created_at else None,
|
||||
"updated_at": str(asset.updated_at) if asset.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@asset_app.command("create")
|
||||
def asset_create(
|
||||
ctx: typer.Context,
|
||||
symbol: str = typer.Argument(..., help="Asset symbol (e.g. 005930, BTC)."),
|
||||
display_name: str | None = typer.Option(None, "--display-name", help="Human-readable name."),
|
||||
market: list[str] | None = typer.Option(None, "--market", help="Market code. Repeatable."),
|
||||
status: str = typer.Option("active", help="Asset status."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Create asset {symbol!r}?")
|
||||
markets = list(market or [])
|
||||
|
||||
def _callback(repository, app_context):
|
||||
return repository.insert_asset(symbol=symbol, markets=markets, display_name=display_name, status=status)
|
||||
|
||||
result = _with_repo(ctx, _callback)
|
||||
d = _asset_to_dict(result)
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=d)
|
||||
else:
|
||||
ctx.obj.console.print(f"Created asset: {d['symbol']} {d['asset_id']}")
|
||||
|
||||
|
||||
@asset_app.command("list")
|
||||
def asset_list(
|
||||
ctx: typer.Context,
|
||||
active_only: bool = typer.Option(False, "--active-only", help="Show active assets only."),
|
||||
) -> None:
|
||||
def _callback(repository, app_context):
|
||||
assets = repository.list_assets()
|
||||
if active_only:
|
||||
assets = [a for a in assets if a.status == "active"]
|
||||
return assets
|
||||
|
||||
results = _with_repo(ctx, _callback)
|
||||
items = [_asset_to_dict(a) for a in results] if results else []
|
||||
if not items:
|
||||
ctx.obj.console.print("No assets found.")
|
||||
return
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=items)
|
||||
else:
|
||||
for a in items:
|
||||
display_name = a["display_name"] or ""
|
||||
markets = ",".join(a["markets"]) if a["markets"] else "-"
|
||||
ctx.obj.console.print(
|
||||
f"{a['symbol']} {a['status']} [{markets}] {display_name} {a['asset_id']}"
|
||||
)
|
||||
|
||||
|
||||
@asset_app.command("update")
|
||||
def asset_update(
|
||||
ctx: typer.Context,
|
||||
asset_id: str = typer.Argument(..., help="Asset UUID."),
|
||||
display_name: str | None = typer.Option(None, "--display-name", help="Updated display name."),
|
||||
market: list[str] | None = typer.Option(None, "--market", help="Updated market codes. Repeatable."),
|
||||
status: str | None = typer.Option(None, help="Updated status."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Update asset {asset_id!r}?")
|
||||
markets = list(market) if market is not None else None
|
||||
|
||||
def _callback(repository, app_context):
|
||||
return repository.update_asset(asset_id, markets=markets, display_name=display_name, status=status)
|
||||
|
||||
result = _with_repo(ctx, _callback)
|
||||
d = _asset_to_dict(result)
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=d)
|
||||
else:
|
||||
ctx.obj.console.print(f"Updated asset: {d['symbol']} {d['status']} {d['asset_id']}")
|
||||
|
||||
|
||||
@asset_app.command("deactivate")
|
||||
def asset_deactivate(
|
||||
ctx: typer.Context,
|
||||
asset_id: str = typer.Argument(..., help="Asset UUID."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Deactivate asset {asset_id!r}?")
|
||||
|
||||
def _callback(repository, app_context):
|
||||
return repository.update_asset(asset_id, status="inactive")
|
||||
|
||||
result = _with_repo(ctx, _callback)
|
||||
d = _asset_to_dict(result)
|
||||
if ctx.obj.output_format == "json":
|
||||
ctx.obj.console.print_json(data=d)
|
||||
else:
|
||||
ctx.obj.console.print(f"Deactivated asset: {d['symbol']} {d['status']} {d['asset_id']}")
|
||||
@@ -145,7 +145,8 @@ def create_portfolio(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Portfolio name (user-scoped unique identifier)."),
|
||||
user: str = typer.Option(..., "--user", help="Owner username (required)."),
|
||||
asset_id: str = typer.Option(..., "--asset-id", help="Asset UUID from the asset catalog."),
|
||||
asset_id: str | None = typer.Option(None, "--asset-id", help="Asset UUID from the asset catalog."),
|
||||
symbol: str | None = typer.Option(None, "--symbol", help="Asset symbol to look up asset_id (alternative to --asset-id)."),
|
||||
broker: str = typer.Option("kis", help="Broker code."),
|
||||
status: str = typer.Option("enabled", help="Portfolio status."),
|
||||
cash_balance: float = typer.Option(0.0, min=0.0, help="Starting cash balance."),
|
||||
@@ -157,16 +158,24 @@ def create_portfolio(
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_validate_portfolio_name(name, ctx.obj.console)
|
||||
_confirm(force, f"Create portfolio {name!r} (asset-id: {asset_id}, user: {user})?")
|
||||
if asset_id is None and symbol is None:
|
||||
ctx.obj.console.print("Error: --asset-id 또는 --symbol 중 하나는 필수입니다.")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
def _callback(service, repository, app_context):
|
||||
resolved_asset_id = asset_id
|
||||
if resolved_asset_id is None:
|
||||
asset = repository.get_asset_by_symbol(symbol)
|
||||
if asset is None:
|
||||
raise ValueError(f"symbol에 해당하는 asset을 찾을 수 없습니다: {symbol}")
|
||||
resolved_asset_id = asset.asset_id
|
||||
owner = repository.get_user_by_username(user)
|
||||
if owner is None:
|
||||
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
|
||||
return service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
name=name,
|
||||
asset_id=asset_id,
|
||||
asset_id=resolved_asset_id,
|
||||
broker=broker,
|
||||
status=status,
|
||||
cash_balance=cash_balance,
|
||||
@@ -179,6 +188,7 @@ def create_portfolio(
|
||||
owner_user_id=owner.id,
|
||||
)
|
||||
|
||||
_confirm(force, f"Create portfolio {name!r} (user: {user})?")
|
||||
detail = _with_service_and_repo(ctx, _callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Callable
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from app.cli.commands.asset import asset_app
|
||||
from app.cli.commands.daemon import daemon_app
|
||||
from app.cli.commands.health import register_health_commands
|
||||
from app.cli.commands.service_tokens import token_app
|
||||
@@ -49,6 +50,7 @@ def create_app(
|
||||
repository_factory=repository_factory,
|
||||
)
|
||||
|
||||
app.add_typer(asset_app, name="asset")
|
||||
app.add_typer(portfolio_app, name="portfolio")
|
||||
app.add_typer(strategy_app, name="strategy")
|
||||
app.add_typer(user_app, name="user")
|
||||
|
||||
@@ -18,6 +18,16 @@ class MarketCode(str, Enum):
|
||||
BITHUMB = "BITHUMB"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tag:
|
||||
tag_id: str
|
||||
name: str
|
||||
created_by_user_id: str
|
||||
is_admin_tag: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Asset:
|
||||
asset_id: str
|
||||
@@ -97,6 +107,7 @@ class Portfolio:
|
||||
symbol: str
|
||||
asset_id: str | None = None
|
||||
tags: list[str] | None = None
|
||||
tag_ids: list[str] | None = None
|
||||
owner_user_id: str | None = None
|
||||
cash_balance: float = 0.0
|
||||
asset_quantity: float = 0.0
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -181,6 +181,7 @@ class PortfolioRepository(Protocol):
|
||||
exclude_tags: list[str] | None = None,
|
||||
match_mode: str | None = None,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
market: str | None = None,
|
||||
) -> list[Portfolio]: ...
|
||||
|
||||
def list_portfolios_with_strategies(
|
||||
@@ -189,8 +190,25 @@ class PortfolioRepository(Protocol):
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
market: str | None = None,
|
||||
) -> list[Portfolio]: ...
|
||||
|
||||
def create_tag(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
created_by_user_id: str,
|
||||
is_admin_tag: bool = False,
|
||||
) -> Tag: ...
|
||||
|
||||
def get_tag_by_id(self, tag_id: str) -> Tag | None: ...
|
||||
|
||||
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]: ...
|
||||
|
||||
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]: ...
|
||||
|
||||
def deactivate_tag(self, tag_id: str) -> Tag: ...
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
|
||||
|
||||
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ...
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -62,11 +62,23 @@ _SELECT_PORTFOLIO = """
|
||||
p.updated_at,
|
||||
p.last_trade_at,
|
||||
p.managed_by_daemon_id,
|
||||
p.asset_id
|
||||
p.asset_id,
|
||||
COALESCE(p.tag_ids, '{}'::uuid[])
|
||||
FROM portfolios p
|
||||
JOIN assets a ON p.asset_id = a.asset_id
|
||||
"""
|
||||
|
||||
_SELECT_TAG = """
|
||||
SELECT
|
||||
tag_id,
|
||||
name,
|
||||
created_by_user_id,
|
||||
is_admin_tag,
|
||||
is_active,
|
||||
created_at
|
||||
FROM tags
|
||||
"""
|
||||
|
||||
_SELECT_ASSET = """
|
||||
SELECT
|
||||
asset_id,
|
||||
@@ -235,6 +247,13 @@ class PostgresPortfolioRepository:
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS asset_id UUID")
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS tag_ids UUID[] NOT NULL DEFAULT '{}'")
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_tag_ids_gin
|
||||
ON portfolios USING GIN (tag_ids)
|
||||
"""
|
||||
)
|
||||
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
|
||||
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
|
||||
cur.execute(
|
||||
@@ -301,6 +320,19 @@ class PostgresPortfolioRepository:
|
||||
ON portfolio_tags (tag_value, portfolio_id)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
tag_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
created_by_user_id TEXT NOT NULL,
|
||||
is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daemons (
|
||||
@@ -410,6 +442,7 @@ class PostgresPortfolioRepository:
|
||||
last_trade_at=row[14] if len(row) > 14 else None,
|
||||
managed_by_daemon_id=row[15] if len(row) > 15 else None,
|
||||
asset_id=str(row[16]) if len(row) > 16 and row[16] is not None else None,
|
||||
tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -432,6 +465,19 @@ class PostgresPortfolioRepository:
|
||||
updated_at=row[6],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_tag(row: Any) -> Tag:
|
||||
if row is None:
|
||||
raise ValueError("태그를 찾을 수 없습니다")
|
||||
return Tag(
|
||||
tag_id=str(row[0]),
|
||||
name=row[1],
|
||||
created_by_user_id=row[2],
|
||||
is_admin_tag=bool(row[3]),
|
||||
is_active=bool(row[4]),
|
||||
created_at=row[5] if len(row) > 5 else None,
|
||||
)
|
||||
|
||||
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
|
||||
if not portfolio_ids:
|
||||
return {}
|
||||
@@ -950,6 +996,7 @@ class PostgresPortfolioRepository:
|
||||
exclude_tags: list[str] | None = None,
|
||||
match_mode: str | None = None,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
market: str | None = None,
|
||||
) -> list[Portfolio]:
|
||||
self.ensure_schema()
|
||||
query = _SELECT_PORTFOLIO
|
||||
@@ -993,6 +1040,9 @@ class PostgresPortfolioRepository:
|
||||
"p.portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
|
||||
)
|
||||
params.append(exclude_tags)
|
||||
if market is not None:
|
||||
clauses.append("%s = ANY(a.markets)")
|
||||
params.append(market)
|
||||
if clauses:
|
||||
query += "WHERE " + " AND ".join(clauses) + " "
|
||||
query += "ORDER BY p.updated_at DESC, p.portfolio_id ASC"
|
||||
@@ -1592,14 +1642,82 @@ class PostgresPortfolioRepository:
|
||||
*,
|
||||
include_disabled: bool = True,
|
||||
managed_by_daemon_id: str | None = None,
|
||||
market: str | None = None,
|
||||
) -> list[Portfolio]:
|
||||
status_filter = None if include_disabled else "enabled"
|
||||
return self.list_portfolios(
|
||||
owner_user_id=owner_user_id,
|
||||
status=status_filter,
|
||||
managed_by_daemon_id=managed_by_daemon_id,
|
||||
market=market,
|
||||
)
|
||||
|
||||
def create_tag(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
created_by_user_id: str,
|
||||
is_admin_tag: bool = False,
|
||||
) -> Tag:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
|
||||
""",
|
||||
(name, created_by_user_id, is_admin_tag),
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError(f"이미 존재하는 태그 이름입니다: {name}") from exc
|
||||
row = cur.fetchone()
|
||||
return self._row_to_tag(row)
|
||||
|
||||
def get_tag_by_id(self, tag_id: str) -> Tag | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_TAG + "WHERE tag_id = %s", (tag_id,))
|
||||
row = cur.fetchone()
|
||||
return self._row_to_tag(row) if row is not None else None
|
||||
|
||||
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]:
|
||||
if not tag_ids:
|
||||
return {}
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(_SELECT_TAG + "WHERE tag_id = ANY(%s)", (tag_ids,))
|
||||
rows = cur.fetchall()
|
||||
return {str(row[0]): self._row_to_tag(row) for row in rows}
|
||||
|
||||
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
|
||||
self.ensure_schema()
|
||||
query = _SELECT_TAG
|
||||
if not include_inactive:
|
||||
query += "WHERE is_active = TRUE "
|
||||
query += "ORDER BY name ASC"
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_tag(row) for row in rows]
|
||||
|
||||
def deactivate_tag(self, tag_id: str) -> Tag:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE tags SET is_active = FALSE
|
||||
WHERE tag_id = %s
|
||||
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
|
||||
""",
|
||||
(tag_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
|
||||
return self._row_to_tag(row)
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
|
||||
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ from pydantic import BaseModel, Field
|
||||
# GET /v1/assets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TagRefItem(BaseModel):
|
||||
tag_id: str
|
||||
archived: bool = False
|
||||
|
||||
|
||||
class AssetItem(BaseModel):
|
||||
asset_id: str
|
||||
symbol: str
|
||||
@@ -29,6 +34,7 @@ class AssetItem(BaseModel):
|
||||
price: float | None = None
|
||||
profit_buffer: float | None = None
|
||||
tags: list[str] | None = None
|
||||
tag_ids: list[TagRefItem] | None = None
|
||||
owner_user_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
@@ -92,3 +98,30 @@ class LedgerTradeResponse(BaseModel):
|
||||
executed_at: datetime
|
||||
recorded_at: datetime | None = None
|
||||
status: Literal["new", "duplicate"] = "new"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST/PATCH /v1/assets (admin catalog management — REQ-010)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AssetCatalogCreateRequest(BaseModel):
|
||||
symbol: str = Field(..., min_length=1)
|
||||
markets: list[str] = Field(default_factory=list)
|
||||
display_name: str | None = None
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class AssetCatalogUpdateRequest(BaseModel):
|
||||
markets: list[str] | None = None
|
||||
display_name: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class AssetCatalogResponse(BaseModel):
|
||||
asset_id: str
|
||||
symbol: str
|
||||
markets: list[str]
|
||||
display_name: str | None = None
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -207,6 +207,12 @@ class PortfolioService:
|
||||
*,
|
||||
owner_user_id: str = "system",
|
||||
) -> PortfolioDetailResponse:
|
||||
asset = self._repository.get_asset_by_id(payload.asset_id)
|
||||
if asset is None:
|
||||
raise ValueError(f"asset not found: {payload.asset_id}")
|
||||
if asset.status != "active":
|
||||
raise ValueError(f"asset is inactive: {payload.asset_id}")
|
||||
|
||||
active_strategy_id = payload.active_strategy_id
|
||||
if active_strategy_id is None:
|
||||
strategy_set = self._repository.get_or_create_strategy_set(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# BAT-002 Design
|
||||
|
||||
- Status: verified
|
||||
- Batch: BAT-002
|
||||
|
||||
## Architecture Summary
|
||||
- **REQ-010**: `app/api/v1_assets.py`에 admin 전용 엔드포인트 3개(`POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate`)를 추가하고, `app/cli/commands/asset.py` 신규 파일로 `pfhub asset` 커맨드 그룹을 구현한다. 내부 로직은 BAT-001에서 구현된 `postgres.py`의 `insert_asset` / `update_asset` / `list_assets`를 직접 호출한다.
|
||||
- **REQ-013**: 현재 service token 전용인 `GET /v1/assets` list_assets 핸들러를 `auth.token_type` 분기로 재작성한다. service/user/admin 각 경로에서 동일한 `list_portfolios_with_strategies` repository 메서드를 호출하되 필터 파라미터를 토큰 타입별로 다르게 설정한다.
|
||||
- **REQ-012**: `PortfolioService.create_portfolio()`에 `repository.get_asset_by_id(asset_id)` 호출을 추가하여 active 상태를 검증한다. CLI에 `--symbol` 옵션을 추가해 symbol→asset_id 조회 경로를 제공한다.
|
||||
- **REQ-015**: 코드 변경 없음. 구현 완료 후 baseline 문서 4종 갱신.
|
||||
|
||||
## Changed Areas
|
||||
- Docs:
|
||||
- `docs/data-model.md` — assets 엔티티 정식 추가, portfolios asset_id FK 반영 (REQ-015)
|
||||
- `docs/interface-contract.md` — POST/PATCH /v1/assets 신규, GET /v1/assets 토큰 분기 명세 (REQ-015)
|
||||
- `docs/runtime-flows.md` — GET /v1/assets 토큰 분기 흐름, admin asset CRUD 흐름 (REQ-015)
|
||||
- `docs/project-structure.md` — pfhub asset 커맨드 그룹, 신규 asset.py 모듈 (REQ-015)
|
||||
- Modules:
|
||||
- `app/cli/commands/asset.py` (신규) — pfhub asset create/list/update/deactivate
|
||||
- `app/cli/main.py` — asset_app typer 그룹 등록
|
||||
- `app/api/v1_assets.py` — admin 엔드포인트 3개 추가 + list_assets 재작성
|
||||
- `app/schemas/v1.py` — AssetCreateRequest, AssetUpdateRequest, AssetResponse 추가
|
||||
- `app/services/portfolio_service.py` — create_portfolio catalog 검증 로직 추가
|
||||
- `app/cli/commands/portfolio.py` — create 커맨드에 `--symbol` 옵션 추가
|
||||
- `app/repositories/postgres.py` — list_portfolios_with_strategies market 필터 파라미터 추가
|
||||
- Interfaces:
|
||||
- `POST /v1/assets` — admin token 필요, AssetCreateRequest body, 201 AssetResponse
|
||||
- `PATCH /v1/assets/{asset_id}` — admin token 필요, AssetUpdateRequest body, 200 AssetResponse
|
||||
- `PATCH /v1/assets/{asset_id}/deactivate` — admin token 필요, 200 AssetResponse
|
||||
- `GET /v1/assets` — service/user/admin 토큰 분기, `?market=<code>` 파라미터 지원
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **REQ-013 분기 구조: `auth.token_type` 필드로 분기**
|
||||
- `service` 토큰: `auth.daemon_id` 추출 → None이면 403 fail-closed (기존 REQ-006/008 동작 유지). list_portfolios_with_strategies(managed_by_daemon_id=auth.daemon_id)
|
||||
- `user` 토큰: list_portfolios_with_strategies(owner_user_id=auth.user_id)
|
||||
- `admin` 토큰: 기본 list_portfolios_with_strategies(owner_user_id=auth.user_id); `?user_id=<id>` 파라미터 시 해당 user id로 필터링
|
||||
- `?market=<code>` 파라미터: 모든 토큰에서 지원 — list_portfolios_with_strategies에 market 필터 파라미터 추가
|
||||
- 이유: 기존 daemon 인증 흐름 test_daemon_assignment.py와 호환성 유지, token_type은 이미 TokenPrincipal에 존재
|
||||
|
||||
2. **REQ-010 admin 권한 검사: `Permission.ADMIN`으로 require_permission 재사용**
|
||||
- 기존 `app/core/auth.py`의 `require_permission` 의존성 또는 `AuthContext` 확인 패턴 활용
|
||||
- non-admin이 POST/PATCH /v1/assets 호출 시 403
|
||||
- 이유: 인증 패턴 일관성 유지, 신규 미들웨어 불필요
|
||||
|
||||
3. **REQ-012 검증 위치: `PortfolioService.create_portfolio()` 서비스 레이어**
|
||||
- CLI와 HTTP API 양쪽이 PortfolioService를 통하므로 한 곳에서 커버
|
||||
- `repository.get_asset_by_id(payload.asset_id)` → None이면 "asset not found" ValueError
|
||||
- asset.status != "active"이면 "asset is inactive" ValueError
|
||||
- `--symbol` 옵션은 CLI에만 추가 — CLI에서 `repository.get_asset_by_symbol(symbol)` 조회 후 asset_id 획득 → CreatePortfolioRequest에 전달
|
||||
- 이유: 서비스 레이어 검증으로 HTTP/CLI 중복 없음, symbol→id 변환은 CLI 관심사
|
||||
|
||||
4. **REQ-010 CLI 구조: 별도 파일 `app/cli/commands/asset.py` 분리**
|
||||
- BAT-001의 `portfolio.py` 패턴과 동일하게 `asset_app = typer.Typer(...)` 생성
|
||||
- `app/cli/main.py`에 `app.add_typer(asset_app, name="asset")`으로 등록
|
||||
- 이유: 커맨드 그룹별 파일 분리 — 기존 패턴 일관성
|
||||
|
||||
5. **구현 순서: REQ-010 → REQ-013 → REQ-012 → REQ-015**
|
||||
- REQ-010이 `v1_assets.py`에 신규 엔드포인트를 추가하고, REQ-013이 같은 파일의 list_assets를 재작성 — 순서 엄수로 충돌 방지
|
||||
- REQ-012는 서비스 레이어만 수정 — 독립적이지만 REQ-010 완료 후 테스트 환경 정비 후 진행
|
||||
|
||||
## Edge Cases
|
||||
- REQ-013: admin token + `?user_id` 파라미터에서 해당 user가 존재하지 않는 경우 → 빈 배열 반환 (user 존재 여부 비공개, 404 미사용)
|
||||
- REQ-013: service token + daemon_id=None → 403 (기존 REQ-008 동작 그대로)
|
||||
- REQ-012: `--symbol` 조회 시 해당 symbol asset이 없는 경우 → CLI에서 즉시 오류 출력 후 종료
|
||||
- REQ-012: portfolio create 시 `get_asset_by_id()` 호출 비용 — 로컬 DB에서 단순 PK 조회로 성능 영향 없음
|
||||
- REQ-010: deactivate된 asset을 참조하는 기존 portfolio → 기존 portfolio 유지, 신규 create만 REQ-012에서 차단
|
||||
|
||||
## Architecture Impact
|
||||
- runtime-flows: `GET /v1/assets` 분기 구조 신규 (service/user/admin 3경로)
|
||||
- project-structure: `pfhub asset` 커맨드 그룹 신규, `app/cli/commands/asset.py` 모듈 신규
|
||||
|
||||
## Reference Doc Update Plan
|
||||
- `project-structure.md`: `app/cli/commands/asset.py` 추가, `pfhub asset` 커맨드 그룹 추가 (REQ-015)
|
||||
- `runtime-flows.md`: GET /v1/assets 토큰 분기 흐름 교체, admin asset CRUD 흐름 신규 추가 (REQ-015)
|
||||
@@ -0,0 +1,57 @@
|
||||
# BAT-002 Implementation
|
||||
|
||||
- Status: verified
|
||||
- Batch: BAT-002
|
||||
|
||||
## Plan Summary
|
||||
- 이번 구현에서 실제 변경할 범위:
|
||||
1. `app/schemas/v1.py` — AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse 추가
|
||||
2. `app/api/v1_assets.py` — admin 엔드포인트 3개 추가(REQ-010) + list_assets 토큰 분기 재작성(REQ-013)
|
||||
3. `app/cli/commands/asset.py` (신규) — pfhub asset create/list/update/deactivate
|
||||
4. `app/cli/main.py` — asset_app 등록
|
||||
5. `app/services/portfolio_service.py` — create_portfolio catalog 검증 추가(REQ-012)
|
||||
6. `app/cli/commands/portfolio.py` — create --symbol 옵션 추가(REQ-012)
|
||||
7. `app/repositories/portfolio_repository.py` — list_portfolios/list_portfolios_with_strategies market 파라미터 추가
|
||||
8. `app/repositories/postgres.py` — list_portfolios market 필터, list_portfolios_with_strategies market 파라미터 추가
|
||||
9. `docs/` 4종 갱신(REQ-015)
|
||||
10. 테스트 파일 갱신 및 신규
|
||||
|
||||
## Changed Files
|
||||
- `app/schemas/v1.py` — AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse 추가
|
||||
- `app/api/v1_assets.py` — 전체 재작성: list_assets 토큰 분기(service/user/admin), admin 엔드포인트 3개 추가
|
||||
- `app/cli/commands/asset.py` (신규) — pfhub asset create/list/update/deactivate typer 커맨드
|
||||
- `app/cli/main.py` — asset_app 등록
|
||||
- `app/services/portfolio_service.py` — create_portfolio에 get_asset_by_id 호출 및 active 검증 추가
|
||||
- `app/cli/commands/portfolio.py` — create 커맨드에 --symbol 옵션 추가
|
||||
- `app/repositories/portfolio_repository.py` — list_portfolios, list_portfolios_with_strategies Protocol에 market 파라미터 추가
|
||||
- `app/repositories/postgres.py` — list_portfolios market 필터 추가, list_portfolios_with_strategies market 파라미터 추가
|
||||
- `docs/data-model.md` — Asset 엔티티 추가, Portfolio asset_id FK 반영
|
||||
- `docs/interface-contract.md` — POST/PATCH /v1/assets 신규, GET /v1/assets 토큰 분기 명세 갱신
|
||||
- `docs/runtime-flows.md` — GET /v1/assets 토큰 분기 흐름 교체, admin asset CRUD 흐름 신규 추가
|
||||
- `docs/project-structure.md` — pfhub asset 커맨드 그룹, 신규 api 모듈 반영
|
||||
- `tests/test_asset_catalog.py` — REQ-010/012/013 AC 테스트 추가 (18개 신규)
|
||||
- `tests/test_v1.py` — REQ-013 AC 테스트 5개 추가, assert_called_once_with market=None 업데이트
|
||||
- `tests/test_daemon_assignment.py` — assert_called_once_with market=None 업데이트
|
||||
- `tests/test_cli.py` — FakeRepo.get_asset_by_id/get_asset_by_symbol 활성 Asset 반환으로 수정
|
||||
- `tests/test_portfolio_uuid.py` — FakeRepo.get_asset_by_id/get_asset_by_symbol 활성 Asset 반환으로 수정
|
||||
|
||||
## Execution Log
|
||||
1. app/schemas/v1.py — AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse 추가
|
||||
2. app/cli/commands/asset.py — 신규 파일 생성 (pfhub asset create/list/update/deactivate)
|
||||
3. app/cli/main.py — asset_app import 및 add_typer 등록
|
||||
4. app/api/v1_assets.py — list_assets 토큰 분기 재작성 + admin 엔드포인트 3개 추가
|
||||
5. app/repositories/portfolio_repository.py — Protocol market 파라미터 추가
|
||||
6. app/repositories/postgres.py — list_portfolios market 필터, list_portfolios_with_strategies market 파라미터 추가
|
||||
7. app/services/portfolio_service.py — create_portfolio catalog 검증 (get_asset_by_id → active 확인)
|
||||
8. app/cli/commands/portfolio.py — create --symbol 옵션 추가 (symbol → asset_id 조회 경로)
|
||||
9. tests/ — FakeRepo 업데이트 (active asset 반환), test_v1.py/test_daemon_assignment.py assert 업데이트
|
||||
10. tests/test_asset_catalog.py — REQ-010/012/013 신규 테스트 추가
|
||||
11. docs/ — data-model.md, interface-contract.md, runtime-flows.md, project-structure.md 갱신
|
||||
|
||||
## Validation
|
||||
- pytest 전체 실행: 180 passed, 0 failed
|
||||
- lint/typecheck: 별도 실행 미수행 (테스트 통과로 대체)
|
||||
|
||||
## Remaining Risks
|
||||
- admin 엔드포인트 route 순서: `/assets/{asset_id}/deactivate`가 `/assets/{asset_id}` 앞에 등록되어야 FastAPI가 올바르게 라우팅함 — 현재 구현에서 순서 보장됨
|
||||
- DB 통합 테스트 (market filter SQL `ANY(a.markets)`)는 live PostgreSQL에서만 검증 가능
|
||||
@@ -0,0 +1,33 @@
|
||||
# BAT-002: asset catalog API 완결
|
||||
|
||||
- Status: release-candidate
|
||||
- Profile: standard
|
||||
- Source Discovery: dcy-001
|
||||
- Included REQ: REQ-010, REQ-012, REQ-013, REQ-015
|
||||
- Excluded REQ: (없음 — dcy-001 잔여 4개 전부 포함)
|
||||
|
||||
## Summary
|
||||
- BAT-001에서 구축한 `assets` 테이블 및 `asset_id FK` 기반 위에 admin이 종목 catalog를 관리할 수 있는 CRUD API/CLI를 추가하고(REQ-010), portfolio 생성 시 catalog 검증을 도입하며(REQ-012), `GET /v1/assets` 엔드포인트를 service/user/admin 토큰 타입별 분기로 재정의한다(REQ-013).
|
||||
- 모든 구현이 완료된 후 baseline 문서(data-model, interface-contract, runtime-flows, project-structure)를 갱신한다(REQ-015).
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- `pfhub asset create/list/update/deactivate` CLI 커맨드 신규 추가 (`app/cli/commands/asset.py`)
|
||||
- `POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate` admin 전용 HTTP API 신규 추가
|
||||
- `GET /v1/assets` 토큰 타입별 분기 재작성 (service→daemon 필터, user→본인, admin→본인+?user_id)
|
||||
- `pfhub portfolio create` —`--symbol` 옵션 추가 + service layer asset catalog 존재/active 검증
|
||||
- baseline 문서 4종 갱신 (REQ-015)
|
||||
- Out of Scope:
|
||||
- tags 관련 모든 변경 (→ dcy-002 batches)
|
||||
- portfolio 조회·수정·삭제 흐름의 catalog 검증 (→ 별도 REQ 필요)
|
||||
- `GET /v1/assets` 이외의 v1 엔드포인트 변경
|
||||
|
||||
## 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): REQ-010과 REQ-013이 `app/api/v1_assets.py` 동일 파일 수정 — 구현 순서 엄수 (REQ-010 신규 엔드포인트 → REQ-013 list_assets 재작성)
|
||||
- BAT-001(verified) 완료가 선행 조건 — assets 테이블, asset CRUD postgres 구현, asset_id FK 모두 존재 확인됨
|
||||
@@ -0,0 +1,64 @@
|
||||
# BAT-002 Planning
|
||||
|
||||
- Status: verified
|
||||
- Batch: BAT-002
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
- REQ-010: admin asset catalog CRUD (Interface, High)
|
||||
- REQ-012: portfolio 생성 CLI catalog 검증 (Interface, High)
|
||||
- REQ-013: GET /v1/assets 토큰 타입별 분기 재정의 (Interface, High)
|
||||
- REQ-015: asset catalog 도입 baseline 문서 갱신 (Documentation, Low)
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### 단계 1 — REQ-010: admin asset catalog CRUD
|
||||
1. `app/schemas/v1.py`에 `AssetCreateRequest`, `AssetUpdateRequest`, `AssetResponse` 스키마 추가
|
||||
2. `app/api/v1_assets.py`에 `POST /v1/assets`, `PATCH /v1/assets/{id}`, `PATCH /v1/assets/{id}/deactivate` 엔드포인트 추가 (admin 권한 필요)
|
||||
3. `app/cli/commands/asset.py` 신규 파일 — `pfhub asset create/list/update/deactivate` typer 커맨드 구현
|
||||
4. `app/cli/main.py`에 asset 커맨드 그룹 등록
|
||||
5. `tests/test_asset_catalog.py` 확장 — REQ-010 AC-1~8
|
||||
|
||||
### 단계 2 — REQ-013: GET /v1/assets 토큰 타입별 분기
|
||||
1. `app/api/v1_assets.py` list_assets 핸들러 재작성 — token_type 분기 (service/user/admin)
|
||||
2. `app/repositories/postgres.py` list_portfolios_with_strategies — `?market=<code>` 필터 지원 추가
|
||||
3. `tests/test_v1.py` 갱신 및 신규 토큰 분기 케이스 추가 — REQ-013 AC-1~7
|
||||
|
||||
### 단계 3 — REQ-012: portfolio create catalog 검증
|
||||
1. `app/services/portfolio_service.py` create_portfolio에 `get_asset_by_id()` 호출 + active 검증 추가
|
||||
2. `app/cli/commands/portfolio.py` create 커맨드에 `--symbol` 옵션 추가 (asset_id 조회 경로)
|
||||
3. `tests/test_asset_catalog.py` 확장 / `tests/test_portfolio_uuid.py` 또는 `tests/test_cli.py` 갱신 — REQ-012 AC-1~4
|
||||
|
||||
### 단계 4 — REQ-015: baseline 문서 갱신
|
||||
1. `docs/data-model.md` — assets 엔티티, portfolios asset_id FK 갱신
|
||||
2. `docs/interface-contract.md` — POST/PATCH /v1/assets, GET /v1/assets 토큰 분기 명세 추가
|
||||
3. `docs/runtime-flows.md` — GET /v1/assets 토큰 분기 흐름, admin asset CRUD 흐름 추가
|
||||
4. `docs/project-structure.md` — pfhub asset 커맨드 그룹 및 신규 api 모듈 반영
|
||||
|
||||
### 단계 5 — 전체 테스트 suite regression 검증
|
||||
- `.venv/bin/python -m pytest --tb=short` 전체 실행 — regression 없음 확인
|
||||
|
||||
## Dependencies
|
||||
- Internal:
|
||||
- BAT-001(verified): assets 테이블, postgres asset CRUD 5종, Portfolio.asset_id FK 모두 구현 완료 — 선행 조건 충족
|
||||
- REQ-010(단계 1) 완료 후 REQ-013(단계 2) 착수 — `app/api/v1_assets.py` 동일 파일 순서 의존
|
||||
- REQ-010(단계 1) 완료 후 REQ-012(단계 3) 착수 — assets catalog 존재가 검증 선행 조건
|
||||
- External:
|
||||
- 없음
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: yes
|
||||
- Reason: (1) REQ-013 토큰 분기 아키텍처 — service/user/admin 각 경로에서 repository 호출 패턴이 다르고 기존 daemon 인증 흐름(test_daemon_assignment.py)과의 정합성 설계 필요; (2) REQ-010 CLI 구조 — `app/cli/commands/asset.py` 신규 파일 구조 및 `pfhub asset` 커맨드 그룹 등록 방식 결정 필요; (3) REQ-012 검증 위치 — CLI vs 서비스 레이어 중 어디서 catalog 검증을 수행할지 결정 필요 (현재 계획: 서비스 레이어)
|
||||
|
||||
## Milestones
|
||||
1. REQ-010: pfhub asset CLI + admin HTTP API 3개 엔드포인트 완성 + AC-1~8 통과
|
||||
2. REQ-013: GET /v1/assets 토큰 분기 재작성 + AC-1~7 통과
|
||||
3. REQ-012: portfolio create catalog 검증 + AC-1~4 통과
|
||||
4. REQ-015: baseline 문서 4종 갱신 + AC-1~4 통과
|
||||
5. 전체 pytest suite regression 통과
|
||||
|
||||
## Risks
|
||||
- R-1(High): REQ-010과 REQ-013이 `app/api/v1_assets.py` 동일 파일을 수정 — 구현 순서를 단계 1→2로 엄수해야 충돌 없음. 대응: planning 단계에서 순서 명시, 단계별 커밋 분리
|
||||
- R-2(Medium): REQ-013 list_assets 재작성이 기존 service token 전용 동작을 변경 — `test_daemon_assignment.py`, `test_v1.py` regression 위험. 대응: 단계 2 완료 직후 전체 pytest 실행으로 조기 탐지
|
||||
- R-3(Medium): REQ-012 service layer 검증 추가 시 기존 FakeRepo에 `get_asset_by_id/symbol` 스텁 미구현으로 테스트 실패 가능. 대응: 단계 3 착수 전 FakeRepo 업데이트 체크리스트 준비
|
||||
- R-4(Low): REQ-015 runtime-flows.md — GET /v1/assets 분기 흐름 다이어그램이 복잡해질 수 있음. 대응: 단계 4는 단계 2 완료 후 실제 구현을 기준으로 작성
|
||||
@@ -0,0 +1,53 @@
|
||||
# BAT-002 Verification
|
||||
|
||||
- Status: release-candidate
|
||||
- Batch: BAT-002
|
||||
|
||||
## Acceptance Mapping
|
||||
|
||||
- REQ-010:
|
||||
- AC-1: admin CLI `pfhub asset create` 성공 → `tests/test_asset_catalog.py::test_asset_cli_create_success` ✓
|
||||
- AC-2: admin CLI `pfhub asset list` — active/inactive 필터 → `tests/test_asset_catalog.py::test_asset_cli_list_returns_all`, `test_asset_cli_list_active_only_filter` ✓
|
||||
- AC-3: admin CLI `pfhub asset update` 성공 → `tests/test_asset_catalog.py::test_asset_cli_update_success` ✓
|
||||
- AC-4: admin CLI `pfhub asset deactivate` 성공 → `tests/test_asset_catalog.py::test_asset_cli_deactivate_success` ✓
|
||||
- AC-5: `POST /v1/assets` admin token → 201 AssetResponse → `tests/test_asset_catalog.py::test_asset_http_create_returns_201` ✓
|
||||
- AC-6: `PATCH /v1/assets/{id}` admin token → 200 → `tests/test_asset_catalog.py::test_asset_http_update_returns_200` ✓
|
||||
- AC-7: `PATCH /v1/assets/{id}/deactivate` admin token → 200 → `tests/test_asset_catalog.py::test_asset_http_deactivate_returns_200` ✓
|
||||
- AC-8: non-admin으로 admin 엔드포인트 호출 시 403 → `tests/test_asset_catalog.py::test_asset_http_non_admin_403` ✓
|
||||
|
||||
- REQ-012:
|
||||
- AC-1: active asset 존재 시 portfolio create 성공 → `tests/test_asset_catalog.py::test_portfolio_create_with_active_asset` ✓
|
||||
- AC-2: 미등록 asset_id 지정 시 "asset not found" 오류, 생성 실패 → `tests/test_asset_catalog.py::test_portfolio_create_unknown_asset` ✓
|
||||
- AC-3: inactive asset 지정 시 "asset is inactive" 오류, 생성 실패 → `tests/test_asset_catalog.py::test_portfolio_create_inactive_asset` ✓
|
||||
- AC-4: `--symbol`로 asset_id 조회 후 portfolio create 성공 → `tests/test_asset_catalog.py::test_portfolio_create_by_symbol` ✓
|
||||
|
||||
- REQ-013:
|
||||
- AC-1: service token(daemon_id 있음) → daemon 관리 portfolio 필터링 → `tests/test_v1.py::test_v1_assets_returns_asset_list` (assert_called_once_with updated) ✓
|
||||
- AC-2: service token(daemon_id 없음) → 403 → `tests/test_daemon_assignment.py::test_assets_403_when_token_has_no_daemon_id` (기존) ✓
|
||||
- AC-3: user token → 본인 portfolio만 반환 → `tests/test_v1.py::test_v1_assets_user_token` ✓
|
||||
- AC-4: admin token 기본 → admin 본인 portfolio → `tests/test_v1.py::test_v1_assets_admin_token_default` ✓
|
||||
- AC-5: admin token + ?user_id → 해당 user portfolio → `tests/test_v1.py::test_v1_assets_admin_token_user_id_param` ✓
|
||||
- AC-6: 모든 토큰 + ?market=<code> → 필터링 → `tests/test_v1.py::test_v1_assets_market_filter` ✓
|
||||
- AC-7: 응답 asset_id는 UUID 형식 → `tests/test_v1.py::test_v1_assets_asset_id_is_uuid` ✓
|
||||
|
||||
- REQ-015:
|
||||
- AC-1: docs/data-model.md에 assets 엔티티, portfolios asset_id FK 반영 → 수동 검토 ✓
|
||||
- AC-2: docs/interface-contract.md에 POST/PATCH /v1/assets, 토큰 분기 명세 포함 → 수동 검토 ✓
|
||||
- AC-3: docs/runtime-flows.md에 GET /v1/assets 토큰 분기, admin CRUD 흐름 추가 → 수동 검토 ✓
|
||||
- AC-4: docs/project-structure.md에 pfhub asset 그룹 및 asset.py 모듈 반영 → 수동 검토 ✓
|
||||
|
||||
## Evidence
|
||||
- Test:
|
||||
- pytest 실행 결과: 180 passed, 0 failed (2026-05-19)
|
||||
- 신규 테스트: test_asset_catalog.py 18개, test_v1.py 5개 추가
|
||||
- 기존 regression: test_cli.py, test_portfolio_uuid.py, test_v1.py, test_daemon_assignment.py 전체 통과
|
||||
- Manual:
|
||||
- 수동 CLI 실행은 live DB 환경에서 별도 검증 필요 (FakeRepo 기반 테스트로 커버)
|
||||
- HTTP admin 엔드포인트는 MagicMock 기반 테스트로 커버
|
||||
- Logs:
|
||||
- `tests/test_asset_catalog.py` — 전체 38개 테스트 통과
|
||||
- `tests/test_v1.py` — 전체 통과 (기존 + 신규 5개)
|
||||
|
||||
## Result
|
||||
- Ready for Release: true
|
||||
- Blocking Issues: 없음
|
||||
@@ -0,0 +1,56 @@
|
||||
# BAT-003 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-003
|
||||
|
||||
## Architecture Summary
|
||||
- **REQ-001**: `migrations/versions/`에 `tags` 테이블 생성 Alembic revision을 추가하고, `app/domain/models.py`에 `Tag` 모델, `app/repositories/portfolio_repository.py`에 tag CRUD 프로토콜, `app/repositories/postgres.py`에 SQL 구현을 추가한다.
|
||||
- **REQ-002 + REQ-003(컬럼)**: 두 REQ가 동일한 `portfolios.tag_ids UUID[]` 컬럼 추가를 공유하므로 단일 Alembic revision으로 통합한다. `Portfolio` 도메인 모델에 `tag_ids` 필드를 추가하고, `postgres.py`에 archived 표시 로직, `app/schemas/v1.py`에 `AssetItem`/`PortfolioItem` 필드를 추가한다.
|
||||
- **REQ-006**: 별도 Alembic data migration revision으로 구현. 실행 순서: (1) admin 계정 ID 존재 검증 → (2) portfolio_tags.tag_value를 소문자 정규화 후 tags 테이블에 INSERT (ON CONFLICT DO NOTHING) → (3) portfolios.tag_ids 채우기 → (4) portfolio_tags DROP.
|
||||
|
||||
## Changed Areas
|
||||
- Docs:
|
||||
- `docs/data-model.md` — tags 테이블 추가, portfolios.tag_ids 추가, portfolio_tags 제거 반영 (이 배치 범위 밖 — REQ-008 배치에서 갱신)
|
||||
- Modules:
|
||||
- `migrations/versions/` — (a) tags 테이블 revision, (b) portfolios.tag_ids + GIN 인덱스 revision, (c) 데이터 마이그레이션 revision (3개)
|
||||
- `app/domain/models.py` — `Tag` 모델 추가, `Portfolio.tag_ids` 필드 추가
|
||||
- `app/repositories/portfolio_repository.py` — `TagRepository` 프로토콜 메서드 추가 (`create_tag`, `get_tag_by_id`, `list_tags`, `deactivate_tag`)
|
||||
- `app/repositories/postgres.py` — tags CRUD SQL 구현, portfolio tag_ids 배열 조작, archived 표시 로직
|
||||
- `app/schemas/v1.py` — `AssetItem.tag_ids`, `PortfolioItem.tag_ids` 필드 추가
|
||||
- Interfaces:
|
||||
- 없음 (데이터 레이어 전용 배치 — HTTP/CLI 변경 없음)
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **REQ-002/REQ-003 Alembic revision 통합**
|
||||
- 두 REQ가 동일한 `portfolios.tag_ids UUID[]` 컬럼 추가를 명시 → 별도 revision으로 분리 시 중복 오류 발생
|
||||
- 단일 revision에서 컬럼 추가 + GIN 인덱스 생성을 수행, REQ-002 AC-1~2 및 REQ-003 AC-1~2 공동 충족
|
||||
- REQ-003의 `portfolio_tags DROP`은 REQ-006 데이터 마이그레이션 revision 끝에 포함
|
||||
|
||||
2. **REQ-006 admin 계정 ID 주입 방식**
|
||||
- Alembic revision 내 모듈 상수 `MIGRATION_ADMIN_ID`로 관리 — 운영 실행 전 운영자가 직접 값을 지정
|
||||
- 테스트 환경: `conftest.py` fixture로 users 테이블에 해당 ID를 사전 INSERT하여 마이그레이션 검증
|
||||
- 이유: 환경 변수 주입도 가능하나, 명시적 상수가 이력 추적에 유리하고 실수 방지
|
||||
|
||||
3. **archived 표시 로직 위치: `postgres.py` 조회 레이어**
|
||||
- `list_portfolios_with_strategies` 내에서 `portfolios.tag_ids`와 `tags.is_active`를 LEFT JOIN으로 함께 조회
|
||||
- 비활성 tag_id는 배열에서 제거하지 않고 `TagRef(tag_id=..., archived=True)` 형태로 응답에 포함
|
||||
- 이유: 서비스 레이어에서 N+1 조회 없이 단일 쿼리로 처리, REQ-002 AC-3 및 REQ-003 AC-4 충족
|
||||
|
||||
4. **REQ-006 rollback (downgrade) 계획**
|
||||
- Alembic `downgrade()` 함수에서: `portfolio_tags` 테이블 재생성 + tags 데이터 역방향 복원 SQL + `portfolios.tag_ids` 초기화 포함
|
||||
- 데이터 유실 없는 downgrade를 보장하되 완전 역복원은 어려우므로 운영 적용 전 스냅샷 백업 병행 권고
|
||||
|
||||
## Edge Cases
|
||||
- REQ-006: 동일 tag_value(소문자 정규화 후)를 여러 portfolio에서 사용 → `ON CONFLICT DO NOTHING`으로 tags 테이블 단일 행 유지
|
||||
- REQ-006: users 테이블에 `MIGRATION_ADMIN_ID`가 없으면 오류 출력 + 마이그레이션 중단 (데이터 무변경)
|
||||
- REQ-002/003: 기존 portfolios 행의 `tag_ids = '{}'` 기본값 — REQ-006 실행 이전에는 빈 배열
|
||||
- REQ-003 DROP: `portfolio_tags`가 이미 없는 경우 Alembic downgrade는 `IF NOT EXISTS`로 재생성, upgrade는 `IF EXISTS`로 DROP
|
||||
|
||||
## Architecture Impact
|
||||
- project-structure: 없음 (신규 모듈 파일 없음, 기존 파일 수정만)
|
||||
- runtime-flows: 없음 (HTTP/CLI 변경 없음)
|
||||
|
||||
## Reference Doc Update Plan
|
||||
- `project-structure.md`: 없음 (이 배치에서 변경 없음)
|
||||
- `runtime-flows.md`: 없음 (이 배치에서 변경 없음)
|
||||
@@ -0,0 +1,43 @@
|
||||
# BAT-003 Implementation
|
||||
|
||||
- Status: completed
|
||||
- Batch: BAT-003
|
||||
|
||||
## Plan Summary
|
||||
- REQ-001: tags 테이블 Alembic revision + Tag 도메인 모델 + tags CRUD 프로토콜/postgres 구현
|
||||
- REQ-002 + REQ-003(컬럼): portfolios.tag_ids UUID[] + GIN revision + domain/schema 업데이트 + archived 표시 로직
|
||||
- REQ-006: portfolio_tags 데이터 → tags master + portfolios.tag_ids 채우기 + portfolio_tags DROP (Alembic revision + 테스트 가능한 Python 함수 분리)
|
||||
- 전체 pytest suite 206 passed, regression 없음
|
||||
|
||||
## Changed Files
|
||||
- `migrations/versions/20260519_0019_tags_master_table.py` (신규)
|
||||
- `migrations/versions/20260519_0020_portfolios_tag_ids_column.py` (신규)
|
||||
- `migrations/versions/20260519_0021_portfolio_tags_data_migration.py` (신규)
|
||||
- `app/domain/models.py` — `Tag` 모델 추가, `Portfolio.tag_ids: list[str] | None = None` 추가
|
||||
- `app/repositories/portfolio_repository.py` — `Tag` 임포트, `create_tag`/`get_tag_by_id`/`get_tags_by_ids`/`list_tags_master`/`deactivate_tag` 프로토콜 추가
|
||||
- `app/repositories/postgres.py` — `Tag` 임포트, `_SELECT_TAG` 상수 추가, `_SELECT_PORTFOLIO`에 `p.tag_ids` 추가, `_row_to_portfolio` tag_ids 처리, `ensure_schema()` 업데이트(tags 테이블 + portfolios.tag_ids + GIN), `_row_to_tag` 추가, 태그 CRUD 메서드 5개 추가
|
||||
- `app/schemas/v1.py` — `TagRefItem` 추가, `AssetItem.tag_ids: list[TagRefItem] | None = None` 추가
|
||||
- `app/api/v1_assets.py` — `TagRefItem` 임포트, `_build_tag_refs` 헬퍼 추가, `_build_asset_items` tag_ids archived 표시 로직 추가
|
||||
- `tests/test_tags_master.py` (신규) — 26개 테스트
|
||||
|
||||
## Execution Log
|
||||
1. Alembic migration revision 0019: tags 테이블 생성 (upgrade/downgrade)
|
||||
2. Alembic migration revision 0020: portfolios.tag_ids + GIN 인덱스 (upgrade/downgrade)
|
||||
3. Alembic migration revision 0021: 데이터 마이그레이션 + portfolio_tags DROP, `run_migration()` 함수 분리
|
||||
4. domain/models.py: `Tag` 추가, `Portfolio.tag_ids` 추가
|
||||
5. portfolio_repository.py: 프로토콜에 `Tag` CRUD 메서드 5개 추가
|
||||
6. postgres.py: `_SELECT_TAG`, `_SELECT_PORTFOLIO` 업데이트, `ensure_schema()` 업데이트, `_row_to_tag` + CRUD 구현
|
||||
7. v1.py: `TagRefItem` + `AssetItem.tag_ids`
|
||||
8. v1_assets.py: `_build_tag_refs` + batch tag lookup + archived 표시
|
||||
9. test_tags_master.py: 26개 AC 테스트 작성 (REQ-001 AC-2~5, REQ-002/003 AC-3~4, REQ-006 AC-1~6, migration 파일 존재 검증)
|
||||
10. pytest 전체 suite 실행 → 206 passed, 0 failed
|
||||
|
||||
## Validation
|
||||
- pytest 실행 결과: **206 passed, 0 failed** (2026-05-19)
|
||||
- 신규 테스트: test_tags_master.py 26개
|
||||
- 기존 regression: test_cli.py(35), test_v1.py(26), test_asset_catalog.py(35), test_portfolio_tags.py(4), test_portfolio_uuid.py(14), test_daemon_assignment.py(8) 전체 통과
|
||||
|
||||
## Remaining Risks
|
||||
- REQ-001 AC-1 (tags 테이블 스키마 검증), REQ-002/003 AC-1~2 (GIN 인덱스 존재), REQ-003 AC-3 (portfolio_tags 테이블 부재): live DB 마이그레이션 실행 후 수동 검증 필요
|
||||
- REQ-006 AC-1~4,6: live DB 마이그레이션 실행 후 DB 상태 SQL 검증 필요
|
||||
- `MIGRATION_ADMIN_ID = "migration-admin"` — live 환경 실행 전 운영자가 유효한 user ID로 변경 필요
|
||||
@@ -0,0 +1,37 @@
|
||||
# BAT-003: tags DB 기반
|
||||
|
||||
- Status: release-candidate
|
||||
- Profile: standard
|
||||
- Source Discovery: dcy-002
|
||||
- Included REQ: REQ-001, REQ-002, REQ-003, REQ-006
|
||||
- Excluded REQ: (없음 — dcy-002 데이터 레이어 4개 전부 포함)
|
||||
|
||||
## Summary
|
||||
- tags 마스터 테이블을 신규 도입하고(REQ-001), portfolios 테이블에 UUID[] 태그 참조 컬럼을 추가하며(REQ-002, REQ-003), 기존 portfolio_tags 자유 문자열 데이터를 신규 tags 마스터로 이전한 뒤 portfolio_tags 테이블을 제거한다(REQ-006).
|
||||
- 이 배치 이후 HTTP API / CLI 계층(REQ-004, REQ-005, REQ-007, REQ-008)은 별도 배치에서 구현한다.
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- `tags` 테이블 생성 Alembic 마이그레이션 (`migrations/versions/`)
|
||||
- `portfolios.tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 + GIN 인덱스 추가 마이그레이션
|
||||
- `portfolio_tags` 데이터를 `tags` 마스터로 이전, `portfolios.tag_ids` 채우기, `portfolio_tags` DROP 데이터 마이그레이션
|
||||
- `Tag` 도메인 모델 추가, `Portfolio` 모델에 `tag_ids` 필드 추가 (`app/domain/models.py`)
|
||||
- tags CRUD 프로토콜 추가 (`app/repositories/portfolio_repository.py`)
|
||||
- tags INSERT/SELECT/UPDATE, portfolio tag_ids 배열 조작, archived 표시 로직 (`app/repositories/postgres.py`)
|
||||
- `AssetItem`/`PortfolioItem`에 `tag_ids` 필드 추가 (`app/schemas/v1.py`)
|
||||
- Out of Scope:
|
||||
- tags HTTP API (→ REQ-004, 다음 배치)
|
||||
- 태그 적용·해제 HTTP API (→ REQ-005, 다음 배치)
|
||||
- pfhub tag CLI (→ REQ-007, 다음 배치)
|
||||
- baseline 문서 갱신 (→ REQ-008, 다음 배치)
|
||||
|
||||
## 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): REQ-006 데이터 마이그레이션은 비가역 — 롤백 계획 설계 단계에서 필수
|
||||
- REQ-002와 REQ-003이 portfolios.tag_ids 컬럼 추가를 공유 → 단일 Alembic revision으로 통합, 구현 순서 설계 단계에서 명시
|
||||
- REQ-006의 선행 조건: REQ-001(tags 테이블)과 단계 2 revision(portfolios.tag_ids 컬럼)이 먼저 적용된 상태
|
||||
@@ -0,0 +1,62 @@
|
||||
# BAT-003 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-003
|
||||
- Profile: standard
|
||||
|
||||
## Included REQ
|
||||
- REQ-001: tags 마스터 테이블 도입 (Data, High)
|
||||
- REQ-002: assets.tag_ids UUID[] 컬럼 추가 (Data, High)
|
||||
- REQ-003: portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 (Data, High)
|
||||
- REQ-006: portfolio_tags 데이터 마이그레이션 (Migration, High)
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### 단계 1 — REQ-001: tags 마스터 테이블 도입
|
||||
1. `migrations/versions/` — `tags` 테이블 생성 Alembic revision
|
||||
2. `app/domain/models.py` — `Tag` 도메인 모델 추가
|
||||
3. `app/repositories/portfolio_repository.py` — tags CRUD 프로토콜 메서드 추가 (`create_tag`, `get_tag_by_id`, `list_tags`, `deactivate_tag`)
|
||||
4. `app/repositories/postgres.py` — tags INSERT/SELECT/UPDATE SQL 구현
|
||||
5. `tests/` — tags 테이블 스키마 검증, `is_admin_tag` 스냅샷 동작 테스트
|
||||
|
||||
### 단계 2 — REQ-002 + REQ-003(컬럼 추가 부분): portfolios.tag_ids 컬럼
|
||||
1. `migrations/versions/` — `portfolios.tag_ids UUID[]` 컬럼 + GIN 인덱스 Alembic revision (단일 revision으로 REQ-002/REQ-003 공동 충족)
|
||||
2. `app/domain/models.py` — `Portfolio` 모델에 `tag_ids: list[str]` 필드 추가
|
||||
3. `app/repositories/postgres.py` — tag_ids 배열 조회 + archived 표시 로직 추가
|
||||
4. `app/schemas/v1.py` — `AssetItem`, `PortfolioItem`에 `tag_ids` 필드 추가
|
||||
5. `tests/` — portfolios 스키마 검증, GIN 인덱스 확인, archived 표시 동작 테스트
|
||||
|
||||
### 단계 3 — REQ-006: portfolio_tags 데이터 마이그레이션
|
||||
1. `migrations/versions/` — 데이터 마이그레이션 revision: admin ID 검증 → tags INSERT → portfolios.tag_ids 채우기 → portfolio_tags DROP
|
||||
2. `tests/` — 마이그레이션 전후 DB 상태 SQL 검증, admin 계정 ID 없을 시 중단 확인, DISTINCT 수 일치 확인
|
||||
|
||||
### 단계 4 — REQ-003(DROP 검증): portfolio_tags 테이블 제거 확인
|
||||
1. REQ-006 revision 실행 후 portfolio_tags 테이블 부재 검증 — REQ-003 AC-3 충족
|
||||
|
||||
### 단계 5 — 전체 테스트 suite regression 검증
|
||||
- `.venv/bin/python -m pytest --tb=short` 전체 실행 — regression 없음 확인
|
||||
|
||||
## Dependencies
|
||||
- Internal:
|
||||
- BAT-001(verified), BAT-002(release-candidate): assets 테이블, portfolios 스키마 확정 — 선행 조건 충족
|
||||
- 단계 2는 단계 1(REQ-001 tags 테이블 revision) 완료 후 착수
|
||||
- 단계 3(REQ-006)은 단계 2(portfolios.tag_ids 컬럼 revision) 완료 후 착수
|
||||
- External:
|
||||
- `users` 테이블 존재 (migration 0004에서 생성) — `tags.created_by_user_id` FK 참조
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: yes
|
||||
- Reason: (1) REQ-002/REQ-003 portfolios.tag_ids 컬럼 추가 중복 → 단일 revision 통합 방식 설계 필요; (2) REQ-006 데이터 마이그레이션 롤백 계획 및 admin 계정 ID 주입 방식 결정; (3) archived 표시 로직 위치(postgres.py vs service layer) 결정 필요
|
||||
|
||||
## Milestones
|
||||
1. REQ-001: tags 테이블 마이그레이션 + domain/repo 구현 + AC-1~5 통과
|
||||
2. REQ-002 + REQ-003(컬럼): portfolios.tag_ids 마이그레이션 + domain/schema 구현 + AC-1~4 통과
|
||||
3. REQ-006: 데이터 마이그레이션 revision 실행 + AC-1~6 통과
|
||||
4. REQ-003 DROP 검증: portfolio_tags 테이블 부재 확인 + AC-3 통과
|
||||
5. 전체 pytest suite regression 통과
|
||||
|
||||
## Risks
|
||||
- R-1(High): REQ-006 데이터 마이그레이션 비가역 — portfolio_tags 데이터 손실 방지를 위해 마이그레이션 전 롤백 revision 준비. 대응: 설계 단계에서 downgrade SQL 명시
|
||||
- R-2(Medium): REQ-002/REQ-003 컬럼 추가 중복 → 단일 Alembic revision 통합 시 두 REQ의 AC 충족 방식 조율 필요. 대응: 설계 단계에서 revision 구조와 AC 매핑 명시
|
||||
- R-3(Medium): portfolios 도메인 모델에 `tag_ids` 필드 추가 시 기존 FakeRepo, `test_portfolio_uuid.py`, `test_cli.py` 등 domain 모델 참조 테스트 영향 가능. 대응: 단계 2 완료 직후 전체 pytest 실행으로 조기 탐지
|
||||
- R-4(Low): REQ-006 admin 계정 ID — 테스트 환경에서 users 테이블에 해당 ID 사전 삽입 필요. 대응: 설계 단계에서 테스트용 fixture admin 계정 ID 결정
|
||||
@@ -0,0 +1,79 @@
|
||||
# BAT-003 Verification
|
||||
|
||||
- Status: release-candidate
|
||||
- Batch: BAT-003
|
||||
|
||||
## Acceptance Mapping
|
||||
|
||||
- REQ-001:
|
||||
- AC-1: tags 테이블 스키마 검증 (tag_id UUID PK, name UNIQUE, created_by_user_id, is_admin_tag, is_active, created_at) → `test_migration_0019_tags_table_exists`, `test_migration_0019_creates_tags_table` (migration file content 검증; live DB 컬럼 타입 검증은 pre-release 수동 항목)
|
||||
- AC-2: admin 권한 토큰 태그 생성 시 is_admin_tag = TRUE → `test_create_tag_with_admin_flag_true`
|
||||
- AC-3: non-admin 토큰 태그 생성 시 is_admin_tag = FALSE → `test_create_tag_with_admin_flag_false`
|
||||
- AC-4: 태그 비활성화 시 is_active = FALSE, 행 삭제 없음 → `test_deactivate_tag_sets_is_active_false`, `test_deactivate_tag_does_not_delete_row`
|
||||
- AC-5: is_active = FALSE 태그 조회 가능, 비활성 상태 확인 → `test_get_inactive_tag_returns_row`
|
||||
|
||||
- REQ-002:
|
||||
- AC-1: portfolios.tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼 존재 → `test_migration_0020_portfolios_tag_ids_column` (migration file content 검증; live DB 컬럼 타입 검증은 pre-release 수동 항목)
|
||||
- AC-2: portfolios.tag_ids GIN 인덱스 존재 → `test_migration_0020_portfolios_tag_ids_column` (GIN 키워드 포함 확인; live DB 인덱스 존재 검증은 pre-release 수동 항목)
|
||||
- AC-3: is_active = FALSE tag_id 포함 시 archived 표시와 함께 반환, 자동 제거 없음 → `test_v1_assets_inactive_tag_archived_true` (GET /v1/assets 응답에서 archived=True, 배열 유지 확인)
|
||||
- AC-4: tag_ids = '{}' 기본값 asset 조회 시 빈 배열 반환 → `test_v1_assets_empty_tag_ids_returns_none`, `test_portfolio_tag_ids_defaults_empty`
|
||||
|
||||
- REQ-003:
|
||||
- AC-1: portfolios.tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼 존재 (REQ-002와 공동 revision) → `test_migration_0020_portfolios_tag_ids_column`
|
||||
- AC-2: portfolios.tag_ids GIN 인덱스 존재 (REQ-002와 공동 revision) → `test_migration_0020_portfolios_tag_ids_column`
|
||||
- AC-3: 마이그레이션 후 portfolio_tags 테이블 부재 → `test_migration_ac4_portfolio_tags_dropped` (revision 0021 파일에 DROP TABLE portfolio_tags 포함 확인)
|
||||
- AC-4: portfolio tag_ids 비활성 태그 archived 표시 반환, 자동 제거 없음 → `test_v1_assets_inactive_tag_archived_true`
|
||||
|
||||
- REQ-006:
|
||||
- AC-1: 소문자 정규화된 DISTINCT tag_value 수만큼 tags 행 생성, is_admin_tag=FALSE, created_by_user_id=지정 admin ID → `test_migration_ac1_tags_created_with_correct_attrs`
|
||||
- AC-2: "TestTag"/"testtag" 중복 → tags에 "testtag" 1행만 생성 (ON CONFLICT DO NOTHING) → `test_migration_ac2_case_insensitive_dedup`
|
||||
- AC-3: 각 portfolio.tag_ids에 기존 portfolio_tags 연결 tag_value의 tag_id 모두 포함 → `test_migration_ac3_portfolio_tag_ids_populated`
|
||||
- AC-4: 마이그레이션 후 portfolio_tags 테이블 부재 → `test_migration_ac4_portfolio_tags_dropped`
|
||||
- AC-5: 유효하지 않은 admin ID 지정 시 데이터 무변경 오류 중단 → `test_migration_ac5_invalid_admin_id_aborts`
|
||||
- AC-6: DISTINCT tag_value 수 N == 마이그레이션 후 tags 행 수 N → `test_migration_ac6_distinct_tag_count_matches`
|
||||
|
||||
## Evidence
|
||||
|
||||
- Test:
|
||||
- pytest 실행 결과: **206 passed, 0 failed** (2026-05-19)
|
||||
- 신규 테스트: `tests/test_tags_master.py` 26개
|
||||
- `test_tag_domain_model_fields`
|
||||
- `test_create_tag_with_admin_flag_true`
|
||||
- `test_create_tag_with_admin_flag_false`
|
||||
- `test_create_tag_duplicate_name_raises`
|
||||
- `test_deactivate_tag_sets_is_active_false`
|
||||
- `test_deactivate_tag_does_not_delete_row`
|
||||
- `test_get_inactive_tag_returns_row`
|
||||
- `test_list_tags_master_active_only_by_default`
|
||||
- `test_list_tags_master_include_inactive`
|
||||
- `test_portfolio_has_tag_ids_field`
|
||||
- `test_portfolio_tag_ids_defaults_empty`
|
||||
- `test_asset_item_tag_ids_field_exists`
|
||||
- `test_v1_assets_active_tag_not_archived`
|
||||
- `test_v1_assets_inactive_tag_archived_true`
|
||||
- `test_v1_assets_empty_tag_ids_returns_none`
|
||||
- `test_migration_ac1_tags_created_with_correct_attrs`
|
||||
- `test_migration_ac2_case_insensitive_dedup`
|
||||
- `test_migration_ac3_portfolio_tag_ids_populated`
|
||||
- `test_migration_ac4_portfolio_tags_dropped`
|
||||
- `test_migration_ac5_invalid_admin_id_aborts`
|
||||
- `test_migration_ac6_distinct_tag_count_matches`
|
||||
- `test_migration_0019_tags_table_exists`
|
||||
- `test_migration_0019_creates_tags_table`
|
||||
- `test_migration_0020_portfolios_tag_ids_column`
|
||||
- `test_migration_0021_data_migration_exists`
|
||||
- `test_migration_0021_has_admin_verification`
|
||||
- 기존 regression 전체 통과: test_cli.py(35), test_v1.py(26), test_asset_catalog.py(35), test_portfolio_tags.py(4), test_portfolio_uuid.py(14), test_daemon_assignment.py(8)
|
||||
- Manual (pre-release 수동 검증 항목 — live DB 마이그레이션 실행 후):
|
||||
- tags 테이블 스키마 확인 (REQ-001 AC-1 live 검증)
|
||||
- portfolios.tag_ids 컬럼 타입 UUID[] NOT NULL DEFAULT '{}' 확인 (REQ-002/003 AC-1 live 검증)
|
||||
- idx_portfolios_tag_ids_gin 인덱스 존재 확인 (REQ-002/003 AC-2 live 검증)
|
||||
- portfolio_tags 테이블 부재 확인 (REQ-003 AC-3 / REQ-006 AC-4 live 검증)
|
||||
- MIGRATION_ADMIN_ID를 운영 환경 유효 user ID로 변경 필요 (revision 0021)
|
||||
- Logs: N/A
|
||||
|
||||
## Result
|
||||
|
||||
- Ready for Release: true
|
||||
- Blocking Issues: 없음
|
||||
- Pre-release 수동 검증 항목: live DB 마이그레이션 실행 후 스키마/인덱스 상태 SQL 확인 (상기 Manual 항목 참조)
|
||||
@@ -9,3 +9,5 @@
|
||||
| BAT ID | Profile | Status | Included REQ | Discovery | Folder |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| BAT-001 | standard | verified | REQ-009, REQ-011, REQ-014 | dcy-001 | [bat-001](./bat-001_20260519_asset-catalog-db-foundation/) |
|
||||
| BAT-002 | standard | release-candidate | REQ-010, REQ-012, REQ-013, REQ-015 | dcy-001 | [bat-002](./bat-002_20260519_asset-catalog-api-completion/) |
|
||||
| BAT-003 | standard | release-candidate | REQ-001, REQ-002, REQ-003, REQ-006 | dcy-002 | [bat-003](./bat-003_20260519_tags-db-foundation/) |
|
||||
|
||||
+36
-2
@@ -33,6 +33,38 @@
|
||||
- AuthToken :: issued client or service token with permission, runtime_id, and optional daemon_id binding
|
||||
- BrokerCredential :: encrypted broker API credentials per user-broker pair
|
||||
- LedgerTrade :: idempotent trade execution record keyed by (asset_id, order_id, execution_id)
|
||||
- Asset :: catalog entry representing a tradeable asset; portfolios reference assets via FK
|
||||
|
||||
## Entity: `Asset`
|
||||
|
||||
- Purpose: represent a tradeable asset in the catalog; serves as FK anchor for portfolios
|
||||
- Source of Truth: assets table; managed exclusively via admin CLI / admin API
|
||||
- Lifecycle: created → optionally deactivated; never deleted (portfolios reference it with RESTRICT FK)
|
||||
|
||||
### Key Fields
|
||||
|
||||
- asset_id :: uuid :: server-generated UUID primary key (gen_random_uuid())
|
||||
- symbol :: text UNIQUE :: exchange symbol (e.g. 005930, BTC)
|
||||
- markets :: text[] :: array of market codes the asset is listed on (e.g. ["KRX", "NASDAQ"])
|
||||
- display_name :: text|null :: human-readable name for display
|
||||
- status :: text :: "active" | "inactive"; only active assets can be referenced by new portfolios
|
||||
|
||||
### Uniqueness Constraints
|
||||
|
||||
- assets_symbol_key :: UNIQUE (symbol) :: one catalog entry per symbol
|
||||
|
||||
### Indexes
|
||||
|
||||
- idx_assets_markets_gin :: GIN index on markets array :: supports efficient array containment queries
|
||||
|
||||
### Relationships
|
||||
|
||||
- Portfolio :: portfolios.asset_id FK → assets.asset_id ON DELETE RESTRICT; one asset → many portfolios
|
||||
|
||||
### State Rules
|
||||
|
||||
- Only `status="active"` assets can be referenced when creating a new portfolio.
|
||||
- Deactivated assets do not affect existing portfolios; only new creation is blocked.
|
||||
|
||||
## Entity: `Portfolio`
|
||||
|
||||
@@ -44,8 +76,8 @@
|
||||
|
||||
- portfolio_id :: uuid :: server-generated UUID primary key; assigned at creation and never changed; BAT-006 migrated all pre-existing TEXT portfolio_id values to UUIDs
|
||||
- name :: text :: human-readable portfolio name; unique per owner_user_id; validated against [a-zA-Z0-9._:-]{1,100}
|
||||
- symbol :: text :: traded asset symbol for the portfolio
|
||||
- market :: text|null :: market identifier for the asset (e.g., exchange code); added by migration 20260502_0014
|
||||
- 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)
|
||||
- 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
|
||||
@@ -58,6 +90,7 @@
|
||||
|
||||
### Relationships
|
||||
|
||||
- Asset :: asset_id FK ties each portfolio to exactly one catalog asset
|
||||
- User :: owner_user_id links a portfolio to its owning user when ownership is assigned
|
||||
- Daemon :: managed_by_daemon_id links a portfolio to exactly one daemon; NULL means unassigned
|
||||
- StrategySet :: active_strategy_id and avabile_strategies connect reusable strategy definitions
|
||||
@@ -242,6 +275,7 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-19 KST: bat-002 — REQ-015: added Entity: Asset (asset catalog with symbol UNIQUE, markets[], status); updated Portfolio.asset_id FK and symbol/market field notes.
|
||||
- 2026-05-13 KST: bat-010 — REQ-009/023/028: added Entity: Daemon; added portfolios.managed_by_daemon_id; added auth_tokens.daemon_id; updated AuthToken State Rules (runtime_id vs daemon_id distinction, fail-closed policy); updated Portfolio Relationships; updated Model Summary.
|
||||
- 2026-05-11 KST: bat-008 — REQ-011 AC-2: added portfolios.market column; added Entity: AuthToken (runtime_id field); added Entity: BrokerCredential; added Entity: LedgerTrade; updated Model Summary.
|
||||
- 2026-05-11 KST: bat-006 — portfolios.portfolio_id changed to server-generated UUID PK; name column added with UNIQUE (owner_user_id, name) constraint; uq_portfolios_owner_name constraint documented; state rules updated.
|
||||
|
||||
@@ -101,22 +101,31 @@
|
||||
|
||||
### Authentication
|
||||
|
||||
- All /v1 endpoints require `Authorization: Bearer <service-token>` header.
|
||||
- Token must be a DB-issued service token (`token_source="db"`, `token_type="service"`); env tokens are rejected with HTTP 403.
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 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/assets/{asset_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.
|
||||
|
||||
### Endpoint: `GET /v1/assets`
|
||||
|
||||
- Auth: service-token bearer (read); token must have `daemon_id` bound (NULL → HTTP 403)
|
||||
- Query Params: `?status=<value>` (optional, filters by portfolio status)
|
||||
- Filter: returns only assets where `portfolios.managed_by_daemon_id = token.daemon_id`
|
||||
- Auth: DB token required; behavior branches by `token_type`
|
||||
- `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → 403
|
||||
- `user`: returns caller's own portfolios (`owner_user_id = token.user_id`)
|
||||
- `admin`: returns caller's own portfolios; `?user_id=<id>` overrides to specified user
|
||||
- Query Params: `?status=<value>` (optional portfolio status filter), `?market=<code>` (optional market code filter), `?user_id=<id>` (admin only)
|
||||
- Response: HTTP 200
|
||||
|
||||
```
|
||||
@@ -124,9 +133,8 @@
|
||||
"total": <int>,
|
||||
"assets": [
|
||||
{
|
||||
"asset_id": "<UUID>", // portfolio.portfolio_id (UUID after BAT-006)
|
||||
"asset_id": "<UUID>", // portfolio.portfolio_id
|
||||
"symbol": "<str>",
|
||||
"market": "<str|null>",
|
||||
"status": "<str>",
|
||||
"cash": <float>,
|
||||
"quantity": <float>,
|
||||
@@ -147,7 +155,41 @@
|
||||
}
|
||||
```
|
||||
|
||||
- Error: HTTP 401 if no token; HTTP 403 if not a service token or daemon_id is NULL
|
||||
- Error: HTTP 401 if no token; HTTP 403 if env token or (service token with daemon_id NULL)
|
||||
|
||||
### Endpoint: `POST /v1/assets` (Admin Catalog)
|
||||
|
||||
- Auth: admin token required
|
||||
- Body: `{"symbol": "<str>", "markets": [<str>], "display_name": "<str|null>", "status": "active"}`
|
||||
- Response: HTTP 201 `AssetCatalogResponse`
|
||||
|
||||
```
|
||||
{
|
||||
"asset_id": "<UUID>",
|
||||
"symbol": "<str>",
|
||||
"markets": [<str>],
|
||||
"display_name": "<str|null>",
|
||||
"status": "<str>",
|
||||
"created_at": "<ISO8601|null>",
|
||||
"updated_at": "<ISO8601|null>"
|
||||
}
|
||||
```
|
||||
|
||||
- Error: HTTP 403 if not admin
|
||||
|
||||
### Endpoint: `PATCH /v1/assets/{asset_id}` (Admin Catalog Update)
|
||||
|
||||
- Auth: admin token required
|
||||
- Body: `{"markets": [<str>]|null, "display_name": "<str|null>", "status": "<str|null>"}`
|
||||
- Response: HTTP 200 `AssetCatalogResponse`
|
||||
- Error: HTTP 403 if not admin
|
||||
|
||||
### Endpoint: `PATCH /v1/assets/{asset_id}/deactivate` (Admin Catalog Deactivate)
|
||||
|
||||
- Auth: admin token required
|
||||
- Body: none
|
||||
- Response: HTTP 200 `AssetCatalogResponse` with `status="inactive"`
|
||||
- Error: HTTP 403 if not admin
|
||||
|
||||
### Endpoint: `GET /v1/user_info`
|
||||
|
||||
@@ -252,6 +294,7 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-19 KST: bat-002 — REQ-010/012/013/015: added admin catalog endpoints (POST/PATCH /v1/assets, PATCH /v1/assets/{id}/deactivate); rewrote GET /v1/assets authentication to support service/user/admin token branching; added market filter param; removed market field from AssetItem; added AssetCatalogResponse schema.
|
||||
- 2026-05-13 KST: bat-010 — REQ-006/007/008/011/023: added daemon authorization model section; updated GET /v1/assets (daemon-scoped filter, NULL 403); updated GET /v1/user_info (daemon_id field, NULL runtime_id → 200+null diagnostic); updated POST /v1/assets/{asset_id}/ledger/trades (daemon assignment authorization, fail-closed policy); added daemon CLI subcommand to CLI surface; updated v1 purpose and source discovery.
|
||||
- 2026-05-11 KST: bat-008 — REQ-011 AC-1: expanded v1 Consumer API section with authentication contract, detailed request/response schemas for GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades; updated GET /health response schema (REQ-004); added idempotency contract.
|
||||
- 2026-05-11 KST: bat-006 — Added v1 consumer API interface section (trex-basic); noted asset_id UUID change and simultaneous deployment constraint; updated CLI portfolio inputs to reflect name+user identification.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Status: confirmed
|
||||
- Owner: repository-maintainers
|
||||
- Last Updated (KST): 2026-05-11 00:00
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-007
|
||||
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-002, bat-006, bat-007
|
||||
|
||||
## Project Summary
|
||||
- PortfolioHub exposes portfolio management and trading ledger workflows through a FastAPI service and a companion CLI.
|
||||
@@ -83,11 +83,11 @@
|
||||
- Hosts StagePilot skills, document templates, and helper scripts such as stagepilot-doctor and stagepilot-bootstrap-seed.
|
||||
- app/api/
|
||||
- Defines FastAPI route surfaces for health, authentication, users, strategy sets, and portfolios.
|
||||
- v1 route surfaces: `v1_assets.py` (GET /v1/assets), `v1_user_info.py` (GET /v1/user_info), `v1_ledger.py` (POST /v1/assets/{asset_id}/ledger/trades).
|
||||
- All v1 endpoints require service-token authentication (token_source=db, token_type=service).
|
||||
- v1 route surfaces: `v1_assets.py` (GET /v1/assets token-branched, POST /v1/assets admin catalog create, PATCH /v1/assets/{id} admin update, PATCH /v1/assets/{id}/deactivate admin deactivate), `v1_user_info.py` (GET /v1/user_info), `v1_ledger.py` (POST /v1/assets/{asset_id}/ledger/trades).
|
||||
- GET /v1/assets supports service/user/admin token branching; admin catalog CRUD requires admin token.
|
||||
- app/cli/
|
||||
- Defines the Typer entrypoint, command groups, output helpers, and operator-facing command flows.
|
||||
- Command groups: `portfolio`, `strategy`, `user`, `service-token`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`).
|
||||
- Command groups: `asset` (catalog CRUD via admin), `portfolio`, `strategy`, `user`, `service-token`, `credential` (broker credential management), and health commands (`health-check`, `init-db`, `migrate-db`).
|
||||
- app/core/
|
||||
- Owns configuration loading, auth guards, security helpers, and shared error handling.
|
||||
- `encryption.py`: Fernet-based credential payload encrypt/decrypt helpers. Reads `CREDENTIAL_ENCRYPTION_KEY` env var via `Settings.require_credential_encryption_key()`.
|
||||
@@ -98,7 +98,7 @@
|
||||
- Key methods added in bat-006: `get_portfolio_by_name_and_user(owner_user_id, name) -> Portfolio | None` for (owner_user_id, name) → portfolio_id (UUID) resolution used by CLI commands.
|
||||
- app/schemas/
|
||||
- Defines request and response schemas for HTTP contracts.
|
||||
- `v1.py`: Contract schemas for v1 API (AssetItem, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response).
|
||||
- `v1.py`: Contract schemas for v1 API (AssetItem, AssetsResponse, UserInfoResponse, BrokerItem, LedgerTradeRequest/Response, AssetCatalogCreateRequest, AssetCatalogUpdateRequest, AssetCatalogResponse).
|
||||
- app/services/
|
||||
- Implements business logic for auth, portfolios, strategy sets, and users.
|
||||
- migrations/
|
||||
|
||||
+21
-11
@@ -68,22 +68,32 @@
|
||||
- Request validation and auth resolution should happen before persistence mutation or response rendering.
|
||||
- The health endpoint remains unauthenticated so infrastructure can probe readiness without a token.
|
||||
|
||||
## Flow: `v1 API — Service-Token Authentication`
|
||||
## Flow: `v1 API — DB Token Authentication`
|
||||
|
||||
1. v1 endpoint receives Bearer token from Authorization header.
|
||||
2. `require_read` resolves the token: checks env tokens first, then DB lookup via `get_token_principal()`.
|
||||
3. If DB token: validates `token_source == "db"` and `token_type == "service"`, else returns HTTP 403.
|
||||
4. `AuthContext` is populated with `user_id`, `token_type`, `permission`, and `runtime_id`.
|
||||
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`.
|
||||
|
||||
## Flow: `v1 API — GET /v1/assets`
|
||||
|
||||
1. Service-token auth required (see above).
|
||||
2. Daemon-id guard: if `auth.daemon_id is None` → HTTP 403 ("daemon_id not assigned to this token"). NULL tokens are fail-closed.
|
||||
3. `list_portfolios_with_strategies()` fetches only the token owner's portfolios scoped to `managed_by_daemon_id=auth.daemon_id`; `owner_user_id=auth.user_id` prevents cross-user leakage.
|
||||
4. Optional `?status=<value>` query param filters portfolios by status field; `include_disabled=False` by default.
|
||||
5. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
|
||||
6. Returns `AssetsResponse` with `total` and `assets[]` using field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
|
||||
7. `asset_id` in each asset item equals `portfolio.portfolio_id` (server-generated UUID after BAT-006 migration).
|
||||
1. DB token required; `token_type` drives branching:
|
||||
- `service`: daemon-id guard — if `auth.daemon_id is None` → HTTP 403 (fail-closed). `list_portfolios_with_strategies(owner_user_id=auth.user_id, managed_by_daemon_id=auth.daemon_id, include_disabled=False, market=market)`.
|
||||
- `user`: `list_portfolios_with_strategies(owner_user_id=auth.user_id, include_disabled=True, market=market)`.
|
||||
- `admin`: `list_portfolios_with_strategies(owner_user_id=filter_user_id, include_disabled=True, market=market)` where `filter_user_id = ?user_id param if provided, else auth.user_id`.
|
||||
2. Optional `?status=<value>` query param post-filters portfolios by status field.
|
||||
3. Optional `?market=<code>` query param filters by `assets.markets` array (SQL: `%s = ANY(a.markets)`).
|
||||
4. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`.
|
||||
5. Returns `AssetsResponse` with `total` and `assets[]` using field names: `cash`, `quantity`, `broker_type`, `strategy_params`.
|
||||
6. `asset_id` in each asset item equals `portfolio.portfolio_id` (server-generated UUID).
|
||||
|
||||
## Flow: `Admin Asset Catalog CRUD — POST/PATCH /v1/assets`
|
||||
|
||||
1. Admin token required (`require_admin`).
|
||||
2. `POST /v1/assets`: `repo.insert_asset(symbol, markets, display_name, status)` → 201 `AssetCatalogResponse`.
|
||||
3. `PATCH /v1/assets/{asset_id}`: `repo.update_asset(asset_id, markets, display_name, status)` → 200 `AssetCatalogResponse`.
|
||||
4. `PATCH /v1/assets/{asset_id}/deactivate`: `repo.update_asset(asset_id, status="inactive")` → 200 `AssetCatalogResponse`.
|
||||
5. Non-admin callers receive HTTP 403.
|
||||
|
||||
## Flow: `v1 API — GET /v1/user_info`
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Create tags master table (REQ-001)
|
||||
|
||||
Revision ID: 20260519_0019
|
||||
Revises: 20260519_0018
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0019"
|
||||
down_revision = "20260519_0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "tags"):
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("tag_id", sa.UUID(), nullable=False, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.Text(), nullable=False),
|
||||
sa.Column("is_admin_tag", sa.Boolean(), nullable=False, server_default=sa.text("FALSE")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("TRUE")),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("tag_id"),
|
||||
sa.UniqueConstraint("name", name="uq_tags_name"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_tags_created_by_user_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "tags"):
|
||||
op.drop_table("tags")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Add portfolios.tag_ids UUID[] column and GIN index (REQ-002, REQ-003)
|
||||
|
||||
Revision ID: 20260519_0020
|
||||
Revises: 20260519_0019
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "20260519_0020"
|
||||
down_revision = "20260519_0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, "portfolios", "tag_ids"):
|
||||
op.add_column(
|
||||
"portfolios",
|
||||
sa.Column(
|
||||
"tag_ids",
|
||||
sa.ARRAY(sa.UUID()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::uuid[]"),
|
||||
),
|
||||
)
|
||||
|
||||
inspector = inspect(bind)
|
||||
if _column_exists(inspector, "portfolios", "tag_ids") and not _index_exists(
|
||||
inspector, "portfolios", "idx_portfolios_tag_ids_gin"
|
||||
):
|
||||
op.create_index(
|
||||
"idx_portfolios_tag_ids_gin",
|
||||
"portfolios",
|
||||
["tag_ids"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if _index_exists(inspector, "portfolios", "idx_portfolios_tag_ids_gin"):
|
||||
op.drop_index("idx_portfolios_tag_ids_gin", table_name="portfolios")
|
||||
|
||||
if _column_exists(inspector, "portfolios", "tag_ids"):
|
||||
op.drop_column("portfolios", "tag_ids")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Migrate portfolio_tags data to tags master, fill tag_ids, drop portfolio_tags (REQ-006)
|
||||
|
||||
Revision ID: 20260519_0021
|
||||
Revises: 20260519_0020
|
||||
Create Date: 2026-05-19 00:00:00
|
||||
|
||||
IMPORTANT: Set MIGRATION_ADMIN_ID to a valid user ID that exists in the users table
|
||||
before running this migration in a live environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "20260519_0021"
|
||||
down_revision = "20260519_0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Operator must confirm this user ID exists in the users table before running.
|
||||
MIGRATION_ADMIN_ID = "migration-admin"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def run_migration(connection, admin_user_id: str) -> None:
|
||||
"""Core migration logic — extracted for testability."""
|
||||
result = connection.execute(
|
||||
text("SELECT COUNT(*) FROM users WHERE id = :uid"),
|
||||
{"uid": admin_user_id},
|
||||
)
|
||||
count = result.scalar()
|
||||
if not count:
|
||||
raise ValueError(
|
||||
f"Migration admin user not found in users table: {admin_user_id}. "
|
||||
"Set MIGRATION_ADMIN_ID to a valid user ID before running."
|
||||
)
|
||||
|
||||
# Migrate distinct (lowercased) tag_values from portfolio_tags → tags master
|
||||
connection.execute(
|
||||
text("""
|
||||
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
|
||||
SELECT LOWER(tag_value), :admin_id, FALSE
|
||||
FROM portfolio_tags
|
||||
GROUP BY LOWER(tag_value)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
"""),
|
||||
{"admin_id": admin_user_id},
|
||||
)
|
||||
|
||||
# Fill portfolios.tag_ids from the migrated tags
|
||||
connection.execute(text("""
|
||||
UPDATE portfolios p
|
||||
SET tag_ids = COALESCE((
|
||||
SELECT ARRAY_AGG(t.tag_id ORDER BY t.name)
|
||||
FROM portfolio_tags pt
|
||||
JOIN tags t ON t.name = LOWER(pt.tag_value)
|
||||
WHERE pt.portfolio_id = p.portfolio_id
|
||||
), '{}'::uuid[])
|
||||
"""))
|
||||
|
||||
# Drop portfolio_tags table (data is now in tags + portfolios.tag_ids)
|
||||
connection.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
# portfolio_tags already gone — migration was already applied or skipped
|
||||
return
|
||||
|
||||
run_migration(bind, MIGRATION_ADMIN_ID)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Re-create portfolio_tags table
|
||||
if not _table_exists(inspector, "portfolio_tags"):
|
||||
op.create_table(
|
||||
"portfolio_tags",
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("tag_value", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["portfolio_id"], ["portfolios.portfolio_id"],
|
||||
name="fk_portfolio_tags_portfolio",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_portfolio_tags_tag_portfolio",
|
||||
"portfolio_tags",
|
||||
["tag_value", "portfolio_id"],
|
||||
)
|
||||
|
||||
# Restore portfolio_tags from tags + portfolios.tag_ids (best-effort reverse)
|
||||
bind.execute(text("""
|
||||
INSERT INTO portfolio_tags (portfolio_id, tag_value)
|
||||
SELECT p.portfolio_id, t.name
|
||||
FROM portfolios p
|
||||
CROSS JOIN LATERAL UNNEST(p.tag_ids) AS tag_id_val
|
||||
JOIN tags t ON t.tag_id = tag_id_val
|
||||
ON CONFLICT DO NOTHING
|
||||
"""))
|
||||
|
||||
# Clear tag_ids arrays and drop tags data added by this migration
|
||||
# (cannot fully reverse is_admin_tag=FALSE origin without original data)
|
||||
bind.execute(text("UPDATE portfolios SET tag_ids = '{}'::uuid[]"))
|
||||
@@ -246,3 +246,507 @@ def test_migration_rev_b_has_data_migration_insert() -> None:
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "INSERT INTO assets" in content
|
||||
assert "ARRAY_AGG" in content
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REQ-010: Admin asset catalog CRUD
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-010 CLI: FakeRepo for asset CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ASSET_UUID = "00000000-0000-0000-0000-000000000099"
|
||||
|
||||
|
||||
class _AssetFakeRepo:
|
||||
"""Minimal in-memory repo supporting asset CRUD for CLI tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._assets: dict[str, Asset] = {}
|
||||
self._seq = 1
|
||||
|
||||
def insert_asset(
|
||||
self,
|
||||
*,
|
||||
symbol: str,
|
||||
markets: list[str],
|
||||
display_name: str | None = None,
|
||||
status: str = "active",
|
||||
) -> Asset:
|
||||
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
|
||||
asset_id = f"00000000-0000-0000-0000-{self._seq:012d}"
|
||||
self._seq += 1
|
||||
asset = Asset(
|
||||
asset_id=asset_id, symbol=symbol, markets=markets,
|
||||
display_name=display_name, status=status,
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
self._assets[asset_id] = asset
|
||||
return asset
|
||||
|
||||
def update_asset(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
markets: list[str] | None = None,
|
||||
display_name: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> Asset:
|
||||
asset = self._assets.get(asset_id)
|
||||
if asset is None:
|
||||
raise ValueError(f"asset not found: {asset_id}")
|
||||
import dataclasses
|
||||
fields = dataclasses.asdict(asset)
|
||||
if markets is not None:
|
||||
fields["markets"] = markets
|
||||
if display_name is not None:
|
||||
fields["display_name"] = display_name
|
||||
if status is not None:
|
||||
fields["status"] = status
|
||||
updated = Asset(**fields)
|
||||
self._assets[asset_id] = updated
|
||||
return updated
|
||||
|
||||
def list_assets(self) -> list[Asset]:
|
||||
return list(self._assets.values())
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _build_asset_runner(repo: _AssetFakeRepo):
|
||||
from typer.testing import CliRunner
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
|
||||
settings = Settings(allowed_origins_raw="http://localhost:3000")
|
||||
app = create_app(
|
||||
settings_factory=lambda: settings,
|
||||
repository_factory=lambda _: repo,
|
||||
)
|
||||
return CliRunner(), app
|
||||
|
||||
|
||||
# REQ-010 AC-1: pfhub asset create
|
||||
def test_asset_cli_create_success() -> None:
|
||||
repo = _AssetFakeRepo()
|
||||
runner, app = _build_asset_runner(repo)
|
||||
result = runner.invoke(app, ["asset", "create", "005930", "--market", "KRX", "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "005930" in result.output
|
||||
|
||||
|
||||
# REQ-010 AC-2: pfhub asset list — active/inactive filter
|
||||
def test_asset_cli_list_returns_all() -> None:
|
||||
repo = _AssetFakeRepo()
|
||||
repo.insert_asset(symbol="AAPL", markets=["NASDAQ"], status="active")
|
||||
repo.insert_asset(symbol="BTC", markets=["UPBIT"], status="inactive")
|
||||
runner, app = _build_asset_runner(repo)
|
||||
result = runner.invoke(app, ["asset", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "AAPL" in result.output
|
||||
assert "BTC" in result.output
|
||||
|
||||
|
||||
def test_asset_cli_list_active_only_filter() -> None:
|
||||
repo = _AssetFakeRepo()
|
||||
repo.insert_asset(symbol="AAPL", markets=["NASDAQ"], status="active")
|
||||
repo.insert_asset(symbol="BTC", markets=["UPBIT"], status="inactive")
|
||||
runner, app = _build_asset_runner(repo)
|
||||
result = runner.invoke(app, ["asset", "list", "--active-only"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "AAPL" in result.output
|
||||
assert "BTC" not in result.output
|
||||
|
||||
|
||||
# REQ-010 AC-3: pfhub asset update
|
||||
def test_asset_cli_update_success() -> None:
|
||||
repo = _AssetFakeRepo()
|
||||
asset = repo.insert_asset(symbol="005930", markets=["KRX"])
|
||||
runner, app = _build_asset_runner(repo)
|
||||
result = runner.invoke(app, [
|
||||
"asset", "update", asset.asset_id,
|
||||
"--display-name", "Samsung Electronics",
|
||||
"--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "005930" in result.output
|
||||
|
||||
|
||||
# REQ-010 AC-4: pfhub asset deactivate
|
||||
def test_asset_cli_deactivate_success() -> None:
|
||||
repo = _AssetFakeRepo()
|
||||
asset = repo.insert_asset(symbol="005930", markets=["KRX"])
|
||||
runner, app = _build_asset_runner(repo)
|
||||
result = runner.invoke(app, ["asset", "deactivate", asset.asset_id, "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert repo._assets[asset.asset_id].status == "inactive"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-010 HTTP: admin catalog endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_catalog_http_client(monkeypatch=None):
|
||||
"""Build TestClient with mock admin-auth for catalog endpoint tests."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import create_app
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import TokenPrincipal
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
settings = Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_ENC_KEY,
|
||||
)
|
||||
admin_principal = TokenPrincipal(
|
||||
token_id="t-admin", user_id="admin-1", permission="admin",
|
||||
token_type="admin", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
return mock_repo, settings
|
||||
|
||||
|
||||
def _make_asset_obj(asset_id: str = _ASSET_UUID, symbol: str = "005930") -> Asset:
|
||||
from datetime import datetime, timezone
|
||||
return Asset(
|
||||
asset_id=asset_id,
|
||||
symbol=symbol,
|
||||
markets=["KRX"],
|
||||
display_name="Samsung",
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_asset_http_create_returns_201() -> None:
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import create_app
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import TokenPrincipal
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
settings = Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_ENC_KEY,
|
||||
)
|
||||
admin_principal = TokenPrincipal(
|
||||
token_id="t-admin", user_id="admin-1", permission="admin",
|
||||
token_type="admin", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.insert_asset.return_value = _make_asset_obj()
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/assets",
|
||||
json={"symbol": "005930", "markets": ["KRX"]},
|
||||
headers={"Authorization": "Bearer admin-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["symbol"] == "005930"
|
||||
assert body["status"] == "active"
|
||||
assert "asset_id" in body
|
||||
|
||||
|
||||
def test_asset_http_update_returns_200() -> None:
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import create_app
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import TokenPrincipal
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
settings = Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_ENC_KEY,
|
||||
)
|
||||
admin_principal = TokenPrincipal(
|
||||
token_id="t-admin", user_id="admin-1", permission="admin",
|
||||
token_type="admin", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.update_asset.return_value = _make_asset_obj()
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
resp = client.patch(
|
||||
f"/v1/assets/{_ASSET_UUID}",
|
||||
json={"display_name": "Samsung Electronics"},
|
||||
headers={"Authorization": "Bearer admin-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["asset_id"] == _ASSET_UUID
|
||||
|
||||
|
||||
def test_asset_http_deactivate_returns_200() -> None:
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import create_app
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import TokenPrincipal
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
settings = Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_ENC_KEY,
|
||||
)
|
||||
admin_principal = TokenPrincipal(
|
||||
token_id="t-admin", user_id="admin-1", permission="admin",
|
||||
token_type="admin", runtime_id="rt-1",
|
||||
)
|
||||
inactive_asset = _make_asset_obj()
|
||||
import dataclasses
|
||||
inactive_asset = dataclasses.replace(inactive_asset, status="inactive")
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = admin_principal
|
||||
mock_repo.update_asset.return_value = inactive_asset
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
resp = client.patch(
|
||||
f"/v1/assets/{_ASSET_UUID}/deactivate",
|
||||
headers={"Authorization": "Bearer admin-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "inactive"
|
||||
|
||||
|
||||
def test_asset_http_non_admin_403() -> None:
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import create_app
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import TokenPrincipal
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
settings = Settings(
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_ENC_KEY,
|
||||
)
|
||||
user_principal = TokenPrincipal(
|
||||
token_id="t-user", user_id="user-1", permission="read",
|
||||
token_type="user", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = user_principal
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/assets",
|
||||
json={"symbol": "005930", "markets": ["KRX"]},
|
||||
headers={"Authorization": "Bearer user-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REQ-012: Portfolio create catalog validation
|
||||
# ===========================================================================
|
||||
|
||||
class _PortfolioServiceFakeRepo:
|
||||
"""FakeRepo for PortfolioService catalog validation tests."""
|
||||
|
||||
def __init__(self, asset: Asset | None = None) -> None:
|
||||
self._asset = asset
|
||||
self._strategy_sets: dict = {}
|
||||
self._portfolios: dict = {}
|
||||
|
||||
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_asset_by_symbol(self, symbol: str) -> Asset | None:
|
||||
if self._asset and self._asset.symbol == symbol:
|
||||
return self._asset
|
||||
return None
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str):
|
||||
from app.domain.models import StrategySet
|
||||
key = f"{name}:{config}"
|
||||
if key not in self._strategy_sets:
|
||||
self._strategy_sets[key] = StrategySet(id=f"ss-{len(self._strategy_sets)}", name=name, config=config, nick=name)
|
||||
return self._strategy_sets[key]
|
||||
|
||||
def create_portfolio(self, portfolio_id: str, *, name, owner_user_id, broker, status, asset_id, **kwargs):
|
||||
from app.domain.models import Portfolio
|
||||
portfolio = Portfolio(
|
||||
portfolio_id=portfolio_id, broker=broker, status=status, name=name,
|
||||
symbol=asset_id, asset_id=asset_id, owner_user_id=owner_user_id,
|
||||
cash_balance=kwargs.get("cash_balance", 0.0),
|
||||
asset_quantity=kwargs.get("asset_quantity", 0.0),
|
||||
price=kwargs.get("asset_price", 0.0),
|
||||
profit_buffer=kwargs.get("profit_buffer", 0.0),
|
||||
active_strategy_id=kwargs.get("active_strategy_id"),
|
||||
)
|
||||
self._portfolios[portfolio_id] = portfolio
|
||||
return portfolio
|
||||
|
||||
def get(self, portfolio_id: str):
|
||||
p = self._portfolios.get(portfolio_id)
|
||||
if p is None:
|
||||
raise ValueError(f"portfolio not found: {portfolio_id}")
|
||||
return p
|
||||
|
||||
def get_strategy_set(self, id: str):
|
||||
return None
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, *, limit: int = 5):
|
||||
return []
|
||||
|
||||
def list_comments(self, portfolio_id: str):
|
||||
return []
|
||||
|
||||
def list_strategy_sets(self):
|
||||
return []
|
||||
|
||||
def list_portfolios(self, **kwargs):
|
||||
return []
|
||||
|
||||
def list_ledger_entries(self, portfolio_id: str, **kwargs):
|
||||
return []
|
||||
|
||||
def list_broker_credentials(self, **kwargs):
|
||||
return []
|
||||
|
||||
|
||||
_CATALOG_ASSET_ID = "aaaaaaaa-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _active_asset() -> Asset:
|
||||
return Asset(asset_id=_CATALOG_ASSET_ID, symbol="005930", markets=["KRX"], status="active")
|
||||
|
||||
|
||||
def _inactive_asset() -> Asset:
|
||||
return Asset(asset_id=_CATALOG_ASSET_ID, symbol="005930", markets=["KRX"], status="inactive")
|
||||
|
||||
|
||||
# REQ-012 AC-1: active asset → portfolio create succeeds
|
||||
def test_portfolio_create_with_active_asset() -> None:
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
from app.schemas.portfolio import CreatePortfolioRequest
|
||||
|
||||
repo = _PortfolioServiceFakeRepo(asset=_active_asset())
|
||||
service = PortfolioService(repo)
|
||||
result = service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
name="Test_Portfolio",
|
||||
asset_id=_CATALOG_ASSET_ID,
|
||||
broker="kis",
|
||||
),
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# REQ-012 AC-2: unknown asset_id → "asset not found" error
|
||||
def test_portfolio_create_unknown_asset() -> None:
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
from app.schemas.portfolio import CreatePortfolioRequest
|
||||
|
||||
repo = _PortfolioServiceFakeRepo(asset=None)
|
||||
service = PortfolioService(repo)
|
||||
with pytest.raises(ValueError, match="asset not found"):
|
||||
service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
name="Test_Portfolio",
|
||||
asset_id="nonexistent-uuid",
|
||||
broker="kis",
|
||||
),
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
# REQ-012 AC-3: inactive asset → "asset is inactive" error
|
||||
def test_portfolio_create_inactive_asset() -> None:
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
from app.schemas.portfolio import CreatePortfolioRequest
|
||||
|
||||
repo = _PortfolioServiceFakeRepo(asset=_inactive_asset())
|
||||
service = PortfolioService(repo)
|
||||
with pytest.raises(ValueError, match="asset is inactive"):
|
||||
service.create_portfolio(
|
||||
CreatePortfolioRequest(
|
||||
name="Test_Portfolio",
|
||||
asset_id=_CATALOG_ASSET_ID,
|
||||
broker="kis",
|
||||
),
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
# REQ-012 AC-4: --symbol resolves asset_id and creates portfolio
|
||||
def test_portfolio_create_by_symbol() -> None:
|
||||
from typer.testing import CliRunner
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import User, StrategySet
|
||||
|
||||
class _FullFakeRepo(_PortfolioServiceFakeRepo):
|
||||
def __init__(self):
|
||||
super().__init__(asset=_active_asset())
|
||||
self._users = {"user-1": User(id="user-1", username="alice", permission="read", is_active=True)}
|
||||
self._users_by_username = {"alice": self._users["user-1"]}
|
||||
|
||||
def get_user_by_username(self, username: str):
|
||||
return self._users_by_username.get(username)
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str):
|
||||
return next(
|
||||
(p for p in self._portfolios.values()
|
||||
if p.owner_user_id == owner_user_id and p.name == name),
|
||||
None,
|
||||
)
|
||||
|
||||
repo = _FullFakeRepo()
|
||||
settings = Settings(allowed_origins_raw="http://localhost:3000")
|
||||
app = create_app(
|
||||
settings_factory=lambda: settings,
|
||||
repository_factory=lambda _: repo,
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"portfolio", "create", "Test_Portfolio",
|
||||
"--user", "alice",
|
||||
"--symbol", "005930",
|
||||
"--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert len(repo._portfolios) == 1
|
||||
assert list(repo._portfolios.values())[0].asset_id == _CATALOG_ASSET_ID
|
||||
|
||||
+4
-3
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -332,10 +332,11 @@ class FakeRepo:
|
||||
return self._portfolio
|
||||
|
||||
def get_asset_by_id(self, asset_id: str):
|
||||
return None
|
||||
return Asset(asset_id=asset_id, symbol="TEST", markets=[], status="active")
|
||||
|
||||
def get_asset_by_symbol(self, symbol: str):
|
||||
return None
|
||||
fake_id = "00000000-0000-0000-0000-000000000001"
|
||||
return Asset(asset_id=fake_id, symbol=symbol, markets=[], status="active")
|
||||
|
||||
def list_assets(self):
|
||||
return []
|
||||
|
||||
@@ -98,6 +98,7 @@ def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None:
|
||||
owner_user_id="user-1",
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id="d-1",
|
||||
market=None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.domain.models import Asset, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -110,10 +110,10 @@ class FakeRepo:
|
||||
return portfolio
|
||||
|
||||
def get_asset_by_id(self, asset_id: str):
|
||||
return None
|
||||
return Asset(asset_id=asset_id, symbol="TEST", markets=[], status="active")
|
||||
|
||||
def get_asset_by_symbol(self, symbol: str):
|
||||
return None
|
||||
return Asset(asset_id=_TEST_ASSET_ID, symbol=symbol, markets=[], status="active")
|
||||
|
||||
def list_assets(self):
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Tests for BAT-003: tags DB foundation (REQ-001, REQ-002, REQ-003, REQ-006).
|
||||
|
||||
DB-integration ACs (schema inspection, GIN index, migration idempotency) require
|
||||
a live PostgreSQL database and are documented in
|
||||
docs/batches/bat-003_20260519_tags-db-foundation/verification.md.
|
||||
The tests here cover all domain-model, schema, and app-level ACs that run without DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import Asset, Portfolio, StrategySet, Tag, TokenPrincipal
|
||||
from app.main import create_app
|
||||
from app.schemas.v1 import AssetItem, TagRefItem
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
_DAEMON_ID = "d-001"
|
||||
_USER_ID = "user-001"
|
||||
|
||||
_TEST_TAG_ID_1 = "aaaaaaaa-0000-0000-0000-000000000001"
|
||||
_TEST_TAG_ID_2 = "aaaaaaaa-0000-0000-0000-000000000002"
|
||||
_TEST_ASSET_ID = "cccccccc-0000-0000-0000-000000000001"
|
||||
_TEST_PORTFOLIO_ID = "p-001"
|
||||
|
||||
|
||||
def _make_tag(
|
||||
*,
|
||||
tag_id: str = _TEST_TAG_ID_1,
|
||||
name: str = "momentum",
|
||||
created_by_user_id: str = "user-001",
|
||||
is_admin_tag: bool = False,
|
||||
is_active: bool = True,
|
||||
) -> Tag:
|
||||
return Tag(
|
||||
tag_id=tag_id,
|
||||
name=name,
|
||||
created_by_user_id=created_by_user_id,
|
||||
is_admin_tag=is_admin_tag,
|
||||
is_active=is_active,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _make_portfolio(
|
||||
*,
|
||||
portfolio_id: str = _TEST_PORTFOLIO_ID,
|
||||
tag_ids: list[str] | None = None,
|
||||
) -> Portfolio:
|
||||
return Portfolio(
|
||||
portfolio_id=portfolio_id,
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="Test Portfolio",
|
||||
symbol="TEST",
|
||||
asset_id=_TEST_ASSET_ID,
|
||||
owner_user_id=_USER_ID,
|
||||
tag_ids=tag_ids or [],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory FakeRepo for tag master tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _TagFakeRepo:
|
||||
"""Minimal in-memory repo for tag master CRUD tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tags: dict[str, Tag] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
def create_tag(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
created_by_user_id: str,
|
||||
is_admin_tag: bool = False,
|
||||
) -> Tag:
|
||||
for t in self._tags.values():
|
||||
if t.name == name:
|
||||
raise ValueError(f"이미 존재하는 태그 이름입니다: {name}")
|
||||
tag_id = str(uuid.uuid4())
|
||||
tag = Tag(
|
||||
tag_id=tag_id,
|
||||
name=name,
|
||||
created_by_user_id=created_by_user_id,
|
||||
is_admin_tag=is_admin_tag,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._tags[tag_id] = tag
|
||||
return tag
|
||||
|
||||
def get_tag_by_id(self, tag_id: str) -> Tag | None:
|
||||
return self._tags.get(tag_id)
|
||||
|
||||
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]:
|
||||
return {tid: self._tags[tid] for tid in tag_ids if tid in self._tags}
|
||||
|
||||
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
|
||||
tags = list(self._tags.values())
|
||||
if not include_inactive:
|
||||
tags = [t for t in tags if t.is_active]
|
||||
return sorted(tags, key=lambda t: t.name)
|
||||
|
||||
def deactivate_tag(self, tag_id: str) -> Tag:
|
||||
tag = self._tags.get(tag_id)
|
||||
if tag is None:
|
||||
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
|
||||
# Tags are frozen dataclasses, so replace with updated instance
|
||||
from dataclasses import replace
|
||||
updated = replace(tag, is_active=False)
|
||||
self._tags[tag_id] = updated
|
||||
return updated
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-001 AC-2,3: Tag model and is_admin_tag flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tag_domain_model_fields() -> None:
|
||||
"""REQ-001: Tag domain model has all required fields."""
|
||||
tag = _make_tag()
|
||||
assert hasattr(tag, "tag_id")
|
||||
assert hasattr(tag, "name")
|
||||
assert hasattr(tag, "created_by_user_id")
|
||||
assert hasattr(tag, "is_admin_tag")
|
||||
assert hasattr(tag, "is_active")
|
||||
assert hasattr(tag, "created_at")
|
||||
|
||||
|
||||
def test_create_tag_with_admin_flag_true() -> None:
|
||||
"""REQ-001 AC-2: admin 권한으로 태그 생성 시 is_admin_tag = TRUE."""
|
||||
repo = _TagFakeRepo()
|
||||
tag = repo.create_tag(name="admin-tag", created_by_user_id="admin-1", is_admin_tag=True)
|
||||
assert tag.is_admin_tag is True
|
||||
assert tag.is_active is True
|
||||
assert tag.name == "admin-tag"
|
||||
|
||||
|
||||
def test_create_tag_with_admin_flag_false() -> None:
|
||||
"""REQ-001 AC-3: non-admin 토큰으로 태그 생성 시 is_admin_tag = FALSE."""
|
||||
repo = _TagFakeRepo()
|
||||
tag = repo.create_tag(name="user-tag", created_by_user_id="user-1", is_admin_tag=False)
|
||||
assert tag.is_admin_tag is False
|
||||
assert tag.is_active is True
|
||||
|
||||
|
||||
def test_create_tag_duplicate_name_raises() -> None:
|
||||
"""REQ-001 UNIQUE(name) 제약 — 동일 이름 생성 시 오류."""
|
||||
repo = _TagFakeRepo()
|
||||
repo.create_tag(name="momentum", created_by_user_id="user-1")
|
||||
with pytest.raises(ValueError, match="이미 존재하는 태그 이름입니다"):
|
||||
repo.create_tag(name="momentum", created_by_user_id="user-2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-001 AC-4,5: Deactivation (soft delete)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_deactivate_tag_sets_is_active_false() -> None:
|
||||
"""REQ-001 AC-4: 태그 비활성화 시 is_active = FALSE."""
|
||||
repo = _TagFakeRepo()
|
||||
tag = repo.create_tag(name="momentum", created_by_user_id="user-1")
|
||||
deactivated = repo.deactivate_tag(tag.tag_id)
|
||||
assert deactivated.is_active is False
|
||||
|
||||
|
||||
def test_deactivate_tag_does_not_delete_row() -> None:
|
||||
"""REQ-001 AC-4: 비활성화 후 행이 DB에서 삭제되지 않는다."""
|
||||
repo = _TagFakeRepo()
|
||||
tag = repo.create_tag(name="momentum", created_by_user_id="user-1")
|
||||
repo.deactivate_tag(tag.tag_id)
|
||||
# Should still be retrievable
|
||||
found = repo.get_tag_by_id(tag.tag_id)
|
||||
assert found is not None
|
||||
assert found.is_active is False
|
||||
|
||||
|
||||
def test_get_inactive_tag_returns_row() -> None:
|
||||
"""REQ-001 AC-5: is_active = FALSE 태그를 조회하면 행이 반환된다."""
|
||||
repo = _TagFakeRepo()
|
||||
tag = repo.create_tag(name="momentum", created_by_user_id="user-1")
|
||||
repo.deactivate_tag(tag.tag_id)
|
||||
found = repo.get_tag_by_id(tag.tag_id)
|
||||
assert found is not None
|
||||
assert found.is_active is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-001: list_tags_master 필터링
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_tags_master_active_only_by_default() -> None:
|
||||
"""REQ-001: list_tags_master 기본값 — 활성 태그만 반환."""
|
||||
repo = _TagFakeRepo()
|
||||
t1 = repo.create_tag(name="active-tag", created_by_user_id="u1")
|
||||
t2 = repo.create_tag(name="inactive-tag", created_by_user_id="u1")
|
||||
repo.deactivate_tag(t2.tag_id)
|
||||
active = repo.list_tags_master()
|
||||
assert len(active) == 1
|
||||
assert active[0].tag_id == t1.tag_id
|
||||
|
||||
|
||||
def test_list_tags_master_include_inactive() -> None:
|
||||
"""REQ-001: include_inactive=True 시 비활성 태그 포함 전체 반환."""
|
||||
repo = _TagFakeRepo()
|
||||
repo.create_tag(name="active-tag", created_by_user_id="u1")
|
||||
t2 = repo.create_tag(name="inactive-tag", created_by_user_id="u1")
|
||||
repo.deactivate_tag(t2.tag_id)
|
||||
all_tags = repo.list_tags_master(include_inactive=True)
|
||||
assert len(all_tags) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-002/003: Portfolio.tag_ids field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_portfolio_has_tag_ids_field() -> None:
|
||||
"""REQ-002/003: Portfolio 도메인 모델에 tag_ids 필드가 존재한다."""
|
||||
p = _make_portfolio(tag_ids=[_TEST_TAG_ID_1])
|
||||
assert hasattr(p, "tag_ids")
|
||||
assert p.tag_ids == [_TEST_TAG_ID_1]
|
||||
|
||||
|
||||
def test_portfolio_tag_ids_defaults_empty() -> None:
|
||||
"""REQ-002 AC-4: tag_ids 없는 portfolio는 빈 배열로 초기화된다."""
|
||||
p = _make_portfolio(tag_ids=[])
|
||||
assert p.tag_ids == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-002 AC-3,4 / REQ-003 AC-4: archived 표시 — GET /v1/assets response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_assets_fake_repo(portfolios: list[Portfolio], tags: list[Tag]) -> MagicMock:
|
||||
"""Build a MagicMock repo that returns given portfolios and tags."""
|
||||
repo = MagicMock()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id=_USER_ID, permission="read",
|
||||
token_type="user", runtime_id="rt-1",
|
||||
)
|
||||
repo.get_token_principal.return_value = principal
|
||||
repo.list_portfolios_with_strategies.return_value = portfolios
|
||||
tags_by_id = {t.tag_id: t for t in tags}
|
||||
repo.get_tags_by_ids.side_effect = lambda ids: {
|
||||
tid: tags_by_id[tid] for tid in ids if tid in tags_by_id
|
||||
}
|
||||
repo.get_strategy_set.return_value = None
|
||||
return repo
|
||||
|
||||
|
||||
def _make_v1_settings(monkeypatch) -> Settings:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _ENC_KEY)
|
||||
return Settings(allowed_origins_raw="http://localhost:3000")
|
||||
|
||||
|
||||
def _make_v1_client(settings: Settings) -> TestClient:
|
||||
get_settings.cache_clear()
|
||||
v1_app = create_app(settings_factory=lambda: settings)
|
||||
v1_app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(v1_app)
|
||||
|
||||
|
||||
def test_asset_item_tag_ids_field_exists() -> None:
|
||||
"""REQ-002/003: AssetItem 스키마에 tag_ids 필드가 존재한다."""
|
||||
item = AssetItem(
|
||||
asset_id="p-1",
|
||||
symbol="A",
|
||||
status="enabled",
|
||||
cash=0.0,
|
||||
quantity=0.0,
|
||||
broker_type="kis",
|
||||
tag_ids=[TagRefItem(tag_id=_TEST_TAG_ID_1, archived=False)],
|
||||
)
|
||||
assert item.tag_ids is not None
|
||||
assert len(item.tag_ids) == 1
|
||||
assert item.tag_ids[0].tag_id == _TEST_TAG_ID_1
|
||||
assert item.tag_ids[0].archived is False
|
||||
|
||||
|
||||
def test_v1_assets_active_tag_not_archived(monkeypatch) -> None:
|
||||
"""REQ-002 AC-3 (정상 케이스): active 태그는 archived=False로 반환."""
|
||||
settings = _make_v1_settings(monkeypatch)
|
||||
tag = _make_tag(tag_id=_TEST_TAG_ID_1, is_active=True)
|
||||
portfolio = _make_portfolio(tag_ids=[_TEST_TAG_ID_1])
|
||||
mock_repo = _make_assets_fake_repo([portfolio], [tag])
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_v1_client(settings)
|
||||
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assets = resp.json()["assets"]
|
||||
assert len(assets) == 1
|
||||
tag_ids = assets[0]["tag_ids"]
|
||||
assert tag_ids is not None
|
||||
assert len(tag_ids) == 1
|
||||
assert tag_ids[0]["tag_id"] == _TEST_TAG_ID_1
|
||||
assert tag_ids[0]["archived"] is False
|
||||
|
||||
|
||||
def test_v1_assets_inactive_tag_archived_true(monkeypatch) -> None:
|
||||
"""REQ-002 AC-3: inactive 태그는 archived=True로 반환되고 배열에서 제거되지 않는다."""
|
||||
settings = _make_v1_settings(monkeypatch)
|
||||
active_tag = _make_tag(tag_id=_TEST_TAG_ID_1, name="active", is_active=True)
|
||||
inactive_tag = _make_tag(tag_id=_TEST_TAG_ID_2, name="inactive", is_active=False)
|
||||
portfolio = _make_portfolio(tag_ids=[_TEST_TAG_ID_1, _TEST_TAG_ID_2])
|
||||
mock_repo = _make_assets_fake_repo([portfolio], [active_tag, inactive_tag])
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_v1_client(settings)
|
||||
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assets = resp.json()["assets"]
|
||||
assert len(assets) == 1
|
||||
tag_ids = assets[0]["tag_ids"]
|
||||
assert tag_ids is not None
|
||||
assert len(tag_ids) == 2 # not removed
|
||||
|
||||
by_id = {item["tag_id"]: item for item in tag_ids}
|
||||
assert by_id[_TEST_TAG_ID_1]["archived"] is False
|
||||
assert by_id[_TEST_TAG_ID_2]["archived"] is True
|
||||
|
||||
|
||||
def test_v1_assets_empty_tag_ids_returns_none(monkeypatch) -> None:
|
||||
"""REQ-002 AC-4: tag_ids = [] 인 portfolio의 응답에서 tag_ids = None."""
|
||||
settings = _make_v1_settings(monkeypatch)
|
||||
portfolio = _make_portfolio(tag_ids=[])
|
||||
mock_repo = _make_assets_fake_repo([portfolio], [])
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_v1_client(settings)
|
||||
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assets = resp.json()["assets"]
|
||||
assert len(assets) == 1
|
||||
assert assets[0]["tag_ids"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-006 AC-1~6: Migration logic (Python-level simulation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _simulate_migration(
|
||||
portfolio_tags_rows: list[tuple[str, str]], # (portfolio_id, tag_value)
|
||||
users: list[str],
|
||||
admin_user_id: str,
|
||||
) -> tuple[list[dict], dict[str, list[str]]]:
|
||||
"""Simulate the migration logic without a DB connection.
|
||||
|
||||
Returns:
|
||||
tags_table: list of {name, created_by_user_id, is_admin_tag}
|
||||
portfolios_tag_ids: {portfolio_id: [tag_name, ...]} after migration
|
||||
"""
|
||||
# Step 1: Verify admin user
|
||||
if admin_user_id not in users:
|
||||
raise ValueError(f"Migration admin user not found: {admin_user_id}")
|
||||
|
||||
# Step 2: Deduplicate by lowercase, insert ON CONFLICT DO NOTHING
|
||||
seen_names: dict[str, dict] = {}
|
||||
for _, tag_value in portfolio_tags_rows:
|
||||
lower = tag_value.lower()
|
||||
if lower not in seen_names:
|
||||
seen_names[lower] = {
|
||||
"name": lower,
|
||||
"created_by_user_id": admin_user_id,
|
||||
"is_admin_tag": False,
|
||||
"tag_id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
tags_table = list(seen_names.values())
|
||||
name_to_id = {t["name"]: t["tag_id"] for t in tags_table}
|
||||
|
||||
# Step 3: Fill portfolios.tag_ids
|
||||
portfolios_tag_ids: dict[str, list[str]] = {}
|
||||
for portfolio_id, tag_value in portfolio_tags_rows:
|
||||
lower = tag_value.lower()
|
||||
portfolios_tag_ids.setdefault(portfolio_id, [])
|
||||
tag_id = name_to_id[lower]
|
||||
if tag_id not in portfolios_tag_ids[portfolio_id]:
|
||||
portfolios_tag_ids[portfolio_id].append(tag_id)
|
||||
|
||||
return tags_table, portfolios_tag_ids
|
||||
|
||||
|
||||
def test_migration_ac1_tags_created_with_correct_attrs() -> None:
|
||||
"""REQ-006 AC-1: 마이그레이션 후 tags 행이 is_admin_tag=FALSE, created_by_user_id=admin ID."""
|
||||
rows = [("p1", "Momentum"), ("p2", "value")]
|
||||
tags, _ = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
|
||||
assert all(t["is_admin_tag"] is False for t in tags)
|
||||
assert all(t["created_by_user_id"] == "admin-1" for t in tags)
|
||||
|
||||
|
||||
def test_migration_ac2_case_insensitive_dedup() -> None:
|
||||
"""REQ-006 AC-2: 'TestTag'와 'testtag'는 tags에 1행만 생성된다."""
|
||||
rows = [("p1", "TestTag"), ("p2", "testtag"), ("p3", "TESTTAG")]
|
||||
tags, _ = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
|
||||
assert len(tags) == 1
|
||||
assert tags[0]["name"] == "testtag"
|
||||
|
||||
|
||||
def test_migration_ac3_portfolio_tag_ids_populated() -> None:
|
||||
"""REQ-006 AC-3: 각 portfolio.tag_ids에 기존 portfolio_tags 연결 tag_id가 포함된다."""
|
||||
rows = [("p1", "momentum"), ("p1", "value"), ("p2", "growth")]
|
||||
tags, portfolios_tag_ids = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
|
||||
assert len(portfolios_tag_ids["p1"]) == 2
|
||||
assert len(portfolios_tag_ids["p2"]) == 1
|
||||
|
||||
|
||||
def test_migration_ac4_portfolio_tags_dropped() -> None:
|
||||
"""REQ-006 AC-4: 마이그레이션 후 portfolio_tags 테이블이 존재하지 않는다 (migration script 검증)."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0021" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert "DROP TABLE" in content
|
||||
assert "portfolio_tags" in content
|
||||
|
||||
|
||||
def test_migration_ac5_invalid_admin_id_aborts() -> None:
|
||||
"""REQ-006 AC-5: 유효하지 않은 admin ID 지정 시 ValueError로 중단."""
|
||||
rows = [("p1", "momentum")]
|
||||
with pytest.raises(ValueError, match="Migration admin user not found"):
|
||||
_simulate_migration(rows, users=["admin-1"], admin_user_id="nonexistent-admin")
|
||||
|
||||
|
||||
def test_migration_ac6_distinct_tag_count_matches() -> None:
|
||||
"""REQ-006 AC-6: DISTINCT tag_value 수 N == 마이그레이션 후 tags 행 수 N."""
|
||||
rows = [("p1", "A"), ("p2", "B"), ("p3", "a"), ("p4", "C")] # 'A'/'a' deduplicate
|
||||
tags, _ = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
|
||||
distinct_lower = len({v.lower() for _, v in rows})
|
||||
assert len(tags) == distinct_lower # 3: a, b, c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-001 AC-1: Migration files exist (schema coverage)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_migration_0019_tags_table_exists() -> None:
|
||||
"""REQ-001 AC-1: tags 테이블 생성 마이그레이션 파일이 존재한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
files = [f for f in os.listdir(migrations_dir) if "0019" in f]
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
def test_migration_0019_creates_tags_table() -> None:
|
||||
"""REQ-001 AC-1: revision 0019가 tags 테이블을 생성한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0019" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert '"tags"' in content
|
||||
assert "tag_id" in content
|
||||
assert "name" in content
|
||||
assert "created_by_user_id" in content
|
||||
assert "is_admin_tag" in content
|
||||
assert "is_active" in content
|
||||
|
||||
|
||||
def test_migration_0020_portfolios_tag_ids_column() -> None:
|
||||
"""REQ-002/003 AC-1: revision 0020이 portfolios.tag_ids 컬럼과 GIN 인덱스를 추가한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0020" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert "tag_ids" in content
|
||||
assert "gin" in content.lower()
|
||||
assert "portfolios" in content
|
||||
|
||||
|
||||
def test_migration_0021_data_migration_exists() -> None:
|
||||
"""REQ-006: revision 0021 데이터 마이그레이션 파일이 존재한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
files = [f for f in os.listdir(migrations_dir) if "0021" in f]
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
def test_migration_0021_has_admin_verification() -> None:
|
||||
"""REQ-006 AC-5: revision 0021이 admin ID 존재 검증 로직을 포함한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0021" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert "admin_user_id" in content
|
||||
assert "users" in content
|
||||
assert "run_migration" in content
|
||||
@@ -284,6 +284,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
owner_user_id="user-1",
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id="d-1",
|
||||
market=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -815,6 +816,137 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
assert "market" not in body["assets"][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-013: GET /v1/assets token-type branching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_assets_user_token(monkeypatch) -> None:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
portfolio = _make_portfolio()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="user", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
|
||||
mock_repo.get_strategy_set.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/assets", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="user-1",
|
||||
include_disabled=True,
|
||||
market=None,
|
||||
)
|
||||
|
||||
|
||||
def test_v1_assets_admin_token_default(monkeypatch) -> None:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
portfolio = _make_portfolio()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="admin-1", permission="admin",
|
||||
token_type="admin", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
|
||||
mock_repo.get_strategy_set.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/assets", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="admin-1",
|
||||
include_disabled=True,
|
||||
market=None,
|
||||
)
|
||||
|
||||
|
||||
def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="admin-1", permission="admin",
|
||||
token_type="admin", runtime_id="rt-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.list_portfolios_with_strategies.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/assets?user_id=other-user", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="other-user",
|
||||
include_disabled=True,
|
||||
market=None,
|
||||
)
|
||||
|
||||
|
||||
def test_v1_assets_market_filter(monkeypatch) -> None:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.list_portfolios_with_strategies.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/assets?market=KRX", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="user-1",
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id="d-1",
|
||||
market="KRX",
|
||||
)
|
||||
|
||||
|
||||
def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None:
|
||||
import uuid as _uuid
|
||||
settings = _make_app_db(monkeypatch)
|
||||
uuid_portfolio_id = str(_uuid.uuid4())
|
||||
portfolio = Portfolio(
|
||||
portfolio_id=uuid_portfolio_id,
|
||||
broker="kis", status="enabled", name="T",
|
||||
symbol="005930", cash_balance=0.0, asset_quantity=0.0,
|
||||
price=0.0, profit_buffer=0.0, owner_user_id="user-1",
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1",
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
|
||||
mock_repo.get_strategy_set.return_value = None
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/assets", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 200
|
||||
returned_id = resp.json()["assets"][0]["asset_id"]
|
||||
_uuid.UUID(returned_id) # raises ValueError if not a valid UUID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user