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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user