diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index d0ceb1b..eac019b 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -1,4 +1,4 @@ -"""GET /v1/assets — service-token authenticated asset list endpoint.""" +"""v1 asset endpoints — portfolio list (service/user/admin) and admin catalog CRUD.""" from __future__ import annotations @@ -7,24 +7,22 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query, status -from app.core.auth import AuthContext, require_read +from app.core.auth import AuthContext, require_admin, require_read from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository -from app.schemas.v1 import AssetItem, AssetsResponse +from app.schemas.v1 import ( + AssetCatalogCreateRequest, + AssetCatalogResponse, + AssetCatalogUpdateRequest, + AssetItem, + AssetsResponse, + TagRefItem, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1", tags=["v1"]) -def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext: - if auth.token_source != "db" or auth.token_type != "service": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Service token required", - ) - return auth - - def _parse_strategy_params(strategy_set_config: str | None) -> dict: if not strategy_set_config: return {} @@ -35,70 +33,194 @@ def _parse_strategy_params(strategy_set_config: str | None) -> dict: return {} +def _get_repo(settings=None): + from app.core.config import get_settings + return get_portfolio_repository_from_settings(settings or get_settings()) + + +def _asset_response(asset) -> AssetCatalogResponse: + return AssetCatalogResponse( + asset_id=asset.asset_id, + symbol=asset.symbol, + markets=asset.markets, + display_name=asset.display_name, + status=asset.status, + created_at=asset.created_at, + updated_at=asset.updated_at, + ) + + +def _build_tag_refs(tag_ids: list[str] | None, active_map: dict[str, bool]) -> list[TagRefItem] | None: + if not tag_ids: + return None + return [ + TagRefItem(tag_id=tid, archived=not active_map.get(tid, True)) + for tid in tag_ids + ] + + +def _build_asset_items(portfolios, status_filter: str | None, repo) -> list[AssetItem]: + # Batch-fetch tag active status for all tag_ids across all portfolios + all_tag_ids: list[str] = [ + tid for p in portfolios for tid in (p.tag_ids or []) + ] + tag_active_map: dict[str, bool] = {} + if all_tag_ids: + try: + tags_by_id = repo.get_tags_by_ids(all_tag_ids) + tag_active_map = {tid: t.is_active for tid, t in tags_by_id.items()} + except Exception: + logger.warning("tag active status lookup failed") + + items = [] + for p in portfolios: + if status_filter is not None and p.status != status_filter: + continue + strategy_params: dict = {} + strategy_type: str | None = None + if p.active_strategy_id: + try: + ss = repo.get_strategy_set(p.active_strategy_id) + if ss is not None: + strategy_params = _parse_strategy_params(ss.config) + strategy_type = ss.name + except Exception: + logger.warning("strategy_set lookup failed for %s", p.active_strategy_id) + items.append( + AssetItem( + asset_id=p.portfolio_id, + symbol=p.symbol, + status=p.status, + cash=p.cash_balance, + quantity=p.asset_quantity, + broker_type=p.broker, + strategy_id=p.active_strategy_id, + strategy_type=strategy_type, + strategy_params=strategy_params, + updated_at=p.updated_at, + name=p.name, + price=p.price, + profit_buffer=p.profit_buffer, + tags=p.tags, + tag_ids=_build_tag_refs(p.tag_ids, tag_active_map), + owner_user_id=p.owner_user_id, + created_at=p.created_at, + last_trade_at=p.last_trade_at, + ) + ) + return items + + +# --------------------------------------------------------------------------- +# GET /v1/assets — token-type-branched portfolio list (REQ-013) +# --------------------------------------------------------------------------- + @router.get("/assets", response_model=AssetsResponse) def list_assets( status_filter: str | None = Query(default=None, alias="status"), - auth: AuthContext = Depends(_require_service_token), + market: str | None = Query(default=None), + user_id: str | None = Query(default=None), + auth: AuthContext = Depends(require_read), ) -> AssetsResponse: - if auth.daemon_id is None: + if auth.token_source != "db": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="daemon_id not assigned to this token", + detail="DB token required", ) - from app.core.config import get_settings - - settings = get_settings() - repo = get_portfolio_repository_from_settings(settings) + repo = _get_repo() try: - portfolios = repo.list_portfolios_with_strategies( - owner_user_id=auth.user_id, - include_disabled=False, - managed_by_daemon_id=auth.daemon_id, - ) - - # Fetch strategy_params per portfolio - items = [] - for p in portfolios: - # Optional status filter - if status_filter is not None and p.status != status_filter: - continue - - strategy_params: dict = {} - strategy_type: str | None = None - if p.active_strategy_id: - try: - ss = repo.get_strategy_set(p.active_strategy_id) - if ss is not None: - strategy_params = _parse_strategy_params(ss.config) - strategy_type = ss.name - except Exception: - logger.warning("strategy_set lookup failed for %s", p.active_strategy_id) - strategy_params = {} - - items.append( - AssetItem( - asset_id=p.portfolio_id, - symbol=p.symbol, - status=p.status, - cash=p.cash_balance, - quantity=p.asset_quantity, - broker_type=p.broker, - strategy_id=p.active_strategy_id, - strategy_type=strategy_type, - strategy_params=strategy_params, - updated_at=p.updated_at, - name=p.name, - price=p.price, - profit_buffer=p.profit_buffer, - tags=p.tags, - owner_user_id=p.owner_user_id, - created_at=p.created_at, - last_trade_at=p.last_trade_at, + if auth.token_type == "service": + if auth.daemon_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="daemon_id not assigned to this token", ) + portfolios = repo.list_portfolios_with_strategies( + owner_user_id=auth.user_id, + include_disabled=False, + managed_by_daemon_id=auth.daemon_id, + market=market, ) + elif auth.token_type == "user": + portfolios = repo.list_portfolios_with_strategies( + owner_user_id=auth.user_id, + include_disabled=True, + market=market, + ) + elif auth.token_type == "admin": + filter_user_id = user_id if user_id is not None else auth.user_id + portfolios = repo.list_portfolios_with_strategies( + owner_user_id=filter_user_id, + include_disabled=True, + market=market, + ) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Unsupported token type", + ) + + items = _build_asset_items(portfolios, status_filter, repo) finally: if callable(getattr(type(repo), "close", None)): repo.close() return AssetsResponse(assets=items, total=len(items)) + + +# --------------------------------------------------------------------------- +# Admin asset catalog CRUD (REQ-010) +# --------------------------------------------------------------------------- + +@router.post("/assets", response_model=AssetCatalogResponse, status_code=201) +def create_catalog_asset( + body: AssetCatalogCreateRequest, + auth: AuthContext = Depends(require_admin), +) -> AssetCatalogResponse: + repo = _get_repo() + try: + asset = repo.insert_asset( + symbol=body.symbol, + markets=body.markets, + display_name=body.display_name, + status=body.status, + ) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + return _asset_response(asset) + + +@router.patch("/assets/{asset_id}/deactivate", response_model=AssetCatalogResponse) +def deactivate_catalog_asset( + asset_id: str, + auth: AuthContext = Depends(require_admin), +) -> AssetCatalogResponse: + repo = _get_repo() + try: + asset = repo.update_asset(asset_id, status="inactive") + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + return _asset_response(asset) + + +@router.patch("/assets/{asset_id}", response_model=AssetCatalogResponse) +def update_catalog_asset( + asset_id: str, + body: AssetCatalogUpdateRequest, + auth: AuthContext = Depends(require_admin), +) -> AssetCatalogResponse: + repo = _get_repo() + try: + asset = repo.update_asset( + asset_id, + markets=body.markets, + display_name=body.display_name, + status=body.status, + ) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + return _asset_response(asset) diff --git a/app/cli/commands/asset.py b/app/cli/commands/asset.py new file mode 100644 index 0000000..24eed63 --- /dev/null +++ b/app/cli/commands/asset.py @@ -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']}") diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index d44cb1e..ff80541 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -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) diff --git a/app/cli/main.py b/app/cli/main.py index 81c1947..5cc9357 100644 --- a/app/cli/main.py +++ b/app/cli/main.py @@ -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") diff --git a/app/domain/models.py b/app/domain/models.py index 0902065..98d7e89 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -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 diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 69cbe95..0f3229a 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -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]: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index b1c600a..7f1beab 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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 diff --git a/app/schemas/v1.py b/app/schemas/v1.py index ac829ab..7d2e07e 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index 62461b1..f0dd035 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -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( diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/design.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/design.md new file mode 100644 index 0000000..278687c --- /dev/null +++ b/docs/batches/bat-002_20260519_asset-catalog-api-completion/design.md @@ -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=` 파라미터 지원 + +## 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=` 파라미터 시 해당 user id로 필터링 + - `?market=` 파라미터: 모든 토큰에서 지원 — 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) diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/implementation.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/implementation.md new file mode 100644 index 0000000..b974780 --- /dev/null +++ b/docs/batches/bat-002_20260519_asset-catalog-api-completion/implementation.md @@ -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에서만 검증 가능 diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/index.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/index.md new file mode 100644 index 0000000..d7c89e8 --- /dev/null +++ b/docs/batches/bat-002_20260519_asset-catalog-api-completion/index.md @@ -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 모두 존재 확인됨 diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/planning.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/planning.md new file mode 100644 index 0000000..a18bff9 --- /dev/null +++ b/docs/batches/bat-002_20260519_asset-catalog-api-completion/planning.md @@ -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=` 필터 지원 추가 +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 완료 후 실제 구현을 기준으로 작성 diff --git a/docs/batches/bat-002_20260519_asset-catalog-api-completion/verification.md b/docs/batches/bat-002_20260519_asset-catalog-api-completion/verification.md new file mode 100644 index 0000000..22e1535 --- /dev/null +++ b/docs/batches/bat-002_20260519_asset-catalog-api-completion/verification.md @@ -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= → 필터링 → `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: 없음 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/design.md b/docs/batches/bat-003_20260519_tags-db-foundation/design.md new file mode 100644 index 0000000..706ba4c --- /dev/null +++ b/docs/batches/bat-003_20260519_tags-db-foundation/design.md @@ -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`: 없음 (이 배치에서 변경 없음) diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/implementation.md b/docs/batches/bat-003_20260519_tags-db-foundation/implementation.md new file mode 100644 index 0000000..d16d282 --- /dev/null +++ b/docs/batches/bat-003_20260519_tags-db-foundation/implementation.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로 변경 필요 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/index.md b/docs/batches/bat-003_20260519_tags-db-foundation/index.md new file mode 100644 index 0000000..6d085de --- /dev/null +++ b/docs/batches/bat-003_20260519_tags-db-foundation/index.md @@ -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 컬럼)이 먼저 적용된 상태 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/planning.md b/docs/batches/bat-003_20260519_tags-db-foundation/planning.md new file mode 100644 index 0000000..cc22b72 --- /dev/null +++ b/docs/batches/bat-003_20260519_tags-db-foundation/planning.md @@ -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 결정 diff --git a/docs/batches/bat-003_20260519_tags-db-foundation/verification.md b/docs/batches/bat-003_20260519_tags-db-foundation/verification.md new file mode 100644 index 0000000..5ea762b --- /dev/null +++ b/docs/batches/bat-003_20260519_tags-db-foundation/verification.md @@ -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 항목 참조) diff --git a/docs/batches/index.md b/docs/batches/index.md index b0b75d4..b7ecfe3 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) | diff --git a/docs/data-model.md b/docs/data-model.md index ae605c9..1447498 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 5015617..c7af02f 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -101,22 +101,31 @@ ### Authentication -- All /v1 endpoints require `Authorization: Bearer ` 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 ` 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=` 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=` (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=` overrides to specified user +- Query Params: `?status=` (optional portfolio status filter), `?market=` (optional market code filter), `?user_id=` (admin only) - Response: HTTP 200 ``` @@ -124,9 +133,8 @@ "total": , "assets": [ { - "asset_id": "", // portfolio.portfolio_id (UUID after BAT-006) + "asset_id": "", // portfolio.portfolio_id "symbol": "", - "market": "", "status": "", "cash": , "quantity": , @@ -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": "", "markets": [], "display_name": "", "status": "active"}` +- Response: HTTP 201 `AssetCatalogResponse` + +``` +{ + "asset_id": "", + "symbol": "", + "markets": [], + "display_name": "", + "status": "", + "created_at": "", + "updated_at": "" +} +``` + +- Error: HTTP 403 if not admin + +### Endpoint: `PATCH /v1/assets/{asset_id}` (Admin Catalog Update) + +- Auth: admin token required +- Body: `{"markets": []|null, "display_name": "", "status": ""}` +- 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. diff --git a/docs/project-structure.md b/docs/project-structure.md index 5bfc443..f0accc6 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -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/ diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 4dc41c0..834bdbb 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -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=` 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=` query param post-filters portfolios by status field. +3. Optional `?market=` 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` diff --git a/migrations/versions/20260519_0019_tags_master_table.py b/migrations/versions/20260519_0019_tags_master_table.py new file mode 100644 index 0000000..9261c32 --- /dev/null +++ b/migrations/versions/20260519_0019_tags_master_table.py @@ -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") diff --git a/migrations/versions/20260519_0020_portfolios_tag_ids_column.py b/migrations/versions/20260519_0020_portfolios_tag_ids_column.py new file mode 100644 index 0000000..fb1eaa7 --- /dev/null +++ b/migrations/versions/20260519_0020_portfolios_tag_ids_column.py @@ -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") diff --git a/migrations/versions/20260519_0021_portfolio_tags_data_migration.py b/migrations/versions/20260519_0021_portfolio_tags_data_migration.py new file mode 100644 index 0000000..0208417 --- /dev/null +++ b/migrations/versions/20260519_0021_portfolio_tags_data_migration.py @@ -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[]")) diff --git a/tests/test_asset_catalog.py b/tests/test_asset_catalog.py index 491cdee..68110f4 100644 --- a/tests/test_asset_catalog.py +++ b/tests/test_asset_catalog.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index b9f7c40..d3e9ff3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 [] diff --git a/tests/test_daemon_assignment.py b/tests/test_daemon_assignment.py index ed7c377..8487b09 100644 --- a/tests/test_daemon_assignment.py +++ b/tests/test_daemon_assignment.py @@ -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, ) diff --git a/tests/test_portfolio_uuid.py b/tests/test_portfolio_uuid.py index 743ce8c..df614e9 100644 --- a/tests/test_portfolio_uuid.py +++ b/tests/test_portfolio_uuid.py @@ -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 [] diff --git a/tests/test_tags_master.py b/tests/test_tags_master.py new file mode 100644 index 0000000..c6cca5c --- /dev/null +++ b/tests/test_tags_master.py @@ -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 diff --git a/tests/test_v1.py b/tests/test_v1.py index fa7d150..6f5be11 100644 --- a/tests/test_v1.py +++ b/tests/test_v1.py @@ -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 # ---------------------------------------------------------------------------