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:
@@ -172,7 +172,7 @@ def apply_asset_tag(
|
||||
if not tag.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다")
|
||||
|
||||
repo.append_portfolio_tag_id(asset_id, body.tag_id)
|
||||
repo.append_asset_tag_id(asset_id, body.tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
@@ -197,7 +197,7 @@ def remove_asset_tag(
|
||||
repo.get(asset_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
repo.remove_portfolio_tag_id(asset_id, tag_id)
|
||||
repo.remove_asset_tag_id(asset_id, tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -1766,6 +1766,36 @@ class PostgresPortfolioRepository:
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
|
||||
|
||||
def append_asset_tag_id(self, asset_id: str, tag_id: str) -> None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE assets
|
||||
SET tag_ids = array_append(tag_ids, %s::uuid)
|
||||
WHERE asset_id = %s AND NOT (%s::uuid = ANY(tag_ids))
|
||||
""",
|
||||
(tag_id, asset_id, tag_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
cur.execute("SELECT asset_id FROM assets WHERE asset_id = %s", (asset_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
|
||||
def remove_asset_tag_id(self, asset_id: str, tag_id: str) -> None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE assets
|
||||
SET tag_ids = array_remove(tag_ids, %s::uuid)
|
||||
WHERE asset_id = %s
|
||||
""",
|
||||
(tag_id, asset_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
|
||||
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# BAT-013 Design
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-013
|
||||
|
||||
## Architecture Summary
|
||||
- 본 batch는 신규 구조나 모듈을 도입하지 않는다. 기존 `app/repositories/postgres.py`에서 portfolios 테이블 대상 tag_ids mutation 메서드(`append_portfolio_tag_id`/`remove_portfolio_tag_id`)를 미러링하여 **assets 테이블 대상 메서드**를 신규 추가하고, `app/api/v1_assets.py`의 `apply_asset_tag`/`remove_asset_tag` 라우트 내부 호출을 새 메서드로 교체한다.
|
||||
- HTTP contract, request/response schema, 라우트 시그니처, 정책 검증(404/403 분기), AuthContext 흐름은 전부 불변. 변경 표면은 두 라우트 내부의 repository 메서드 1줄씩 + 신규 repository 메서드 정의 + 테스트.
|
||||
- 본질적으로 dcy-002 FR-2의 원래 의도(admin이 assets에 태그 부여)를 실제 SQL 경로에 반영하는 **정합화** 작업이며, BAT-012에서 도입한 `assets.tag_ids` 컬럼이 처음으로 정상 채워지게 된다.
|
||||
|
||||
## Changed Areas
|
||||
- Docs:
|
||||
- `docs/runtime-flows.md` — Flow `v1 API — Tag Apply/Remove`의 L161, L165 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` → `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. Change Log 1줄 추가.
|
||||
- Modules:
|
||||
- `app/repositories/postgres.py` — `append_asset_tag_id(asset_id, tag_id)`, `remove_asset_tag_id(asset_id, tag_id)` 메서드 신규.
|
||||
- `app/api/v1_assets.py` — L175, L200 내부 호출 교체 (라우트 시그니처/응답 schema/error handling 불변).
|
||||
- Interfaces:
|
||||
- HTTP: 무변. `POST /v1/assets/{asset_id}/tags`, `DELETE /v1/assets/{asset_id}/tags/{tag_id}` request/response 동일.
|
||||
- Repository: `PostgresRepository`에 2개 메서드 신규. MemoryRepository 등 다른 구현체는 본 batch 범위 외 (실 사용 경로가 Postgres만 의존).
|
||||
- Tests:
|
||||
- `tests/test_asset_catalog.py` 또는 `tests/test_v1.py` — DB-integration fixture로 POST 후 `assets.tag_ids` mutation 확인 + DELETE 후 제거 확인 회귀 케이스 추가.
|
||||
|
||||
## Key Decisions
|
||||
1. **메서드 명명**: `append_asset_tag_id`/`remove_asset_tag_id` — 기존 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 명명 패턴과 일관(테이블명_컬럼명_id). 분리된 메서드명을 채택하는 이유는 라우트 코드에서 호출 의도를 명확히 표현하기 위함 + 미래 정책 차이(예: assets에는 admin tag만, portfolios에는 owner tag 허용) 발생 시 두 경로의 분기를 안전하게 다룰 수 있게 하기 위함이다. 단일 `_append_tag_id(table, id, tag_id)` 헬퍼로 합치는 안은 채택하지 않는다 — 동적 테이블명 SQL은 가독성·정적 분석 모두 손해.
|
||||
2. **멱등성 가드**: `append_asset_tag_id`는 기존 `append_portfolio_tag_id`와 동일하게 `WHERE asset_id = %s AND NOT (%s::uuid = ANY(tag_ids))` 가드를 적용한다. 동일 tag_id 재적용은 no-op (HTTP 200 그대로 반환). AC-1은 "tag_ids 배열에 추가된다"만 요구하고 중복 정책을 명시하지 않으므로 기존 portfolios 측 동작과 평행하게 유지.
|
||||
3. **에러 분기**: rowcount=0인 경우 기존 portfolios 패턴과 동일하게 asset 존재 여부 추가 확인 후 미존재 시 `ValueError(f"asset을 찾을 수 없습니다: {asset_id}")` raise. 라우트 측은 이미 `repo.get(asset_id)`를 선행 호출해 404를 분기하므로 ValueError가 라우트까지 올라올 가능성은 사실상 race condition 한정. 그래도 가드는 유지(deletion 사이의 race로 빈 update가 발생할 경우 silent success로 빠지지 않도록).
|
||||
4. **MemoryRepository 보강 여부**: 본 batch에서는 보강하지 않는다. 실제 운영 경로는 Postgres만 사용하며, MemoryRepository에 `append_portfolio_tag_id` 정의가 있는지 확인 후 동일 위치에 신규 메서드 정의가 필요한지만 implementation 단계에서 점검. 만약 MemoryRepository에 portfolios 측 메서드가 없다면 assets 측도 추가하지 않아 일관성 유지.
|
||||
5. **테스트 위치**: `tests/test_asset_catalog.py`에 추가. BAT-012에서 동일 파일에 DB-integration 패턴(`test_get_v1_assets_*`)이 자리 잡았으므로 fixture·marker 재사용 최적. `tests/test_v1.py`는 기존에 portfolios.tag_ids 의존 fixture가 잔존할 가능성이 있어 신규 작성은 catalog 파일에 모은다.
|
||||
|
||||
## Edge Cases
|
||||
- **EC-1**: 존재하지 않는 `asset_id`로 POST → 라우트 선행 `repo.get(asset_id)` 단계에서 404 (기존 동작 그대로 회귀).
|
||||
- **EC-2**: 존재하지 않는 `tag_id`로 POST → 라우트 선행 `repo.get_tag_by_id` 단계에서 404 (기존 동작 회귀).
|
||||
- **EC-3**: `is_admin_tag=FALSE` 또는 `is_active=FALSE` → 403 (기존 동작 회귀).
|
||||
- **EC-4**: 동일 tag_id 중복 POST → 멱등 (no-op + HTTP 200) — Key Decision 2.
|
||||
- **EC-5**: 존재하지 않는 tag_id로 DELETE → 현재 `remove_portfolio_tag_id` 동작과 평행하게, asset이 존재하면 array_remove no-op (HTTP 200, status=removed)로 처리. 라우트 측 사전 검증(`repo.get(asset_id)`)이 asset 존재만 보장하고 tag 존재는 검증하지 않는 기존 의도 유지.
|
||||
- **EC-6**: 동일 UUID가 우연히 asset과 portfolio 양쪽에 존재 → 본 batch 이후 POST `/v1/assets/{id}/tags`는 assets만 mutation, portfolios는 무변동. 회귀 테스트로 명시 검증.
|
||||
- **EC-7**: `tag_ids` 컬럼 부재(스키마 마이그레이션 미적용 환경) → BAT-012 선행 가정. 본 batch는 컬럼 존재를 전제로 한다. (회귀 시 DB-integration fixture가 alembic upgrade head를 거치는지 implementation 단계에서 확인.)
|
||||
|
||||
## Architecture Impact
|
||||
- runtime-flows
|
||||
|
||||
## Reference Doc Update Plan
|
||||
- project-structure.md: none (파일·디렉토리·모듈 경계 무변).
|
||||
- runtime-flows.md: L161, L165의 `repo.append_portfolio_tag_id(asset_id, ...)`/`repo.remove_portfolio_tag_id(asset_id, ...)`를 `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. Change Log에 `2026-05-21 KST: BAT-013 (REQ-005 CHG-20260521-02) — /v1/assets/{id}/tags apply/remove SQL 대상 assets 테이블로 정합화. portfolios 테이블 호출은 portfolio 라우트 전용으로 한정.` 추가.
|
||||
- **별도 후속 항목 (out of scope, follow-up REQ 후보)**: `docs/runtime-flows.md` L67-77 (CLI Flow `pfhub tag apply asset` / `pfhub tag remove asset`)도 동일 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id`를 호출하고 있어 동일한 mismatch가 CLI 레이어에 잔존. REQ-007/008 또는 신규 REQ로 처리. 본 batch는 REQ-005 HTTP API 범위에 한정.
|
||||
@@ -0,0 +1,48 @@
|
||||
# BAT-013 Implementation
|
||||
|
||||
- Status: completed
|
||||
- Batch: BAT-013
|
||||
|
||||
## Plan Summary
|
||||
- `app/repositories/postgres.py`에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 (기존 portfolios 패턴 미러링, assets 테이블 대상).
|
||||
- `app/api/v1_assets.py` `apply_asset_tag`(L175) / `remove_asset_tag`(L200) 내부 호출 교체.
|
||||
- `tests/test_asset_catalog.py`에 BAT-013 회귀 케이스 추가 (메서드 존재·SQL 대상 테이블·에러 분기·멱등 가드 검증).
|
||||
- `tests/test_tags_http_api.py` 기존 AC-1/AC-5 mock 단언을 신규 메서드로 갱신 + portfolios 메서드 무호출 단언 추가.
|
||||
- `docs/runtime-flows.md` Flow `v1 API — Tag Apply/Remove` L161/L165 갱신 + Change Log 1줄 추가.
|
||||
|
||||
## Changed Files
|
||||
- `app/repositories/postgres.py` — `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 (총 30줄 추가). 기존 portfolios 메서드 무변동.
|
||||
- `app/api/v1_assets.py` — L175 / L200 호출 교체 (2줄). 라우트 시그니처·정책 검증·에러 분기 무변동.
|
||||
- `tests/test_asset_catalog.py` — BAT-013 회귀 7건 신규 (메서드 존재 2건 + SQL 대상 검증 2건 + 에러 분기 2건 + 멱등 가드 1건).
|
||||
- `tests/test_tags_http_api.py` — `_make_db_repo` 헬퍼에 `append_asset_tag_id`/`remove_asset_tag_id` MagicMock 보강; `test_apply_asset_tag_admin_success` / `test_remove_asset_tag_admin_success` 단언 교체 + portfolios 메서드 무호출 단언 추가.
|
||||
- `docs/runtime-flows.md` — Flow `v1 API — Tag Apply/Remove` L161 / L165 메서드명 교체; Change Log에 BAT-013 항목 1줄 추가.
|
||||
|
||||
## Execution Log
|
||||
1. Repository 신규 메서드 추가 (`postgres.py` L1755 직후, `rotate_service_token` 앞에 삽입).
|
||||
2. 라우트 호출 교체 (`apply_asset_tag` → `append_asset_tag_id`; `remove_asset_tag` → `remove_asset_tag_id`).
|
||||
3. 기존 BAT-004 mock 테스트(`test_tags_http_api.py:343, 437`) 단언 갱신 — `mock_repo.append_portfolio_tag_id.assert_called_once_with` → `mock_repo.append_asset_tag_id.assert_called_once_with`. portfolios 호출은 `assert_not_called()`로 명시.
|
||||
4. `_make_db_repo` 헬퍼에 신규 메서드 MagicMock 보강 (`return_value = None`).
|
||||
5. BAT-013 회귀 테스트 7건 추가 (`tests/test_asset_catalog.py` 말미). 클래스명은 `PostgresPortfolioRepository`(실제 모듈 클래스). `__new__`로 인스턴스 생성 후 `_connect`/`ensure_schema` 패치하여 cursor mock으로 SQL 문자열 캡처.
|
||||
6. `docs/runtime-flows.md` Flow 본문 L161/L165 갱신 + Change Log 추가.
|
||||
7. pytest 1차: `tests/test_asset_catalog.py tests/test_tags_http_api.py` — 처음에 `ImportError: cannot import name 'PostgresRepository'` 발견 → 실제 클래스명 `PostgresPortfolioRepository`로 정정 후 79 passed.
|
||||
8. pytest 전체: **369 passed in 11.48s** (BAT-012 직후 362 baseline 대비 +7 신규).
|
||||
|
||||
## Validation
|
||||
- 단위/회귀 검증:
|
||||
- `tests/test_asset_catalog.py::test_postgres_repository_has_append_asset_tag_id_method` ✅
|
||||
- `tests/test_asset_catalog.py::test_postgres_repository_has_remove_asset_tag_id_method` ✅
|
||||
- `tests/test_asset_catalog.py::test_append_asset_tag_id_targets_assets_table_not_portfolios` ✅
|
||||
- `tests/test_asset_catalog.py::test_remove_asset_tag_id_targets_assets_table_not_portfolios` ✅
|
||||
- `tests/test_asset_catalog.py::test_append_asset_tag_id_raises_when_asset_not_found` ✅
|
||||
- `tests/test_asset_catalog.py::test_remove_asset_tag_id_raises_when_asset_not_found` ✅
|
||||
- `tests/test_asset_catalog.py::test_append_asset_tag_id_idempotent_guard_present` ✅
|
||||
- `tests/test_tags_http_api.py::test_apply_asset_tag_admin_success` (갱신) — `append_asset_tag_id`로 호출 단언 + portfolios 미호출 ✅
|
||||
- `tests/test_tags_http_api.py::test_remove_asset_tag_admin_success` (갱신) — `remove_asset_tag_id`로 호출 단언 + portfolios 미호출 ✅
|
||||
- 기존 AC-2~4, AC-6~11 케이스 회귀 ✅ (정책 검증 분기 무영향 확인)
|
||||
- 전체 회귀: `pytest -q` → **369 passed** (BAT-012 직후 362 baseline 대비 +7 신규).
|
||||
- DB-integration 실 mutation 검증: 본 batch는 cursor mock 기반 SQL 문자열 검증으로 대체. 실 PostgreSQL 환경 검증은 REL 배포 시 smoke로 수행 예정.
|
||||
|
||||
## Remaining Risks
|
||||
- **R1 (Low, 잔존)**: `pfhub tag apply asset` / `pfhub tag remove asset` CLI도 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출함(`docs/runtime-flows.md` L67-77 참조). REQ-005 scope는 HTTP API 한정이라 본 batch는 손대지 않음. **후속 REQ 후보**로 별도 발의 필요 — Feedback Handoff 또는 confirm-batch-verification 결과에 명시.
|
||||
- **R2 (Low, 잔존)**: 실 PostgreSQL 환경에서 `assets.tag_ids` 실 mutation 검증은 REL 배포 시 smoke로 처리. unit-level SQL 문자열 검증으로 1차 안전망 확보.
|
||||
- **R3 (Resolved)**: 클래스명 오류(`PostgresRepository` vs `PostgresPortfolioRepository`)는 implementation 단계에서 즉시 정정.
|
||||
@@ -0,0 +1,35 @@
|
||||
# BAT-013: asset tag apply/remove SQL 정합화 (REQ-005)
|
||||
|
||||
- Status: release-candidate
|
||||
- Profile: batch-lite
|
||||
- Source Discovery: dcy-002 (REQ-005)
|
||||
- Included REQ: REQ-005
|
||||
- Excluded REQ: none
|
||||
|
||||
## Summary
|
||||
- REQ-005 CHG-20260521-02 후속. `POST /v1/assets/{asset_id}/tags`와 `DELETE /v1/assets/{asset_id}/tags/{tag_id}` 라우트가 내부적으로 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출해 **portfolios 테이블**을 mutation하는 불일치를 해소한다.
|
||||
- `app/repositories/postgres.py`에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드를 신규 추가하고, `app/api/v1_assets.py`의 라우트 호출을 새 메서드로 교체한다.
|
||||
- BAT-012로 도입된 `assets.tag_ids` 컬럼이 비로소 정상 채워지도록 apply/remove 경로를 정합화한다.
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- `app/repositories/postgres.py` — `append_asset_tag_id(asset_id, tag_id)`, `remove_asset_tag_id(asset_id, tag_id)` 메서드 신규 (assets 테이블 대상 `array_append`/`array_remove`)
|
||||
- `app/api/v1_assets.py` — `apply_asset_tag`(L175) / `remove_asset_tag`(L200) 내부 호출 교체 (HTTP contract·라우트 surface 불변)
|
||||
- `tests/test_asset_catalog.py` 또는 `tests/test_v1.py` — assets.tag_ids 실 mutation 회귀 케이스 추가; portfolios.tag_ids 무변동 확인
|
||||
- Out of Scope:
|
||||
- HTTP API surface 변경 (요청/응답 schema 동일)
|
||||
- portfolios 태그 apply/remove 경로(REQ-004) — 기존 동작 그대로 유지
|
||||
- BAT-004의 portfolio tagging 회귀 — 영향 없음
|
||||
- DB 마이그레이션 (assets.tag_ids 컬럼은 BAT-012/20260521_0023에서 이미 도입됨)
|
||||
|
||||
## Documents
|
||||
- Planning: [planning.md](./planning.md)
|
||||
- Design: [design.md](./design.md)
|
||||
- Implementation: [implementation.md](./implementation.md)
|
||||
- Verification: [verification.md](./verification.md)
|
||||
|
||||
## Notes
|
||||
- 선행 완료: BAT-012(assets.tag_ids 컬럼/GIN 인덱스 도입) ✅, BAT-004(라우트·정책 검증 시점의 기존 구현) ✅
|
||||
- profile 선정 근거: 단일 Approved REQ + HTTP contract 무변 + DB 스키마 무변 → minor-change 후보. 라우트 내부 SQL 대상 테이블만 교체하는 좁은 범위 변경이라 `batch-lite`로 출발.
|
||||
- design 생성 여부는 planning Design Gate에서 판단; 기존 `append_portfolio_tag_id` 패턴을 그대로 미러링하므로 design 생략 후보가 유력.
|
||||
- BAT-004 trace는 REQ-005 evidence 무효(`Implemented Invalidated: yes`, REQ-005 CHG-20260521-02 참조). 본 batch 완료 시 REQ-005 trace는 BAT-013으로 갱신; BAT-004의 REQ-005 trace는 superseded로 표기.
|
||||
@@ -0,0 +1,58 @@
|
||||
# BAT-013 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-013
|
||||
- Profile: batch-lite
|
||||
|
||||
## Included REQ
|
||||
- REQ-005 (Interface, Approved) — 태그 적용·해제 HTTP API. CHG-20260521-02 augmentation으로 AC-1/AC-5의 "assets 테이블 tag_ids" 의미 명문화. BAT-013에서 실제 SQL 대상 테이블 교체.
|
||||
|
||||
## Delivery Plan
|
||||
1. **Repository 메서드 추가** (`app/repositories/postgres.py`)
|
||||
- `append_asset_tag_id(asset_id: str, tag_id: str) -> None`
|
||||
- 기존 `append_portfolio_tag_id` 패턴 미러링 (UPDATE assets SET tag_ids = array_append..., NOT (tag_id = ANY(tag_ids)) 가드, rowcount=0 시 존재 확인 후 ValueError).
|
||||
- `remove_asset_tag_id(asset_id: str, tag_id: str) -> None`
|
||||
- 기존 `remove_portfolio_tag_id` 패턴 미러링 (UPDATE assets SET tag_ids = array_remove..., rowcount=0 시 ValueError).
|
||||
2. **라우트 호출 교체** (`app/api/v1_assets.py`)
|
||||
- L175: `repo.append_portfolio_tag_id(asset_id, body.tag_id)` → `repo.append_asset_tag_id(asset_id, body.tag_id)`
|
||||
- L200: `repo.remove_portfolio_tag_id(asset_id, tag_id)` → `repo.remove_asset_tag_id(asset_id, tag_id)`
|
||||
3. **회귀 테스트 추가** (`tests/test_asset_catalog.py` 우선; 적합치 않으면 `tests/test_v1.py`)
|
||||
- AC-1 회귀: POST /v1/assets/{id}/tags 200 후 PostgresRepository.get(asset_id).tag_ids 배열에 tag_id 포함 확인.
|
||||
- AC-5 회귀: DELETE 후 동일 배열에서 tag_id 제거 확인.
|
||||
- 부수 확인: 동일 호출 후 portfolios.tag_ids는 무변동(또는 해당 portfolio_id가 없어 mutation 안 일어남).
|
||||
4. **BAT-004 기존 fixture 점검**
|
||||
- BAT-004 verification에서 사용한 AC-1/AC-5 케이스가 portfolios.tag_ids 의존 fixture에 묻혀 통과한 흔적이 있는지 확인.
|
||||
- 발견 시 assets 전용 fixture로 교체 또는 명확히 split.
|
||||
5. **회귀 suite 실행**
|
||||
- `pytest -q` 전체 → 362 baseline 유지 + 신규 회귀 통과 확인.
|
||||
|
||||
## Dependencies
|
||||
- Internal:
|
||||
- REQ-002 / BAT-012 (assets.tag_ids 컬럼 + GIN 인덱스) — 완료. 본 batch의 SQL 대상 컬럼.
|
||||
- REQ-001 / BAT-003 (tags 마스터 테이블) — 완료. 정책 검증(tag 존재/admin/active)에 사용.
|
||||
- External:
|
||||
- 없음.
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: **no**
|
||||
- Reason:
|
||||
- 변경은 기존 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 패턴의 1:1 미러링이며, 새 구조나 설계 결정 없음.
|
||||
- HTTP contract·schema·라우트 시그니처 모두 불변.
|
||||
- DB 스키마 변경 없음 (assets.tag_ids는 BAT-012에서 도입 완료).
|
||||
- 정책 검증 로직(tag is_admin_tag/is_active 확인, 404/403 분기)은 기존 코드 그대로 유지.
|
||||
- implementation 진입 전 design 산출물 생성 시 잉여 작업이 됨.
|
||||
|
||||
## Milestones
|
||||
1. Repository 메서드 2종 추가 + 단위 동작 확인
|
||||
2. 라우트 호출 교체 (2 lines)
|
||||
3. 회귀 테스트 추가 (AC-1/AC-5, 최소 2~4 케이스)
|
||||
4. pytest 전체 통과 + BAT-004 잔존 fixture 점검 결과 기록
|
||||
5. `draft-batch-verification` → `confirm-batch-verification` → `confirm-req-implemented`
|
||||
|
||||
## Risks
|
||||
- **R1 (Low)**: BAT-004의 AC-1/AC-5 기존 테스트가 portfolios.tag_ids 검증으로 통과한 흔적이 남아 있어 본 batch 회귀 케이스와 의도 충돌.
|
||||
- **대응**: 기존 케이스 식별 후 assets 전용 회귀로 교체 또는 split. 의도가 명확하지 않으면 BAT-004 측 케이스 제거 후 본 batch 케이스로 일원화.
|
||||
- **R2 (Low)**: PostgresRepository가 없는 in-memory 환경에서 회귀 테스트 미작동.
|
||||
- **대응**: 기존 BAT-012 회귀(test_asset_catalog.py)가 사용하는 DB-integration fixture 패턴 재사용. DB-integration이 어렵다면 `MemoryRepository`에도 동일 메서드를 보강(필요 시).
|
||||
- **R3 (Low)**: 동일 호출자가 동일 tag_id를 중복 append 시 멱등성 가드 부재.
|
||||
- **대응**: 기존 `append_portfolio_tag_id`가 `NOT (... = ANY(tag_ids))` 가드를 가짐 → 동일 패턴 채택.
|
||||
@@ -0,0 +1,47 @@
|
||||
# BAT-013 Verification
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-013
|
||||
|
||||
## Acceptance Mapping
|
||||
- REQ-005 (태그 적용·해제 HTTP API) — CHG-20260521-02 명문화 범위:
|
||||
- AC-1 (admin + admin 활성 tag → POST /v1/assets/{id}/tags 200, **assets 테이블 tag_ids에 추가**):
|
||||
- HTTP 200 + status="applied" → `tests/test_tags_http_api.py::test_apply_asset_tag_admin_success` (라우트 호출 단언 갱신: `mock_repo.append_asset_tag_id.assert_called_once_with(...)` + `mock_repo.append_portfolio_tag_id.assert_not_called()`).
|
||||
- SQL 대상 테이블 검증 → `tests/test_asset_catalog.py::test_append_asset_tag_id_targets_assets_table_not_portfolios` (`UPDATE assets` 포함, `UPDATE portfolios` 부재).
|
||||
- 메서드 존재 → `tests/test_asset_catalog.py::test_postgres_repository_has_append_asset_tag_id_method`.
|
||||
- 멱등 가드 → `tests/test_asset_catalog.py::test_append_asset_tag_id_idempotent_guard_present` (`NOT ... = ANY(tag_ids)`).
|
||||
- AC-2 (admin + is_admin_tag=FALSE → 403): `tests/test_tags_http_api.py::test_apply_asset_tag_non_admin_tag_returns_403` (BAT-004 기존 케이스, 정책 검증 라우트 분기 무영향 회귀).
|
||||
- AC-3 (admin + is_active=FALSE → 403): `tests/test_tags_http_api.py::test_apply_asset_tag_inactive_returns_403`.
|
||||
- AC-4 (non-admin → 403): `tests/test_tags_http_api.py::test_apply_asset_tag_non_admin_token_returns_403`.
|
||||
- AC-5 (admin → DELETE 200, **assets 테이블 tag_ids에서 제거**):
|
||||
- HTTP 200 + status="removed" → `tests/test_tags_http_api.py::test_remove_asset_tag_admin_success` (라우트 호출 단언 갱신: `mock_repo.remove_asset_tag_id.assert_called_once_with(...)` + `mock_repo.remove_portfolio_tag_id.assert_not_called()`).
|
||||
- SQL 대상 테이블 검증 → `tests/test_asset_catalog.py::test_remove_asset_tag_id_targets_assets_table_not_portfolios`.
|
||||
- 메서드 존재 → `tests/test_asset_catalog.py::test_postgres_repository_has_remove_asset_tag_id_method`.
|
||||
- asset 부재 시 ValueError → `tests/test_asset_catalog.py::test_remove_asset_tag_id_raises_when_asset_not_found` (라우트는 사전 `repo.get(asset_id)`로 404 분기).
|
||||
- AC-6~11 (portfolio 측 apply/remove + 404 분기): 본 batch 범위 외(portfolios 경로). BAT-004 기존 케이스 회귀 — `tests/test_tags_http_api.py::test_apply_portfolio_tag_owner_own_tag / test_apply_portfolio_tag_owner_admin_tag / test_apply_portfolio_tag_other_user_non_admin_tag_returns_403 / test_remove_portfolio_tag_owner_success / test_apply_portfolio_tag_nonexistent_tag_returns_404 / *_nonexistent_portfolio_*` PASS (assets 경로 변경이 portfolios 경로에 무영향임을 회귀 확인).
|
||||
- EC-1 (asset_id 부재 시 404): 라우트 사전 `repo.get(asset_id)` 분기 무변동 + `tests/test_asset_catalog.py::test_append_asset_tag_id_raises_when_asset_not_found` (repository 레이어 안전망).
|
||||
|
||||
## Evidence
|
||||
- Test:
|
||||
- `.venv/bin/python -m pytest tests/test_asset_catalog.py tests/test_tags_http_api.py -q` → **79 passed in 2.65s** (BAT-013 신규 7건 + BAT-004 기존 회귀 + BAT-012 기존 회귀 모두 통과).
|
||||
- `.venv/bin/python -m pytest -q` (전체) → **369 passed in 11.48s** (BAT-012 직후 362 baseline 대비 +7 신규, 회귀 0).
|
||||
- Manual:
|
||||
- 로컬 smoke 미실행 — TestClient 기반 mock + cursor-mock SQL 검증으로 1차 안전망 확보. 실 PostgreSQL `assets.tag_ids` mutation 검증은 REL 배포 시 smoke로 수행 예정.
|
||||
- Logs:
|
||||
- pytest 출력은 implementation.md Execution Log 항목 7~8 참조.
|
||||
|
||||
## Architecture Impact Sync
|
||||
- design.md `Architecture Impact: runtime-flows` 명시됨.
|
||||
- `docs/runtime-flows.md` 갱신 완료:
|
||||
- Flow `v1 API — Tag Apply/Remove` L161, L165의 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` → `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체 + BAT-013 정합화 사유 inline 주석.
|
||||
- Change Log entry 추가 (2026-05-21 KST BAT-013).
|
||||
- `docs/project-structure.md`: Architecture Impact = none, 갱신 불요.
|
||||
|
||||
## Result
|
||||
- Ready for Release: true
|
||||
- Blocking Issues:
|
||||
- 없음 (본 batch 범위 내 AC 전수 evidence 확보)
|
||||
- Follow-up (release 또는 후속 REQ):
|
||||
- **CLI 동일 mismatch 잔존**: `pfhub tag apply asset` / `pfhub tag remove asset` CLI(`docs/runtime-flows.md` L67-77)도 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출함. REQ-005 scope는 HTTP API 한정이라 본 batch는 손대지 않음. **REQ-007/008 augmentation 또는 신규 REQ 후보**.
|
||||
- 실 PostgreSQL `assets.tag_ids` mutation 회귀: REL 배포 직후 smoke로 admin POST → GET /v1/assets에서 tag_ids 채워짐 확인.
|
||||
- BAT-004의 REQ-005 trace는 본 batch 완료 후 `confirm-req-implemented` 단계에서 `superseded by BAT-013`로 표기 권장.
|
||||
@@ -20,3 +20,4 @@
|
||||
| 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-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/) |
|
||||
| BAT-013 | batch-lite | release-candidate | REQ-005 | dcy-002 | [bat-013](./bat-013_20260521_asset-tag-apply-remove-fix/) |
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|
||||
| REL ID | Profile | Status | Included Batch | Date | Doc |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| REL-001 | app-service | draft | BAT-001~012 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) |
|
||||
| REL-001 | app-service | draft | BAT-001~013 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- Status: draft
|
||||
- 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, BAT-012
|
||||
- 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, BAT-013
|
||||
|
||||
## Scope
|
||||
- Included:
|
||||
@@ -18,6 +18,7 @@
|
||||
- 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-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 불일치를 해소.
|
||||
- BAT-013 (REQ-005 CHG-20260521-02): `POST/DELETE /v1/assets/{asset_id}/tags` 라우트 SQL 대상 정합화 — `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트 호출 교체. BAT-012로 도입된 `assets.tag_ids` 컬럼이 비로소 정상 채워지도록 apply/remove 경로 정합화. HTTP contract·DB 스키마 무변.
|
||||
- Excluded:
|
||||
- REQ-013 (Deprecated): GET /v1/assets 토큰 타입별 분기 재정의 — dcy-003 옵션 B 결정으로 무효화
|
||||
- trex-basic 내부 코드 변경 (별도 레포 — 본 릴리즈와 동시 배포 필요)
|
||||
@@ -29,6 +30,7 @@
|
||||
3. **portfolio-hub 서비스 배포**: 신규 이미지 rolling restart
|
||||
- 신 API surface: `/v1/portfolios/*`, `/v1/tags`, `/v1/assets` (catalog), `/v1/portfolios/{id}/ledger/trades`
|
||||
- 구 path `/v1/assets/{id}/ledger/trades` → HTTP 404 (BAT-011)
|
||||
- `POST/DELETE /v1/assets/{id}/tags` 내부 SQL이 `assets` 테이블 대상으로 정합화됨 (BAT-013) — HTTP contract 무변, DB 스키마 무변
|
||||
4. **trex-basic 배포**: 신 path로 전환된 클라이언트 배포
|
||||
- **이 단계가 완료되기 전까지 구 path 호출 불가 — portfolio-hub 배포 후 즉시 trex-basic 배포 수행**
|
||||
5. **배포 후 smoke 검증**: health endpoint + 신 path 동작 확인
|
||||
@@ -46,12 +48,14 @@
|
||||
- [x] trex-basic 신 path 빌드 완료 확인
|
||||
- [x] DB 스냅샷(백업) 생성 완료
|
||||
- [x] `alembic upgrade head` dry-run 또는 revision 목록 점검
|
||||
- [x] 전체 테스트 suite pass 확인 (BAT-001~011: 340 passed @ main 4bf6b55; BAT-012 추가 후: **362 passed @ 2026-05-21 BAT-012 confirm-batch-verification**)
|
||||
- [x] 전체 테스트 suite pass 확인 (BAT-001~011: 340 passed @ main 4bf6b55; BAT-012 추가 후: 362 passed @ 2026-05-21 BAT-012 confirm-batch-verification; BAT-013 추가 후: **369 passed @ 2026-05-21 BAT-013 confirm-batch-verification** — +7 신규 회귀: assets 테이블 mutation 정합성 검증)
|
||||
- **배포 직후 (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 의존 동작으로 통과한 것으로 우회 확인.
|
||||
- [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/assets/{id}/ledger/trades` → HTTP 404 (구 path 제거 확인) — **로컬 smoke (2026-05-21 17:13 KST)**: HTTP 404 `{"detail":"Not Found"}` 확인. BAT-011 신/구 path 동시 서빙 없음 정책 충족.
|
||||
- [ ] `POST /v1/assets/{asset_id}/tags` (admin token) → HTTP 200 + 응답 후 `GET /v1/assets`에서 해당 asset의 `tag_ids`에 적용된 tag_id 포함 확인 (BAT-013 정합화 검증) — 실 배포 환경에서 수집. unit-level은 cursor-mock SQL 검증으로 1차 안전망 확보.
|
||||
- [ ] `DELETE /v1/assets/{asset_id}/tags/{tag_id}` (admin token) → HTTP 200 + 응답 후 `GET /v1/assets`에서 해당 asset의 `tag_ids`에서 tag_id 제거 확인 (BAT-013 정합화 검증) — 실 배포 환경에서 수집.
|
||||
- **trex-basic 배포 직후**:
|
||||
- [ ] `POST /v1/portfolios/{id}/ledger/trades` (service token) → HTTP 201 + `status="new"` (신 path 동작 확인)
|
||||
- [ ] daemon assignment 인가 3단계 정상 동작 확인
|
||||
@@ -69,7 +73,9 @@
|
||||
- Operator:
|
||||
- (배포 수행자 — 배포 시 기록)
|
||||
- Revision or Artifact:
|
||||
- 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 기록)
|
||||
- main branch commit: `4bf6b55` (feat: v1 portfolio mutation, sub-resources, ledger path migration BAT-008~011)
|
||||
- + BAT-012 commit: `9038f41` (feat: REQ-002/REQ-010 implementation — assets.tag_ids column + GET /v1/assets admin catalog)
|
||||
- + BAT-013 commit: (assets 테이블 tag apply/remove SQL 정합화 — 본 release 진입 시 commit hash 기록)
|
||||
- Commands or Procedure:
|
||||
- `alembic upgrade head` → portfolio-hub rolling restart → trex-basic 배포
|
||||
|
||||
@@ -86,6 +92,7 @@
|
||||
- 통과 2건: POST /v1/portfolios (201), POST /v1/assets/{id}/ledger/trades (404)
|
||||
- 부분 통과 1건: GET /health (200, 단 `db_enabled` 필드 미노출)
|
||||
- **해소 1건**: GET /v1/assets (admin session token → 이전 403, BAT-012 augmentation으로 admin catalog 응답 재정의. 자동 회귀 통과 — tests/test_asset_catalog.py 50 passed)
|
||||
- BAT-013 신규 회귀(자동 7건): assets 테이블 mutation 정합화 (`append_asset_tag_id`/`remove_asset_tag_id` 메서드 존재·SQL 대상 검증·에러 분기·멱등 가드). 실 PostgreSQL `assets.tag_ids` mutation 확인은 배포 직후 smoke 2건(POST/DELETE /v1/assets/{id}/tags)으로 수집 예정.
|
||||
- 실제 배포 환경(prod/staging) smoke는 본 릴리즈 시 별도 수집.
|
||||
|
||||
### Rollback Outcome
|
||||
@@ -114,7 +121,8 @@
|
||||
- session token의 no_expiry 코드 enforcement 미구현 (REQ-021 follow-up) — 후속 REQ 후보
|
||||
- ~~**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 분리 후보.
|
||||
- **`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 후보**.
|
||||
- ~~**`POST/DELETE /v1/assets/{asset_id}/tags` 잘못된 테이블 대상화**~~: **Resolved by BAT-013** (2026-05-21). REQ-005 CHG-20260521-02 (AC-1/AC-5 명문화) + CHG-20260521-03 (Implemented 전환). `PostgresPortfolioRepository.append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트([app/api/v1_assets.py:175,200](../../app/api/v1_assets.py#L175))의 호출 교체. 자동 회귀 7건 추가(`test_postgres_repository_has_*`, `test_*_targets_assets_table_not_portfolios`, `test_*_raises_when_asset_not_found`, `test_append_asset_tag_id_idempotent_guard_present`). 실 PostgreSQL `assets.tag_ids` mutation 검증은 본 release 배포 직후 smoke 항목으로 추가됨.
|
||||
- **`pfhub tag apply asset` / `pfhub tag remove asset` CLI 동일 mismatch 잔존**: BAT-013 implementation/verification 단계에서 추가 발견. CLI 측도 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id`를 호출하여 portfolios 테이블을 대상화 ([docs/runtime-flows.md:67-77](../runtime-flows.md#L67-L77)). REQ-005 본 batch scope는 HTTP API에 한정. **REQ-007/008 augmentation 또는 신규 REQ 후보**.
|
||||
- REQ Input:
|
||||
- (운영 관찰 후 기록)
|
||||
- Change Request Input:
|
||||
|
||||
@@ -158,11 +158,11 @@
|
||||
- `repo.get(asset_id)` → ValueError → HTTP 404.
|
||||
- `repo.get_tag_by_id(tag_id)` → None → HTTP 404.
|
||||
- `not tag.is_admin_tag` → HTTP 403; `not tag.is_active` → HTTP 403.
|
||||
- `repo.append_portfolio_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}`.
|
||||
- `repo.append_asset_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "applied"}` (BAT-013에서 portfolios → assets 테이블 대상으로 정합화).
|
||||
2. **DELETE /v1/assets/{asset_id}/tags/{tag_id}** (remove tag from asset — admin only):
|
||||
- DB token required; admin permission.
|
||||
- `repo.get(asset_id)` → ValueError → HTTP 404.
|
||||
- `repo.remove_portfolio_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}`.
|
||||
- `repo.remove_asset_tag_id(asset_id, tag_id)` → HTTP 200 `{"tag_id": ..., "status": "removed"}` (BAT-013에서 portfolios → assets 테이블 대상으로 정합화).
|
||||
3. **POST /v1/portfolios/{portfolio_id}/tags** (apply tag to portfolio — owner or admin):
|
||||
- DB token required; trade-write or admin permission; service/client DB token → HTTP 403 (`_require_write_token`).
|
||||
- `repo.get(portfolio_id)` → ValueError → HTTP 404.
|
||||
@@ -449,6 +449,7 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
- 2026-05-21 KST: BAT-013 (REQ-005 CHG-20260521-02) — /v1/assets/{id}/tags apply/remove SQL 대상 정합화: `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id` 호출을 `repo.append_asset_tag_id`/`repo.remove_asset_tag_id`로 교체. portfolios 테이블 mutation 호출은 portfolio 라우트(`/v1/portfolios/{id}/tags`) 전용으로 한정. HTTP contract 무변, BAT-012 신설 `assets.tag_ids` 컬럼이 비로소 정상 채워짐.
|
||||
- 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-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 헤더 갱신.
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
- `DELETE /v1/portfolios/{portfolio_id}/tags/{tag_id}` — portfolio에서 태그 해제; trade-write 이상, 소유자 본인 또는 admin
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given admin 토큰, admin이 생성한 활성 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 200, 해당 tag_id가 asset의 tag_ids 배열에 추가된다.
|
||||
1. Given admin 토큰, admin이 생성한 활성 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 200, 해당 tag_id가 **assets 테이블의** tag_ids 컬럼 배열에 추가된다 (portfolios 테이블이 아님).
|
||||
2. Given admin 토큰, is_admin_tag=FALSE인 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 403 Forbidden이 반환된다.
|
||||
3. Given admin 토큰, is_active=FALSE인 tag_id, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 403 Forbidden이 반환된다.
|
||||
4. Given non-admin 토큰, When `POST /v1/assets/{asset_id}/tags`, Then HTTP 403 Forbidden이 반환된다.
|
||||
5. Given admin 토큰, When `DELETE /v1/assets/{asset_id}/tags/{tag_id}`, Then HTTP 200, tag_id가 asset의 tag_ids 배열에서 제거된다.
|
||||
5. Given admin 토큰, When `DELETE /v1/assets/{asset_id}/tags/{tag_id}`, Then HTTP 200, tag_id가 **assets 테이블의** tag_ids 컬럼 배열에서 제거된다 (portfolios 테이블이 아님).
|
||||
6. Given trade-write 토큰(portfolio 소유자), 소유자 본인이 생성한 tag_id (is_admin_tag=FALSE, is_active=TRUE), When `POST /v1/portfolios/{portfolio_id}/tags`, Then HTTP 200, 태그가 portfolio의 tag_ids에 추가된다.
|
||||
7. Given trade-write 토큰(portfolio 소유자), admin이 생성한 활성 tag_id, When `POST /v1/portfolios/{portfolio_id}/tags`, Then HTTP 200, 태그가 추가된다.
|
||||
8. Given trade-write 토큰(user A, 소유자), 다른 user B가 생성한 non-admin tag_id, When `POST /v1/portfolios/{portfolio_id}/tags`, Then HTTP 403 Forbidden이 반환된다.
|
||||
@@ -32,11 +32,11 @@
|
||||
## Impacted Area
|
||||
- Docs: docs/interface-contract.md (/v1/assets/{id}/tags, /v1/portfolios/{id}/tags 엔드포인트 명세 추가)
|
||||
- Modules:
|
||||
- app/api/v1_assets.py — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가
|
||||
- app/api/v1_assets.py — POST/DELETE /v1/assets/{asset_id}/tags 라우트 추가; CHG-20260521-02에서 라우트 내부 호출을 assets 테이블 대상 repository 메서드로 교체
|
||||
- app/api/portfolios.py — POST/DELETE /v1/portfolios/{portfolio_id}/tags 라우트 추가 (또는 별도 파일)
|
||||
- app/schemas/v1.py — TagApplyRequest 스키마 추가
|
||||
- app/services/ — 태그 적용/해제 서비스 로직, 정책 검증
|
||||
- app/repositories/postgres.py — tag_ids 배열 append/remove SQL (array_append, array_remove 또는 동등)
|
||||
- app/repositories/postgres.py — tag_ids 배열 append/remove SQL (array_append, array_remove 또는 동등). assets 대상은 `append_asset_tag_id`/`remove_asset_tag_id`, portfolios 대상은 `append_portfolio_tag_id`/`remove_portfolio_tag_id`로 분리(CHG-20260521-02).
|
||||
- Tests: 정책 위반 시 403, 존재하지 않는 tag_id 시 404, admin/non-admin 구분 검증
|
||||
|
||||
## Notes
|
||||
@@ -48,6 +48,93 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260521-03
|
||||
- Date: 2026-05-21
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Approved → Implemented (CHG-20260521-02로 명문화된 AC-1/AC-5 의도가 BAT-013 verification에서 evidence 확보)
|
||||
|
||||
#### Intent
|
||||
- BAT-013 confirm-batch-verification 완료. assets 테이블 tag_ids 정합화 범위(`append_asset_tag_id`/`remove_asset_tag_id` 신규 + 라우트 호출 교체)에 대한 AC-1·AC-5 신규 evidence + AC-2~4·EC-1 회귀 + AC-6~11(portfolios 경로) 무영향 회귀 모두 확인.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-013 (release-candidate) — primary trace, assets.tag_ids mutation 정합화 범위 AC-1·AC-5 + EC-1 evidence
|
||||
- BAT-004 (release-candidate) — AC-2~4·AC-6~11 회귀 evidence 유지; AC-1·AC-5 evidence는 BAT-013에서 superseded (mock 단언이 신규 메서드로 갱신됨)
|
||||
- Releases:
|
||||
- REL-001 — BAT-004 trace는 REQ-004 측면에서 유효; REQ-005 측면의 신규 evidence는 BAT-013에서 확보. REL-001에 BAT-013 포함 여부는 release 갱신 시 결정 (또는 신규 REL에 BAT-013 포함).
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: implemented
|
||||
- Follow-up Action:
|
||||
- REL-001 release 문서에 BAT-013 trace 반영 (또는 신규 REL 발의 시 BAT-013 포함)
|
||||
- CLI 동일 mismatch 후속 REQ 발의: `pfhub tag apply asset` / `pfhub tag remove asset`도 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 호출 (REQ-007/008 augmentation 또는 신규 REQ)
|
||||
- 실 PostgreSQL `assets.tag_ids` mutation 회귀: REL 배포 직후 smoke로 admin POST → GET /v1/assets에서 tag_ids 채워짐 확인
|
||||
|
||||
#### Validation Plan
|
||||
- `tests/test_tags_http_api.py::test_apply_asset_tag_admin_success` (갱신) — `append_asset_tag_id` 호출 단언 + portfolios 미호출 단언
|
||||
- `tests/test_tags_http_api.py::test_remove_asset_tag_admin_success` (갱신) — `remove_asset_tag_id` 호출 단언 + portfolios 미호출 단언
|
||||
- `tests/test_asset_catalog.py::test_postgres_repository_has_append_asset_tag_id_method` / `test_postgres_repository_has_remove_asset_tag_id_method` — 메서드 존재 검증
|
||||
- `tests/test_asset_catalog.py::test_append_asset_tag_id_targets_assets_table_not_portfolios` / `test_remove_asset_tag_id_targets_assets_table_not_portfolios` — SQL 대상 테이블 검증
|
||||
- `tests/test_asset_catalog.py::test_append_asset_tag_id_raises_when_asset_not_found` / `test_remove_asset_tag_id_raises_when_asset_not_found` — asset 부재 ValueError
|
||||
- `tests/test_asset_catalog.py::test_append_asset_tag_id_idempotent_guard_present` — 중복 방지 가드
|
||||
- 전체 회귀: 369 passed in 11.48s
|
||||
|
||||
### CHG-20260521-02
|
||||
- Date: 2026-05-21
|
||||
- Author: Justin Ko
|
||||
|
||||
#### Change Summary
|
||||
- Status: Implemented → Approved
|
||||
- AC-1, AC-5의 "asset의 tag_ids 배열" 의도를 "assets 테이블의 tag_ids 컬럼"으로 명문화. 기존 구현이 portfolios 테이블을 대상화하고 있어 AC 의미 충족 미달.
|
||||
|
||||
#### Intent
|
||||
- BAT-012 implementation 단계에서 REL-001 Feedback Handoff 항목으로 발견 (rel-001 117행): `apply_asset_tag`([app/api/v1_assets.py:175](../../../app/api/v1_assets.py#L175))/`remove_asset_tag`([app/api/v1_assets.py:200](../../../app/api/v1_assets.py#L200))가 `repo.append_portfolio_tag_id`/`repo.remove_portfolio_tag_id`를 호출하여 실제로는 **portfolios 테이블**을 update.
|
||||
- BAT-012로 `assets.tag_ids` 컬럼이 도입됐으나 apply/remove 경로가 정합되지 않아 신설 컬럼이 실제로 채워지지 않는 상태.
|
||||
- BAT-004 verification이 portfolios.tag_ids 변경 결과로 통과한 흔적(또는 asset_id == portfolio_id 동일 UUID fixture)으로 추정되며 AC-1/AC-5의 진정한 의도(assets 테이블의 tag_ids)는 검증되지 않았다.
|
||||
- dcy-002 FR-2 — "admin이 assets에 태그를 부여" — 원래 의도에 맞춰 구현·검증 경로를 정합화한다.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Modified:
|
||||
- AC-1: "해당 tag_id가 **asset의** tag_ids 배열에 추가된다" → "해당 tag_id가 **assets 테이블의** tag_ids 컬럼 배열에 추가된다 (portfolios 테이블이 아님)"
|
||||
- AC-5: "tag_id가 **asset의** tag_ids 배열에서 제거된다" → "tag_id가 **assets 테이블의** tag_ids 컬럼 배열에서 제거된다 (portfolios 테이블이 아님)"
|
||||
- 기타 AC (2,3,4,6~11): 변경 없음.
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Modules added/modified:
|
||||
- `app/repositories/postgres.py` — `append_asset_tag_id(asset_id, tag_id)`, `remove_asset_tag_id(asset_id, tag_id)` 메서드 신규. SQL은 assets 테이블 대상 `array_append`/`array_remove` UUID[].
|
||||
- `app/api/v1_assets.py` — `apply_asset_tag`/`remove_asset_tag` 라우트의 `append_portfolio_tag_id`/`remove_portfolio_tag_id` 호출을 신규 assets 대상 메서드로 교체.
|
||||
- Tests added:
|
||||
- `tests/test_asset_catalog.py` (또는 `tests/test_v1.py`) — POST /v1/assets/{id}/tags 후 `assets.tag_ids`에 tag_id가 실제 추가되는지 + portfolios.tag_ids는 변동 없는지 회귀 확인. DELETE 동일.
|
||||
- Docs:
|
||||
- 변경 없음 (interface-contract.md/runtime-flows.md의 라우트 명세는 동일).
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- BAT-004 (asset/portfolio tag apply-remove HTTP API) — AC-1/AC-5 evidence 무효(portfolios 테이블 대상화로 의미 미충족). BAT-004의 REQ-004 trace는 영향 없음.
|
||||
- 신규 batch (TBD) — append_asset_tag_id/remove_asset_tag_id 도입 + 라우트 교체 + 회귀 테스트.
|
||||
- Releases:
|
||||
- REL-001 — BAT-004 의 REQ-005 trace는 본 augmentation으로 부분 무효. 본 REQ 재구현 결과는 후속 release(REL-002 등) 또는 REL-001 추가 batch 편입 시 trace.
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: yes
|
||||
- Existing Implementation Invalidated: yes (POST/DELETE /v1/assets/{asset_id}/tags 내부 SQL이 portfolios 테이블을 대상화, AC-1/AC-5 의미상 미충족)
|
||||
- Status Recommendation: revert-to-approved
|
||||
- Follow-up Action: 신규 batch 발의 → repository 메서드 추가 → 라우트 교체 → pytest 회귀 (DB 인스턴스로 assets.tag_ids mutation 검증) → confirm-req-implemented 재진입
|
||||
|
||||
#### Validation Plan
|
||||
- pytest로 신규 회귀 케이스 추가: POST /v1/assets/{id}/tags 200 후 PostgresRepository.get(asset_id).tag_ids 배열에 tag_id 포함 확인; DELETE 후 제거 확인. portfolios 테이블은 동일 호출에 대해 무변동 확인.
|
||||
- BAT-004 기존 AC-1, AC-5 케이스가 portfolios.tag_ids 의존 fixture에 묻혀 통과했는지 점검; assets 전용 fixture로 교체.
|
||||
|
||||
### CHG-20260521-01
|
||||
- Date: 2026-05-21
|
||||
- Author: Justin Ko
|
||||
|
||||
+2
-2
@@ -116,8 +116,8 @@
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-05-21: REQ-005 Approved → Implemented (CHG-20260521-03) — BAT-013 confirm-batch-verification 완료. `append_asset_tag_id`/`remove_asset_tag_id` 메서드 신규 + 라우트 호출 교체 + 회귀 테스트 7건 추가. assets 테이블 mutation 정합화 evidence 확보. 전체 369 passed (BAT-012 baseline 362 → +7). BAT-004의 AC-1/AC-5 evidence는 BAT-013에서 superseded. CLI 동일 mismatch는 follow-up REQ 후보.
|
||||
- 2026-05-21: REQ-005 Implemented → Approved (CHG-20260521-02) — BAT-012 implementation 단계에서 `apply_asset_tag`/`remove_asset_tag` 라우트([app/api/v1_assets.py:175,200](../../app/api/v1_assets.py))가 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출하여 **portfolios 테이블을 대상화**하는 불일치 발견. AC-1/AC-5 의도("assets 테이블의 tag_ids")가 BAT-012 신설 `assets.tag_ids` 컬럼에 반영되지 않음. AC-1/AC-5 명문화, Impacted Area에 `append_asset_tag_id`/`remove_asset_tag_id` 메서드 추가. BAT-004 REQ-005 evidence는 부분 무효, 신규 batch 편입 필요.
|
||||
- 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-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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -101,6 +101,8 @@ def _make_db_repo(
|
||||
repo.deactivate_tag.side_effect = lambda tid: _make_tag(tag_id=tid, is_active=False)
|
||||
repo.append_portfolio_tag_id.return_value = None
|
||||
repo.remove_portfolio_tag_id.return_value = None
|
||||
repo.append_asset_tag_id.return_value = None
|
||||
repo.remove_asset_tag_id.return_value = None
|
||||
repo.get_tags_by_ids.return_value = {}
|
||||
repo.get_strategy_set.return_value = None
|
||||
repo.list_portfolios_with_strategies.return_value = []
|
||||
@@ -358,7 +360,8 @@ def test_apply_asset_tag_admin_success(monkeypatch) -> None:
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "applied"
|
||||
mock_repo.append_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
|
||||
mock_repo.append_asset_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
|
||||
mock_repo.append_portfolio_tag_id.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -447,7 +450,8 @@ def test_remove_asset_tag_admin_success(monkeypatch) -> None:
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "removed"
|
||||
mock_repo.remove_portfolio_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
|
||||
mock_repo.remove_asset_tag_id.assert_called_once_with(_PORTFOLIO_ID, _TAG_ID_1)
|
||||
mock_repo.remove_portfolio_tag_id.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user