feat: REQ-005 assets table tag apply/remove SQL alignment (BAT-013)

- REQ-005 CHG-20260521-02 follow-up: clarify AC-1/AC-5 to target assets table
- Add append_asset_tag_id/remove_asset_tag_id on PostgresPortfolioRepository
- Replace internal calls in apply_asset_tag/remove_asset_tag routes (HTTP contract unchanged)
- 7 new regression tests: method existence, SQL target table, error branches, idempotency guard
- Update existing AC-1/AC-5 assertions to verify new methods + portfolios methods uncalled
- Documentation: runtime-flows.md flow body + change log; rel-001 includes BAT-013
- REQ-005 transition: Approved → Implemented (CHG-20260521-03) via BAT-013 verification
- Total tests: 362 → 369 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 00:04:33 +09:00
co-authored by Claude Opus 4.7
parent 9038f41010
commit 58899b1a94
15 changed files with 509 additions and 17 deletions
+128
View File
@@ -1042,3 +1042,131 @@ def test_get_v1_assets_service_token_returns_403() -> None:
for p in patches:
p.stop()
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# BAT-013: REQ-005 CHG-20260521-02 — assets 테이블 tag_ids mutation 정합화
# ---------------------------------------------------------------------------
def test_postgres_repository_has_append_asset_tag_id_method() -> None:
"""BAT-013 / REQ-005 AC-1: PostgresRepository에 append_asset_tag_id 메서드 존재."""
from app.repositories.postgres import PostgresPortfolioRepository
assert hasattr(PostgresPortfolioRepository, "append_asset_tag_id")
assert callable(PostgresPortfolioRepository.append_asset_tag_id)
def test_postgres_repository_has_remove_asset_tag_id_method() -> None:
"""BAT-013 / REQ-005 AC-5: PostgresRepository에 remove_asset_tag_id 메서드 존재."""
from app.repositories.postgres import PostgresPortfolioRepository
assert hasattr(PostgresPortfolioRepository, "remove_asset_tag_id")
assert callable(PostgresPortfolioRepository.remove_asset_tag_id)
def test_append_asset_tag_id_targets_assets_table_not_portfolios() -> None:
"""BAT-013 / REQ-005 AC-1: append_asset_tag_id SQL이 assets 테이블을 UPDATE한다 (portfolios 아님)."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
mock_cursor = MagicMock()
mock_cursor.rowcount = 1
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=None)
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
with patch.object(PostgresPortfolioRepository, "_connect", return_value=mock_conn):
repo.append_asset_tag_id("asset-uuid-1", "tag-uuid-1")
sql_executed = mock_cursor.execute.call_args_list[0][0][0]
assert "UPDATE assets" in sql_executed
assert "UPDATE portfolios" not in sql_executed
assert "WHERE asset_id" in sql_executed
def test_remove_asset_tag_id_targets_assets_table_not_portfolios() -> None:
"""BAT-013 / REQ-005 AC-5: remove_asset_tag_id SQL이 assets 테이블을 UPDATE한다 (portfolios 아님)."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
mock_cursor = MagicMock()
mock_cursor.rowcount = 1
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=None)
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
with patch.object(PostgresPortfolioRepository, "_connect", return_value=mock_conn):
repo.remove_asset_tag_id("asset-uuid-1", "tag-uuid-1")
sql_executed = mock_cursor.execute.call_args_list[0][0][0]
assert "UPDATE assets" in sql_executed
assert "UPDATE portfolios" not in sql_executed
assert "WHERE asset_id" in sql_executed
def test_append_asset_tag_id_raises_when_asset_not_found() -> None:
"""BAT-013 / REQ-005 EC-1: 존재하지 않는 asset_id는 ValueError."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
mock_cursor = MagicMock()
mock_cursor.rowcount = 0
mock_cursor.fetchone.return_value = None
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=None)
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
with patch.object(PostgresPortfolioRepository, "_connect", return_value=mock_conn):
with pytest.raises(ValueError, match="asset을 찾을 수 없습니다"):
repo.append_asset_tag_id("missing-asset-uuid", "tag-uuid-1")
def test_remove_asset_tag_id_raises_when_asset_not_found() -> None:
"""BAT-013 / REQ-005 EC-1: DELETE 시 asset 부재면 ValueError."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
mock_cursor = MagicMock()
mock_cursor.rowcount = 0
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=None)
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
with patch.object(PostgresPortfolioRepository, "_connect", return_value=mock_conn):
with pytest.raises(ValueError, match="asset을 찾을 수 없습니다"):
repo.remove_asset_tag_id("missing-asset-uuid", "tag-uuid-1")
def test_append_asset_tag_id_idempotent_guard_present() -> None:
"""BAT-013 / REQ-005 EC-4: append_asset_tag_id SQL이 중복 방지 가드(`NOT ... = ANY(tag_ids)`)를 포함한다."""
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
mock_cursor = MagicMock()
mock_cursor.rowcount = 1
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=None)
mock_conn = MagicMock()
mock_conn.cursor.return_value = mock_cursor
with patch.object(PostgresPortfolioRepository, "_connect", return_value=mock_conn):
repo.append_asset_tag_id("asset-uuid-1", "tag-uuid-1")
sql_executed = mock_cursor.execute.call_args_list[0][0][0]
assert "NOT (" in sql_executed
assert "= ANY(tag_ids)" in sql_executed
assert "array_append" in sql_executed