feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)

This commit is contained in:
2026-05-19 22:15:55 +00:00
parent e2226e28b6
commit 158929b2d9
33 changed files with 2575 additions and 102 deletions
+157 -35
View File
@@ -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,35 +33,49 @@ def _parse_strategy_params(strategy_set_config: str | None) -> dict:
return {}
@router.get("/assets", response_model=AssetsResponse)
def list_assets(
status_filter: str | None = Query(default=None, alias="status"),
auth: AuthContext = Depends(_require_service_token),
) -> AssetsResponse:
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id not assigned to this token",
)
def _get_repo(settings=None):
from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings())
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id,
include_disabled=False,
managed_by_daemon_id=auth.daemon_id,
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,
)
# Fetch strategy_params per portfolio
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:
# 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:
@@ -74,8 +86,6 @@ def list_assets(
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,
@@ -92,13 +102,125 @@ def list_assets(
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"),
market: str | None = Query(default=None),
user_id: str | None = Query(default=None),
auth: AuthContext = Depends(require_read),
) -> AssetsResponse:
if auth.token_source != "db":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="DB token required",
)
repo = _get_repo()
try:
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)
+130
View File
@@ -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']}")
+13 -3
View File
@@ -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)
+2
View File
@@ -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")
+11
View File
@@ -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
+19 -1
View File
@@ -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]: ...
+120 -2
View File
@@ -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
+33
View File
@@ -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
+6
View File
@@ -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 항목 참조)
+2
View File
@@ -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
View File
@@ -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.
+52 -9
View File
@@ -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 -5
View File
@@ -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
View File
@@ -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[]"))
+504
View File
@@ -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
View File
@@ -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 []
+1
View File
@@ -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,
)
+3 -3
View File
@@ -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 []
+505
View File
@@ -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
+132
View File
@@ -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
# ---------------------------------------------------------------------------