feat: REQ-001~018 Implemented 전환, REQ-010 GET /v1/assets admin catalog 재정의, REL-001 draft

- confirm-req-implemented: REQ-001~018 (17건, REQ-013 제외) Approved → 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)
  - CHG-20260521-01 entry 각 REQ에 추가; SRS index Recent Change Log 갱신

- REQ-010 CHG-20260521-02: GET /v1/assets admin catalog 전용 재정의
  - 기존 token_type 분기 portfolio 조회 코드 제거, admin permission + list_assets() 호출로 대체
  - AssetCatalogListResponse 스키마 추가 (items/total)
  - REQ-010 Status: Implemented → Approved (재검증 필요)
  - 테스트 343건 전체 통과: service/user token 403, admin token 200 + catalog schema 검증

- migration 보강: portfolios_asset_migration market 컬럼 부재 fallback,
  portfolio_tags 빈 테이블 early-return, service_token index fix autocommit_block 정리

- docs/releases/rel-001: REL-001 draft 추가 (BAT-001~011 포함)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 10:17:39 +00:00
co-authored by Claude Sonnet 4.6
parent 4bf6b554a7
commit 6add0ade29
28 changed files with 894 additions and 393 deletions
+21 -119
View File
@@ -1,40 +1,26 @@
"""v1 asset endpoints — portfolio list (service/user/admin) and admin catalog CRUD.""" """v1 asset endpoints — admin asset catalog CRUD + tag apply/remove (REQ-010, REQ-005)."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.core.auth import AuthContext, require_admin, require_read from app.core.auth import AuthContext, require_admin
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.v1 import ( from app.schemas.v1 import (
AssetCatalogCreateRequest, AssetCatalogCreateRequest,
AssetCatalogListResponse,
AssetCatalogResponse, AssetCatalogResponse,
AssetCatalogUpdateRequest, AssetCatalogUpdateRequest,
AssetItem,
AssetsResponse,
TagApplyRequest, TagApplyRequest,
TagApplyResponse, TagApplyResponse,
TagRefItem,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1", tags=["v1"]) router = APIRouter(prefix="/v1", tags=["v1"])
def _parse_strategy_params(strategy_set_config: str | None) -> dict:
if not strategy_set_config:
return {}
try:
parsed = json.loads(strategy_set_config)
return parsed if isinstance(parsed, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
def _get_repo(settings=None): def _get_repo(settings=None):
from app.core.config import get_settings from app.core.config import get_settings
return get_portfolio_repository_from_settings(settings or get_settings()) return get_portfolio_repository_from_settings(settings or get_settings())
@@ -52,123 +38,39 @@ def _asset_response(asset) -> AssetCatalogResponse:
) )
def _build_tag_refs(tag_ids: list[str] | None, active_map: dict[str, bool]) -> list[TagRefItem] | None: _CATALOG_STATUS_FILTERS = {"active", "inactive", "all"}
if not tag_ids:
return None
return [
TagRefItem(tag_id=tid, archived=not active_map.get(tid, True))
for tid in tag_ids
]
def _build_asset_items(portfolios, status_filter: str | None, repo) -> list[AssetItem]:
# Batch-fetch tag active status for all tag_ids across all portfolios
all_tag_ids: list[str] = [
tid for p in portfolios for tid in (p.tag_ids or [])
]
tag_active_map: dict[str, bool] = {}
if all_tag_ids:
try:
tags_by_id = repo.get_tags_by_ids(all_tag_ids)
tag_active_map = {tid: t.is_active for tid, t in tags_by_id.items()}
except Exception:
logger.warning("tag active status lookup failed")
items = []
for p in portfolios:
if status_filter is not None and p.status != status_filter:
continue
strategy_params: dict = {}
strategy_type: str | None = None
if p.active_strategy_id:
try:
ss = repo.get_strategy_set(p.active_strategy_id)
if ss is not None:
strategy_params = _parse_strategy_params(ss.config)
strategy_type = ss.name
except Exception:
logger.warning("strategy_set lookup failed for %s", p.active_strategy_id)
items.append(
AssetItem(
asset_id=p.portfolio_id,
symbol=p.symbol,
status=p.status,
cash=p.cash_balance,
quantity=p.asset_quantity,
broker_type=p.broker,
strategy_id=p.active_strategy_id,
strategy_type=strategy_type,
strategy_params=strategy_params,
updated_at=p.updated_at,
name=p.name,
price=p.price,
profit_buffer=p.profit_buffer,
tags=p.tags,
tag_ids=_build_tag_refs(p.tag_ids, tag_active_map),
owner_user_id=p.owner_user_id,
created_at=p.created_at,
last_trade_at=p.last_trade_at,
)
)
return items
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /v1/assets — token-type-branched portfolio list (REQ-013) # GET /v1/assets — admin asset catalog list (REQ-010 CHG-20260521-02)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/assets", response_model=AssetsResponse) @router.get("/assets", response_model=AssetCatalogListResponse)
def list_assets( def list_catalog_assets(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str = Query(default="active", alias="status"),
market: str | None = Query(default=None), market: str | None = Query(default=None),
user_id: str | None = Query(default=None), auth: AuthContext = Depends(require_admin),
auth: AuthContext = Depends(require_read), ) -> AssetCatalogListResponse:
) -> AssetsResponse: if status_filter not in _CATALOG_STATUS_FILTERS:
if auth.token_source != "db":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="DB token required", detail=f"status must be one of {sorted(_CATALOG_STATUS_FILTERS)}",
) )
repo = _get_repo() repo = _get_repo()
try: try:
if auth.token_type == "service": assets = repo.list_assets()
if auth.daemon_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="daemon_id not assigned to this token",
)
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id,
include_disabled=False,
managed_by_daemon_id=auth.daemon_id,
market=market,
)
elif auth.token_type == "user":
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=auth.user_id,
include_disabled=True,
market=market,
)
elif auth.token_type == "admin":
filter_user_id = user_id if user_id is not None else auth.user_id
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=filter_user_id,
include_disabled=True,
market=market,
)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Unsupported token type",
)
items = _build_asset_items(portfolios, status_filter, repo)
finally: finally:
if callable(getattr(type(repo), "close", None)): if callable(getattr(type(repo), "close", None)):
repo.close() repo.close()
return AssetsResponse(assets=items, total=len(items)) if status_filter != "all":
assets = [a for a in assets if a.status == status_filter]
if market is not None:
assets = [a for a in assets if market in (a.markets or [])]
items = [_asset_response(a) for a in assets]
return AssetCatalogListResponse(items=items, total=len(items))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+5
View File
@@ -239,3 +239,8 @@ class AssetCatalogResponse(BaseModel):
status: str status: str
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
class AssetCatalogListResponse(BaseModel):
items: list[AssetCatalogResponse]
total: int
+11
View File
@@ -0,0 +1,11 @@
# Release Index
## 목적
- 이 문서는 release 단위의 전역 인덱스다.
- 각 release는 `docs/releases/rel-XXX_YYYYMMDD_<slug>.md`에서 관리한다.
## Release Register
| REL ID | Profile | Status | Included Batch | Date | Doc |
| --- | --- | --- | --- | --- | --- |
| REL-001 | app-service | draft | BAT-001~011 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) |
@@ -0,0 +1,119 @@
# REL-001: v1 API full surface
- 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
## Scope
- Included:
- BAT-001 (REQ-009, REQ-011, REQ-014): assets 테이블 스키마 + portfolios.asset_id FK + data migration
- BAT-002 (REQ-010, REQ-012, REQ-013, REQ-015): admin asset catalog CRUD API + portfolio 생성 CLI catalog 검증 + baseline 갱신
- BAT-003 (REQ-001, REQ-002, REQ-003, REQ-006): tags 마스터 테이블 + assets/portfolios.tag_ids 컬럼 + data migration
- BAT-004 (REQ-004, REQ-005): 태그 HTTP API (POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate, POST/DELETE /v1/assets|portfolios/{id}/tags)
- BAT-005 (REQ-007, REQ-008): pfhub tag CLI + baseline 문서 갱신
- BAT-006 (REQ-016, REQ-017, REQ-018): client token 발급 폐지 + service-token rotate unique index 보강 + POST /auth/service-tokens daemon_id forward
- BAT-007 (REQ-022, REQ-023): daemon deactivation auto-revoke + NULL daemon migration CLI
- BAT-008 (REQ-019, REQ-020, REQ-021, REQ-024): 토큰 surface baseline 문서 갱신 (AuthContext, 권한 매트릭스, TTL 정책, env admin token SOP)
- BAT-009 (REQ-025, REQ-026, REQ-027, REQ-028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설
- BAT-010 (REQ-030, REQ-031, REQ-032): GET/POST/DELETE /v1/portfolios/{id}/tags + POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments + baseline 갱신
- BAT-011 (REQ-029): POST /v1/portfolios/{id}/ledger/trades 신설 + 구 path POST /v1/assets/{id}/ledger/trades 제거
- Excluded:
- REQ-013 (Deprecated): GET /v1/assets 토큰 타입별 분기 재정의 — dcy-003 옵션 B 결정으로 무효화
- trex-basic 내부 코드 변경 (별도 레포 — 본 릴리즈와 동시 배포 필요)
## Rollout Plan
1. **사전 확인**: trex-basic 신 path(`POST /v1/portfolios/{id}/ledger/trades`) 빌드 완료 및 배포 준비 확인
2. **DB 마이그레이션**: `alembic upgrade head` — BAT-001/003/006/007 관련 revision 순차 적용
- assets 테이블, portfolios.asset_id FK, tags 마스터, tag_ids 컬럼, auth_tokens.runtime_id/daemon_id, daemons 테이블, ledger_trades
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)
4. **trex-basic 배포**: 신 path로 전환된 클라이언트 배포
- **이 단계가 완료되기 전까지 구 path 호출 불가 — portfolio-hub 배포 후 즉시 trex-basic 배포 수행**
5. **배포 후 smoke 검증**: health endpoint + 신 path 동작 확인
## Rollback Plan
- **트리거 조건**: portfolio-hub 배포 후 health 실패, 신 API 500/401 대규모 발생, trex-basic 배포 실패
- **BAT-011 포함 전체 롤백**:
1. portfolio-hub → 이전 이미지로 즉시 롤백 (`alembic downgrade` 또는 이전 이미지 재기동)
2. trex-basic → 구 path 사용 버전으로 동반 롤백 (portfolio-hub 롤백 완료 후)
- **BAT-011 단독 부분 롤백 불가** — 신/구 path 동시 서빙은 설계상 없음
- **DB 마이그레이션 롤백**: 데이터 손실 리스크를 평가한 뒤 DBA 판단 하에 수행. tag_ids 컬럼·ledger_trades·daemons 테이블은 마이그레이션 downgrade 시 데이터 소실 가능성 존재 — 사전 DB 스냅샷 필수
## Verification Checklist
- **배포 전**:
- [x] trex-basic 신 path 빌드 완료 확인
- [x] DB 스냅샷(백업) 생성 완료
- [x] `alembic upgrade head` dry-run 또는 revision 목록 점검
- [x] 전체 테스트 suite pass 확인 (340 passed, main branch 4bf6b55)
- **배포 직후 (portfolio-hub)**:
- [~] `GET /health` → HTTP 200 + `db_enabled: true` (DB 연결 정상) — **로컬 smoke (2026-05-21 17:08 KST)**: HTTP 200 응답, 단 `HealthResponse` 모델([app/api/health.py:17-21](app/api/health.py#L17-L21))에 `db_enabled` 필드 미노출. DB 연동은 `PORTFOLIOHUB_DB_ENABLED=true` + `alembic upgrade head` head revision 적용 + 본 smoke의 다른 항목들이 DB 의존 동작으로 통과한 것으로 우회 확인.
- [!] `GET /v1/assets` (admin token) → HTTP 200 (asset catalog 조회) — **로컬 smoke (2026-05-21 17:08 KST)**: admin session token으로 호출 시 HTTP 403 `{"detail":"Unsupported token type"}`. 현재 [app/api/v1_assets.py:135-164](app/api/v1_assets.py#L135-L164) token_type 분기는 `user/admin/service` 케이스만 처리하고 `session`은 누락. dcy-003 옵션 B 결정으로 REQ-013(Deprecated) 후 GET /v1/assets를 admin catalog 조회 전용으로 재정의하는 작업(REQ-010 Notes 후속)이 아직 반영되지 않음. **이 체크리스트 항목은 현재 코드 상태와 불일치 — 본 릴리즈 범위 결정 필요(아래 Feedback Handoff 참조).**
- [x] `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 동시 서빙 없음 정책 충족.
- **trex-basic 배포 직후**:
- [ ] `POST /v1/portfolios/{id}/ledger/trades` (service token) → HTTP 201 + `status="new"` (신 path 동작 확인)
- [ ] daemon assignment 인가 3단계 정상 동작 확인
## Release Execution Evidence
- Release Status Transition Target: released
### Deployment Facts
- Executed At:
- (배포 시작 시각 — 배포 시 기록)
- Completed At:
- (배포 완료 시각 — 배포 시 기록)
- Environment:
- (배포 대상 환경 — 배포 시 기록)
- Operator:
- (배포 수행자 — 배포 시 기록)
- Revision or Artifact:
- main branch commit: `4bf6b55` (feat: v1 portfolio mutation, sub-resources, ledger path migration BAT-008~011)
- Commands or Procedure:
- `alembic upgrade head` → portfolio-hub rolling restart → trex-basic 배포
### Profile-Specific Checks
- app-service:
- Health Endpoint:
- 로컬 smoke (2026-05-21 17:08 KST, 127.0.0.1:18000): `{"status":"ok","service":"portfolio-hub","version":"0.1.0","checked_at":"2026-05-21T17:08:45.608024+09:00"}` HTTP 200. `db_enabled` 필드는 응답 모델에 미정의 — 별도 후속 항목으로 등록.
- Startup Log Fields:
- (실제 배포 후 기록 — app, event, command, run_id 확인)
- Request Log Fields:
- (실제 배포 후 기록 — request_id 포함 여부)
- Smoke or Manual Verification:
- 로컬 smoke 결과(Verification Checklist 배포 직후 4개 항목 참조):
- 통과 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 "Unsupported token type"). 본 릴리즈 진행 전 결정 필요.
- 실제 배포 환경(prod/staging) smoke는 본 릴리즈 시 별도 수집.
### Rollback Outcome
- Rollback Required:
- (배포 후 기록)
- Rollback Trigger:
- none
- Rollback Action:
- none
### Release Decision Evidence
- Released Decision:
- (배포 후 기록)
- Rationale:
- (배포 후 기록)
- Recorded By:
- (배포 후 기록)
- Recorded At:
- (배포 후 기록)
## Feedback Handoff
- Observation Summary:
- (운영 관찰 후 기록)
- Discovery Input:
- ledger_trades.asset_id 컬럼명이 portfolio_id를 가리키는 혼용 상태 잔존 (REQ-029 Notes) — 컬럼명 정합화 후속 REQ 후보
- session token의 no_expiry 코드 enforcement 미구현 (REQ-021 follow-up) — 후속 REQ 후보
- **GET /v1/assets token_type 게이트와 admin catalog 재정의 미정합**: dcy-003 옵션 B로 REQ-013이 Deprecated되었으나 [app/api/v1_assets.py:135-164](app/api/v1_assets.py#L135-L164)의 token_type 분기(user/admin/service)는 그대로 잔존하고, REQ-010 Notes에 명시된 GET /v1/assets의 "admin catalog 조회 전용" 재정의가 아직 별도 REQ로 분리되지 않음. session token이 v1 portfolio path와 달리 v1 assets path에서 거부되는 inconsistency 발생. **후속 Discovery 또는 dcy-001 batch design 단계의 REQ-010 augmentation 후보**.
- **/health 응답 모델에 `db_enabled` 필드 부재**: 운영 관점에서 health probe 결과로 DB 연결 상태를 외부 관찰자에게 노출하지 않는 상태. 체크리스트의 의도와 응답 모델 간 gap. `HealthResponse`([app/api/health.py:17-21](app/api/health.py#L17-L21)) 보강 또는 별도 readiness endpoint 분리 후보.
- REQ Input:
- (운영 관찰 후 기록)
- Change Request Input:
- (운영 관찰 후 기록)
+29 -1
View File
@@ -1,6 +1,6 @@
# REQ-001: tags 마스터 테이블 도입 # REQ-001: tags 마스터 테이블 도입
- Status: Approved - Status: Implemented
- Type: Data - Type: Data
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -39,6 +39,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-003 confirm-batch-verification 완료. tags 마스터 테이블 스키마, is_admin_tag 스냅샷 동작 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-003
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
+29 -1
View File
@@ -1,6 +1,6 @@
# REQ-002: assets.tag_ids UUID[] 컬럼 추가 # REQ-002: assets.tag_ids UUID[] 컬럼 추가
- Status: Approved - Status: Implemented
- Type: Data - Type: Data
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -38,6 +38,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-003 confirm-batch-verification 완료. portfolios.tag_ids UUID[] 컬럼, GIN 인덱스, archived 표시 동작 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-003
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-003: portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 # REQ-003: portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거
- Status: Approved - Status: Implemented
- Type: Data - Type: Data
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -38,6 +38,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-003 confirm-batch-verification 완료. portfolios.tag_ids, GIN 인덱스, portfolio_tags 테이블 제거, archived 표시 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-003
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
+29 -1
View File
@@ -1,6 +1,6 @@
# REQ-009: assets 테이블 스키마 도입 # REQ-009: assets 테이블 스키마 도입
- Status: Approved - Status: Implemented
- Type: Data - Type: Data
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -43,6 +43,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-001 confirm-batch-verification 완료. assets 테이블 스키마·GIN 인덱스·MarketCode Enum 검증 통과. DB-integration ACs는 실 DB 의존 — CI에서 migration 파일 구조 검증으로 대체, release 배포 시 최종 확인 예정.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-001
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-011: portfolios 테이블 asset_id FK 추가 및 symbol/market 제거 # REQ-011: portfolios 테이블 asset_id FK 추가 및 symbol/market 제거
- Status: Approved - Status: Implemented
- Type: Data - Type: Data
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -50,6 +50,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-001 confirm-batch-verification 완료. portfolios.asset_id FK, symbol/market 컬럼 제거, 기존 조회 API 회귀 없음 확인. DB-integration ACs는 실 DB 의존 — release 배포 시 최종 확인 예정.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-001
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-008: 태그 모델 도입 baseline 문서 갱신 # REQ-008: 태그 모델 도입 baseline 문서 갱신
- Status: Approved - Status: Implemented
- Type: Documentation - Type: Documentation
- Priority: Low - Priority: Low
- Owner: Justin Ko - Owner: Justin Ko
@@ -38,6 +38,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-005 confirm-batch-verification 완료. data-model/interface-contract/project-structure/runtime-flows 4개 문서 tags 모델 반영 확인. 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-005
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-015: asset catalog 도입 baseline 문서 갱신 # REQ-015: asset catalog 도입 baseline 문서 갱신
- Status: Approved - Status: Implemented
- Type: Documentation - Type: Documentation
- Priority: Low - Priority: Low
- Owner: Justin Ko - Owner: Justin Ko
@@ -37,6 +37,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-002 confirm-batch-verification 완료. data-model/interface-contract/runtime-flows/project-structure 4개 문서 asset catalog 반영 확인. 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-002
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
+29 -1
View File
@@ -1,6 +1,6 @@
# REQ-004: tags 마스터 HTTP API # REQ-004: tags 마스터 HTTP API
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -48,6 +48,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-004 confirm-batch-verification 완료. POST/GET /v1/tags, PATCH /v1/tags/{id}/deactivate 권한 분기·소문자 정규화·멱등성 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-004
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-005: 태그 적용·해제 HTTP API # REQ-005: 태그 적용·해제 HTTP API
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -48,6 +48,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-004 confirm-batch-verification 완료. asset/portfolio 태그 적용·해제 HTTP API 정책 검증·403/404 분기 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-004
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
+29 -1
View File
@@ -1,6 +1,6 @@
# REQ-007: pfhub tag CLI 명령어 # REQ-007: pfhub tag CLI 명령어
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: Medium - Priority: Medium
- Owner: Justin Ko - Owner: Justin Ko
@@ -47,6 +47,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-005 confirm-batch-verification 완료. pfhub tag create/list/deactivate/apply/remove CLI 모든 AC 검증 통과. 기존 pfhub portfolio tag add/list/remove 제거 확인.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-005
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -17,7 +17,8 @@
- HTTP API: `POST /v1/assets` — admin token 필요. request body에 symbol, markets, display_name. 성공 시 HTTP 201 + 생성된 asset 반환. - HTTP API: `POST /v1/assets` — admin token 필요. request body에 symbol, markets, display_name. 성공 시 HTTP 201 + 생성된 asset 반환.
- HTTP API: `PATCH /v1/assets/{asset_id}` — admin token 필요. markets, display_name 변경 가능. 성공 시 HTTP 200 + 갱신된 asset 반환. - HTTP API: `PATCH /v1/assets/{asset_id}` — admin token 필요. markets, display_name 변경 가능. 성공 시 HTTP 200 + 갱신된 asset 반환.
- HTTP API: `PATCH /v1/assets/{asset_id}/deactivate` — admin token 필요. `status='inactive'`로 변경. 성공 시 HTTP 200. - HTTP API: `PATCH /v1/assets/{asset_id}/deactivate` — admin token 필요. `status='inactive'`로 변경. 성공 시 HTTP 200.
- admin 이외의 token으로 POST/PATCH 요청 시 HTTP 403을 반환한다. - HTTP API: `GET /v1/assets` — admin token 필요. query param `status` (active|inactive|all, 기본 `active`), `market` (선택, MarketCode 1개). 응답: `{ items: [{ asset_id, symbol, markets, display_name, status, created_at, updated_at }] }` 형식. 응답 본문에 portfolio 관련 필드는 포함하지 않는다(path 의미 분리 — portfolio 조회는 `GET /v1/portfolios`).
- admin 이외의 token으로 POST/PATCH/GET 요청 시 HTTP 403을 반환한다.
- asset hard DELETE는 제공하지 않는다(soft delete 전용). - asset hard DELETE는 제공하지 않는다(soft delete 전용).
- `app/cli/commands/asset.py` 신규 파일로 pfhub asset 커맨드 그룹을 구현한다. - `app/cli/commands/asset.py` 신규 파일로 pfhub asset 커맨드 그룹을 구현한다.
- `app/cli/main.py`에 `app.add_typer(asset_app, name="asset")`을 추가한다. - `app/cli/main.py`에 `app.add_typer(asset_app, name="asset")`을 추가한다.
@@ -32,28 +33,104 @@
6. Given admin 토큰, When `PATCH /v1/assets/{asset_id}` with updated markets, Then HTTP 200과 변경된 asset이 반환된다. 6. Given admin 토큰, When `PATCH /v1/assets/{asset_id}` with updated markets, Then HTTP 200과 변경된 asset이 반환된다.
7. Given admin 토큰, When `PATCH /v1/assets/{asset_id}/deactivate`, Then HTTP 200과 status='inactive'가 반환된다. 7. Given admin 토큰, When `PATCH /v1/assets/{asset_id}/deactivate`, Then HTTP 200과 status='inactive'가 반환된다.
8. Given non-admin token (user 또는 service token), When `POST /v1/assets` 요청, Then HTTP 403이 반환된다. 8. Given non-admin token (user 또는 service token), When `POST /v1/assets` 요청, Then HTTP 403이 반환된다.
9. Given admin 토큰 + active/inactive asset 혼재, When `GET /v1/assets`, Then HTTP 200 + status='active' 항목만 포함된 `{items: [...]}` 응답. 각 item은 `asset_id, symbol, markets, display_name, status, created_at, updated_at` 필드만 포함하고 portfolio 관련 필드(`portfolio_id`, `owner_user_id`, `cash_balance` 등)는 포함하지 않는다.
10. Given admin 토큰 + active/inactive asset 혼재, When `GET /v1/assets?status=all`, Then 두 상태 모두 포함; `GET /v1/assets?status=inactive`, Then inactive만 포함.
11. Given admin 토큰 + 서로 다른 markets 값을 가진 asset 다수, When `GET /v1/assets?market=KRX`, Then `markets`에 `KRX`가 포함된 asset만 응답된다.
12. Given non-admin token (read/trade-write 권한 또는 service token), When `GET /v1/assets`, Then HTTP 403이 반환된다.
## Impacted Area ## Impacted Area
- Docs: - Docs:
- docs/project-structure.md — `app/cli/` Command groups에 `asset` 추가 (REQ-015에서 처리) - docs/project-structure.md — `app/cli/` Command groups에 `asset` 추가 (REQ-015에서 처리)
- docs/interface-contract.md — POST/PATCH /v1/assets 엔드포인트 명세 추가 (REQ-015에서 처리) - docs/interface-contract.md — POST/PATCH/GET /v1/assets 엔드포인트 명세 추가 (CHG-20260521-02에서 GET 추가; 후속 docs sync 필요 시 별도 REQ)
- Modules: - Modules:
- `app/cli/commands/asset.py` — 신규 파일, pfhub asset 커맨드 그룹 - `app/cli/commands/asset.py` — 신규 파일, pfhub asset 커맨드 그룹
- `app/cli/main.py` — `app.add_typer(asset_app, name="asset")` 추가 - `app/cli/main.py` — `app.add_typer(asset_app, name="asset")` 추가
- `app/services/` — asset create/update/deactivate/list 서비스 메서드 추가 - `app/services/` — asset create/update/deactivate/list 서비스 메서드 추가
- `app/api/v1_assets.py` — POST, PATCH 라우트 추가 - `app/api/v1_assets.py` — POST, PATCH 라우트 추가; GET /v1/assets 라우트는 admin catalog 조회로 재정의(기존 token_type 분기 portfolio 조회 코드 제거)
- `app/schemas/` — `CreateAssetRequest`, `UpdateAssetRequest`, `AssetCatalogResponse` 스키마 추가 - `app/schemas/v1.py` — `CreateAssetRequest`, `UpdateAssetRequest`, `AssetCatalogItem`, `AssetCatalogListResponse` 스키마 추가
- Tests: - Tests:
- `tests/test_asset_catalog.py` (신규) — admin CRUD CLI/API AC 검증, non-admin 403 검증 - `tests/test_asset_catalog.py` (신규) — admin CRUD CLI/API AC 검증, non-admin 403 검증, GET /v1/assets AC-9~12 검증
- `tests/test_v1.py` (기존) — GET /v1/assets에 portfolio 응답을 기대하는 케이스가 있다면 GET /v1/portfolios로 이전 또는 catalog 응답 schema로 갱신
## Notes ## Notes
- `pfhub asset list`의 HTTP 대응(catalog 전용 GET): dcy-003 옵션 B(path 명명 분리) 결정에 따라 REQ-013(GET /v1/assets 포트폴리오 조회)이 Deprecated(2026-05-20) 처리되어 path 충돌 우려가 해소됨. **`GET /v1/assets`는 admin catalog 조회 전용**으로 본 REQ에서 다루며, dcy-001 batch design 단계에서 본 REQ에 GET 명세를 보강(change-req)하거나 별도 신규 REQ로 분리한다. 포지션 조회 `GET /v1/portfolios`는 dcy-003의 별도 신규 REQ로 분리 예정. - `pfhub asset list`의 HTTP 대응(catalog 전용 GET): dcy-003 옵션 B(path 명명 분리) 결정에 따라 REQ-013(GET /v1/assets 포트폴리오 조회)이 Deprecated(2026-05-20) 처리되어 path 충돌 우려 해소. **`GET /v1/assets`는 admin catalog 조회 전용**으로 본 REQ에서 다룬다(2026-05-21 CHG-20260521-02로 augmentation 적용). 포지션 조회 `GET /v1/portfolios`는 REQ-028에서 분리 구현됨.
- deactivate 후 해당 asset을 참조하는 기존 portfolio는 보존된다. 신규 portfolio 생성만 거부(REQ-012에서 처리). - deactivate 후 해당 asset을 참조하는 기존 portfolio는 보존된다. 신규 portfolio 생성만 거부(REQ-012에서 처리).
- REQ-009 선행 필요. - REQ-009 선행 필요.
- Source: dcy-001 FR-2, NFR-3 - Source: dcy-001 FR-2, NFR-3; CHG-20260521-02 augmentation는 dcy-003 옵션 B (REQ-013 Deprecation) 후속 작업.
## Change Log ## Change Log
### CHG-20260521-02
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Implemented → Approved (REL-001 smoke 검증에서 GET /v1/assets가 dcy-001 시점 portfolio 조회 코드를 유지 중인 불일치 확인. 본 REQ Notes의 1순위 경로(REQ-010 augmentation)에 따라 GET 명세를 추가하고 기존 구현을 재정의.)
#### Intent
- dcy-003 옵션 B로 REQ-013 Deprecated 후 본 REQ Notes에 명시된 "GET /v1/assets는 admin catalog 조회 전용" 재정의가 미구현 상태로 잔존. REL-001 배포 직전 로컬 smoke([rel-001.md:50](../../releases/rel-001_20260521_v1-api-full-surface.md#L50))에서 admin session token → HTTP 403 "Unsupported token type" 발생으로 release 체크리스트와 코드 상태 불일치 확인. 본 augmentation으로 GET /v1/assets를 admin catalog 조회로 재정의해 path 분리 정책(dcy-003 옵션 B)을 코드에 반영.
#### Acceptance Criteria Delta
- Added:
- AC-9 (admin token GET 200 + asset catalog 응답)
- AC-10 (`status=all`/`status=inactive` 필터)
- AC-11 (`market=<code>` 필터)
- AC-12 (non-admin token → HTTP 403)
- 기존 AC-1~8: 변경 없음.
#### Impacted Area Delta
- Modules added:
- `app/api/v1_assets.py` — GET /v1/assets 라우트를 portfolio 조회에서 asset catalog 조회로 재정의 (token_type 분기 제거, admin permission 검증 + `list_assets` 호출)
- `app/schemas/v1.py` — `AssetCatalogItem`, `AssetCatalogListResponse` 신규 schema
- Tests added/updated:
- `tests/test_asset_catalog.py` — GET /v1/assets AC-9~12 검증 신규 케이스 (기존 portfolio 조회 의미의 GET /v1/assets 테스트가 있다면 제거/대체)
- `tests/test_v1.py` — 기존 GET /v1/assets 호출 패턴이 portfolio 응답을 기대한다면 GET /v1/portfolios로 이전 또는 응답 schema 변경 반영
- Repository:
- `app/repositories/postgres.py` — `list_assets` 메서드가 이미 존재하면 재사용; 없으면 보강
#### Delivery Trace Delta
- Batches:
- BAT-002 (asset-catalog-api-completion) — augmentation 누적, verification 재실행 필요
- Releases:
- REL-001 — scope 잔존, 본 augmentation 포함 (`docs/releases/rel-001_20260521_v1-api-full-surface.md`)
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: yes (기존 GET /v1/assets portfolio 조회 동작이 제거됨)
- Status Recommendation: revert-to-approved
- Follow-up Action: 구현 → pytest 회귀 → 로컬 smoke 재실행 → confirm-req-implemented 재진입
#### Validation Plan
- pytest로 AC-9~12 검증; 로컬 smoke로 admin session token + GET /v1/assets → HTTP 200 + asset catalog 응답 확인
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-002 confirm-batch-verification 완료. admin asset catalog CRUD CLI/HTTP API(POST/PATCH/deactivate), non-admin 403 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-002
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-012: portfolio 생성 CLI catalog 검증 # REQ-012: portfolio 생성 CLI catalog 검증
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -36,6 +36,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-002 confirm-batch-verification 완료. pfhub portfolio create --asset-id/--symbol catalog 검증(존재·비활성 거부) 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-002
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-016: client token 발급 경로 코드 폐지 # REQ-016: client token 발급 경로 코드 폐지
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -40,6 +40,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-006 confirm-batch-verification 완료. client token 발급 경로(POST /auth/tokens, issue_client_token) 코드 제거, HTTP 404 확인, token_type 게이트 회귀 없음 확인(AC-1/2/4). AC-3(release 직전 DATA-1 재확인)은 운영 릴리즈 단계에서 evidence 수집 예정.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-006
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 배포 직전 DATA-1 SELECT COUNT 재확인(AC-3) evidence를 release execution evidence에 첨부
### CHG-20260520-02 ### CHG-20260520-02
- Date: 2026-05-20 - Date: 2026-05-20
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-018: POST /auth/service-tokens body daemon_id forward 보정 # REQ-018: POST /auth/service-tokens body daemon_id forward 보정
- Status: Approved - Status: Implemented
- Type: Interface - Type: Interface
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -38,6 +38,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-006 confirm-batch-verification 완료. POST /auth/service-tokens daemon_id forward 1-line 보정 구현. HTTP/CLI daemon binding 동등성, non-existing daemon 4xx 분기 모든 AC 검증 통과.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-006
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: none
### CHG-20260520-02 ### CHG-20260520-02
- Date: 2026-05-20 - Date: 2026-05-20
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-006: portfolio_tags 데이터 마이그레이션 # REQ-006: portfolio_tags 데이터 마이그레이션
- Status: Approved - Status: Implemented
- Type: Migration - Type: Migration
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -44,6 +44,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-003 confirm-batch-verification 완료. portfolio_tags → tags 마스터 데이터 마이그레이션 Alembic revision 구현. DB-integration AC(1~6)는 실 DB 의존 — CI에서 migration 파일 구조 검증으로 대체, release 배포 시 alembic upgrade head로 최종 확인 예정.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-003
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-014: portfolios 데이터 migration # REQ-014: portfolios 데이터 migration
- Status: Approved - Status: Implemented
- Type: Migration - Type: Migration
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -43,6 +43,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-001 confirm-batch-verification 완료. portfolios → assets catalog 데이터 이전 Alembic revision 구현. DB-integration ACs(1~6)는 실 DB 의존 — CI에서 migration 파일 구조 검증으로 대체, release 배포 시 alembic upgrade head로 최종 확인 예정.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-001
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인
### CHG-20260519-02 ### CHG-20260519-02
- Date: 2026-05-19 - Date: 2026-05-19
- Author: Justin Ko - Author: Justin Ko
@@ -1,6 +1,6 @@
# REQ-017: service-token rotate unique index 보강 # REQ-017: service-token rotate unique index 보강
- Status: Approved - Status: Implemented
- Type: Migration - Type: Migration
- Priority: High - Priority: High
- Owner: Justin Ko - Owner: Justin Ko
@@ -41,6 +41,34 @@
## Change Log ## Change Log
### CHG-20260521-01
- Date: 2026-05-21
- Author: Justin Ko
#### Change Summary
- Status: Approved → Implemented
#### Intent
- BAT-006 confirm-batch-verification 완료. idx_auth_tokens_service_name_unique partial index 조건 `AND revoked_at IS NULL` 보강 Alembic revision + ensure_schema() 갱신 구현. AC-1~3/5(실 DB issue/rotate 시퀀스 및 staging lock 검증)는 실 DB 의존 — release 배포 시 최종 확인 예정.
#### Acceptance Criteria Delta
- No change
#### Impacted Area Delta
- No change
#### Delivery Trace Delta
- Batches:
- BAT-006
- Releases:
- REL-001
#### Revalidation Impact
- Reverification Needed: no
- Existing Implementation Invalidated: no
- Status Recommendation: implemented
- Follow-up Action: REL-001 배포 시 실 DB에서 issue → rotate 시퀀스로 AC-1~3 최종 확인
### CHG-20260520-02 ### CHG-20260520-02
- Date: 2026-05-20 - Date: 2026-05-20
- Author: Justin Ko - Author: Justin Ko
+18 -23
View File
@@ -81,24 +81,24 @@
| ID | Title | Type | Status | Priority | Owner | Link | | ID | Title | Type | Status | Priority | Owner | Link |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| REQ-001 | tags 마스터 테이블 도입 | Data | Approved | High | Justin Ko | [req-001](./Data/req-001_tags-master-table.md) | | REQ-001 | tags 마스터 테이블 도입 | Data | Implemented | High | Justin Ko | [req-001](./Data/req-001_tags-master-table.md) |
| REQ-002 | assets.tag_ids UUID[] 컬럼 추가 | Data | Approved | High | Justin Ko | [req-002](./Data/req-002_assets-tag-ids-column.md) | | REQ-002 | assets.tag_ids UUID[] 컬럼 추가 | Data | Implemented | High | Justin Ko | [req-002](./Data/req-002_assets-tag-ids-column.md) |
| REQ-003 | portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 | Data | Approved | High | Justin Ko | [req-003](./Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md) | | REQ-003 | portfolios.tag_ids UUID[] 컬럼 추가 및 portfolio_tags 테이블 제거 | Data | Implemented | High | Justin Ko | [req-003](./Data/req-003_portfolios-tag-ids-and-portfolio-tags-removal.md) |
| REQ-004 | tags 마스터 HTTP API | Interface | Approved | High | Justin Ko | [req-004](./Interface/req-004_tags-http-api.md) | | REQ-004 | tags 마스터 HTTP API | Interface | Implemented | High | Justin Ko | [req-004](./Interface/req-004_tags-http-api.md) |
| REQ-005 | 태그 적용·해제 HTTP API | Interface | Approved | High | Justin Ko | [req-005](./Interface/req-005_tag-apply-remove-http-api.md) | | REQ-005 | 태그 적용·해제 HTTP API | Interface | Implemented | High | Justin Ko | [req-005](./Interface/req-005_tag-apply-remove-http-api.md) |
| REQ-006 | portfolio_tags 데이터 마이그레이션 | Migration | Approved | High | Justin Ko | [req-006](./Migration/req-006_portfolio-tags-data-migration.md) | | REQ-006 | portfolio_tags 데이터 마이그레이션 | Migration | Implemented | High | Justin Ko | [req-006](./Migration/req-006_portfolio-tags-data-migration.md) |
| REQ-007 | pfhub tag CLI 명령어 | Interface | Approved | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) | | REQ-007 | pfhub tag CLI 명령어 | Interface | Implemented | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) |
| REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Approved | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) | | REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) |
| REQ-009 | assets 테이블 스키마 도입 | Data | Approved | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) | | REQ-009 | assets 테이블 스키마 도입 | Data | Implemented | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) |
| REQ-010 | admin asset catalog CRUD | Interface | Approved | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) | | REQ-010 | admin asset catalog CRUD | Interface | Approved | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) |
| REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Approved | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) | | REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Implemented | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) |
| REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Approved | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) | | REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Implemented | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) |
| REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) | | REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) |
| REQ-014 | portfolios 데이터 migration | Migration | Approved | High | Justin Ko | [req-014](./Migration/req-014_portfolios-data-migration.md) | | REQ-014 | portfolios 데이터 migration | Migration | Implemented | High | Justin Ko | [req-014](./Migration/req-014_portfolios-data-migration.md) |
| REQ-015 | asset catalog 도입 baseline 문서 갱신 | Documentation | Approved | Low | Justin Ko | [req-015](./Documentation/req-015_asset-catalog-baseline-docs-update.md) | | REQ-015 | asset catalog 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-015](./Documentation/req-015_asset-catalog-baseline-docs-update.md) |
| REQ-016 | client token 발급 경로 코드 폐지 | Interface | Approved | High | Justin Ko | [req-016](./Interface/req-016_client-token-issuance-deprecation.md) | | REQ-016 | client token 발급 경로 코드 폐지 | Interface | Implemented | High | Justin Ko | [req-016](./Interface/req-016_client-token-issuance-deprecation.md) |
| REQ-017 | service-token rotate unique index 보강 | Migration | Approved | High | Justin Ko | [req-017](./Migration/req-017_service-token-rotate-unique-index-fix.md) | | REQ-017 | service-token rotate unique index 보강 | Migration | Implemented | High | Justin Ko | [req-017](./Migration/req-017_service-token-rotate-unique-index-fix.md) |
| REQ-018 | POST /auth/service-tokens body daemon_id forward 보정 | Interface | Approved | High | Justin Ko | [req-018](./Interface/req-018_service-token-http-daemon-id-forward.md) | | REQ-018 | POST /auth/service-tokens body daemon_id forward 보정 | Interface | Implemented | High | Justin Ko | [req-018](./Interface/req-018_service-token-http-daemon-id-forward.md) |
| REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Implemented | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) | | REQ-019 | 토큰 surface baseline 문서 정합성 회복 | Documentation | Implemented | Medium | Justin Ko | [req-019](./Documentation/req-019_token-surface-baseline-docs-consistency.md) |
| REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Implemented | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) | | REQ-020 | 토큰 발급 권한 매트릭스 정의 | Documentation | Implemented | Medium | Justin Ko | [req-020](./Documentation/req-020_token-issuance-authorization-matrix.md) |
| REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Implemented | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) | | REQ-021 | 토큰 만료·회전 정책 정의 | Documentation | Implemented | Medium | Justin Ko | [req-021](./Documentation/req-021_token-expiry-and-rotation-policy.md) |
@@ -116,13 +116,8 @@
## Recent Change Log Summary ## Recent Change Log Summary
- 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). - 2026-05-21: REQ-030/031/032 Approved → Implemented — BAT-010 confirm-batch-verification 완료. 330/330 PASSED (32 신규 tests in test_v1_portfolios_tags_comments.py). GET /v1/portfolios/{id}/tags 신설; POST/DELETE tag disabled 409 + service/client 403 보강; comment CRUD 4종 신설; baseline 4종 갱신(interface-contract/runtime-flows/data-model/project-structure).
- 2026-05-21: REQ-025/026/027/028 Approved → Implemented — BAT-009 confirm-batch-verification 완료. 298 PASSED (0 failed). test_v1_portfolios_crud.py 22건(REQ-025/026/027), test_v1_portfolios_list.py 10건(REQ-028) 전 AC 통과. - 2026-05-21: REQ-025/026/027/028 Approved → Implemented — BAT-009 confirm-batch-verification 완료. 298 PASSED (0 failed). test_v1_portfolios_crud.py 22건(REQ-025/026/027), test_v1_portfolios_list.py 10건(REQ-028) 전 AC 통과.
- 2026-05-21: REQ-019/020/021/024 Approved → Implemented — BAT-008 confirm-batch-verification 완료. 모든 grep AC 통과, 사람 리뷰 완료. REQ-021 follow-up: session no_expiry 코드 enforcement 미구현 → 별도 처리. REQ-024 AC-5: staging dry-run은 release plan 단계로 이관. - 2026-05-21: REQ-019/020/021/024 Approved → Implemented — BAT-008 confirm-batch-verification 완료. 모든 grep AC 통과, 사람 리뷰 완료. REQ-021 follow-up: session no_expiry 코드 enforcement 미구현 → 별도 처리. REQ-024 AC-5: staging dry-run은 release plan 단계로 이관.
- 2026-05-20: REQ-016~032 (17건) 일괄 Proposed → Approved 전환 — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. 각 REQ에 CHG-20260520-02 entry 추가
- 2026-05-20: REQ-025~032 최초 생성 — dcy-003 (User Portfolio Mutation v1 API 이전, confirmed 2026-05-20) 후 draft-req 실행. POST/PATCH/DELETE/GET /v1/portfolios + ledger path 이전 + tag/comment v1 + baseline 정합성 (FR-1~10 분해, FR-9는 REQ-013 Deprecated로 이미 처리됨)
- 2026-05-20: REQ-016~024 최초 생성 — dcy-004 (토큰 발급 및 관리 전면 정비, confirmed 2026-05-20) 후 draft-req 실행. client token 폐지 / rotate index 보강 / HTTP daemon_id forward / baseline 정합성 / 발급 권한 매트릭스 / TTL·회전 정책 / daemon deactivation 자동 revoke / NULL daemon 마이그레이션 CLI / env admin SOP
- 2026-05-20: REQ-013 Approved → Deprecated — dcy-003(confirmed 2026-05-20) 옵션 B(path 명명 분리) 결정으로 통합 endpoint 정의 무효화. 후속: `GET /v1/assets`는 REQ-010 보강으로 catalog 전용, `GET /v1/portfolios`는 dcy-003 draft-req 단계에서 신규 REQ로 작성
- 2026-05-19: REQ-001~015 전체 Proposed → Approved 전환 — confirm-req 일괄 승인; 승인 게이트(필수 필드, AC 구체성, Change Log) 모두 충족
- 2026-05-19: REQ-009~015 최초 생성 — dcy-001 (자산 관리 구조 재설계) confirm-discovery 후 draft-req 실행
- 2026-05-19: REQ-001~008 최초 생성 — dcy-002 (태그 소유권 및 적용 대상 정책) confirm-discovery 후 draft-req 실행
@@ -31,8 +31,12 @@ def upgrade() -> None:
inspector = inspect(bind) inspector = inspect(bind)
# Step 1: Populate assets catalog from existing portfolios (REQ-014) # Step 1: Populate assets catalog from existing portfolios (REQ-014)
# Only if portfolios.symbol column still exists (idempotent guard) # Only if portfolios.symbol column still exists (idempotent guard).
# portfolios.market 컬럼은 이전 BAT-008 migration(20260502_0014) 또는
# ensure_schema() 경로에 따라 존재하지 않을 수 있다. REQ-014 본문 "market 값이
# NULL인 기존 행은 빈 배열로 처리"에 따라 컬럼 부재 시 markets=[]로 fallback.
if _column_exists(inspector, "portfolios", "symbol"): if _column_exists(inspector, "portfolios", "symbol"):
if _column_exists(inspector, "portfolios", "market"):
bind.execute(text(""" bind.execute(text("""
INSERT INTO assets (symbol, markets) INSERT INTO assets (symbol, markets)
SELECT SELECT
@@ -45,6 +49,13 @@ def upgrade() -> None:
GROUP BY symbol GROUP BY symbol
ON CONFLICT (symbol) DO NOTHING ON CONFLICT (symbol) DO NOTHING
""")) """))
else:
bind.execute(text("""
INSERT INTO assets (symbol, markets)
SELECT DISTINCT symbol, ARRAY[]::text[]
FROM portfolios
ON CONFLICT (symbol) DO NOTHING
"""))
# Step 2: Add portfolios.asset_id column (nullable, temporary) # Step 2: Add portfolios.asset_id column (nullable, temporary)
if not _column_exists(inspector, "portfolios", "asset_id"): if not _column_exists(inspector, "portfolios", "asset_id"):
@@ -10,6 +10,8 @@ before running this migration in a live environment.
from __future__ import annotations from __future__ import annotations
import os
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
@@ -20,7 +22,10 @@ branch_labels = None
depends_on = None depends_on = None
# Operator must confirm this user ID exists in the users table before running. # Operator must confirm this user ID exists in the users table before running.
MIGRATION_ADMIN_ID = "migration-admin" # Override via `MIGRATION_ADMIN_ID` env var (recommended for live environments
# with existing portfolio_tags rows). Default placeholder triggers admin check
# failure on purpose if real data is present.
MIGRATION_ADMIN_ID = os.environ.get("MIGRATION_ADMIN_ID", "migration-admin")
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
@@ -75,6 +80,13 @@ def upgrade() -> None:
# portfolio_tags already gone — migration was already applied or skipped # portfolio_tags already gone — migration was already applied or skipped
return return
# 빈 portfolio_tags 환경에서는 admin 검증·INSERT가 의미 없으므로 테이블만 drop.
# 데이터가 있으면 REQ-006 본문 정책에 따라 MIGRATION_ADMIN_ID 사전 검증을 강제.
row_count = bind.execute(text("SELECT COUNT(*) FROM portfolio_tags")).scalar() or 0
if row_count == 0:
bind.execute(text("DROP TABLE IF EXISTS portfolio_tags"))
return
run_migration(bind, MIGRATION_ADMIN_ID) run_migration(bind, MIGRATION_ADMIN_ID)
@@ -8,7 +8,7 @@ Create Date: 2026-05-20 00:00:00
from __future__ import annotations from __future__ import annotations
from alembic import op from alembic import op
from sqlalchemy import inspect from sqlalchemy import inspect, text
revision = "20260520_0022" revision = "20260520_0022"
@@ -26,15 +26,12 @@ def upgrade() -> None:
inspector = inspect(bind) inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"): if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
bind.execute( op.execute("DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique")
__import__("sqlalchemy").text(
"DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique"
)
)
autocommit_bind = bind.execution_options(isolation_level="AUTOCOMMIT") # CREATE INDEX CONCURRENTLY는 트랜잭션 밖에서 실행해야 하므로 autocommit_block 사용.
autocommit_bind.execute( with op.get_context().autocommit_block():
__import__("sqlalchemy").text( op.execute(
text(
""" """
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_auth_tokens_service_name_unique CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name) ON auth_tokens (name)
@@ -49,14 +46,10 @@ def downgrade() -> None:
inspector = inspect(bind) inspector = inspect(bind)
if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"): if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_service_name_unique"):
bind.execute( op.execute("DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique")
__import__("sqlalchemy").text(
"DROP INDEX IF EXISTS idx_auth_tokens_service_name_unique"
)
)
bind.execute( op.execute(
__import__("sqlalchemy").text( text(
""" """
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name) ON auth_tokens (name)
+6 -19
View File
@@ -60,10 +60,12 @@ def _ledger_trade_payload() -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REQ-006: GET /v1/assets daemon-scoped + NULL fail-closed # REQ-010 CHG-20260521-02: GET /v1/assets admin catalog — service token rejected
# (daemon-scoped portfolio list moved to GET /v1/portfolios)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None: def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token with no daemon_id gets 403 (Insufficient permission)."""
client = _app_with_db(monkeypatch) client = _app_with_db(monkeypatch)
principal = _principal(daemon_id=None) principal = _principal(daemon_id=None)
mock_repo = MagicMock() mock_repo = MagicMock()
@@ -73,34 +75,19 @@ def test_assets_403_when_token_has_no_daemon_id(monkeypatch) -> None:
resp = client.get("/v1/assets", headers=_svc_header()) resp = client.get("/v1/assets", headers=_svc_header())
assert resp.status_code == 403 assert resp.status_code == 403
assert "daemon_id" in resp.json()["detail"]
def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None: def test_assets_200_with_daemon_scoped_filter(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token with daemon_id is also rejected."""
client = _app_with_db(monkeypatch) client = _app_with_db(monkeypatch)
principal = _principal(daemon_id="d-1") principal = _principal(daemon_id="d-1")
portfolio = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T",
symbol="005930", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0, owner_user_id="user-1", managed_by_daemon_id="d-1",
)
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
resp = client.get("/v1/assets", headers=_svc_header()) resp = client.get("/v1/assets", headers=_svc_header())
assert resp.status_code == 200 assert resp.status_code == 403
assert resp.json()["total"] == 1
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
market=None,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+70 -60
View File
@@ -246,26 +246,11 @@ def test_portfolio_tag_ids_defaults_empty() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REQ-002 AC-3,4 / REQ-003 AC-4: archived 표시 — GET /v1/assets response # REQ-002 AC-3,4 / REQ-003 AC-4: archived 표시 — AssetItem 스키마 레벨 검증
# (GET /v1/assets는 REQ-010 CHG-20260521-02에서 admin catalog 전용으로 재정의됨.
# 포트폴리오 tag_ids archived 표시는 GET /v1/portfolios 응답으로 이관 예정.)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_assets_fake_repo(portfolios: list[Portfolio], tags: list[Tag]) -> MagicMock:
"""Build a MagicMock repo that returns given portfolios and tags."""
repo = MagicMock()
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
repo.get_token_principal.return_value = principal
repo.list_portfolios_with_strategies.return_value = portfolios
tags_by_id = {t.tag_id: t for t in tags}
repo.get_tags_by_ids.side_effect = lambda ids: {
tid: tags_by_id[tid] for tid in ids if tid in tags_by_id
}
repo.get_strategy_set.return_value = None
return repo
def _make_v1_settings(monkeypatch) -> Settings: def _make_v1_settings(monkeypatch) -> Settings:
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _ENC_KEY) monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", _ENC_KEY)
@@ -296,68 +281,93 @@ def test_asset_item_tag_ids_field_exists() -> None:
assert item.tag_ids[0].archived is False assert item.tag_ids[0].archived is False
def test_v1_assets_active_tag_not_archived(monkeypatch) -> None: def test_asset_item_active_tag_not_archived() -> None:
"""REQ-002 AC-3 (정상 케이스): active 태그는 archived=False로 반환.""" """REQ-002 AC-3 (스키마 레벨): active 태그는 archived=False로 표시 — AssetItem 모델 직접 검증."""
settings = _make_v1_settings(monkeypatch) item = AssetItem(
tag = _make_tag(tag_id=_TEST_TAG_ID_1, is_active=True) asset_id="p-1", symbol="A", status="enabled",
portfolio = _make_portfolio(tag_ids=[_TEST_TAG_ID_1]) cash=0.0, quantity=0.0, broker_type="kis",
mock_repo = _make_assets_fake_repo([portfolio], [tag]) tag_ids=[TagRefItem(tag_id=_TEST_TAG_ID_1, archived=False)],
)
assert item.tag_ids is not None
assert item.tag_ids[0].archived is False
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): def test_asset_item_inactive_tag_archived_true() -> None:
"""REQ-002 AC-3: inactive 태그는 archived=True로 표시되고 목록에서 제거되지 않는다 — AssetItem 모델 직접 검증."""
item = AssetItem(
asset_id="p-1", symbol="A", status="enabled",
cash=0.0, quantity=0.0, broker_type="kis",
tag_ids=[
TagRefItem(tag_id=_TEST_TAG_ID_1, archived=False),
TagRefItem(tag_id=_TEST_TAG_ID_2, archived=True),
],
)
assert item.tag_ids is not None
assert len(item.tag_ids) == 2
by_id = {t.tag_id: t for t in item.tag_ids}
assert by_id[_TEST_TAG_ID_1].archived is False
assert by_id[_TEST_TAG_ID_2].archived is True
def test_asset_item_empty_tag_ids_returns_none() -> None:
"""REQ-002 AC-4: tag_ids=None이면 AssetItem.tag_ids도 None."""
item = AssetItem(
asset_id="p-1", symbol="A", status="enabled",
cash=0.0, quantity=0.0, broker_type="kis",
tag_ids=None,
)
assert item.tag_ids is None
def test_v1_assets_active_tag_not_archived(monkeypatch) -> None:
"""GET /v1/assets는 admin 전용 — user token은 403 반환 (archived 표시는 GET /v1/portfolios로 이관)."""
settings = _make_v1_settings(monkeypatch)
principal = TokenPrincipal(
token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings) client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"}) resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200 assert resp.status_code == 403
assets = resp.json()["assets"]
assert len(assets) == 1
tag_ids = assets[0]["tag_ids"]
assert tag_ids is not None
assert len(tag_ids) == 1
assert tag_ids[0]["tag_id"] == _TEST_TAG_ID_1
assert tag_ids[0]["archived"] is False
def test_v1_assets_inactive_tag_archived_true(monkeypatch) -> None: def test_v1_assets_inactive_tag_archived_true(monkeypatch) -> None:
"""REQ-002 AC-3: inactive 태그는 archived=True로 반환되고 배열에서 제거되지 않는다.""" """GET /v1/assets는 admin 전용 — user token은 403 반환."""
settings = _make_v1_settings(monkeypatch) settings = _make_v1_settings(monkeypatch)
active_tag = _make_tag(tag_id=_TEST_TAG_ID_1, name="active", is_active=True) principal = TokenPrincipal(
inactive_tag = _make_tag(tag_id=_TEST_TAG_ID_2, name="inactive", is_active=False) token_id="t1", user_id=_USER_ID, permission="read",
portfolio = _make_portfolio(tag_ids=[_TEST_TAG_ID_1, _TEST_TAG_ID_2]) token_type="user", runtime_id="rt-1",
mock_repo = _make_assets_fake_repo([portfolio], [active_tag, inactive_tag]) )
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings) client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"}) resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200 assert resp.status_code == 403
assets = resp.json()["assets"]
assert len(assets) == 1
tag_ids = assets[0]["tag_ids"]
assert tag_ids is not None
assert len(tag_ids) == 2 # not removed
by_id = {item["tag_id"]: item for item in tag_ids}
assert by_id[_TEST_TAG_ID_1]["archived"] is False
assert by_id[_TEST_TAG_ID_2]["archived"] is True
def test_v1_assets_empty_tag_ids_returns_none(monkeypatch) -> None: def test_v1_assets_empty_tag_ids_returns_none(monkeypatch) -> None:
"""REQ-002 AC-4: tag_ids = [] 인 portfolio의 응답에서 tag_ids = None.""" """GET /v1/assets는 admin 전용 — user token은 403 반환."""
settings = _make_v1_settings(monkeypatch) settings = _make_v1_settings(monkeypatch)
portfolio = _make_portfolio(tag_ids=[]) principal = TokenPrincipal(
mock_repo = _make_assets_fake_repo([portfolio], []) token_id="t1", user_id=_USER_ID, permission="read",
token_type="user", runtime_id="rt-1",
)
mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_v1_client(settings) client = _make_v1_client(settings)
resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"}) resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc-token"})
assert resp.status_code == 200 assert resp.status_code == 403
assets = resp.json()["assets"]
assert len(assets) == 1
assert assets[0]["tag_ids"] is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+44 -113
View File
@@ -290,94 +290,52 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
def test_v1_assets_status_filter(monkeypatch) -> None: def test_v1_assets_status_filter(monkeypatch) -> None:
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
p_enabled = _make_portfolio("p1") # status="enabled"
p_active = Portfolio(
portfolio_id="p2", broker="kis", status="active", name="Active",
symbol="000660", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0,
)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1" token_type="service", runtime_id="rt-1", daemon_id="d-1"
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p_enabled, p_active]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?status=active", headers=_service_token_header()) resp = client.get("/v1/assets?status=active", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p2"
def test_v1_assets_strategy_params(monkeypatch) -> None: def test_v1_assets_strategy_params(monkeypatch) -> None:
"""GET /v1/assets is admin catalog only — service token is rejected (strategy data is on GET /v1/portfolios)."""
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
from app.domain.models import StrategySet
import datetime as _dt
ss = StrategySet(id="ss-1", name="strategy", config='{"buy_threshold": 0.05}', nick="s1",
created_at=_dt.datetime.now(_dt.timezone.utc))
p = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
cash_balance=0.0, asset_quantity=0.0, price=0.0,
profit_buffer=0.0, active_strategy_id="ss-1",
)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1" token_type="service", runtime_id="rt-1", daemon_id="d-1"
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.return_value = ss
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header()) resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
assert resp.json()["assets"][0]["strategy_params"] == {"buy_threshold": 0.05}
assert resp.json()["assets"][0]["strategy_type"] == "strategy"
def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None: def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
"""strategy_set lookup failure → strategy_params == {}.""" """GET /v1/assets is admin catalog only — service token is rejected."""
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
p = Portfolio(
portfolio_id="p1", broker="kis", status="enabled", name="T", symbol="005930",
cash_balance=0.0, asset_quantity=0.0, price=0.0,
profit_buffer=0.0, active_strategy_id="ss-bad",
)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1" token_type="service", runtime_id="rt-1", daemon_id="d-1"
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [p]
mock_repo.get_strategy_set.side_effect = RuntimeError("DB error")
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header()) resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
assert resp.json()["assets"][0]["strategy_params"] == {}
assert resp.json()["assets"][0]["strategy_type"] is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -792,71 +750,61 @@ def test_v1_assets_rejects_env_token() -> None:
def test_v1_assets_returns_asset_list(monkeypatch) -> None: def test_v1_assets_returns_asset_list(monkeypatch) -> None:
"""GET /v1/assets requires admin — service token is rejected."""
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
portfolio = _make_portfolio()
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1" token_type="service", runtime_id="rt-1", daemon_id="d-1"
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app() client = _make_app()
resp = client.get("/v1/assets", headers=_service_token_header()) resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
body = resp.json()
assert body["total"] == 1
assert body["assets"][0]["asset_id"] == "p1"
assert "market" not in body["assets"][0]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# REQ-013: GET /v1/assets token-type branching # REQ-010 CHG-20260521-02: GET /v1/assets admin catalog (path 분리)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_v1_assets_user_token(monkeypatch) -> None: def test_v1_assets_user_token(monkeypatch) -> None:
"""User token is rejected — admin catalog endpoint requires admin permission."""
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio()
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="user", runtime_id="rt-1", token_type="user", runtime_id="rt-1",
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header()) resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
assert resp.json()["total"] == 1
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=True,
market=None,
)
def test_v1_assets_admin_token_default(monkeypatch) -> None: def test_v1_assets_admin_token_default(monkeypatch) -> None:
"""Admin token returns catalog list — REQ-010 AC-9."""
import datetime as _dt
from app.domain.models import Asset
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
portfolio = _make_portfolio() asset = Asset(
asset_id="a1-uuid", symbol="005930", markets=["KRX"],
display_name="Samsung", status="active",
created_at=_dt.datetime.now(_dt.timezone.utc),
updated_at=_dt.datetime.now(_dt.timezone.utc),
)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="admin-1", permission="admin", token_id="t1", user_id="admin-1", permission="admin",
token_type="admin", runtime_id="rt-1", token_type="admin", runtime_id="rt-1",
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio] mock_repo.list_assets.return_value = [asset]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
@@ -864,14 +812,19 @@ def test_v1_assets_admin_token_default(monkeypatch) -> None:
resp = client.get("/v1/assets", headers=_service_token_header()) resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with( body = resp.json()
owner_user_id="admin-1", assert body["total"] == 1
include_disabled=True, item = body["items"][0]
market=None, assert item["symbol"] == "005930"
) assert item["status"] == "active"
assert "portfolio_id" not in item
mock_repo.list_assets.assert_called_once()
def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None: def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
"""user_id param is not supported on catalog endpoint — returns 200 with full catalog."""
import datetime as _dt
from app.domain.models import Asset
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="admin-1", permission="admin", token_id="t1", user_id="admin-1", permission="admin",
@@ -879,7 +832,7 @@ def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [] mock_repo.list_assets.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
@@ -887,14 +840,12 @@ def test_v1_assets_admin_token_user_id_param(monkeypatch) -> None:
resp = client.get("/v1/assets?user_id=other-user", headers=_service_token_header()) resp = client.get("/v1/assets?user_id=other-user", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 200
mock_repo.list_portfolios_with_strategies.assert_called_once_with( assert resp.json()["total"] == 0
owner_user_id="other-user", mock_repo.list_assets.assert_called_once()
include_disabled=True,
market=None,
)
def test_v1_assets_market_filter(monkeypatch) -> None: def test_v1_assets_market_filter(monkeypatch) -> None:
"""Service token is rejected by admin catalog endpoint — REQ-010 AC-12."""
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
@@ -902,49 +853,29 @@ def test_v1_assets_market_filter(monkeypatch) -> None:
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = []
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets?market=KRX", headers=_service_token_header()) resp = client.get("/v1/assets?market=KRX", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
owner_user_id="user-1",
include_disabled=False,
managed_by_daemon_id="d-1",
market="KRX",
)
def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None: def test_v1_assets_asset_id_is_uuid(monkeypatch) -> None:
import uuid as _uuid """Service token is rejected by admin catalog endpoint."""
settings = _make_app_db(monkeypatch) settings = _make_app_db(monkeypatch)
uuid_portfolio_id = str(_uuid.uuid4())
portfolio = Portfolio(
portfolio_id=uuid_portfolio_id,
broker="kis", status="enabled", name="T",
symbol="005930", cash_balance=0.0, asset_quantity=0.0,
price=0.0, profit_buffer=0.0, owner_user_id="user-1",
)
principal = TokenPrincipal( principal = TokenPrincipal(
token_id="t1", user_id="user-1", permission="read", token_id="t1", user_id="user-1", permission="read",
token_type="service", runtime_id="rt-1", daemon_id="d-1", token_type="service", runtime_id="rt-1", daemon_id="d-1",
) )
mock_repo = MagicMock() mock_repo = MagicMock()
mock_repo.get_token_principal.return_value = principal mock_repo.get_token_principal.return_value = principal
mock_repo.list_portfolios_with_strategies.return_value = [portfolio]
mock_repo.get_strategy_set.return_value = None
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo):
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY)) client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
resp = client.get("/v1/assets", headers=_service_token_header()) resp = client.get("/v1/assets", headers=_service_token_header())
assert resp.status_code == 200 assert resp.status_code == 403
returned_id = resp.json()["assets"][0]["asset_id"]
_uuid.UUID(returned_id) # raises ValueError if not a valid UUID
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------