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

This commit is contained in:
2026-05-19 22:15:55 +00:00
parent e2226e28b6
commit 158929b2d9
33 changed files with 2575 additions and 102 deletions
+504
View File
@@ -246,3 +246,507 @@ def test_migration_rev_b_has_data_migration_insert() -> None:
content = open(os.path.join(migrations_dir, rev_b)).read()
assert "INSERT INTO assets" in content
assert "ARRAY_AGG" in content
# ===========================================================================
# REQ-010: Admin asset catalog CRUD
# ===========================================================================
# ---------------------------------------------------------------------------
# REQ-010 CLI: FakeRepo for asset CRUD
# ---------------------------------------------------------------------------
_ASSET_UUID = "00000000-0000-0000-0000-000000000099"
class _AssetFakeRepo:
"""Minimal in-memory repo supporting asset CRUD for CLI tests."""
def __init__(self) -> None:
self._assets: dict[str, Asset] = {}
self._seq = 1
def insert_asset(
self,
*,
symbol: str,
markets: list[str],
display_name: str | None = None,
status: str = "active",
) -> Asset:
now = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
asset_id = f"00000000-0000-0000-0000-{self._seq:012d}"
self._seq += 1
asset = Asset(
asset_id=asset_id, symbol=symbol, markets=markets,
display_name=display_name, status=status,
created_at=now, updated_at=now,
)
self._assets[asset_id] = asset
return asset
def update_asset(
self,
asset_id: str,
*,
markets: list[str] | None = None,
display_name: str | None = None,
status: str | None = None,
) -> Asset:
asset = self._assets.get(asset_id)
if asset is None:
raise ValueError(f"asset not found: {asset_id}")
import dataclasses
fields = dataclasses.asdict(asset)
if markets is not None:
fields["markets"] = markets
if display_name is not None:
fields["display_name"] = display_name
if status is not None:
fields["status"] = status
updated = Asset(**fields)
self._assets[asset_id] = updated
return updated
def list_assets(self) -> list[Asset]:
return list(self._assets.values())
def close(self) -> None:
pass
def ensure_schema(self) -> None:
pass
def _build_asset_runner(repo: _AssetFakeRepo):
from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
settings = Settings(allowed_origins_raw="http://localhost:3000")
app = create_app(
settings_factory=lambda: settings,
repository_factory=lambda _: repo,
)
return CliRunner(), app
# REQ-010 AC-1: pfhub asset create
def test_asset_cli_create_success() -> None:
repo = _AssetFakeRepo()
runner, app = _build_asset_runner(repo)
result = runner.invoke(app, ["asset", "create", "005930", "--market", "KRX", "--force"])
assert result.exit_code == 0, result.output
assert "005930" in result.output
# REQ-010 AC-2: pfhub asset list — active/inactive filter
def test_asset_cli_list_returns_all() -> None:
repo = _AssetFakeRepo()
repo.insert_asset(symbol="AAPL", markets=["NASDAQ"], status="active")
repo.insert_asset(symbol="BTC", markets=["UPBIT"], status="inactive")
runner, app = _build_asset_runner(repo)
result = runner.invoke(app, ["asset", "list"])
assert result.exit_code == 0, result.output
assert "AAPL" in result.output
assert "BTC" in result.output
def test_asset_cli_list_active_only_filter() -> None:
repo = _AssetFakeRepo()
repo.insert_asset(symbol="AAPL", markets=["NASDAQ"], status="active")
repo.insert_asset(symbol="BTC", markets=["UPBIT"], status="inactive")
runner, app = _build_asset_runner(repo)
result = runner.invoke(app, ["asset", "list", "--active-only"])
assert result.exit_code == 0, result.output
assert "AAPL" in result.output
assert "BTC" not in result.output
# REQ-010 AC-3: pfhub asset update
def test_asset_cli_update_success() -> None:
repo = _AssetFakeRepo()
asset = repo.insert_asset(symbol="005930", markets=["KRX"])
runner, app = _build_asset_runner(repo)
result = runner.invoke(app, [
"asset", "update", asset.asset_id,
"--display-name", "Samsung Electronics",
"--force",
])
assert result.exit_code == 0, result.output
assert "005930" in result.output
# REQ-010 AC-4: pfhub asset deactivate
def test_asset_cli_deactivate_success() -> None:
repo = _AssetFakeRepo()
asset = repo.insert_asset(symbol="005930", markets=["KRX"])
runner, app = _build_asset_runner(repo)
result = runner.invoke(app, ["asset", "deactivate", asset.asset_id, "--force"])
assert result.exit_code == 0, result.output
assert repo._assets[asset.asset_id].status == "inactive"
# ---------------------------------------------------------------------------
# REQ-010 HTTP: admin catalog endpoints
# ---------------------------------------------------------------------------
def _make_catalog_http_client(monkeypatch=None):
"""Build TestClient with mock admin-auth for catalog endpoint tests."""
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import create_app
from app.core.config import Settings, get_settings
from app.domain.models import TokenPrincipal
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
settings = Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_ENC_KEY,
)
admin_principal = TokenPrincipal(
token_id="t-admin", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
return mock_repo, settings
def _make_asset_obj(asset_id: str = _ASSET_UUID, symbol: str = "005930") -> Asset:
from datetime import datetime, timezone
return Asset(
asset_id=asset_id,
symbol=symbol,
markets=["KRX"],
display_name="Samsung",
status="active",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
def test_asset_http_create_returns_201() -> None:
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import create_app
from app.core.config import Settings, get_settings
from app.domain.models import TokenPrincipal
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
settings = Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_ENC_KEY,
)
admin_principal = TokenPrincipal(
token_id="t-admin", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.insert_asset.return_value = _make_asset_obj()
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.post(
"/v1/assets",
json={"symbol": "005930", "markets": ["KRX"]},
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 201
body = resp.json()
assert body["symbol"] == "005930"
assert body["status"] == "active"
assert "asset_id" in body
def test_asset_http_update_returns_200() -> None:
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import create_app
from app.core.config import Settings, get_settings
from app.domain.models import TokenPrincipal
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
settings = Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_ENC_KEY,
)
admin_principal = TokenPrincipal(
token_id="t-admin", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.update_asset.return_value = _make_asset_obj()
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.patch(
f"/v1/assets/{_ASSET_UUID}",
json={"display_name": "Samsung Electronics"},
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 200
assert resp.json()["asset_id"] == _ASSET_UUID
def test_asset_http_deactivate_returns_200() -> None:
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import create_app
from app.core.config import Settings, get_settings
from app.domain.models import TokenPrincipal
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
settings = Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_ENC_KEY,
)
admin_principal = TokenPrincipal(
token_id="t-admin", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
inactive_asset = _make_asset_obj()
import dataclasses
inactive_asset = dataclasses.replace(inactive_asset, status="inactive")
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = admin_principal
mock_repo.update_asset.return_value = inactive_asset
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.patch(
f"/v1/assets/{_ASSET_UUID}/deactivate",
headers={"Authorization": "Bearer admin-token"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "inactive"
def test_asset_http_non_admin_403() -> None:
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import create_app
from app.core.config import Settings, get_settings
from app.domain.models import TokenPrincipal
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
settings = Settings(
allowed_origins_raw="http://localhost:3000",
db_enabled=True,
credential_encryption_key=_ENC_KEY,
)
user_principal = TokenPrincipal(
token_id="t-user", user_id="user-1", permission="read",
token_type="user", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = user_principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
client = TestClient(app)
resp = client.post(
"/v1/assets",
json={"symbol": "005930", "markets": ["KRX"]},
headers={"Authorization": "Bearer user-token"},
)
assert resp.status_code == 403
# ===========================================================================
# REQ-012: Portfolio create catalog validation
# ===========================================================================
class _PortfolioServiceFakeRepo:
"""FakeRepo for PortfolioService catalog validation tests."""
def __init__(self, asset: Asset | None = None) -> None:
self._asset = asset
self._strategy_sets: dict = {}
self._portfolios: dict = {}
def get_asset_by_id(self, asset_id: str) -> Asset | None:
if self._asset and self._asset.asset_id == asset_id:
return self._asset
return None
def get_asset_by_symbol(self, symbol: str) -> Asset | None:
if self._asset and self._asset.symbol == symbol:
return self._asset
return None
def get_or_create_strategy_set(self, name: str, config: str):
from app.domain.models import StrategySet
key = f"{name}:{config}"
if key not in self._strategy_sets:
self._strategy_sets[key] = StrategySet(id=f"ss-{len(self._strategy_sets)}", name=name, config=config, nick=name)
return self._strategy_sets[key]
def create_portfolio(self, portfolio_id: str, *, name, owner_user_id, broker, status, asset_id, **kwargs):
from app.domain.models import Portfolio
portfolio = Portfolio(
portfolio_id=portfolio_id, broker=broker, status=status, name=name,
symbol=asset_id, asset_id=asset_id, owner_user_id=owner_user_id,
cash_balance=kwargs.get("cash_balance", 0.0),
asset_quantity=kwargs.get("asset_quantity", 0.0),
price=kwargs.get("asset_price", 0.0),
profit_buffer=kwargs.get("profit_buffer", 0.0),
active_strategy_id=kwargs.get("active_strategy_id"),
)
self._portfolios[portfolio_id] = portfolio
return portfolio
def get(self, portfolio_id: str):
p = self._portfolios.get(portfolio_id)
if p is None:
raise ValueError(f"portfolio not found: {portfolio_id}")
return p
def get_strategy_set(self, id: str):
return None
def list_recent_trades(self, portfolio_id: str, *, limit: int = 5):
return []
def list_comments(self, portfolio_id: str):
return []
def list_strategy_sets(self):
return []
def list_portfolios(self, **kwargs):
return []
def list_ledger_entries(self, portfolio_id: str, **kwargs):
return []
def list_broker_credentials(self, **kwargs):
return []
_CATALOG_ASSET_ID = "aaaaaaaa-0000-0000-0000-000000000001"
def _active_asset() -> Asset:
return Asset(asset_id=_CATALOG_ASSET_ID, symbol="005930", markets=["KRX"], status="active")
def _inactive_asset() -> Asset:
return Asset(asset_id=_CATALOG_ASSET_ID, symbol="005930", markets=["KRX"], status="inactive")
# REQ-012 AC-1: active asset → portfolio create succeeds
def test_portfolio_create_with_active_asset() -> None:
from app.services.portfolio_service import PortfolioService
from app.schemas.portfolio import CreatePortfolioRequest
repo = _PortfolioServiceFakeRepo(asset=_active_asset())
service = PortfolioService(repo)
result = service.create_portfolio(
CreatePortfolioRequest(
name="Test_Portfolio",
asset_id=_CATALOG_ASSET_ID,
broker="kis",
),
owner_user_id="user-1",
)
assert result is not None
# REQ-012 AC-2: unknown asset_id → "asset not found" error
def test_portfolio_create_unknown_asset() -> None:
from app.services.portfolio_service import PortfolioService
from app.schemas.portfolio import CreatePortfolioRequest
repo = _PortfolioServiceFakeRepo(asset=None)
service = PortfolioService(repo)
with pytest.raises(ValueError, match="asset not found"):
service.create_portfolio(
CreatePortfolioRequest(
name="Test_Portfolio",
asset_id="nonexistent-uuid",
broker="kis",
),
owner_user_id="user-1",
)
# REQ-012 AC-3: inactive asset → "asset is inactive" error
def test_portfolio_create_inactive_asset() -> None:
from app.services.portfolio_service import PortfolioService
from app.schemas.portfolio import CreatePortfolioRequest
repo = _PortfolioServiceFakeRepo(asset=_inactive_asset())
service = PortfolioService(repo)
with pytest.raises(ValueError, match="asset is inactive"):
service.create_portfolio(
CreatePortfolioRequest(
name="Test_Portfolio",
asset_id=_CATALOG_ASSET_ID,
broker="kis",
),
owner_user_id="user-1",
)
# REQ-012 AC-4: --symbol resolves asset_id and creates portfolio
def test_portfolio_create_by_symbol() -> None:
from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.domain.models import User, StrategySet
class _FullFakeRepo(_PortfolioServiceFakeRepo):
def __init__(self):
super().__init__(asset=_active_asset())
self._users = {"user-1": User(id="user-1", username="alice", permission="read", is_active=True)}
self._users_by_username = {"alice": self._users["user-1"]}
def get_user_by_username(self, username: str):
return self._users_by_username.get(username)
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str):
return next(
(p for p in self._portfolios.values()
if p.owner_user_id == owner_user_id and p.name == name),
None,
)
repo = _FullFakeRepo()
settings = Settings(allowed_origins_raw="http://localhost:3000")
app = create_app(
settings_factory=lambda: settings,
repository_factory=lambda _: repo,
)
runner = CliRunner()
result = runner.invoke(app, [
"portfolio", "create", "Test_Portfolio",
"--user", "alice",
"--symbol", "005930",
"--force",
])
assert result.exit_code == 0, result.output
assert len(repo._portfolios) == 1
assert list(repo._portfolios.values())[0].asset_id == _CATALOG_ASSET_ID
+4 -3
View File
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.core.security import hash_token, token_prefix
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
class FakeRepo:
@@ -332,10 +332,11 @@ class FakeRepo:
return self._portfolio
def get_asset_by_id(self, asset_id: str):
return None
return Asset(asset_id=asset_id, symbol="TEST", markets=[], status="active")
def get_asset_by_symbol(self, symbol: str):
return None
fake_id = "00000000-0000-0000-0000-000000000001"
return Asset(asset_id=fake_id, symbol=symbol, markets=[], status="active")
def list_assets(self):
return []
+1
View File
@@ -98,6 +98,7 @@ def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None:
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
market=None,
)
+3 -3
View File
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
from app.cli.main import create_app
from app.core.config import Settings
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
from app.domain.models import Asset, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
class FakeRepo:
@@ -110,10 +110,10 @@ class FakeRepo:
return portfolio
def get_asset_by_id(self, asset_id: str):
return None
return Asset(asset_id=asset_id, symbol="TEST", markets=[], status="active")
def get_asset_by_symbol(self, symbol: str):
return None
return Asset(asset_id=_TEST_ASSET_ID, symbol=symbol, markets=[], status="active")
def list_assets(self):
return []
+505
View File
@@ -0,0 +1,505 @@
"""Tests for BAT-003: tags DB foundation (REQ-001, REQ-002, REQ-003, REQ-006).
DB-integration ACs (schema inspection, GIN index, migration idempotency) require
a live PostgreSQL database and are documented in
docs/batches/bat-003_20260519_tags-db-foundation/verification.md.
The tests here cover all domain-model, schema, and app-level ACs that run without DB.
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timezone
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.models import Asset, Portfolio, StrategySet, Tag, TokenPrincipal
from app.main import create_app
from app.schemas.v1 import AssetItem, TagRefItem
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
_DAEMON_ID = "d-001"
_USER_ID = "user-001"
_TEST_TAG_ID_1 = "aaaaaaaa-0000-0000-0000-000000000001"
_TEST_TAG_ID_2 = "aaaaaaaa-0000-0000-0000-000000000002"
_TEST_ASSET_ID = "cccccccc-0000-0000-0000-000000000001"
_TEST_PORTFOLIO_ID = "p-001"
def _make_tag(
*,
tag_id: str = _TEST_TAG_ID_1,
name: str = "momentum",
created_by_user_id: str = "user-001",
is_admin_tag: bool = False,
is_active: bool = True,
) -> Tag:
return Tag(
tag_id=tag_id,
name=name,
created_by_user_id=created_by_user_id,
is_admin_tag=is_admin_tag,
is_active=is_active,
created_at=datetime.now(timezone.utc),
)
def _make_portfolio(
*,
portfolio_id: str = _TEST_PORTFOLIO_ID,
tag_ids: list[str] | None = None,
) -> Portfolio:
return Portfolio(
portfolio_id=portfolio_id,
broker="kis",
status="enabled",
name="Test Portfolio",
symbol="TEST",
asset_id=_TEST_ASSET_ID,
owner_user_id=_USER_ID,
tag_ids=tag_ids or [],
)
# ---------------------------------------------------------------------------
# Simple in-memory FakeRepo for tag master tests
# ---------------------------------------------------------------------------
class _TagFakeRepo:
"""Minimal in-memory repo for tag master CRUD tests."""
def __init__(self) -> None:
self._tags: dict[str, Tag] = {}
def close(self) -> None:
return None
def create_tag(
self,
*,
name: str,
created_by_user_id: str,
is_admin_tag: bool = False,
) -> Tag:
for t in self._tags.values():
if t.name == name:
raise ValueError(f"이미 존재하는 태그 이름입니다: {name}")
tag_id = str(uuid.uuid4())
tag = Tag(
tag_id=tag_id,
name=name,
created_by_user_id=created_by_user_id,
is_admin_tag=is_admin_tag,
is_active=True,
created_at=datetime.now(timezone.utc),
)
self._tags[tag_id] = tag
return tag
def get_tag_by_id(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]:
return {tid: self._tags[tid] for tid in tag_ids if tid in self._tags}
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
tags = list(self._tags.values())
if not include_inactive:
tags = [t for t in tags if t.is_active]
return sorted(tags, key=lambda t: t.name)
def deactivate_tag(self, tag_id: str) -> Tag:
tag = self._tags.get(tag_id)
if tag is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
# Tags are frozen dataclasses, so replace with updated instance
from dataclasses import replace
updated = replace(tag, is_active=False)
self._tags[tag_id] = updated
return updated
# ---------------------------------------------------------------------------
# REQ-001 AC-2,3: Tag model and is_admin_tag flag
# ---------------------------------------------------------------------------
def test_tag_domain_model_fields() -> None:
"""REQ-001: Tag domain model has all required fields."""
tag = _make_tag()
assert hasattr(tag, "tag_id")
assert hasattr(tag, "name")
assert hasattr(tag, "created_by_user_id")
assert hasattr(tag, "is_admin_tag")
assert hasattr(tag, "is_active")
assert hasattr(tag, "created_at")
def test_create_tag_with_admin_flag_true() -> None:
"""REQ-001 AC-2: admin 권한으로 태그 생성 시 is_admin_tag = TRUE."""
repo = _TagFakeRepo()
tag = repo.create_tag(name="admin-tag", created_by_user_id="admin-1", is_admin_tag=True)
assert tag.is_admin_tag is True
assert tag.is_active is True
assert tag.name == "admin-tag"
def test_create_tag_with_admin_flag_false() -> None:
"""REQ-001 AC-3: non-admin 토큰으로 태그 생성 시 is_admin_tag = FALSE."""
repo = _TagFakeRepo()
tag = repo.create_tag(name="user-tag", created_by_user_id="user-1", is_admin_tag=False)
assert tag.is_admin_tag is False
assert tag.is_active is True
def test_create_tag_duplicate_name_raises() -> None:
"""REQ-001 UNIQUE(name) 제약 — 동일 이름 생성 시 오류."""
repo = _TagFakeRepo()
repo.create_tag(name="momentum", created_by_user_id="user-1")
with pytest.raises(ValueError, match="이미 존재하는 태그 이름입니다"):
repo.create_tag(name="momentum", created_by_user_id="user-2")
# ---------------------------------------------------------------------------
# REQ-001 AC-4,5: Deactivation (soft delete)
# ---------------------------------------------------------------------------
def test_deactivate_tag_sets_is_active_false() -> None:
"""REQ-001 AC-4: 태그 비활성화 시 is_active = FALSE."""
repo = _TagFakeRepo()
tag = repo.create_tag(name="momentum", created_by_user_id="user-1")
deactivated = repo.deactivate_tag(tag.tag_id)
assert deactivated.is_active is False
def test_deactivate_tag_does_not_delete_row() -> None:
"""REQ-001 AC-4: 비활성화 후 행이 DB에서 삭제되지 않는다."""
repo = _TagFakeRepo()
tag = repo.create_tag(name="momentum", created_by_user_id="user-1")
repo.deactivate_tag(tag.tag_id)
# Should still be retrievable
found = repo.get_tag_by_id(tag.tag_id)
assert found is not None
assert found.is_active is False
def test_get_inactive_tag_returns_row() -> None:
"""REQ-001 AC-5: is_active = FALSE 태그를 조회하면 행이 반환된다."""
repo = _TagFakeRepo()
tag = repo.create_tag(name="momentum", created_by_user_id="user-1")
repo.deactivate_tag(tag.tag_id)
found = repo.get_tag_by_id(tag.tag_id)
assert found is not None
assert found.is_active is False
# ---------------------------------------------------------------------------
# REQ-001: list_tags_master 필터링
# ---------------------------------------------------------------------------
def test_list_tags_master_active_only_by_default() -> None:
"""REQ-001: list_tags_master 기본값 — 활성 태그만 반환."""
repo = _TagFakeRepo()
t1 = repo.create_tag(name="active-tag", created_by_user_id="u1")
t2 = repo.create_tag(name="inactive-tag", created_by_user_id="u1")
repo.deactivate_tag(t2.tag_id)
active = repo.list_tags_master()
assert len(active) == 1
assert active[0].tag_id == t1.tag_id
def test_list_tags_master_include_inactive() -> None:
"""REQ-001: include_inactive=True 시 비활성 태그 포함 전체 반환."""
repo = _TagFakeRepo()
repo.create_tag(name="active-tag", created_by_user_id="u1")
t2 = repo.create_tag(name="inactive-tag", created_by_user_id="u1")
repo.deactivate_tag(t2.tag_id)
all_tags = repo.list_tags_master(include_inactive=True)
assert len(all_tags) == 2
# ---------------------------------------------------------------------------
# REQ-002/003: Portfolio.tag_ids field
# ---------------------------------------------------------------------------
def test_portfolio_has_tag_ids_field() -> None:
"""REQ-002/003: Portfolio 도메인 모델에 tag_ids 필드가 존재한다."""
p = _make_portfolio(tag_ids=[_TEST_TAG_ID_1])
assert hasattr(p, "tag_ids")
assert p.tag_ids == [_TEST_TAG_ID_1]
def test_portfolio_tag_ids_defaults_empty() -> None:
"""REQ-002 AC-4: tag_ids 없는 portfolio는 빈 배열로 초기화된다."""
p = _make_portfolio(tag_ids=[])
assert p.tag_ids == []
# ---------------------------------------------------------------------------
# REQ-002 AC-3,4 / REQ-003 AC-4: archived 표시 — GET /v1/assets response
# ---------------------------------------------------------------------------
def _make_assets_fake_repo(portfolios: list[Portfolio], tags: list[Tag]) -> MagicMock:
"""Build a MagicMock repo that returns given portfolios and tags."""
repo = MagicMock()
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
repo.get_token_principal.return_value = principal
repo.list_portfolios_with_strategies.return_value = portfolios
tags_by_id = {t.tag_id: t for t in tags}
repo.get_tags_by_ids.side_effect = lambda ids: {
tid: tags_by_id[tid] for tid in ids if tid in tags_by_id
}
repo.get_strategy_set.return_value = None
return repo
def _make_v1_settings(monkeypatch) -> Settings:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _ENC_KEY)
return Settings(allowed_origins_raw="http://localhost:3000")
def _make_v1_client(settings: Settings) -> TestClient:
get_settings.cache_clear()
v1_app = create_app(settings_factory=lambda: settings)
v1_app.dependency_overrides[get_settings] = lambda: settings
return TestClient(v1_app)
def test_asset_item_tag_ids_field_exists() -> None:
"""REQ-002/003: AssetItem 스키마에 tag_ids 필드가 존재한다."""
item = AssetItem(
asset_id="p-1",
symbol="A",
status="enabled",
cash=0.0,
quantity=0.0,
broker_type="kis",
tag_ids=[TagRefItem(tag_id=_TEST_TAG_ID_1, archived=False)],
)
assert item.tag_ids is not None
assert len(item.tag_ids) == 1
assert item.tag_ids[0].tag_id == _TEST_TAG_ID_1
assert item.tag_ids[0].archived is False
def test_v1_assets_active_tag_not_archived(monkeypatch) -> None:
"""REQ-002 AC-3 (정상 케이스): active 태그는 archived=False로 반환."""
settings = _make_v1_settings(monkeypatch)
tag = _make_tag(tag_id=_TEST_TAG_ID_1, is_active=True)
portfolio = _make_portfolio(tag_ids=[_TEST_TAG_ID_1])
mock_repo = _make_assets_fake_repo([portfolio], [tag])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
assets = resp.json()["assets"]
assert len(assets) == 1
tag_ids = assets[0]["tag_ids"]
assert tag_ids is not None
assert len(tag_ids) == 1
assert tag_ids[0]["tag_id"] == _TEST_TAG_ID_1
assert tag_ids[0]["archived"] is False
def test_v1_assets_inactive_tag_archived_true(monkeypatch) -> None:
"""REQ-002 AC-3: inactive 태그는 archived=True로 반환되고 배열에서 제거되지 않는다."""
settings = _make_v1_settings(monkeypatch)
active_tag = _make_tag(tag_id=_TEST_TAG_ID_1, name="active", is_active=True)
inactive_tag = _make_tag(tag_id=_TEST_TAG_ID_2, name="inactive", is_active=False)
portfolio = _make_portfolio(tag_ids=[_TEST_TAG_ID_1, _TEST_TAG_ID_2])
mock_repo = _make_assets_fake_repo([portfolio], [active_tag, inactive_tag])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
assets = resp.json()["assets"]
assert len(assets) == 1
tag_ids = assets[0]["tag_ids"]
assert tag_ids is not None
assert len(tag_ids) == 2 # not removed
by_id = {item["tag_id"]: item for item in tag_ids}
assert by_id[_TEST_TAG_ID_1]["archived"] is False
assert by_id[_TEST_TAG_ID_2]["archived"] is True
def test_v1_assets_empty_tag_ids_returns_none(monkeypatch) -> None:
"""REQ-002 AC-4: tag_ids = [] 인 portfolio의 응답에서 tag_ids = None."""
settings = _make_v1_settings(monkeypatch)
portfolio = _make_portfolio(tag_ids=[])
mock_repo = _make_assets_fake_repo([portfolio], [])
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200
assets = resp.json()["assets"]
assert len(assets) == 1
assert assets[0]["tag_ids"] is None
# ---------------------------------------------------------------------------
# REQ-006 AC-1~6: Migration logic (Python-level simulation)
# ---------------------------------------------------------------------------
def _simulate_migration(
portfolio_tags_rows: list[tuple[str, str]], # (portfolio_id, tag_value)
users: list[str],
admin_user_id: str,
) -> tuple[list[dict], dict[str, list[str]]]:
"""Simulate the migration logic without a DB connection.
Returns:
tags_table: list of {name, created_by_user_id, is_admin_tag}
portfolios_tag_ids: {portfolio_id: [tag_name, ...]} after migration
"""
# Step 1: Verify admin user
if admin_user_id not in users:
raise ValueError(f"Migration admin user not found: {admin_user_id}")
# Step 2: Deduplicate by lowercase, insert ON CONFLICT DO NOTHING
seen_names: dict[str, dict] = {}
for _, tag_value in portfolio_tags_rows:
lower = tag_value.lower()
if lower not in seen_names:
seen_names[lower] = {
"name": lower,
"created_by_user_id": admin_user_id,
"is_admin_tag": False,
"tag_id": str(uuid.uuid4()),
}
tags_table = list(seen_names.values())
name_to_id = {t["name"]: t["tag_id"] for t in tags_table}
# Step 3: Fill portfolios.tag_ids
portfolios_tag_ids: dict[str, list[str]] = {}
for portfolio_id, tag_value in portfolio_tags_rows:
lower = tag_value.lower()
portfolios_tag_ids.setdefault(portfolio_id, [])
tag_id = name_to_id[lower]
if tag_id not in portfolios_tag_ids[portfolio_id]:
portfolios_tag_ids[portfolio_id].append(tag_id)
return tags_table, portfolios_tag_ids
def test_migration_ac1_tags_created_with_correct_attrs() -> None:
"""REQ-006 AC-1: 마이그레이션 후 tags 행이 is_admin_tag=FALSE, created_by_user_id=admin ID."""
rows = [("p1", "Momentum"), ("p2", "value")]
tags, _ = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
assert all(t["is_admin_tag"] is False for t in tags)
assert all(t["created_by_user_id"] == "admin-1" for t in tags)
def test_migration_ac2_case_insensitive_dedup() -> None:
"""REQ-006 AC-2: 'TestTag'와 'testtag'는 tags에 1행만 생성된다."""
rows = [("p1", "TestTag"), ("p2", "testtag"), ("p3", "TESTTAG")]
tags, _ = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
assert len(tags) == 1
assert tags[0]["name"] == "testtag"
def test_migration_ac3_portfolio_tag_ids_populated() -> None:
"""REQ-006 AC-3: 각 portfolio.tag_ids에 기존 portfolio_tags 연결 tag_id가 포함된다."""
rows = [("p1", "momentum"), ("p1", "value"), ("p2", "growth")]
tags, portfolios_tag_ids = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
assert len(portfolios_tag_ids["p1"]) == 2
assert len(portfolios_tag_ids["p2"]) == 1
def test_migration_ac4_portfolio_tags_dropped() -> None:
"""REQ-006 AC-4: 마이그레이션 후 portfolio_tags 테이블이 존재하지 않는다 (migration script 검증)."""
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
rev_file = next(f for f in os.listdir(migrations_dir) if "0021" in f)
content = open(os.path.join(migrations_dir, rev_file)).read()
assert "DROP TABLE" in content
assert "portfolio_tags" in content
def test_migration_ac5_invalid_admin_id_aborts() -> None:
"""REQ-006 AC-5: 유효하지 않은 admin ID 지정 시 ValueError로 중단."""
rows = [("p1", "momentum")]
with pytest.raises(ValueError, match="Migration admin user not found"):
_simulate_migration(rows, users=["admin-1"], admin_user_id="nonexistent-admin")
def test_migration_ac6_distinct_tag_count_matches() -> None:
"""REQ-006 AC-6: DISTINCT tag_value 수 N == 마이그레이션 후 tags 행 수 N."""
rows = [("p1", "A"), ("p2", "B"), ("p3", "a"), ("p4", "C")] # 'A'/'a' deduplicate
tags, _ = _simulate_migration(rows, users=["admin-1"], admin_user_id="admin-1")
distinct_lower = len({v.lower() for _, v in rows})
assert len(tags) == distinct_lower # 3: a, b, c
# ---------------------------------------------------------------------------
# REQ-001 AC-1: Migration files exist (schema coverage)
# ---------------------------------------------------------------------------
def test_migration_0019_tags_table_exists() -> None:
"""REQ-001 AC-1: tags 테이블 생성 마이그레이션 파일이 존재한다."""
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
files = [f for f in os.listdir(migrations_dir) if "0019" in f]
assert len(files) == 1
def test_migration_0019_creates_tags_table() -> None:
"""REQ-001 AC-1: revision 0019가 tags 테이블을 생성한다."""
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
rev_file = next(f for f in os.listdir(migrations_dir) if "0019" in f)
content = open(os.path.join(migrations_dir, rev_file)).read()
assert '"tags"' in content
assert "tag_id" in content
assert "name" in content
assert "created_by_user_id" in content
assert "is_admin_tag" in content
assert "is_active" in content
def test_migration_0020_portfolios_tag_ids_column() -> None:
"""REQ-002/003 AC-1: revision 0020이 portfolios.tag_ids 컬럼과 GIN 인덱스를 추가한다."""
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
rev_file = next(f for f in os.listdir(migrations_dir) if "0020" in f)
content = open(os.path.join(migrations_dir, rev_file)).read()
assert "tag_ids" in content
assert "gin" in content.lower()
assert "portfolios" in content
def test_migration_0021_data_migration_exists() -> None:
"""REQ-006: revision 0021 데이터 마이그레이션 파일이 존재한다."""
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
files = [f for f in os.listdir(migrations_dir) if "0021" in f]
assert len(files) == 1
def test_migration_0021_has_admin_verification() -> None:
"""REQ-006 AC-5: revision 0021이 admin ID 존재 검증 로직을 포함한다."""
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
rev_file = next(f for f in os.listdir(migrations_dir) if "0021" in f)
content = open(os.path.join(migrations_dir, rev_file)).read()
assert "admin_user_id" in content
assert "users" in content
assert "run_migration" in content
+132
View File
@@ -284,6 +284,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
market=None,
)
@@ -815,6 +816,137 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
assert "market" not in body["assets"][0]
# ---------------------------------------------------------------------------
# REQ-013: GET /v1/assets token-type branching
# ---------------------------------------------------------------------------
def test_v1_assets_user_token(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="user", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
assert resp.json()["total"] == 1
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=True,
market=None,
)
def test_v1_assets_admin_token_default(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
principal = TokenPrincipal(
token_id="t1", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="admin-1",
include_disabled=True,
market=None,
)
def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?user_id=other-user", headers=_service_token_header())
assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="other-user",
include_disabled=True,
market=None,
)
def test_v1_assets_market_filter(monkeypatch) -> None:
settings = _make_app_db(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?market=KRX", headers=_service_token_header())
assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
market="KRX",
)
def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None:
import uuid as _uuid
settings = _make_app_db(monkeypatch)
uuid_portfolio_id = str(_uuid.uuid4())
portfolio = Portfolio(
portfolio_id=uuid_portfolio_id,
broker="kis", status="enabled", name="T",
symbol="005930", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0, owner_user_id="user-1",
)
principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200
returned_id = resp.json()["assets"][0]["asset_id"]
_uuid.UUID(returned_id) # raises ValueError if not a valid UUID
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------