diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index c16f4e9..0a546b8 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -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: diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index d018fa8..30e909e 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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 diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/design.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/design.md new file mode 100644 index 0000000..71fe3d4 --- /dev/null +++ b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/design.md @@ -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 범위에 한정. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/implementation.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/implementation.md new file mode 100644 index 0000000..9d39ce1 --- /dev/null +++ b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/implementation.md @@ -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 단계에서 즉시 정정. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/index.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/index.md new file mode 100644 index 0000000..ed97bfe --- /dev/null +++ b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/index.md @@ -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로 표기. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/planning.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/planning.md new file mode 100644 index 0000000..563e2c2 --- /dev/null +++ b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/planning.md @@ -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))` 가드를 가짐 → 동일 패턴 채택. diff --git a/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/verification.md b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/verification.md new file mode 100644 index 0000000..caa026d --- /dev/null +++ b/docs/batches/bat-013_20260521_asset-tag-apply-remove-fix/verification.md @@ -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`로 표기 권장. diff --git a/docs/batches/index.md b/docs/batches/index.md index 6116898..fd08635 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) | diff --git a/docs/releases/index.md b/docs/releases/index.md index c1edbd8..232041e 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -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) | diff --git a/docs/releases/rel-001_20260521_v1-api-full-surface.md b/docs/releases/rel-001_20260521_v1-api-full-surface.md index 8f1531e..b9c3b2c 100644 --- a/docs/releases/rel-001_20260521_v1-api-full-surface.md +++ b/docs/releases/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: diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index b760d69..1156eed 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -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 헤더 갱신. diff --git a/docs/srs/Interface/req-005_tag-apply-remove-http-api.md b/docs/srs/Interface/req-005_tag-apply-remove-http-api.md index 1d67aca..1897317 100644 --- a/docs/srs/Interface/req-005_tag-apply-remove-http-api.md +++ b/docs/srs/Interface/req-005_tag-apply-remove-http-api.md @@ -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 diff --git a/docs/srs/index.md b/docs/srs/index.md index 71ee665..bf1c7dd 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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). diff --git a/tests/test_asset_catalog.py b/tests/test_asset_catalog.py index 20596d0..fe27e26 100644 --- a/tests/test_asset_catalog.py +++ b/tests/test_asset_catalog.py @@ -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 diff --git a/tests/test_tags_http_api.py b/tests/test_tags_http_api.py index 1d15f00..88977f4 100644 --- a/tests/test_tags_http_api.py +++ b/tests/test_tags_http_api.py @@ -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() # ---------------------------------------------------------------------------