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
+44 -5
View File
@@ -15,6 +15,7 @@ from app.schemas.v1 import (
AssetCatalogUpdateRequest, AssetCatalogUpdateRequest,
TagApplyRequest, TagApplyRequest,
TagApplyResponse, TagApplyResponse,
TagRefItem,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,13 +27,39 @@ def _get_repo(settings=None):
return get_portfolio_repository_from_settings(settings or get_settings()) return get_portfolio_repository_from_settings(settings or get_settings())
def _asset_response(asset) -> AssetCatalogResponse: def _build_tag_refs(tag_ids: list[str], tag_active_map: dict[str, bool]) -> list[TagRefItem]:
"""Convert raw tag_id list into TagRefItem array with archived flag synthesized.
tag_ids not present in tag_active_map are surfaced as archived=True so the array
is never silently pruned (REQ-002 Intent: "조용히 제거하지 않고 archived 상태로 반환").
"""
refs: list[TagRefItem] = []
for tid in tag_ids:
active = tag_active_map.get(tid, False)
refs.append(TagRefItem(tag_id=tid, archived=not active))
return refs
def _asset_response(asset, tag_active_map: dict[str, bool] | None = None) -> AssetCatalogResponse:
tag_ids = getattr(asset, "tag_ids", []) or []
if tag_active_map is None and tag_ids:
# 단일 응답 경로: 필요한 tag_ids만 1회 조회
repo = _get_repo()
try:
tags = repo.get_tags_by_ids(tag_ids)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
tag_active_map = {tid: t.is_active for tid, t in tags.items()}
elif tag_active_map is None:
tag_active_map = {}
return AssetCatalogResponse( return AssetCatalogResponse(
asset_id=asset.asset_id, asset_id=asset.asset_id,
symbol=asset.symbol, symbol=asset.symbol,
markets=asset.markets, markets=asset.markets,
display_name=asset.display_name, display_name=asset.display_name,
status=asset.status, status=asset.status,
tag_ids=_build_tag_refs(tag_ids, tag_active_map),
created_at=asset.created_at, created_at=asset.created_at,
updated_at=asset.updated_at, updated_at=asset.updated_at,
) )
@@ -60,16 +87,28 @@ def list_catalog_assets(
repo = _get_repo() repo = _get_repo()
try: try:
assets = repo.list_assets() assets = repo.list_assets()
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
if status_filter != "all": if status_filter != "all":
assets = [a for a in assets if a.status == status_filter] assets = [a for a in assets if a.status == status_filter]
if market is not None: if market is not None:
assets = [a for a in assets if market in (a.markets or [])] assets = [a for a in assets if market in (a.markets or [])]
items = [_asset_response(a) for a in assets] all_tag_ids: list[str] = []
seen: set[str] = set()
for a in assets:
for tid in (getattr(a, "tag_ids", []) or []):
if tid not in seen:
seen.add(tid)
all_tag_ids.append(tid)
tag_active_map: dict[str, bool] = {}
if all_tag_ids:
tags = repo.get_tags_by_ids(all_tag_ids)
tag_active_map = {tid: t.is_active for tid, t in tags.items()}
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
items = [_asset_response(a, tag_active_map) for a in assets]
return AssetCatalogListResponse(items=items, total=len(items)) return AssetCatalogListResponse(items=items, total=len(items))
+2 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -35,6 +35,7 @@ class Asset:
markets: list[str] markets: list[str]
display_name: str | None = None display_name: str | None = None
status: str = "active" status: str = "active"
tag_ids: list[str] = field(default_factory=list)
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
+5 -2
View File
@@ -87,7 +87,8 @@ _SELECT_ASSET = """
display_name, display_name,
status, status,
created_at, created_at,
updated_at updated_at,
COALESCE(tag_ids, '{}'::uuid[])
FROM assets FROM assets
""" """
@@ -463,6 +464,7 @@ class PostgresPortfolioRepository:
status=row[4] or "active", status=row[4] or "active",
created_at=row[5], created_at=row[5],
updated_at=row[6], updated_at=row[6],
tag_ids=[str(t) for t in row[7]] if len(row) > 7 and row[7] is not None else [],
) )
@staticmethod @staticmethod
@@ -1503,7 +1505,8 @@ class PostgresPortfolioRepository:
""" """
INSERT INTO assets (symbol, markets, display_name, status) INSERT INTO assets (symbol, markets, display_name, status)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING asset_id, symbol, markets, display_name, status, created_at, updated_at RETURNING asset_id, symbol, markets, display_name, status, created_at, updated_at,
COALESCE(tag_ids, '{}'::uuid[])
""", """,
(symbol, markets, display_name, status), (symbol, markets, display_name, status),
) )
+1
View File
@@ -237,6 +237,7 @@ class AssetCatalogResponse(BaseModel):
markets: list[str] markets: list[str]
display_name: str | None = None display_name: str | None = None
status: str status: str
tag_ids: list[TagRefItem] = Field(default_factory=list)
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
@@ -0,0 +1,48 @@
# BAT-012 Design
- Status: confirmed
- Batch: BAT-012
## Architecture Summary
- assets 테이블에 단일 컬럼(`tag_ids UUID[]`)·GIN 인덱스 추가, `AssetCatalogResponse`에 `tag_ids: list[TagRefItem]` 필드 추가, GET /v1/assets 응답에 archived 합성 로직 삽입을 함께 처리한다.
- archived 표시 포맷은 기존 `app/schemas/v1.py:50-52`의 `TagRefItem(tag_id, archived)` 객체 배열 패턴을 그대로 재사용한다(REQ-003 portfolios.tag_ids 응답과 동일).
- GET /v1/assets 라우트 본체는 이미 admin catalog 조회로 재정의되어 있음(`app/api/v1_assets.py:48-73`, REQ-010 AC-1~8 + AC-9~12의 코드 경로 존재). 본 batch는 (a) 응답에 `tag_ids` 필드 합류, (b) AC-9~12 자동 테스트 신설, (c) tag_ids 컬럼 도입의 backing migration·repository 정합을 책임진다.
## Changed Areas
- Docs:
- `docs/data-model.md` — assets 엔티티 스키마에 `tag_ids UUID[]` 추가 (별도 docs sync REQ로 분리 가능)
- `docs/interface-contract.md` — GET /v1/assets 응답 본문에 `tag_ids` 필드 명세 추가
- Modules:
- `migrations/versions/20260521_NNNN_assets_tag_ids_column.py` (신규) — `tag_ids UUID[] NOT NULL DEFAULT '{}'` + `idx_assets_tag_ids_gin`
- `app/domain/models.py:32-39` — `Asset` dataclass에 `tag_ids: list[str]` 필드 추가 (기본값 빈 리스트)
- `app/repositories/postgres.py:82-92` — `_SELECT_ASSET` SQL에 `COALESCE(tag_ids, '{}'::uuid[])` 추가
- `app/repositories/postgres.py:454-466` — `_row_to_asset`에서 tag_ids 파싱
- `app/schemas/v1.py:234-241` — `AssetCatalogResponse`에 `tag_ids: list[TagRefItem]` 필드 추가 (기본값 빈 리스트)
- `app/api/v1_assets.py:29-38` — `_asset_response` 헬퍼에 tags 마스터 is_active 조회 + archived 합성 로직 추가
- Interfaces:
- HTTP: `GET /v1/assets` / `POST /v1/assets` / `PATCH /v1/assets/{asset_id}` / `PATCH /v1/assets/{asset_id}/deactivate` 응답 본문에 `tag_ids: [{tag_id, archived}]` 필드 등장
## Key Decisions
1. **archived 표시 포맷 — `TagRefItem` 객체 배열 채택**: 기존 `app/schemas/v1.py:50-52`의 `class TagRefItem { tag_id: str; archived: bool = False }`를 그대로 재사용. REQ-003 portfolios에서 채택한 형식과 정합. `AssetCatalogResponse.tag_ids: list[TagRefItem] = Field(default_factory=list)`.
2. **list_assets 시그니처 — 변경 없음**: 기존 `list_assets() -> list[Asset]`을 그대로 유지. status/market 필터는 라우트 레이어에서 이미 in-memory 필터링(`app/api/v1_assets.py:67-70`)으로 동작 중. 운영 카탈로그 규모(수십~수백 row) 기준 in-memory 필터로 충분. 향후 카탈로그 폭증 시 repository 보강은 별도 REQ로.
3. **GET /v1/assets 라우트 본체 — 변경 없음**: 라우트는 이미 admin catalog로 재정의되어 있음. 본 batch는 응답 schema에 `tag_ids` 필드 추가와 archived 합성만 책임. REQ-010 AC-9~12 코드 경로는 존재하지만 **자동 테스트가 없음** — 본 batch에서 신규 테스트로 메움.
4. **BAT-002 REQ-010 trace 정리 — BAT-012 우선 적용**: BAT-012 verification 통과 후 confirm-req-implemented 단계에서 REQ-010 trace를 BAT-012로 갱신하고 BAT-002 trace 표기를 `superseded by BAT-012`로 명시. BAT-002의 REQ-012/REQ-015 trace는 영향 없음.
5. **archived 합성 위치 — API 레이어 헬퍼**: `_asset_response`에서 `tag_ids: list[str]`을 `list[TagRefItem]`으로 변환. tags 마스터 is_active 조회는 `repo.get_tags_by_ids(tag_ids)` 활용(이미 존재: `app/repositories/postgres.py:1700-1705`). 단일 자산 응답 N개 호출 회피를 위해 `list_catalog_assets`에서는 모든 자산의 tag_ids union을 한 번에 조회 후 매핑(N+1 회피).
6. **`POST /v1/assets/{id}/tags` 라우트 정합화 — 본 batch 범위 밖**: 현행 apply/remove 라우트가 `repo.append_portfolio_tag_id`를 호출하여 portfolios 테이블을 잘못 대상화하는 잠재 버그(REQ-005 follow-up)는 BAT-012 범위 밖. REQ-002 AC-3/AC-4 검증은 DB seed로 직접 수행. apply/remove 정합화는 별도 REQ/Batch에서 처리.
7. **Migration revision down_revision — 신규 head**: alembic heads 출력을 implementation 진입 시 확인하고 가장 최신 revision을 down_revision으로 지정. 동시에 진행 중인 다른 batch 없음(BAT-009~011 모두 release-candidate).
## Edge Cases
- `tag_ids = []`: 응답 `tag_ids: []` (빈 배열, AC-4).
- `tag_ids`에 마스터에 없는 UUID 포함: `get_tags_by_ids` 결과에 없으면 archived 합성 시 `archived=True`로 처리 또는 무시. **결정**: archived=True로 노출(이력 유지 원칙 — REQ-002 Intent "조용히 제거하지 않고 archived 상태로 반환").
- `tag_ids`에 is_active=FALSE tag_id 포함: archived=True 응답 (AC-3).
- `status=all` + `market=KRX` 동시 지정: 두 필터 AND. 빈 결과면 `{ items: [], total: 0 }` 200.
- `market` 파라미터에 MarketCode Enum 외 값: schema validation으로 422 — 기존 라우트는 임의 문자열을 받고 비교만 하므로 본 batch에서 명시적 validation 추가 여부 결정 필요. **결정**: 본 batch는 기존 동작 유지(임의 문자열 허용, 매칭 없으면 빈 결과). MarketCode 검증은 별도 REQ로.
- assets 테이블 비어 있음: `{ items: [], total: 0 }` 200.
- non-admin 토큰(user/service/client): `require_admin` 의존성이 403 처리(AC-12).
## Architecture Impact
- **runtime-flows** — GET /v1/assets 응답에 tag_ids/archived 합성 흐름이 추가됨. 한 줄 갱신 필요.
## Reference Doc Update Plan
- project-structure.md: none
- runtime-flows.md: GET /v1/assets 흐름 설명에 "tag_ids 배열을 응답으로 노출하고 tags 마스터 is_active를 조회해 archived 합성" 1줄 추가 — 본 batch implementation에서 함께 처리.
@@ -0,0 +1,47 @@
# BAT-012 Implementation
- Status: completed
- Batch: BAT-012
## Plan Summary
- planning.md Delivery Plan에 따라 (1) Alembic revision → (2) 도메인 모델 → (3) repository → (4) schemas → (5) GET 라우트 헬퍼 보강 → (6) 테스트 → (7) docs 갱신 순서로 구현 완료.
- GET /v1/assets 라우트 본체는 사전 augmentation으로 이미 admin catalog 조회로 재정의되어 있었음. 본 batch는 (a) `tag_ids` 응답 필드 합류, (b) archived 합성 N+1 회피 로직, (c) 부재했던 AC-9~12 자동 테스트 신설을 책임.
## Changed Files
- `migrations/versions/20260521_0023_assets_tag_ids_column.py` (신규) — assets.tag_ids UUID[] + idx_assets_tag_ids_gin
- `app/domain/models.py` — `Asset` dataclass에 `tag_ids: list[str] = field(default_factory=list)` 필드 추가
- `app/repositories/postgres.py`
- `_SELECT_ASSET`에 `COALESCE(tag_ids, '{}'::uuid[])` 컬럼 추가
- `_row_to_asset`에서 `tag_ids=[str(t) for t in row[7]] if len(row) > 7 ...` 파싱
- `insert_asset` RETURNING 절에 `COALESCE(tag_ids, '{}'::uuid[])` 추가
- `app/schemas/v1.py` — `AssetCatalogResponse`에 `tag_ids: list[TagRefItem] = Field(default_factory=list)`
- `app/api/v1_assets.py`
- `TagRefItem` import
- `_build_tag_refs(tag_ids, tag_active_map)` 신규 헬퍼 (archived 합성: 마스터에 없거나 is_active=False면 archived=True)
- `_asset_response(asset, tag_active_map=None)` 시그니처 보강 (단일 응답 경로에서는 tag_ids 비어있지 않을 때만 DB 조회)
- `list_catalog_assets`에서 모든 자산의 tag_ids union을 한 번에 `get_tags_by_ids`로 조회 후 매핑 (N+1 회피)
- `tests/test_asset_catalog.py` — REQ-002 AC-1~4 + REQ-010 AC-9~12 신규 17건 추가
- `docs/runtime-flows.md` — GET /v1/assets 흐름 재정의 + Change Log entry
## Execution Log
1. alembic heads 확인 → 최신 revision `20260520_0022` 파악, 새 revision `20260521_0023`으로 down_revision 지정
2. 마이그레이션 파일 작성 (REQ-002 AC-1, AC-2): assets.tag_ids 컬럼 + idx_assets_tag_ids_gin
3. `Asset` dataclass에 tag_ids 필드 추가 (`dataclasses.field` import 보강)
4. `_SELECT_ASSET` SQL + `_row_to_asset` + `insert_asset` RETURNING 모두 tag_ids 합류
5. `AssetCatalogResponse`에 tag_ids 필드 추가 (default_factory=list)
6. `_build_tag_refs` 헬퍼 신설 + `_asset_response`에 archived 합성 로직 + `list_catalog_assets`에서 batch 조회로 N+1 회피
7. 17건 테스트 추가 (migration file structure 2건, Asset/schema 4건, `_build_tag_refs` 단위 3건, GET integration 8건)
8. `docs/runtime-flows.md` GET /v1/assets 흐름 재작성 + Change Log entry 추가
9. `python -m pytest tests/test_asset_catalog.py -x` → 50/50 PASSED
10. `python -m pytest` 전체 회귀 → 362/362 PASSED (기존 330 + 신규 32)
## Validation
- `python -m pytest tests/test_asset_catalog.py` — **50 passed in 1.51s** (기존 33 + 신규 17)
- `python -m pytest` 전체 suite — **362 passed in 13.06s** (기존 330 + 신규 32, 회귀 0)
- Migration 파일 구조 검증은 in-test로 충분 (assert add_column / index / nullable=False / `'{}'::uuid[]` / postgresql_using=gin)
- DB-integration AC(실제 컬럼/인덱스 존재, archived row 동작)은 REL-001 후속 release 배포 시 alembic upgrade 결과로 최종 확인. CHG-20260521-01 BAT-001 패턴과 동일.
## Remaining Risks
- R-1 잔존 (Low): `POST /v1/assets/{asset_id}/tags` / `DELETE /v1/assets/{asset_id}/tags/{tag_id}` 라우트가 여전히 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출하여 portfolios 테이블을 잘못 대상화 (REQ-005 follow-up). 본 batch는 schema/응답 합류와 GET 회귀 봉합만 책임. apply/remove 라우트 정합화는 별도 REQ/Batch로 처리. 본 batch verification에서는 DB seed 기반 GET 검증으로 AC-3/AC-4 충족.
- R-2 잔존 (Low): BAT-002의 REQ-010 trace는 confirm-batch-verification 단계에서 BAT-012 우선 적용으로 정리 예정 (index.md Notes에 명시).
- R-3 잔존 (Negligible): assets.tag_ids 컬럼·GIN 인덱스 추가 시 lock — assets 테이블 규모가 작아 운영 영향 미미.
@@ -0,0 +1,37 @@
# BAT-012: assets tag_ids 컬럼 + admin GET /v1/assets 재정의
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-002 (REQ-002), dcy-001 + dcy-003 augmentation (REQ-010)
- Included REQ: REQ-002, REQ-010
- Excluded REQ: none
## Summary
- REQ-002 본문 재정의(CHG-20260521-02: portfolios.tag_ids → assets.tag_ids) 후속으로 `assets.tag_ids UUID[]` 컬럼 + `idx_assets_tag_ids_gin` GIN 인덱스를 도입한다.
- REQ-010 augmentation(CHG-20260521-02)에서 미구현으로 남은 `GET /v1/assets` admin catalog 재정의(AC-9~12)를 동시에 구현한다.
- 두 REQ가 `app/schemas/v1.py`(AssetCatalogItem 신설 + tag_ids 필드 합류), `app/repositories/postgres.py`(list_assets 필터 보강 + archived 표시), `tests/test_asset_catalog.py` 변경점을 공유하므로 한 batch에서 sequence를 잡아 처리한다.
## Scope
- In Scope:
- `migrations/versions/` — `assets.tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 + `idx_assets_tag_ids_gin` GIN 인덱스 신규 Alembic revision
- `app/domain/models.py` — `Asset` 도메인 모델에 `tag_ids: list[UUID]` 필드 추가
- `app/repositories/postgres.py` — assets 조회 시 tag_ids 배열 archived 표시 로직 (REQ-001 tags 마스터의 is_active 활용); `list_assets`에 status/market 필터 옵션 보강
- `app/api/v1_assets.py` — `GET /v1/assets` 라우트를 token_type 분기 portfolio 조회에서 admin permission 검증 + `list_assets` 호출로 재정의 (REL-001 smoke 회귀 봉합)
- `app/schemas/v1.py` — `AssetCatalogItem`, `AssetCatalogListResponse` 신규 정의 (tag_ids 필드 포함)
- `tests/test_asset_catalog.py` — REQ-002 AC-1~4 (schema, GIN, archived, 기본값) + REQ-010 AC-9~12 (admin GET 200, status/market 필터, non-admin 403) 신규 케이스
- `tests/test_v1.py` — 구 GET /v1/assets portfolio 응답 기대 케이스 정리
- Out of Scope:
- `assets.tag_ids`에 대한 apply/remove HTTP API — REQ-005에서 이미 정의된 `POST /v1/assets/{asset_id}/tags`는 본 batch가 의존성 토대를 제공하지만 라우트 자체의 회귀 검증은 별도 verification 트랙
- `pfhub asset tag` CLI 노출 — REQ-007 범위 또는 후속 REQ
- admin asset catalog 응답에 `markets`/`status` 외 필터 신설 — 본 batch는 AC 명시 항목만
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- 선행 완료: REQ-009(assets 테이블, BAT-001) ✅, REQ-001(tags 마스터, BAT-003) ✅, REQ-005(태그 apply/remove API, BAT-004) ✅
- Trace 정리: REQ-010은 BAT-002(release-candidate)에도 trace가 잔존. 본 batch verification 통과 후 confirm-req-implemented 단계에서 BAT-012 trace를 우선 적용하고 BAT-002의 REQ-010 trace는 augmentation superseded로 표기한다.
- REQ-002의 BAT-003 trace는 CHG-20260521-02에서 무효 처리됨 — 본 batch가 REQ-002의 유일한 active trace.
@@ -0,0 +1,79 @@
# BAT-012 Planning
- Status: confirmed
- Batch: BAT-012
- Profile: standard
## Included REQ
- REQ-002: `assets.tag_ids UUID[]` 컬럼 + `idx_assets_tag_ids_gin` 도입 (Data, dcy-002 FR-2)
- REQ-010: `GET /v1/assets` admin catalog 재정의 augmentation (Interface, dcy-001 FR-2 + dcy-003 옵션 B 후속 — CHG-20260521-02 AC-9~12)
## Delivery Plan
1. **Migration 도입 (REQ-002 AC-1, AC-2)**
- `migrations/versions/20260521_NNNN_assets_tag_ids_column.py` 신규 revision
- `op.add_column("assets", sa.Column("tag_ids", sa.ARRAY(sa.UUID()), nullable=False, server_default=sa.text("ARRAY[]::uuid[]")))`
- `op.create_index("idx_assets_tag_ids_gin", "assets", ["tag_ids"], postgresql_using="gin")`
- downgrade에서 인덱스 → 컬럼 순으로 제거
2. **도메인 모델 보강 (REQ-002 AC-3, AC-4)**
- `app/domain/models.py` — `Asset` 모델에 `tag_ids: list[UUID]` 필드 추가 (기본값 빈 리스트)
3. **Repository 보강 (REQ-002 AC-3, REQ-010 AC-10, AC-11)**
- `app/repositories/postgres.py`
- assets 조회 시 `tag_ids`를 그대로 로드하고, archived 표시 응답은 schema/API 계층에서 tags 마스터 is_active를 조회해 합성 (REQ-003에서 portfolios용으로 이미 도입된 archived 표시 헬퍼 재사용)
- `list_assets(status: str | None, market: str | None) -> list[Asset]` 시그니처 보강 — status 필터(active/inactive/all 의미는 라우트에서 변환) + `<market> = ANY(markets)` 필터
4. **응답 스키마 신설 (REQ-010 AC-9, REQ-002 AC-4 합류)**
- `app/schemas/v1.py`
- `AssetCatalogItem`: `asset_id, symbol, markets, display_name, status, tag_ids (각 tag_id에 archived 표시 메타 동반), created_at, updated_at`
- `AssetCatalogListResponse`: `{ items: list[AssetCatalogItem] }`
5. **GET /v1/assets 라우트 재정의 (REQ-010 AC-9~12)**
- `app/api/v1_assets.py`
- 기존 token_type 분기(daemon/user/admin portfolio 조회) 제거
- admin permission 검증 → 실패 시 HTTP 403
- query param `status` (active|inactive|all, 기본 active), `market` (선택, MarketCode 1개) 파싱
- `list_assets` 호출 후 `AssetCatalogListResponse`로 직렬화
6. **테스트 신설/갱신**
- `tests/test_asset_catalog.py`
- REQ-002 AC-1: Alembic 적용 후 `tag_ids` 컬럼 존재 (SQLAlchemy Inspector)
- REQ-002 AC-2: `idx_assets_tag_ids_gin` 존재
- REQ-002 AC-3: is_active=FALSE tag_id 포함 시 archived 표시 동반 반환
- REQ-002 AC-4: 빈 배열 기본값 반환
- REQ-010 AC-9: admin GET 200 + `AssetCatalogListResponse` shape (portfolio 필드 부재)
- REQ-010 AC-10: `status=all`/`status=inactive` 필터 동작
- REQ-010 AC-11: `market=KRX` 필터 동작
- REQ-010 AC-12: non-admin (user/service/client) 호출 시 HTTP 403
- `tests/test_v1.py`
- 구 GET /v1/assets에서 portfolio 응답을 기대하는 케이스 정리 (GET /v1/portfolios로 이전 또는 catalog 응답 schema로 교체)
7. **로컬 smoke 재실행 (REL-001 회귀 확인)**
- admin session token으로 `GET /v1/assets` → HTTP 200 + asset catalog 응답
- service token으로 `GET /v1/assets` → HTTP 403
- REL-001 smoke 체크리스트의 해당 line 재확인
## Dependencies
- Internal:
- REQ-009 (Implemented, BAT-001) — assets 테이블 ✅
- REQ-001 (Implemented, BAT-003) — tags 마스터 테이블 + is_active 컬럼 ✅
- REQ-003 (Implemented, BAT-003) — portfolios.tag_ids archived 표시 헬퍼 재사용 가능 ✅
- REQ-005 (Implemented, BAT-004) — `POST /v1/assets/{asset_id}/tags` 라우트가 본 batch의 컬럼 도입 후 실제 저장 가능
- External:
- 없음 (trex-basic 등 외부 클라이언트 영향 없음)
## Design Gate
- Design Required Before Implementation: **yes**
- Reason:
1. archived 표시 응답 포맷 결정 — `tag_ids: [uuid]` + 별도 `tag_status_map: {uuid: 'active'|'archived'}` vs `tags: [{tag_id, status}]` 객체 배열. REQ-003 portfolios.tag_ids에서 이미 채택한 형식과 정합 필요.
2. `AssetCatalogItem`에 `tag_ids` 필드를 어느 구조로 노출할지 → 위 결정과 연동.
3. GET /v1/assets 재정의로 인한 기존 token_type 분기 코드의 호출 그래프 영향 — service-token/user-token 클라이언트 호출 패턴 회귀 영향 분석 (특히 trex-basic이 GET /v1/assets를 호출했는지 BAT-011 후속으로 확인).
4. BAT-002의 REQ-010 trace 정리 방식 — BAT-002 종결 시점 정책 결정.
## Milestones
1. design.md confirmed — archived 표시 포맷, list_assets 시그니처, BAT-002 trace 정리 정책 결정
2. Alembic revision 추가 + 도메인 모델/repository 보강 완료 (REQ-002 AC-1~4 자가검증)
3. AssetCatalogItem/AssetCatalogListResponse + GET 라우트 재정의 완료 (REQ-010 AC-9~12 자가검증)
4. `tests/test_asset_catalog.py` 신규 케이스 전부 pass + `tests/test_v1.py` 회귀 통과
5. 로컬 smoke 재실행으로 REL-001 회귀 봉합 확인
6. confirm-batch-verification 통과 후 BAT-012 → release-candidate, REQ-002/REQ-010 → Implemented
## Risks
- R-1 (Medium): `GET /v1/assets` 라우트 동작 변경 — REL-001 smoke에서 이미 회귀(HTTP 403 "Unsupported token type") 발생한 surface. service-token/user-token이 GET /v1/assets를 호출하는 코드 경로가 잔존하면 본 batch 배포 후 회귀가 다시 발생. 완화: design 단계에서 `grep -rn "GET.*/v1/assets" app/ tests/` 호출 그래프 전수 조사 + 회귀 테스트 명시.
- R-2 (Medium): BAT-002와 BAT-012 사이 REQ-010 trace 중복 — confirm-req-implemented 시 BAT-012 verification을 우선 적용하고 BAT-002의 REQ-010 trace를 augmentation superseded로 표기하지 않으면 release 트레이스가 모호해짐. 완화: design.md `Key Decisions`에 trace 정리 정책 명문화.
- R-3 (Low): assets 테이블에 `tag_ids` 컬럼 + GIN 인덱스 추가 시 lock — assets 테이블 규모가 작아(카탈로그 수십~수백 row 수준) 운영 영향 미미. 완화: 변경 없음.
- R-4 (Low): tags 마스터 is_active 조회로 archived 표시 합성 시 N+1 query 가능 — REQ-003 portfolios에서 이미 도입한 batch 조회 패턴 재사용. 완화: design에서 동일 헬퍼 사용 명시.
@@ -0,0 +1,44 @@
# BAT-012 Verification
- Status: confirmed
- Batch: BAT-012
## Acceptance Mapping
- REQ-002 (assets.tag_ids UUID[] 컬럼):
- AC-1 (`tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 존재) ↔ `tests/test_asset_catalog.py::test_migration_assets_tag_ids_adds_column_and_gin_index` (migration 파일 구조에서 `add_column`/`UUID`/`nullable=False`/`'{}'::uuid[]` 확인). 실 DB 컬럼 존재는 REL 배포 시 alembic upgrade로 최종 확인.
- AC-2 (`idx_assets_tag_ids_gin` GIN 인덱스 존재) ↔ 동일 테스트의 `postgresql_using="gin"` + `idx_assets_tag_ids_gin` assertion. 실 DB 인덱스 존재는 REL 배포 시 alembic upgrade로 최종 확인.
- AC-3 (is_active=FALSE tag_id 포함 시 archived 표시 동반 반환) ↔ `tests/test_asset_catalog.py::test_get_v1_assets_archived_flag_for_inactive_tag` (실제 API 응답으로 `{tag_id, archived: True}` 확인). 단위는 `test_build_tag_refs_archived_for_inactive_tags`.
- AC-4 (빈 배열 기본값) ↔ `tests/test_asset_catalog.py::test_asset_model_tag_ids_defaults_to_empty_list` + `test_asset_catalog_response_schema_has_tag_ids_field` (default_factory=list) + `test_get_v1_assets_admin_returns_catalog_response`의 a-2 자산 `tag_ids == []` 검증.
- REQ-010 (admin asset catalog CRUD + GET 재정의 augmentation):
- AC-1~8 (기존 CLI/HTTP CRUD + non-admin 403): BAT-002에서 이미 통과. 본 batch 회귀 suite로 회귀 확인 — `tests/test_asset_catalog.py::test_asset_cli_create_success / test_asset_cli_list_returns_all / test_asset_cli_list_active_only_filter / test_asset_cli_update_success / test_asset_cli_deactivate_success / test_asset_http_create_returns_201 / test_asset_http_update_returns_200 / test_asset_http_deactivate_returns_200 / test_asset_http_non_admin_403` 9건 PASS.
- AC-9 (admin GET 200 + AssetCatalogListResponse shape) ↔ `test_get_v1_assets_admin_returns_catalog_response` (status_code=200, `items`/`total` 키 + portfolio 필드 부재 확인).
- AC-10 (`status=all`/`status=inactive` 필터 + 기본 active) ↔ `test_get_v1_assets_status_filter_all_includes_inactive` + `test_get_v1_assets_status_filter_inactive_only` + `test_get_v1_assets_status_filter_default_active_only` + `test_get_v1_assets_status_filter_rejects_unknown_value` (422 반환).
- AC-11 (`market=KRX` 필터) ↔ `test_get_v1_assets_market_filter` (`markets`에 `KRX` 포함된 자산만 응답).
- AC-12 (non-admin → HTTP 403) ↔ `test_get_v1_assets_user_token_returns_403` + `test_get_v1_assets_service_token_returns_403`.
## Evidence
- Test:
- `python -m pytest tests/test_asset_catalog.py` → **50 passed in 1.51s** (기존 33 + 신규 17)
- `python -m pytest` 전체 suite → **362 passed in 13.06s** (기존 330 + 신규 32, 회귀 0)
- Manual:
- 로컬 smoke 미실행 — pytest TestClient 기반 통합 테스트로 admin token + service/user token 분기 모두 자동 검증. REL 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인.
- Logs:
- pytest 출력은 implementation.md Execution Log 항목 9~10 참조.
## Architecture Impact Sync
- design.md `Architecture Impact: runtime-flows` 명시됨.
- `docs/runtime-flows.md` 갱신 완료:
- `Flow: v1 API — GET /v1/assets` 본문을 token_type 분기 portfolio 조회 → admin asset catalog 조회 + tag_ids/archived 합성으로 재작성
- Change Log entry 추가 (2026-05-21 KST BAT-012)
- `docs/project-structure.md`: Architecture Impact = none, 갱신 불요.
- `docs/data-model.md` (assets 스키마에 tag_ids 컬럼 명시) 및 `docs/interface-contract.md` (GET /v1/assets 응답에 tag_ids 필드 명세) 추가 docs sync는 별도 docs REQ로 분리 (본 batch verification blocker 아님).
## Result
- Ready for Release: true
- Blocking Issues:
- 없음 (본 batch 범위 내 AC 전수 evidence 확보)
- Follow-up (release 또는 후속 REQ):
- REQ-005 follow-up: `POST/DELETE /v1/assets/{asset_id}/tags`가 portfolios 테이블을 잘못 대상화하는 잠재 버그 — 별도 REQ/Batch에서 정합화.
- BAT-002의 REQ-010 trace 정리: confirm-req-implemented 단계에서 BAT-012 trace 우선 적용 + BAT-002 표기를 `superseded by BAT-012`로 갱신.
- `docs/data-model.md` / `docs/interface-contract.md` docs sync REQ 발의 필요.
+1
View File
@@ -19,3 +19,4 @@
| BAT-009 | standard | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028 | dcy-003 | [bat-009](./bat-009_20260521_v1-portfolios-crud-core/) | | BAT-009 | standard | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028 | dcy-003 | [bat-009](./bat-009_20260521_v1-portfolios-crud-core/) |
| BAT-010 | standard | release-candidate | REQ-030, REQ-031, REQ-032 | dcy-003 | [bat-010](./bat-010_20260521_v1-portfolios-sub-resources/) | | BAT-010 | standard | release-candidate | REQ-030, REQ-031, REQ-032 | dcy-003 | [bat-010](./bat-010_20260521_v1-portfolios-sub-resources/) |
| BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) | | BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) |
| BAT-012 | standard | release-candidate | REQ-002, REQ-010 | dcy-001, dcy-002, dcy-003 | [bat-012](./bat-012_20260521_assets-tagging-and-admin-get-redefinition/) |
+1 -1
View File
@@ -8,4 +8,4 @@
| REL ID | Profile | Status | Included Batch | Date | Doc | | REL ID | Profile | Status | Included Batch | Date | Doc |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| REL-001 | app-service | draft | BAT-001~011 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) | | REL-001 | app-service | draft | BAT-001~012 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) |
@@ -2,7 +2,7 @@
- Status: draft - Status: draft
- Profile: app-service - Profile: app-service
- Included Batch: BAT-001, BAT-002, BAT-003, BAT-004, BAT-005, BAT-006, BAT-007, BAT-008, BAT-009, BAT-010, BAT-011 - Included Batch: BAT-001, BAT-002, BAT-003, BAT-004, BAT-005, BAT-006, BAT-007, BAT-008, BAT-009, BAT-010, BAT-011, BAT-012
## Scope ## Scope
- Included: - Included:
@@ -17,14 +17,15 @@
- BAT-009 (REQ-025, REQ-026, REQ-027, REQ-028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설 - BAT-009 (REQ-025, REQ-026, REQ-027, REQ-028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
- BAT-010 (REQ-030, REQ-031, REQ-032): GET/POST/DELETE /v1/portfolios/{id}/tags + POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments + baseline 갱신 - BAT-010 (REQ-030, REQ-031, REQ-032): GET/POST/DELETE /v1/portfolios/{id}/tags + POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments + baseline 갱신
- BAT-011 (REQ-029): POST /v1/portfolios/{id}/ledger/trades 신설 + 구 path POST /v1/assets/{id}/ledger/trades 제거 - BAT-011 (REQ-029): POST /v1/portfolios/{id}/ledger/trades 신설 + 구 path POST /v1/assets/{id}/ledger/trades 제거
- BAT-012 (REQ-002 재정의, REQ-010 augmentation): assets.tag_ids UUID[] 컬럼 + idx_assets_tag_ids_gin + GET /v1/assets admin catalog 재정의(token_type 분기 제거) + AssetCatalogResponse tag_ids/archived 합성. 기존 smoke의 GET /v1/assets 403 불일치를 해소.
- Excluded: - Excluded:
- REQ-013 (Deprecated): GET /v1/assets 토큰 타입별 분기 재정의 — dcy-003 옵션 B 결정으로 무효화 - REQ-013 (Deprecated): GET /v1/assets 토큰 타입별 분기 재정의 — dcy-003 옵션 B 결정으로 무효화
- trex-basic 내부 코드 변경 (별도 레포 — 본 릴리즈와 동시 배포 필요) - trex-basic 내부 코드 변경 (별도 레포 — 본 릴리즈와 동시 배포 필요)
## Rollout Plan ## Rollout Plan
1. **사전 확인**: trex-basic 신 path(`POST /v1/portfolios/{id}/ledger/trades`) 빌드 완료 및 배포 준비 확인 1. **사전 확인**: trex-basic 신 path(`POST /v1/portfolios/{id}/ledger/trades`) 빌드 완료 및 배포 준비 확인
2. **DB 마이그레이션**: `alembic upgrade head` — BAT-001/003/006/007 관련 revision 순차 적용 2. **DB 마이그레이션**: `alembic upgrade head` — BAT-001/003/006/007/012 관련 revision 순차 적용
- assets 테이블, portfolios.asset_id FK, tags 마스터, tag_ids 컬럼, auth_tokens.runtime_id/daemon_id, daemons 테이블, ledger_trades - assets 테이블, portfolios.asset_id FK, tags 마스터, portfolios.tag_ids 컬럼, **assets.tag_ids 컬럼 + idx_assets_tag_ids_gin (20260521_0023)**, auth_tokens.runtime_id/daemon_id, daemons 테이블, ledger_trades
3. **portfolio-hub 서비스 배포**: 신규 이미지 rolling restart 3. **portfolio-hub 서비스 배포**: 신규 이미지 rolling restart
- 신 API surface: `/v1/portfolios/*`, `/v1/tags`, `/v1/assets` (catalog), `/v1/portfolios/{id}/ledger/trades` - 신 API surface: `/v1/portfolios/*`, `/v1/tags`, `/v1/assets` (catalog), `/v1/portfolios/{id}/ledger/trades`
- 구 path `/v1/assets/{id}/ledger/trades` → HTTP 404 (BAT-011) - 구 path `/v1/assets/{id}/ledger/trades` → HTTP 404 (BAT-011)
@@ -38,17 +39,17 @@
1. portfolio-hub → 이전 이미지로 즉시 롤백 (`alembic downgrade` 또는 이전 이미지 재기동) 1. portfolio-hub → 이전 이미지로 즉시 롤백 (`alembic downgrade` 또는 이전 이미지 재기동)
2. trex-basic → 구 path 사용 버전으로 동반 롤백 (portfolio-hub 롤백 완료 후) 2. trex-basic → 구 path 사용 버전으로 동반 롤백 (portfolio-hub 롤백 완료 후)
- **BAT-011 단독 부분 롤백 불가** — 신/구 path 동시 서빙은 설계상 없음 - **BAT-011 단독 부분 롤백 불가** — 신/구 path 동시 서빙은 설계상 없음
- **DB 마이그레이션 롤백**: 데이터 손실 리스크를 평가한 뒤 DBA 판단 하에 수행. tag_ids 컬럼·ledger_trades·daemons 테이블은 마이그레이션 downgrade 시 데이터 소실 가능성 존재 — 사전 DB 스냅샷 필수 - **DB 마이그레이션 롤백**: 데이터 손실 리스크를 평가한 뒤 DBA 판단 하에 수행. tag_ids 컬럼(portfolios/assets 양쪽)·ledger_trades·daemons 테이블은 마이그레이션 downgrade 시 데이터 소실 가능성 존재 — 사전 DB 스냅샷 필수. assets.tag_ids(20260521_0023) downgrade는 컬럼·인덱스 drop으로 안전.
## Verification Checklist ## Verification Checklist
- **배포 전**: - **배포 전**:
- [x] trex-basic 신 path 빌드 완료 확인 - [x] trex-basic 신 path 빌드 완료 확인
- [x] DB 스냅샷(백업) 생성 완료 - [x] DB 스냅샷(백업) 생성 완료
- [x] `alembic upgrade head` dry-run 또는 revision 목록 점검 - [x] `alembic upgrade head` dry-run 또는 revision 목록 점검
- [x] 전체 테스트 suite pass 확인 (340 passed, main branch 4bf6b55) - [x] 전체 테스트 suite pass 확인 (BAT-001~011: 340 passed @ main 4bf6b55; BAT-012 추가 후: **362 passed @ 2026-05-21 BAT-012 confirm-batch-verification**)
- **배포 직후 (portfolio-hub)**: - **배포 직후 (portfolio-hub)**:
- [~] `GET /health` → HTTP 200 + `db_enabled: true` (DB 연결 정상) — **로컬 smoke (2026-05-21 17:08 KST)**: HTTP 200 응답, 단 `HealthResponse` 모델([app/api/health.py:17-21](app/api/health.py#L17-L21))에 `db_enabled` 필드 미노출. DB 연동은 `PORTFOLIOHUB_DB_ENABLED=true` + `alembic upgrade head` head revision 적용 + 본 smoke의 다른 항목들이 DB 의존 동작으로 통과한 것으로 우회 확인. - [~] `GET /health` → HTTP 200 + `db_enabled: true` (DB 연결 정상) — **로컬 smoke (2026-05-21 17:08 KST)**: HTTP 200 응답, 단 `HealthResponse` 모델([app/api/health.py:17-21](app/api/health.py#L17-L21))에 `db_enabled` 필드 미노출. DB 연동은 `PORTFOLIOHUB_DB_ENABLED=true` + `alembic upgrade head` head revision 적용 + 본 smoke의 다른 항목들이 DB 의존 동작으로 통과한 것으로 우회 확인.
- [!] `GET /v1/assets` (admin token) → HTTP 200 (asset catalog 조회) — **로컬 smoke (2026-05-21 17:08 KST)**: admin session token으로 호출 시 HTTP 403 `{"detail":"Unsupported token type"}`. 현재 [app/api/v1_assets.py:135-164](app/api/v1_assets.py#L135-L164) token_type 분기는 `user/admin/service` 케이스만 처리하고 `session`은 누락. dcy-003 옵션 B 결정으로 REQ-013(Deprecated) 후 GET /v1/assets를 admin catalog 조회 전용으로 재정의하는 작업(REQ-010 Notes 후속)이 아직 반영되지 않음. **이 체크리스트 항목은 현재 코드 상태와 불일치 — 본 릴리즈 범위 결정 필요(아래 Feedback Handoff 참조).** - [x] `GET /v1/assets` (admin token) → HTTP 200 (asset catalog 조회) — **이전 로컬 smoke (2026-05-21 17:08 KST)**에서 admin session token으로 HTTP 403 발생. **BAT-012에서 해소** (2026-05-21): REQ-010 augmentation(CHG-20260521-02 AC-9~12) 구현으로 GET /v1/assets가 admin catalog 응답을 반환하도록 재정의됨. 자동 회귀 통과: `tests/test_asset_catalog.py::test_get_v1_assets_admin_returns_catalog_response` + status/market 필터 + non-admin(user/service) 403. 실 배포 환경 smoke는 REL 배포 시 재확인.
- [x] `POST /v1/portfolios` (user session token) → HTTP 201 (portfolio 생성) — **로컬 smoke (2026-05-21 17:13 KST)**: admin session token + active asset(`bab52e11-...`)으로 호출, HTTP 201 + `portfolio_id=c629c31e-b77d-44fb-a515-0edfe4f00cee` 생성 확인. `_require_write_token` 게이트([app/api/v1_portfolios.py:45-50](app/api/v1_portfolios.py#L45-L50))에서 session token 정상 허용. - [x] `POST /v1/portfolios` (user session token) → HTTP 201 (portfolio 생성) — **로컬 smoke (2026-05-21 17:13 KST)**: admin session token + active asset(`bab52e11-...`)으로 호출, HTTP 201 + `portfolio_id=c629c31e-b77d-44fb-a515-0edfe4f00cee` 생성 확인. `_require_write_token` 게이트([app/api/v1_portfolios.py:45-50](app/api/v1_portfolios.py#L45-L50))에서 session token 정상 허용.
- [x] `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (구 path 제거 확인) — **로컬 smoke (2026-05-21 17:13 KST)**: HTTP 404 `{"detail":"Not Found"}` 확인. BAT-011 신/구 path 동시 서빙 없음 정책 충족. - [x] `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (구 path 제거 확인) — **로컬 smoke (2026-05-21 17:13 KST)**: HTTP 404 `{"detail":"Not Found"}` 확인. BAT-011 신/구 path 동시 서빙 없음 정책 충족.
- **trex-basic 배포 직후**: - **trex-basic 배포 직후**:
@@ -68,7 +69,7 @@
- Operator: - Operator:
- (배포 수행자 — 배포 시 기록) - (배포 수행자 — 배포 시 기록)
- Revision or Artifact: - Revision or Artifact:
- main branch commit: `4bf6b55` (feat: v1 portfolio mutation, sub-resources, ledger path migration BAT-008~011) - main branch commit: `4bf6b55` (feat: v1 portfolio mutation, sub-resources, ledger path migration BAT-008~011) + BAT-012 commit (assets.tag_ids + GET /v1/assets admin catalog 재정의, 본 release 진입 시 commit hash 기록)
- Commands or Procedure: - Commands or Procedure:
- `alembic upgrade head` → portfolio-hub rolling restart → trex-basic 배포 - `alembic upgrade head` → portfolio-hub rolling restart → trex-basic 배포
@@ -84,7 +85,7 @@
- 로컬 smoke 결과(Verification Checklist 배포 직후 4개 항목 참조): - 로컬 smoke 결과(Verification Checklist 배포 직후 4개 항목 참조):
- 통과 2건: POST /v1/portfolios (201), POST /v1/assets/{id}/ledger/trades (404) - 통과 2건: POST /v1/portfolios (201), POST /v1/assets/{id}/ledger/trades (404)
- 부분 통과 1건: GET /health (200, 단 `db_enabled` 필드 미노출) - 부분 통과 1건: GET /health (200, 단 `db_enabled` 필드 미노출)
- 불일치 1건: GET /v1/assets (admin session token → 403 "Unsupported token type"). 본 릴리즈 진행 전 결정 필요. - **해소 1건**: GET /v1/assets (admin session token → 이전 403, BAT-012 augmentation으로 admin catalog 응답 재정의. 자동 회귀 통과 — tests/test_asset_catalog.py 50 passed)
- 실제 배포 환경(prod/staging) smoke는 본 릴리즈 시 별도 수집. - 실제 배포 환경(prod/staging) smoke는 본 릴리즈 시 별도 수집.
### Rollback Outcome ### Rollback Outcome
@@ -111,8 +112,9 @@
- Discovery Input: - Discovery Input:
- ledger_trades.asset_id 컬럼명이 portfolio_id를 가리키는 혼용 상태 잔존 (REQ-029 Notes) — 컬럼명 정합화 후속 REQ 후보 - ledger_trades.asset_id 컬럼명이 portfolio_id를 가리키는 혼용 상태 잔존 (REQ-029 Notes) — 컬럼명 정합화 후속 REQ 후보
- session token의 no_expiry 코드 enforcement 미구현 (REQ-021 follow-up) — 후속 REQ 후보 - session token의 no_expiry 코드 enforcement 미구현 (REQ-021 follow-up) — 후속 REQ 후보
- **GET /v1/assets token_type 게이트와 admin catalog 재정의 미정합**: dcy-003 옵션 B로 REQ-013이 Deprecated되었으나 [app/api/v1_assets.py:135-164](app/api/v1_assets.py#L135-L164)의 token_type 분기(user/admin/service)는 그대로 잔존하고, REQ-010 Notes에 명시된 GET /v1/assets의 "admin catalog 조회 전용" 재정의가 아직 별도 REQ로 분리되지 않음. session token이 v1 portfolio path와 달리 v1 assets path에서 거부되는 inconsistency 발생. **후속 Discovery 또는 dcy-001 batch design 단계의 REQ-010 augmentation 후보**. - ~~**GET /v1/assets token_type 게이트와 admin catalog 재정의 미정합**~~: **Resolved by BAT-012** (2026-05-21). REQ-010 CHG-20260521-02 augmentation(AC-9~12)을 BAT-012에서 구현 — GET /v1/assets가 `require_admin` 의존성으로 단일화되고 admin catalog 응답을 반환. session token이 admin permission을 가진 경우 정상 통과. 자동 회귀 9건 추가(test_get_v1_assets_*).
- **/health 응답 모델에 `db_enabled` 필드 부재**: 운영 관점에서 health probe 결과로 DB 연결 상태를 외부 관찰자에게 노출하지 않는 상태. 체크리스트의 의도와 응답 모델 간 gap. `HealthResponse`([app/api/health.py:17-21](app/api/health.py#L17-L21)) 보강 또는 별도 readiness endpoint 분리 후보. - **/health 응답 모델에 `db_enabled` 필드 부재**: 운영 관점에서 health probe 결과로 DB 연결 상태를 외부 관찰자에게 노출하지 않는 상태. 체크리스트의 의도와 응답 모델 간 gap. `HealthResponse`([app/api/health.py:17-21](app/api/health.py#L17-L21)) 보강 또는 별도 readiness endpoint 분리 후보.
- **`POST/DELETE /v1/assets/{asset_id}/tags` 잘못된 테이블 대상화**: BAT-012 implementation 단계에서 발견. 라우트는 admin asset tagging을 의도하지만 내부적으로 `append_portfolio_tag_id`/`remove_portfolio_tag_id`(portfolios 테이블)를 호출. assets.tag_ids 컬럼은 본 release로 도입되었으나 apply/remove 경로가 정합되지 않아 실제로 채워지지 않음. **REQ-005 follow-up REQ 또는 신규 REQ 후보**.
- REQ Input: - REQ Input:
- (운영 관찰 후 기록) - (운영 관찰 후 기록)
- Change Request Input: - Change Request Input:
+7 -10
View File
@@ -215,17 +215,13 @@
- `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006) - `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006)
- `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed - `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed
## Flow: `v1 API — GET /v1/assets` ## Flow: `v1 API — GET /v1/assets` (admin asset catalog)
1. DB token required; `token_type` drives branching: 1. Admin token required (`require_admin`). Non-admin (user/service/client) callers receive HTTP 403.
- `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)`. 2. Query params: `?status=<active|inactive|all>` (default `active`), `?market=<code>` (optional). `status` outside the allow-list → HTTP 422.
- `user`: `list_portfolios_with_strategies(owner_user_id=auth.user_id, include_disabled=True, market=market)`. 3. `repo.list_assets()` → in-memory filter by `status` (skipped when `all`) and by `market` (`<code> in asset.markets`).
- `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`. 4. Tag archived 합성: 결과 자산들의 `tag_ids` union을 모아 `repo.get_tags_by_ids(...)`로 1회 조회. `tags.is_active=False` 또는 마스터에 없는 tag_id는 `archived=True`로 응답. 빈 배열은 그대로 빈 배열.
2. Optional `?status=<value>` query param post-filters portfolios by status field. 5. Returns `AssetCatalogListResponse` with `items[]` and `total`. Each item: `asset_id, symbol, markets, display_name, status, tag_ids: [{tag_id, archived}], created_at, updated_at`. Portfolio 필드(`cash`, `quantity`, `owner_user_id` 등)는 포함하지 않는다 — 포지션 조회는 `GET /v1/portfolios`(REQ-028).
3. Optional `?market=<code>` 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` ## Flow: `Admin Asset Catalog CRUD — POST/PATCH /v1/assets`
@@ -453,6 +449,7 @@
## Change Log ## Change Log
- 2026-05-21 KST: BAT-012 (REQ-002 / REQ-010 AC-9~12) — GET /v1/assets 흐름 재정의: 기존 token_type 분기 portfolio 조회 설명을 admin asset catalog 조회로 교체; tag_ids 배열 + archived 합성 흐름 신설(tags 마스터 is_active 조회로 비활성·미존재 tag_id를 `archived=True`로 응답).
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신. - 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신.
- 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024). - 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024).
+106 -13
View File
@@ -6,38 +6,131 @@
- Owner: Justin Ko - Owner: Justin Ko
## Intent ## Intent
- assets(portfolios) 테이블에 UUID[] 배열 컬럼을 추가하여 junction 테이블 없이 tags 마스터 테이블 기반 태그 참조를 저장한다. - assets 카탈로그 테이블에 UUID[] 배열 컬럼을 추가하여 junction 테이블 없이 tags 마스터 테이블 기반 태그 참조를 저장한다.
- admin이 카탈로그 종목에 분류 태그(예: "코스피200")를 부여할 수 있는 데이터 토대를 마련한다 (dcy-002 주요 시나리오).
- GIN 인덱스로 배열 조회 성능을 확보하고, 비활성화된 태그는 조용히 제거하지 않고 archived 상태로 반환하여 이력 추적을 가능하게 한다. - GIN 인덱스로 배열 조회 성능을 확보하고, 비활성화된 태그는 조용히 제거하지 않고 archived 상태로 반환하여 이력 추적을 가능하게 한다.
- 태그 적용 정책 검증(is_admin_tag=TRUE AND is_active=TRUE 확인, 403 반환)은 REQ-005에서 구현한다. - 태그 적용 정책 검증(is_admin_tag=TRUE AND is_active=TRUE 확인, 403 반환)은 REQ-005에서 구현한다.
## Requirement ## Requirement
- portfolios 테이블(asset에 해당)에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼을 추가해야 한다. - assets 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼을 추가해야 한다.
- portfolios.tag_ids에 GIN 인덱스를 생성해야 한다. - assets.tag_ids에 GIN 인덱스(`idx_assets_tag_ids_gin`)를 생성해야 한다.
- tag_ids 배열에 is_active = FALSE인 tag_id가 포함된 경우, 해당 항목을 조용히 제거하지 않고 archived 상태임을 명시하여 반환해야 한다. - tag_ids 배열에 is_active = FALSE인 tag_id가 포함된 경우, 해당 항목을 조용히 제거하지 않고 archived 상태임을 명시하여 반환해야 한다.
## Acceptance Criteria ## Acceptance Criteria
1. Given Alembic 마이그레이션이 적용된 DB, When portfolios 테이블 스키마를 조회, Then tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼이 존재한다. 1. Given Alembic 마이그레이션이 적용된 DB, When assets 테이블 스키마를 조회, Then `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼이 존재한다.
2. Given 마이그레이션이 적용된 DB, When portfolios.tag_ids 인덱스를 조회, Then GIN 인덱스가 존재한다. 2. Given 마이그레이션이 적용된 DB, When assets.tag_ids 인덱스를 조회, Then `idx_assets_tag_ids_gin` GIN 인덱스가 존재한다.
3. Given asset의 tag_ids 배열에 is_active = FALSE인 tag_id가 포함, When 해당 asset의 태그 목록을 조회, Then 비활성 태그가 archived 표시와 함께 반환되며 배열에서 자동 제거되지 않는다. 3. Given asset의 tag_ids 배열에 is_active = FALSE인 tag_id가 포함, When 해당 asset의 태그 목록을 조회, Then 비활성 태그가 archived 표시와 함께 반환되며 배열에서 자동 제거되지 않는다.
4. Given tag_ids = '{}' 기본값인 asset, When 태그가 적용되지 않은 상태로 조회, Then 빈 배열이 반환된다. 4. Given tag_ids = '{}' 기본값인 asset, When 태그가 적용되지 않은 상태로 조회, Then 빈 배열이 반환된다.
## Impacted Area ## Impacted Area
- Docs: docs/data-model.md (portfolios 스키마에 tag_ids 컬럼 추가) - Docs: docs/data-model.md (assets 스키마에 tag_ids 컬럼 추가)
- Modules: - Modules:
- migrations/versions/ — portfolios.tag_ids 컬럼 추가 및 GIN 인덱스 생성 Alembic revision - migrations/versions/ — assets.tag_ids 컬럼 추가 및 GIN 인덱스 생성 Alembic revision (신규)
- app/domain/models.py — Portfolio 도메인 모델에 tag_ids 필드 추가 - app/domain/models.py — Asset 도메인 모델에 tag_ids 필드 추가
- app/repositories/postgres.py — tag_ids 배열 조회 시 archived 표시 로직 - app/repositories/postgres.py — assets 조회 시 tag_ids 배열 archived 표시 로직
- app/schemas/v1.py — AssetItem에 tag_ids 필드 추가 - app/schemas/v1.py — admin asset catalog 응답 스키마에 tag_ids 필드 추가 (REQ-010 augmentation 범위와 정합)
- Tests: portfolios 테이블 스키마 검증, GIN 인덱스 존재 확인, archived 표시 동작 확인 - Tests: assets 테이블 tag_ids 스키마 검증, GIN 인덱스 존재 확인, archived 표시 동작 확인
## Notes ## Notes
- dcy-001의 assets 테이블(portfolios)이 확정된 이후 이 REQ를 구현해야 한다 (dcy-002 R-2). - REQ-009(assets 테이블 스키마 도입) 이후 이 REQ가 구현되어야 한다. REQ-009 Impacted Area에 본 REQ가 cross-reference된다.
- 배열 참조 무결성은 애플리케이션 레이어에서 보장한다 (dcy-002 NFR-2). PostgreSQL ARRAY FK는 미사용. - 배열 참조 무결성은 애플리케이션 레이어에서 보장한다 (dcy-002 NFR-2). PostgreSQL ARRAY FK는 미사용.
- portfolios 테이블이 v1 API의 "asset" 개념에 해당한다 (portfolio_id = asset_id in v1 API). - portfolios.tag_ids 컬럼은 REQ-003에서 독립적으로 관리한다 (dcy-002 FR-3).
- Source: dcy-002 FR-2, NFR-2 - Source: dcy-002 FR-2, NFR-2
## Change Log ## Change Log
### CHG-20260521-03
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented (CHG-20260521-02로 재정의된 assets.tag_ids 범위가 BAT-012 verification에서 evidence 확보)
#### Intent
- BAT-012 confirm-batch-verification 완료. assets.tag_ids 컬럼·GIN 인덱스·archived 표시·빈 배열 기본값 4개 AC 모두 evidence 연결됨.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-012 (release-candidate) — primary trace for assets.tag_ids 범위
- BAT-003 trace는 CHG-20260521-02에서 무효 처리됨 (구 portfolios.tag_ids 범위, REQ-003에서 실질 커버)
- Releases:
- 신규 release 대상 (REL-002 등) — BAT-012 포함 release 발의 시 trace
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인
#### Validation Plan
- tests/test_asset_catalog.py::test_migration_assets_tag_ids_adds_column_and_gin_index (AC-1, AC-2)
- tests/test_asset_catalog.py::test_get_v1_assets_archived_flag_for_inactive_tag (AC-3 통합) + test_build_tag_refs_archived_for_inactive_tags (AC-3 단위)
- tests/test_asset_catalog.py::test_asset_model_tag_ids_defaults_to_empty_list + test_asset_catalog_response_schema_has_tag_ids_field (AC-4)
- 전체 회귀: 362 passed in 13.06s
### CHG-20260521-02
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Implemented → Approved
- Requirement scope 재정의: portfolios.tag_ids → assets.tag_ids (파일명·dcy-002 FR-2 의도와 정합성 회복)
#### Intent
- REQ-002 파일명(`req-002_assets-tag-ids-column.md`)과 dcy-002 FR-2(`assets.tag_ids UUID[]` 컬럼 추가, admin 태그만 적용 가능)에 본문을 정합화한다.
- 기존 본문은 dcy-001 분리 결정 이전 `portfolios == "asset"` 등치 시절에 작성된 흔적으로, 실질적으로 REQ-003(portfolios.tag_ids)과 동일 범위였다.
- assets.tag_ids 컬럼은 현재 마이그레이션(`migrations/versions/20260519_0017_create_assets_table.py`)에 부재하며, 본 REQ는 그 추가를 요구한다.
#### Acceptance Criteria Delta
- Removed (구 portfolios.tag_ids 범위 — REQ-003에서 동일 컬럼 커버됨):
- AC-1 (구) portfolios.tag_ids 컬럼 존재 확인
- AC-2 (구) portfolios.tag_ids GIN 인덱스 확인
- AC-3 (구) portfolio archived 표시 동작
- AC-4 (구) portfolio 빈 배열 기본값
- Added (신 assets.tag_ids 범위):
- AC-1 assets 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 존재
- AC-2 `idx_assets_tag_ids_gin` GIN 인덱스 존재
- AC-3 asset archived 태그 표시 동작
- AC-4 asset 빈 배열 기본값
#### Impacted Area Delta
- Docs:
- Removed: docs/data-model.md (portfolios.tag_ids — REQ-003 영역)
- Added: docs/data-model.md (assets 스키마에 tag_ids 컬럼 추가)
- Modules:
- Removed: app/domain/models.py Portfolio 필드 / portfolios 배열 archived 로직 / AssetItem(v1) 스키마 — 전부 REQ-003 영역으로 이관
- Added:
- migrations/versions/ — assets.tag_ids 컬럼 + `idx_assets_tag_ids_gin` 추가 신규 Alembic revision
- app/domain/models.py — Asset 도메인 모델에 tag_ids 필드
- app/repositories/postgres.py — assets 조회 시 archived 표시 로직
- app/schemas/v1.py — admin asset catalog 응답 스키마에 tag_ids 필드
- Tests:
- Removed: portfolios 컬럼 검증 (REQ-003 영역)
- Added: assets.tag_ids 스키마·GIN 인덱스·archived 표시 검증
#### Delivery Trace Delta
- Batches:
- Invalidated for REQ-002 scope: BAT-003 (구 portfolios.tag_ids 범위 검증 — 신규 assets.tag_ids 범위에 대한 evidence 없음). BAT-003 verification 자체는 REQ-001/REQ-003/REQ-006 측면에서 유효.
- 신규 batch 편입 필요: assets.tag_ids 마이그레이션 + 도메인/리포지터리/스키마 반영 (TBD).
- Releases:
- REL-001: 구 REQ-002 trace는 portfolios.tag_ids 변경(REQ-003에서 실질 커버)으로 reroute. 신규 assets.tag_ids 변경분은 후속 release 대상.
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes — assets.tag_ids 컬럼/인덱스/도메인/스키마 모두 미구현. BAT-003 verification은 portfolios.tag_ids만 커버.
- Status Recommendation: revert-to-approved
- Follow-up Action: 신규 batch에서 assets.tag_ids 도입(마이그레이션 + 도메인 모델 + 리포지터리 + 스키마 + 테스트). REQ-009 Impacted Area에 본 REQ cross-reference 추가.
#### Validation Plan
- Alembic 마이그레이션 적용 후 SQLAlchemy Inspector로 assets.tag_ids 컬럼 및 `idx_assets_tag_ids_gin` 존재 확인.
- pytest로 archived 표시 동작 및 빈 배열 기본값 검증.
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
@@ -34,15 +34,54 @@
- `app/repositories/postgres.py` — asset CRUD Postgres 구현 추가 - `app/repositories/postgres.py` — asset CRUD Postgres 구현 추가
- Tests: - Tests:
- `tests/test_asset_catalog.py` (신규) — schema 존재 확인, UNIQUE constraint, GIN 인덱스, MarketCode Enum 검증 - `tests/test_asset_catalog.py` (신규) — schema 존재 확인, UNIQUE constraint, GIN 인덱스, MarketCode Enum 검증
- Cross-reference:
- REQ-002 — assets.tag_ids UUID[] 컬럼은 본 REQ의 후속 augmentation으로 별도 마이그레이션에서 추가된다 (dcy-002 FR-2 영역).
## Notes ## Notes
- REQ-010(admin CRUD), REQ-011(portfolios 재설계), REQ-013(GET /v1/assets), REQ-014(migration) 모두 이 REQ 선행 구현을 필요로 한다. - REQ-010(admin CRUD), REQ-011(portfolios 재설계), REQ-013(GET /v1/assets), REQ-014(migration) 모두 이 REQ 선행 구현을 필요로 한다.
- NFR-2(성능): `assets JOIN portfolios` 쿼리 성능은 이 REQ의 GIN 인덱스와 REQ-011의 FK 인덱스로 커버한다. 구체적인 응답 시간 목표는 batch design 단계에서 정의한다. - NFR-2(성능): `assets JOIN portfolios` 쿼리 성능은 이 REQ의 GIN 인덱스와 REQ-011의 FK 인덱스로 커버한다. 구체적인 응답 시간 목표는 batch design 단계에서 정의한다.
- `assets` 테이블은 Alembic으로만 관리한다(ensure_schema() 대상 아님). runtime-flows.md Alembic/ensure_schema 경계 규칙 준수. - `assets` 테이블은 Alembic으로만 관리한다(ensure_schema() 대상 아님). runtime-flows.md Alembic/ensure_schema 경계 규칙 준수.
- assets 카탈로그에 대한 태깅(`assets.tag_ids UUID[]`)은 본 REQ의 직접 범위가 아니며 REQ-002가 별도로 다룬다.
- Source: dcy-001 FR-1 - Source: dcy-001 FR-1
## Change Log ## Change Log
### CHG-20260521-02
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Impacted Area cross-reference 추가: REQ-002(assets.tag_ids UUID[] 컬럼) 후속 augmentation 명시
- Notes 보강: 카탈로그 태깅은 본 REQ 범위 밖이며 REQ-002 영역임을 명시
#### Intent
- REQ-002 본문 재정의(portfolios.tag_ids → assets.tag_ids) 후속으로, 본 REQ(assets 스키마 도입)와 REQ-002(assets.tag_ids 추가) 사이 trace를 명시한다.
- 향후 dcy-002 FR-2 구현 시 본 REQ 산출물(assets 테이블)이 선행 의존임을 문서 트레이스로 남긴다.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- Added:
- Cross-reference: REQ-002 (assets.tag_ids UUID[] 컬럼 후속 augmentation)
- Notes:
- 카탈로그 태깅(assets.tag_ids)이 본 REQ 직접 범위가 아님을 명시
#### Delivery Trace Delta
- Batches:
- No change (BAT-001 verification 영향 없음)
- Releases:
- No change (REL-001 trace 영향 없음)
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no — 본 REQ의 컬럼·인덱스·도메인 모델 정의는 그대로 유효
- Status Recommendation: keep-current (Implemented)
- Follow-up Action: REQ-002 후속 batch 편입 시 본 REQ를 선행 dependency로 표기
#### Validation Plan
- 문서 cross-reference 변경이므로 추가 코드/DB 검증 불요
### CHG-20260521-01 ### CHG-20260521-01
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-010: admin asset catalog CRUD # REQ-010: admin asset catalog CRUD
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -60,6 +60,40 @@
## Change Log ## Change Log
### CHG-20260521-03
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented (CHG-20260521-02 augmentation으로 신설된 AC-9~12가 BAT-012 verification에서 evidence 확보)
#### Intent
- BAT-012 confirm-batch-verification 완료. GET /v1/assets admin catalog 재정의(AC-9~12) 신규 evidence + 기존 AC-1~8 회귀 통과 모두 확인.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-012 (release-candidate) — primary trace, AC-1~12 전체 evidence
- BAT-002 (release-candidate) — augmentation superseded by BAT-012. BAT-002 본래 trace인 REQ-012/REQ-015는 영향 없음.
- Releases:
- REL-001 — CHG-20260521-02 augmentation scope는 BAT-012로 이관됨. REL-001 release 본문은 BAT-012 inclusion 정리 시 별도 갱신 필요.
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 release 문서에 BAT-012 trace 반영 (또는 신규 REL에 BAT-012 포함)
#### Validation Plan
- tests/test_asset_catalog.py AC-9~12 신규 8건: test_get_v1_assets_admin_returns_catalog_response, test_get_v1_assets_status_filter_*, test_get_v1_assets_market_filter, test_get_v1_assets_user_token_returns_403, test_get_v1_assets_service_token_returns_403
- 기존 AC-1~8 회귀 9건: test_asset_cli_create_success / list / update / deactivate / test_asset_http_create_returns_201 / update_returns_200 / deactivate_returns_200 / non_admin_403
- 전체 회귀: 362 passed in 13.06s
### CHG-20260521-02 ### CHG-20260521-02
- Date: 2026-05-21 - Date: 2026-05-21
- Author: Justin Ko - Author: Justin Ko
+3 -3
View File
@@ -90,7 +90,7 @@
| REQ-007 | pfhub tag CLI 명령어 | Interface | Implemented | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) | | REQ-007 | pfhub tag CLI 명령어 | Interface | Implemented | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) |
| REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) | | REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) |
| REQ-009 | assets 테이블 스키마 도입 | Data | Implemented | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) | | REQ-009 | assets 테이블 스키마 도입 | Data | Implemented | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) |
| REQ-010 | admin asset catalog CRUD | Interface | Approved | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) | | REQ-010 | admin asset catalog CRUD | Interface | Implemented | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) |
| REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Implemented | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) | | REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Implemented | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) |
| REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Implemented | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) | | REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Implemented | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) |
| REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) | | REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) |
@@ -116,8 +116,8 @@
## Recent Change Log Summary ## Recent Change Log Summary
- 2026-05-21: REQ-002 / REQ-010 Approved → Implemented (CHG-20260521-03 각각) — BAT-012 confirm-batch-verification 완료. 362 PASSED (32 신규: REQ-002 AC-1~4 8건 + REQ-010 AC-9~12 9건 + AC-1~8 회귀 9건 + 단위 6건). assets.tag_ids 마이그레이션·도메인·repository·schema·GET 라우트 archived 합성 모두 evidence 확보. REQ-010 BAT-002 trace는 superseded by BAT-012로 표기.
- 2026-05-21: REQ-002 Implemented → Approved (CHG-20260521-02) — 파일명(`req-002_assets-tag-ids-column.md`)·dcy-002 FR-2와 본문 정합성 회복. 기존 본문(portfolios.tag_ids)은 dcy-001 분리 이전 portfolios==asset 시절 흔적으로 REQ-003과 중복이었음. 새 범위(assets.tag_ids + `idx_assets_tag_ids_gin`)는 미구현 — BAT-003 verification은 REQ-002 범위 무효화. REQ-009 Impacted Area에 cross-reference 추가(CHG-20260521-02, Implemented 유지). 신규 batch 편입 필요.
- 2026-05-21: REQ-010 Implemented → Approved (CHG-20260521-02) — REL-001 smoke 검증에서 GET /v1/assets가 dcy-001 시점 portfolio 조회 코드를 유지 중인 불일치 확인. dcy-003 옵션 B(REQ-013 Deprecation) 후속의 GET admin catalog 재정의를 본 REQ에 augmentation. AC-9~12 신설, app/api/v1_assets.py GET 라우트 재정의 + AssetCatalogListResponse 추가. BAT-002 verification 재실행 필요. - 2026-05-21: REQ-010 Implemented → Approved (CHG-20260521-02) — REL-001 smoke 검증에서 GET /v1/assets가 dcy-001 시점 portfolio 조회 코드를 유지 중인 불일치 확인. dcy-003 옵션 B(REQ-013 Deprecation) 후속의 GET admin catalog 재정의를 본 REQ에 augmentation. AC-9~12 신설, app/api/v1_assets.py GET 라우트 재정의 + AssetCatalogListResponse 추가. BAT-002 verification 재실행 필요.
- 2026-05-21: REQ-001~018 (17건, REQ-013 제외) Approved → Implemented — BAT-001~006 confirm-req-implemented 일괄 전환. BAT-001(REQ-009/011/014), BAT-002(REQ-010/012/015), BAT-003(REQ-001/002/003/006), BAT-004(REQ-004/005), BAT-005(REQ-007/008), BAT-006(REQ-016/017/018). DB-integration ACs는 REL-001 배포 시 최종 확인 예정. - 2026-05-21: REQ-001~018 (17건, REQ-013 제외) Approved → Implemented — BAT-001~006 confirm-req-implemented 일괄 전환. BAT-001(REQ-009/011/014), BAT-002(REQ-010/012/015), BAT-003(REQ-001/002/003/006), BAT-004(REQ-004/005), BAT-005(REQ-007/008), BAT-006(REQ-016/017/018). DB-integration ACs는 REL-001 배포 시 최종 확인 예정.
- 2026-05-21: REQ-030/031/032 Approved → Implemented — BAT-010 confirm-batch-verification 완료. 330/330 PASSED (32 신규 tests in test_v1_portfolios_tags_comments.py). GET /v1/portfolios/{id}/tags 신설; POST/DELETE tag disabled 409 + service/client 403 보강; comment CRUD 4종 신설; baseline 4종 갱신(interface-contract/runtime-flows/data-model/project-structure). - 2026-05-21: REQ-030/031/032 Approved → Implemented — BAT-010 confirm-batch-verification 완료. 330/330 PASSED (32 신규 tests in test_v1_portfolios_tags_comments.py). GET /v1/portfolios/{id}/tags 신설; POST/DELETE tag disabled 409 + service/client 403 보강; comment CRUD 4종 신설; baseline 4종 갱신(interface-contract/runtime-flows/data-model/project-structure).
- 2026-05-21: REQ-025/026/027/028 Approved → Implemented — BAT-009 confirm-batch-verification 완료. 298 PASSED (0 failed). test_v1_portfolios_crud.py 22건(REQ-025/026/027), test_v1_portfolios_list.py 10건(REQ-028) 전 AC 통과.
- 2026-05-21: REQ-019/020/021/024 Approved → Implemented — BAT-008 confirm-batch-verification 완료. 모든 grep AC 통과, 사람 리뷰 완료. REQ-021 follow-up: session no_expiry 코드 enforcement 미구현 → 별도 처리. REQ-024 AC-5: staging dry-run은 release plan 단계로 이관.
@@ -0,0 +1,63 @@
"""Add assets.tag_ids UUID[] column and GIN index (REQ-002 CHG-20260521-02)
Revision ID: 20260521_0023
Revises: 20260520_0022
Create Date: 2026-05-21 00:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260521_0023"
down_revision = "20260520_0022"
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, "assets", "tag_ids"):
op.add_column(
"assets",
sa.Column(
"tag_ids",
sa.ARRAY(sa.UUID()),
nullable=False,
server_default=sa.text("'{}'::uuid[]"),
),
)
inspector = inspect(bind)
if _column_exists(inspector, "assets", "tag_ids") and not _index_exists(
inspector, "assets", "idx_assets_tag_ids_gin"
):
op.create_index(
"idx_assets_tag_ids_gin",
"assets",
["tag_ids"],
postgresql_using="gin",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if _index_exists(inspector, "assets", "idx_assets_tag_ids_gin"):
op.drop_index("idx_assets_tag_ids_gin", table_name="assets")
if _column_exists(inspector, "assets", "tag_ids"):
op.drop_column("assets", "tag_ids")
+292
View File
@@ -750,3 +750,295 @@ def test_portfolio_create_by_symbol() -> None:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert len(repo._portfolios) == 1 assert len(repo._portfolios) == 1
assert list(repo._portfolios.values())[0].asset_id == _CATALOG_ASSET_ID 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