feat: REQ-002/REQ-010 implementation — assets.tag_ids column + GET /v1/assets admin catalog (BAT-012)

- REQ-002 scope redefinition: portfolios.tag_ids → assets.tag_ids UUID[] column with GIN index
- REQ-010 augmentation: GET /v1/assets redesigned as admin catalog endpoint (admin-only, no token_type branching)
- Asset tagging infrastructure: tag_ids column, archived flag synthesis for inactive tags, list_assets N+1 optimization
- DB migration 20260521_0023: assets.tag_ids UUID[] NOT NULL DEFAULT '{}', idx_assets_tag_ids_gin
- Test suite: 17 new test cases for assets.tag_ids + GET /v1/assets AC-9~12 (362 tests total)
- Documentation: runtime flows, release baseline, batch verification all updated
- BAT-012 added to REL-001 Included Batch and marked release-candidate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:37:08 +09:00
co-authored by Claude Opus 4.7
parent d01039e98f
commit 9038f41010
19 changed files with 867 additions and 47 deletions
+292
View File
@@ -750,3 +750,295 @@ def test_portfolio_create_by_symbol() -> None:
assert result.exit_code == 0, result.output
assert len(repo._portfolios) == 1
assert list(repo._portfolios.values())[0].asset_id == _CATALOG_ASSET_ID
# ===========================================================================
# BAT-012: REQ-002 (assets.tag_ids) + REQ-010 AC-9~12 (GET /v1/assets)
# ===========================================================================
# ---------------------------------------------------------------------------
# REQ-002 AC-1, AC-2: migration file structure for assets.tag_ids
# ---------------------------------------------------------------------------
def test_migration_assets_tag_ids_file_exists() -> None:
import os
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
matches = [f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f]
assert len(matches) == 1, f"assets_tag_ids migration (0023) not found in {migrations_dir}"
def test_migration_assets_tag_ids_adds_column_and_gin_index() -> None:
import os
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
rev = next(f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f)
content = open(os.path.join(migrations_dir, rev)).read()
# AC-1: tag_ids UUID[] NOT NULL DEFAULT '{}' on assets
assert "add_column" in content
assert '"assets"' in content
assert '"tag_ids"' in content
assert "ARRAY" in content
assert "UUID" in content
assert "nullable=False" in content
assert "'{}'::uuid[]" in content
# AC-2: idx_assets_tag_ids_gin GIN index
assert "idx_assets_tag_ids_gin" in content
assert 'postgresql_using="gin"' in content
# ---------------------------------------------------------------------------
# REQ-002 AC-3, AC-4: archived 표시 + 빈 배열 기본값 (Asset model + schema)
# ---------------------------------------------------------------------------
def test_asset_model_tag_ids_defaults_to_empty_list() -> None:
asset = Asset(asset_id="a-1", symbol="AAPL", markets=["NASDAQ"])
assert asset.tag_ids == []
def test_asset_model_accepts_tag_ids() -> None:
asset = Asset(
asset_id="a-1",
symbol="AAPL",
markets=["NASDAQ"],
tag_ids=["00000000-0000-0000-0000-00000000aaaa"],
)
assert asset.tag_ids == ["00000000-0000-0000-0000-00000000aaaa"]
def test_asset_catalog_response_schema_has_tag_ids_field() -> None:
from app.schemas.v1 import AssetCatalogResponse, TagRefItem
fields = AssetCatalogResponse.model_fields
assert "tag_ids" in fields
# Default factory should yield empty list
inst = AssetCatalogResponse(asset_id="a", symbol="s", markets=[], status="active")
assert inst.tag_ids == []
def test_build_tag_refs_archived_for_inactive_tags() -> None:
from app.api.v1_assets import _build_tag_refs
tag_active_map = {
"tid-active": True,
"tid-inactive": False,
}
refs = _build_tag_refs(["tid-active", "tid-inactive"], tag_active_map)
assert len(refs) == 2
assert refs[0].tag_id == "tid-active"
assert refs[0].archived is False
assert refs[1].tag_id == "tid-inactive"
assert refs[1].archived is True
def test_build_tag_refs_archived_for_missing_tags() -> None:
"""tag_id not present in tag master (deleted/never existed) → archived=True (이력 유지)."""
from app.api.v1_assets import _build_tag_refs
refs = _build_tag_refs(["tid-unknown"], {})
assert len(refs) == 1
assert refs[0].tag_id == "tid-unknown"
assert refs[0].archived is True
def test_build_tag_refs_empty_returns_empty_list() -> None:
from app.api.v1_assets import _build_tag_refs
assert _build_tag_refs([], {}) == []
# ---------------------------------------------------------------------------
# REQ-010 AC-9~12: GET /v1/assets admin catalog list
# ---------------------------------------------------------------------------
def _make_catalog_get_client(principal_kwargs: dict, assets: list[Asset], tags: dict[str, "object"] | None = None):
"""Build TestClient with mocked auth + list_assets + get_tags_by_ids."""
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,
)
principal = TokenPrincipal(**principal_kwargs)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
mock_repo.list_assets.return_value = assets
mock_repo.get_tags_by_ids.return_value = tags or {}
patches = (
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),
)
for p in patches:
p.start()
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app), mock_repo, patches
def _make_asset_with_tags(asset_id: str, symbol: str, markets: list[str], status: str = "active", tag_ids: list[str] | None = None) -> Asset:
from datetime import datetime, timezone
return Asset(
asset_id=asset_id,
symbol=symbol,
markets=markets,
display_name=symbol,
status=status,
tag_ids=tag_ids or [],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
_ADMIN_KW = dict(token_id="t-admin", user_id="admin-1", permission="admin", token_type="admin", runtime_id="rt-1")
_USER_KW = dict(token_id="t-user", user_id="user-1", permission="read", token_type="user", runtime_id="rt-1")
_SERVICE_KW = dict(token_id="t-svc", user_id=None, permission="trade-write", token_type="service", runtime_id="rt-1", daemon_id="d-1")
# REQ-010 AC-9: admin GET 200 + AssetCatalogListResponse shape (portfolio 필드 부재)
def test_get_v1_assets_admin_returns_catalog_response() -> None:
from app.domain.models import Tag
assets = [
_make_asset_with_tags("a-1", "005930", ["KRX"], status="active", tag_ids=["tag-active"]),
_make_asset_with_tags("a-2", "AAPL", ["NASDAQ"], status="active", tag_ids=[]),
]
tags = {"tag-active": Tag(tag_id="tag-active", name="kospi200", created_by_user_id="admin-1", is_active=True)}
client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, tags)
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 200, resp.text
body = resp.json()
assert "items" in body and "total" in body
assert body["total"] == 2
item = body["items"][0]
# Catalog-shape only — no portfolio fields
assert set(item.keys()) >= {"asset_id", "symbol", "markets", "display_name", "status", "tag_ids", "created_at", "updated_at"}
assert "cash" not in item and "quantity" not in item and "owner_user_id" not in item
# AC-3 boundary: active tag → archived False
a1 = next(i for i in body["items"] if i["asset_id"] == "a-1")
assert a1["tag_ids"] == [{"tag_id": "tag-active", "archived": False}]
# AC-4: empty default
a2 = next(i for i in body["items"] if i["asset_id"] == "a-2")
assert a2["tag_ids"] == []
# REQ-002 AC-3 integration: archived 표시 동반 반환 (inactive tag in GET response)
def test_get_v1_assets_archived_flag_for_inactive_tag() -> None:
from app.domain.models import Tag
assets = [
_make_asset_with_tags("a-1", "005930", ["KRX"], tag_ids=["tag-inactive"]),
]
tags = {"tag-inactive": Tag(tag_id="tag-inactive", name="legacy", created_by_user_id="admin-1", is_active=False)}
client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, tags)
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 200
item = resp.json()["items"][0]
assert item["tag_ids"] == [{"tag_id": "tag-inactive", "archived": True}]
# REQ-010 AC-10: status=all/inactive 필터
def test_get_v1_assets_status_filter_all_includes_inactive() -> None:
assets = [
_make_asset_with_tags("a-1", "AAPL", ["NASDAQ"], status="active"),
_make_asset_with_tags("a-2", "DEFUNCT", ["KRX"], status="inactive"),
]
client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {})
try:
resp = client.get("/v1/assets?status=all", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 200
symbols = {i["symbol"] for i in resp.json()["items"]}
assert symbols == {"AAPL", "DEFUNCT"}
def test_get_v1_assets_status_filter_inactive_only() -> None:
assets = [
_make_asset_with_tags("a-1", "AAPL", ["NASDAQ"], status="active"),
_make_asset_with_tags("a-2", "DEFUNCT", ["KRX"], status="inactive"),
]
client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {})
try:
resp = client.get("/v1/assets?status=inactive", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["symbol"] == "DEFUNCT"
def test_get_v1_assets_status_filter_default_active_only() -> None:
assets = [
_make_asset_with_tags("a-1", "AAPL", ["NASDAQ"], status="active"),
_make_asset_with_tags("a-2", "DEFUNCT", ["KRX"], status="inactive"),
]
client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {})
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["symbol"] == "AAPL"
def test_get_v1_assets_status_filter_rejects_unknown_value() -> None:
client, _, patches = _make_catalog_get_client(_ADMIN_KW, [], {})
try:
resp = client.get("/v1/assets?status=garbage", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 422
# REQ-010 AC-11: market 필터
def test_get_v1_assets_market_filter() -> None:
assets = [
_make_asset_with_tags("a-1", "005930", ["KRX", "NXT"]),
_make_asset_with_tags("a-2", "AAPL", ["NASDAQ"]),
]
client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {})
try:
resp = client.get("/v1/assets?market=KRX", headers={"Authorization": "Bearer admin"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 200
items = resp.json()["items"]
assert len(items) == 1
assert items[0]["symbol"] == "005930"
# REQ-010 AC-12: non-admin → HTTP 403
def test_get_v1_assets_user_token_returns_403() -> None:
client, _, patches = _make_catalog_get_client(_USER_KW, [], {})
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer user"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 403
def test_get_v1_assets_service_token_returns_403() -> None:
client, _, patches = _make_catalog_get_client(_SERVICE_KW, [], {})
try:
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc"})
finally:
for p in patches:
p.stop()
assert resp.status_code == 403