feat(portfolios): persist and validate daemon assignment on create/update

POST /v1/portfolios now forwards a non-null managed_by_daemon_id into
create persistence, and both create and PATCH apply the same daemon
validation contract (UUID format -> 422, missing/foreign -> 404,
inactive -> 409, admin target-owner basis). Daemon validation locks the
daemon row FOR SHARE inside the portfolio write transaction so
validation and write form one atomic decision boundary.

Adds domain errors, repository protocol/signature updates, API/PostgreSQL
tests (POST->GET persistence, no partial state on failure, assignment vs
deactivation race), and contract/data-model documentation.
This commit is contained in:
2026-08-15 12:56:53 +09:00
parent bb3bb5d3f0
commit e2102b6f2b
10 changed files with 861 additions and 7 deletions
@@ -0,0 +1,40 @@
## 구현 진행 상황
- **대상:** 포트폴리오 추가 시 연결 데몬을 설정해도 지정되지 않음 (GitHub Issue #27)
- **브랜치:** `line/line-0004-implementation`
- **기준:** `main` (`bb3bb5d3f0ce57358f7385084fba1343f7ff06e1`)
- **상태:** 로컬 구현·검증 완료 (미커밋)
- **최종 업데이트:** 2026-08-15 15:20 KST
### 구현 계획 및 작업 목록
- [x] 공유 daemon validation 도메인 오류 정의
- `DaemonNotFoundError`(HTTP 404)·`DaemonInactiveError`(HTTP 409)를 `app/domain/errors.py`에 추가해 전 계층에서 같은 오류 계약을 사용한다.
- [x] `create_portfolio` 저장소 계약·구현에 `managed_by_daemon_id` 전달
- 프로토콜 시그니처(`portfolio_repository.py`)와 PostgreSQL 구현(`postgres.py`)에 `managed_by_daemon_id` 파라미터를 추가하고 INSERT 컬럼까지 반영해 생성 persistence 경계에 누락을 제거한다.
- [x] 생성·수정 트랜잭션 안의 원자적 daemon 검증 경계
- 기존 `_lock_active_strategy` 패턴처럼 daemon row를 `FOR SHARE` lock하고 존재·소유권·active를 같은 트랜잭션에서 검증해, portfolio write와 하나의 원자적 판정으로 묶어 TOCTOU를 차단한다.
- [x] `POST /v1/portfolios` 생성 handler에서 비-null daemon 전달·오류 매핑
- UUID 형식이 아니면 422, 미존재·foreign(타인 소유)이면 404, inactive면 409로 매핑하고 admin의 `user_id` override 생성은 최종 target owner 기준으로 검증한다.
- [x] `PATCH /v1/portfolios/{portfolio_id}` 비-null 지정·재지정에도 동일 검증 적용
- 기존 비-null PATCH 지정·재지정 경로에 생성과 동일한 daemon 검증·오류 계약을 적용하되, 포트폴리오 소유권·disabled 보호·service/client 거부 등 기존 mutation 계약은 유지한다.
- [x] 단위/API 테스트 추가
- 성공 생성 응답의 `managed_by_daemon_id` 일치, 미선택 생성 시 null 유지, malformed 422, 미존재·foreign 404, inactive 409, admin target-owner 허용/거부, PATCH 지정·재지정을 검증한다.
- [x] 실제 PostgreSQL 통합 테스트 추가
- `POST → GET` persistence 보존, 검증 실패 시 portfolio 무변경, assignment와 daemon 비활성화가 경합해도 inactive daemon에 새 assignment가 확정되지 않는 동시성 race를 검증한다.
- [x] 문서 갱신
- `docs/interface-contract.md`, `docs/data-model.md`에 daemon 배정 검증 계약·오류 코드·원자적 판정 경계를 반영한다.
- [x] 회귀·정적 검사 및 진행 상황 업데이트
- 기존 portfolio 생성·수정의 인증·소유권·strategy·asset·disabled 계약 회귀, 전체 관련 테스트, compile·`git diff --check`·Ruff 검사를 실행하고 진행 상황 파일을 갱신한다.
### 진행 기록
- 2026-08-15 13:00 KST — Issue #27의 승인된 Discovery(`dcy-0004`) 범위를 기준으로 구현 계획을 확정했다. 근본 원인(`v1_portfolios.py` 생성 handler가 `repo.create_portfolio(...)`에 `managed_by_daemon_id`를 전달하지 않음)과 승인된 결정(존재·active·동일 소유자, 404/409/422 오류 계약, 원자적 판정 경계)을 반영해 작업 목록을 구성했다.
- 2026-08-15 15:10 KST — `app/domain/errors.py`에 `DaemonNotFoundError`·`DaemonInactiveError`를 추가하고, `create_portfolio` 프로토콜·구현과 INSERT 컬럼에 `managed_by_daemon_id`를 반영했다. `postgres.py`에 `_lock_active_daemon`(daemon row `FOR SHARE` + 존재·동일 소유자·active 검증)을 추가해 생성·수정 트랜잭션 안에서 원자적 판정 경계를 구성했다.
- 2026-08-15 15:10 KST — `POST /v1/portfolios`와 `PATCH /v1/portfolios/{id}` handler에 UUID 형식(422)·미존재/foreign(404)·inactive(409) 검증과 오류 매핑을 적용했다. admin target-user 생성은 최종 target owner 기준으로 daemon 소유권을 검증한다.
- 2026-08-15 15:12 KST — 기존 `test_v1_portfolios_crud.py`의 `_FakeRepo.create_portfolio` 시그니처를 새 파라미터에 맞춰 보완했다.
- 2026-08-15 15:15 KST — 전용 API 테스트 `tests/test_v1_portfolios_daemon_assignment.py`(13개)를 추가했다. POST 성공·null 유지·malformed 422·미존재/foreign 404·inactive 409·admin target-owner 허용/거부, PATCH 지정·재지정·malformed·foreign·inactive를 검증해 통과했다.
- 2026-08-15 15:18 KST — 실제 PostgreSQL 통합 테스트 `tests/test_v1_portfolios_daemon_postgres_integration.py`(4개)를 추가했다. HTTP `POST → GET` persistence, 검증 실패 시 portfolio 무변경, assignment vs deactivation race의 허용 outcome, PATCH 재배정 persistence를 실제 DB에서 검증했다.
- 2026-08-15 15:20 KST — 회귀 검증: 전체 단위 스위트 `625 passed, 11 skipped`, 실제 PostgreSQL 통합(daemon 4 + strategy 4) `8 passed`. Python compile, `git diff --check`, 변경 핵심 파일 Ruff 검사 통과(Ruff 이슈 44건은 기준 커밋과 동일한 기존 항목이며 신규 도입 없음).
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다. 현재 변경은 기준 HEAD 위 미커밋 상태로 보존되어 있으며 커밋·push·PR·main 반영·배포는 수행하지 않았다.
+25
View File
@@ -15,6 +15,7 @@ from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import Portfolio, PortfolioComment
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.repositories.factory import get_portfolio_repository_from_settings
@@ -67,6 +68,18 @@ def _require_write_token(auth: AuthContext) -> None:
)
def _require_valid_daemon_uuid(daemon_id: str | None) -> None:
if daemon_id is None:
return
try:
uuid.UUID(daemon_id)
except (ValueError, AttributeError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"managed_by_daemon_id는 UUID 형식이어야 합니다: {daemon_id}",
)
def _portfolio_to_item(
portfolio: Portfolio,
*,
@@ -167,6 +180,8 @@ def create_portfolio_v1(
if strategy_set.status != "active":
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
_require_valid_daemon_uuid(body.managed_by_daemon_id)
portfolio = repo.create_portfolio(
str(uuid.uuid4()),
owner_user_id=owner_user_id,
@@ -180,6 +195,7 @@ def create_portfolio_v1(
profit_buffer=body.profit_buffer,
active_strategy_id=strategy_set.id,
avabile_strategies=[strategy_set.id],
managed_by_daemon_id=body.managed_by_daemon_id,
)
except HTTPException:
raise
@@ -187,6 +203,10 @@ def create_portfolio_v1(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except StrategyInactiveError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except DaemonNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except DaemonInactiveError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
finally:
@@ -230,6 +250,7 @@ def update_portfolio_v1(
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
fields["active_strategy_id"] = strategy_set.id
fields["avabile_strategies"] = [strategy_set.id]
_require_valid_daemon_uuid(fields.get("managed_by_daemon_id"))
repo.update_portfolio(portfolio_id, **fields)
portfolio = repo.get(portfolio_id)
@@ -239,6 +260,10 @@ def update_portfolio_v1(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except StrategyInactiveError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except DaemonNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except DaemonInactiveError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
finally:
+9 -1
View File
@@ -1,4 +1,4 @@
"""Shared strategy-catalog domain errors used across API, service, and persistence layers."""
"""Shared domain errors used across API, service, and persistence layers."""
class StrategyNotFoundError(ValueError):
@@ -7,3 +7,11 @@ class StrategyNotFoundError(ValueError):
class StrategyInactiveError(ValueError):
"""Raised when a new portfolio selection targets an inactive strategy."""
class DaemonNotFoundError(ValueError):
"""Raised when a referenced daemon ID does not exist or is not owned by the portfolio owner."""
class DaemonInactiveError(ValueError):
"""Raised when a new portfolio assignment targets an inactive daemon."""
+1
View File
@@ -162,6 +162,7 @@ class PortfolioRepository(Protocol):
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
managed_by_daemon_id: str | None = None,
) -> Portfolio: ...
def get_asset_by_id(self, asset_id: str) -> Asset | None: ...
+37 -3
View File
@@ -13,7 +13,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.errors import StrategyInactiveError, StrategyNotFoundError
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
logger = logging.getLogger(__name__)
@@ -159,6 +159,26 @@ class PostgresPortfolioRepository:
if row[0] != "active":
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
@staticmethod
def _lock_active_daemon(cur: psycopg.Cursor, daemon_id: str, owner_user_id: str) -> None:
"""Serialize new portfolio assignments with daemon deactivation/owner transfer.
Locks the daemon row FOR SHARE inside the portfolio write transaction and
enforces existence, same-owner, and active status so validation and write
form a single atomic decision boundary.
"""
cur.execute(
"SELECT owner_user_id, is_active FROM daemons WHERE id = %s FOR SHARE",
(daemon_id,),
)
row = cur.fetchone()
if row is None:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if row[0] != owner_user_id:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if not row[1]:
raise DaemonInactiveError(f"daemon is inactive: {daemon_id}")
def ensure_schema(self) -> None:
conn = self._connect()
with conn.cursor() as cur:
@@ -1128,12 +1148,15 @@ class PostgresPortfolioRepository:
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
managed_by_daemon_id: str | None = None,
) -> Portfolio:
self.ensure_schema()
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
if active_strategy_id is not None:
self._lock_active_strategy(cur, active_strategy_id)
if managed_by_daemon_id is not None:
self._lock_active_daemon(cur, managed_by_daemon_id, owner_user_id)
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
@@ -1142,8 +1165,8 @@ class PostgresPortfolioRepository:
INSERT INTO portfolios (
portfolio_id, broker, status, name, asset_id,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
active_strategy_id, avabile_strategies, managed_by_daemon_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio_id,
@@ -1158,6 +1181,7 @@ class PostgresPortfolioRepository:
float(profit_buffer),
active_strategy_id,
avabile_strategies,
managed_by_daemon_id,
),
)
portfolio = self.get(portfolio_id)
@@ -1195,6 +1219,16 @@ class PostgresPortfolioRepository:
strategy_id = fields.get("active_strategy_id")
if isinstance(strategy_id, str):
self._lock_active_strategy(cur, strategy_id)
daemon_id = fields.get("managed_by_daemon_id")
if isinstance(daemon_id, str):
cur.execute(
"SELECT owner_user_id FROM portfolios WHERE portfolio_id = %s",
(portfolio_id,),
)
owner_row = cur.fetchone()
if owner_row is None:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
self._lock_active_daemon(cur, daemon_id, owner_row[0])
cur.execute(
f"""
UPDATE portfolios
+3 -1
View File
@@ -166,6 +166,7 @@
- tag_ids stores UUID references to active tags; duplicates are prevented by conditional array_append; comments are retrieved in created_at descending order.
- `status="disabled"` (soft delete): `managed_by_daemon_id` is set to NULL; portfolio is excluded from `GET /v1/portfolios` by default. Dependent data (`portfolio_comments`, `tag_ids`) is preserved — never deleted on soft delete. READ operations on sub-resources (GET tags, GET comments) remain allowed; write mutations raise HTTP 409.
- Status restoration: PATCH with `{"status": "enabled"}` re-activates a disabled portfolio; no other fields may be set in the same request.
- Daemon assignment: a non-null `managed_by_daemon_id` on create or PATCH must reference an existing, active daemon owned by the target portfolio owner (admin `user_id` override uses the final target owner). The daemon `FOR SHARE` row lock and the portfolio INSERT/UPDATE share one transaction, so validation and write form a single atomic decision boundary; a concurrently deactivated daemon is rejected (HTTP 409) rather than committed. Deactivation after an assignment commits preserves the portfolio reference and follows the existing token-revocation contract.
## Entity: `User`
@@ -255,7 +256,8 @@
### State Rules
- A daemon must exist and be active before a service token can be bound to it at issuance.
- Deactivating a daemon (is_active=false) immediately revokes all bound service tokens via `revoke_tokens_by_daemon_id` (REQ-022/BAT-007) and blocks new token issuance.
- A daemon must exist, be active, and be owned by the target portfolio owner before a portfolio can be assigned to it (create or PATCH). Assignment validation locks the daemon row `FOR SHARE` inside the portfolio write transaction (Issue #27 / dcy-0004).
- Deactivating a daemon (is_active=false) immediately revokes all bound service tokens via `revoke_tokens_by_daemon_id` (REQ-022/BAT-007) and blocks new token issuance. It does not auto-remove existing portfolio references; deactivation racing a new assignment is rejected with HTTP 409 when the deactivation commits first.
- daemon_slug is immutable after creation; only display_name and is_active can be updated.
## Entity: `BrokerCredential`
+4 -2
View File
@@ -304,7 +304,8 @@
- Body: `{"asset_id": "<uuid>", "broker": "<str>", "name": "<str|null>", "cash_balance": <float>, "asset_quantity": <float>, "asset_price": <float>, "profit_buffer": <float>, "strategy_id": "<required strategy ID>", "managed_by_daemon_id": "<str|null>", "user_id": "<str|null> (admin only)"}`
- Response: HTTP 201 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
- Note: `symbol` is populated from the server-side joined asset row (`portfolios.asset_id -> assets.asset_id -> assets.symbol`), not from client-side catalog reconstruction.
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown; HTTP 409 if the strategy is inactive; HTTP 422 for a missing/invalid `strategy_id` or an unknown/inactive asset
- Note: a non-null `managed_by_daemon_id` must be a daemon server-generated UUID that exists, is active, and is owned by the target portfolio owner (admin `user_id` override uses the final target owner, not the calling admin). Daemon validation and the portfolio write share one atomic transaction (`FOR SHARE` daemon lock); a selected daemon that is deactivated concurrently is rejected rather than committed.
- Error: HTTP 403 if service/client token; HTTP 404 if `strategy_id` is unknown or the daemon does not exist/is not owned by the portfolio owner; HTTP 409 if the strategy is inactive or the daemon is inactive; HTTP 422 for a missing/invalid `strategy_id`, an unknown/inactive asset, or a non-UUID `managed_by_daemon_id`
### Endpoint: `PATCH /v1/portfolios/{portfolio_id}`
@@ -312,7 +313,8 @@
- Body: `{"broker": "<str|null>", "status": "<str|null>", "name": "<str|null>", "strategy_id": "<str|null>", "asset_price": <float|null>, "managed_by_daemon_id": "<str|null>"}`
- Response: HTTP 200 `PortfolioItem {"portfolio_id": "<uuid>", ..., "active_strategy_id": "<str>", "strategy_name": "<str>", "strategy_params": {<dict>}, "strategy_revision": <int>, ...}`
- Note: response `symbol` is emitted from the joined asset row referenced by `asset_id`.
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio or strategy is not found; HTTP 409 if the portfolio is disabled for the mutation or the selected strategy is inactive; HTTP 422 for an invalid request body
- Note: a non-null `managed_by_daemon_id` assignment/reassignment follows the same daemon validation contract as create: it must be a UUID that exists, is active, and is owned by the portfolio owner. The daemon `FOR SHARE` lock and the portfolio UPDATE share one atomic transaction, so a concurrently deactivated daemon is rejected with HTTP 409.
- Error: HTTP 403 if service/client or ownership mismatch; HTTP 404 if portfolio, strategy, or daemon is not found (foreign daemon is hidden as 404); HTTP 409 if the portfolio is disabled for the mutation, the selected strategy is inactive, or the daemon is inactive; HTTP 422 for an invalid request body or a non-UUID `managed_by_daemon_id`
## Interface: `v1 Strategy Catalog API`
+2
View File
@@ -97,6 +97,7 @@ class _FakeRepo:
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
managed_by_daemon_id: str | None = None,
) -> Portfolio:
p = Portfolio(
portfolio_id=portfolio_id,
@@ -111,6 +112,7 @@ class _FakeRepo:
price=asset_price,
profit_buffer=profit_buffer,
tag_ids=[],
managed_by_daemon_id=managed_by_daemon_id,
)
self._portfolios[portfolio_id] = p
return p
@@ -0,0 +1,415 @@
"""Tests for daemon assignment validation on POST/PATCH /v1/portfolios (Issue #27 / dcy-0004).
Covers the approved discovery contract: non-null managed_by_daemon_id is validated
(exists, active, same-owner as the portfolio owner) with HTTP 422/404/409 mapping,
admin target-owner semantics, and persistence of the selected daemon.
"""
from __future__ import annotations
from contextlib import ExitStack
from typing import Any
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import Asset, Daemon, Portfolio, StrategySet
from app.main import create_app
_ASSET_UUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_PORTFOLIO_UUID = "cccccccc-cccc-cccc-cccc-cccccccccccc"
_USER_ID = "user-001"
_OTHER_USER_ID = "user-002"
_DAEMON_UUID = "dddddddd-dddd-dddd-dddd-dddddddddddd"
_DAEMON2_UUID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"
def _make_settings(**kwargs) -> Settings:
return Settings(allowed_origins_raw="http://localhost:3000", **kwargs)
def _make_asset(asset_id: str = _ASSET_UUID, status: str = "active") -> Asset:
return Asset(asset_id=asset_id, symbol="005930", markets=["KRX"], status=status)
def _make_daemon(
daemon_id: str = _DAEMON_UUID,
owner: str = _USER_ID,
is_active: bool = True,
) -> Daemon:
return Daemon(id=daemon_id, owner_user_id=owner, daemon_slug="d-slug", is_active=is_active)
def _make_portfolio(owner: str = _USER_ID) -> Portfolio:
return Portfolio(
portfolio_id=_PORTFOLIO_UUID,
broker="kiwoom",
status="enabled",
name="test-portfolio",
symbol="005930",
asset_id=_ASSET_UUID,
owner_user_id=owner,
cash_balance=1000.0,
asset_quantity=10.0,
price=50000.0,
profit_buffer=0.0,
tag_ids=[],
managed_by_daemon_id=None,
)
def _make_strategy_set() -> StrategySet:
return StrategySet(id="strat-001", name="target_value", config="{}", nick="tv")
class _FakeRepo:
"""Fake repository that models the repository-side atomic daemon validation."""
def __init__(
self,
asset: Asset | None = None,
portfolios: dict[str, Portfolio] | None = None,
daemons: dict[str, Daemon] | None = None,
) -> None:
self._asset = asset or _make_asset()
self._portfolios: dict[str, Portfolio] = portfolios or {}
self._daemons: dict[str, Daemon] = daemons or {}
self._strategy_set = _make_strategy_set()
def close(self) -> None:
pass
def get_asset_by_id(self, asset_id: str) -> Asset | None:
if self._asset and self._asset.asset_id == asset_id:
return self._asset
return None
def get_strategy_set(self, id: str) -> StrategySet | None:
return self._strategy_set if id == self._strategy_set.id else None
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
return self._daemons.get(daemon_id)
def get(self, portfolio_id: str) -> Portfolio:
p = self._portfolios.get(portfolio_id)
if p is None:
raise ValueError(f"포트폴리오를 찾을 수 없습니다: {portfolio_id}")
return p
def _validate_daemon(self, daemon_id: str, owner_user_id: str) -> None:
daemon = self._daemons.get(daemon_id)
if daemon is None or daemon.owner_user_id != owner_user_id:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if not daemon.is_active:
raise DaemonInactiveError(f"daemon is inactive: {daemon_id}")
def create_portfolio(
self,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
asset_id: str,
cash_balance: float,
asset_quantity: float,
asset_price: float,
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
managed_by_daemon_id: str | None = None,
) -> Portfolio:
if managed_by_daemon_id is not None:
self._validate_daemon(managed_by_daemon_id, owner_user_id)
p = Portfolio(
portfolio_id=portfolio_id,
broker=broker,
status=status,
name=name,
symbol="005930",
asset_id=asset_id,
owner_user_id=owner_user_id,
cash_balance=cash_balance,
asset_quantity=asset_quantity,
price=asset_price,
profit_buffer=profit_buffer,
tag_ids=[],
managed_by_daemon_id=managed_by_daemon_id,
)
self._portfolios[portfolio_id] = p
return p
def update_portfolio(self, portfolio_id: str, **fields: Any) -> Portfolio:
p = self.get(portfolio_id)
daemon_id = fields.get("managed_by_daemon_id")
if isinstance(daemon_id, str):
self._validate_daemon(daemon_id, p.owner_user_id)
for k, v in fields.items():
if v is not None and hasattr(p, k):
setattr(p, k, v)
return p
def _make_client() -> TestClient:
get_settings.cache_clear()
settings = _make_settings(
admin_tokens_raw="admin-tok",
trade_write_tokens_raw="user-tok",
)
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _with_repo(repo: _FakeRepo) -> ExitStack:
stack = ExitStack()
stack.enter_context(
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)
)
return stack
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# POST /v1/portfolios — daemon assignment
# ---------------------------------------------------------------------------
def test_post_with_owner_active_daemon_returns_201_with_daemon_id() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner="system")})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"managed_by_daemon_id": _DAEMON_UUID,
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 201
assert resp.json()["managed_by_daemon_id"] == _DAEMON_UUID
def test_post_without_daemon_keeps_null() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={"asset_id": _ASSET_UUID, "broker": "kiwoom", "strategy_id": "strat-001"},
headers=_auth("admin-tok"),
)
assert resp.status_code == 201
assert resp.json()["managed_by_daemon_id"] is None
def test_post_malformed_daemon_id_returns_422() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"managed_by_daemon_id": "not-a-uuid",
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 422
assert len(repo._portfolios) == 0
def test_post_missing_daemon_returns_404() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"managed_by_daemon_id": "99999999-9999-9999-9999-999999999999",
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 404
assert len(repo._portfolios) == 0
def test_post_foreign_daemon_returns_404() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_OTHER_USER_ID)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"managed_by_daemon_id": _DAEMON_UUID,
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 404
assert len(repo._portfolios) == 0
def test_post_inactive_daemon_returns_409() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner="system", is_active=False)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"managed_by_daemon_id": _DAEMON_UUID,
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 409
assert len(repo._portfolios) == 0
def test_post_admin_target_user_accepts_target_owner_daemon() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_OTHER_USER_ID)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"user_id": _OTHER_USER_ID,
"managed_by_daemon_id": _DAEMON_UUID,
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 201
assert resp.json()["owner_user_id"] == _OTHER_USER_ID
assert resp.json()["managed_by_daemon_id"] == _DAEMON_UUID
def test_post_admin_target_user_rejects_admin_owned_daemon() -> None:
repo = _FakeRepo(daemons={_DAEMON_UUID: _make_daemon(owner=_USER_ID)})
client = _make_client()
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": _ASSET_UUID,
"broker": "kiwoom",
"strategy_id": "strat-001",
"user_id": _OTHER_USER_ID,
"managed_by_daemon_id": _DAEMON_UUID,
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 404
assert len(repo._portfolios) == 0
# ---------------------------------------------------------------------------
# PATCH /v1/portfolios/{portfolio_id} — daemon assignment / reassignment
# ---------------------------------------------------------------------------
def _repo_with_portfolio_and_daemons(**daemon_kwargs: Any) -> _FakeRepo:
repo = _FakeRepo(
daemons={_DAEMON_UUID: _make_daemon(**daemon_kwargs)},
)
repo._portfolios[_PORTFOLIO_UUID] = _make_portfolio()
return repo
def test_patch_assign_daemon_success() -> None:
repo = _repo_with_portfolio_and_daemons(owner=_USER_ID)
client = _make_client()
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"managed_by_daemon_id": _DAEMON_UUID},
headers=_auth("admin-tok"),
)
assert resp.status_code == 200
assert resp.json()["managed_by_daemon_id"] == _DAEMON_UUID
def test_patch_reassign_daemon_success() -> None:
repo = _FakeRepo(
daemons={
_DAEMON_UUID: _make_daemon(owner=_USER_ID),
_DAEMON2_UUID: _make_daemon(daemon_id=_DAEMON2_UUID, owner=_USER_ID),
}
)
repo._portfolios[_PORTFOLIO_UUID] = _make_portfolio()
client = _make_client()
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"managed_by_daemon_id": _DAEMON2_UUID},
headers=_auth("admin-tok"),
)
assert resp.status_code == 200
assert resp.json()["managed_by_daemon_id"] == _DAEMON2_UUID
def test_patch_malformed_daemon_id_returns_422() -> None:
repo = _repo_with_portfolio_and_daemons(owner=_USER_ID)
client = _make_client()
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"managed_by_daemon_id": "not-a-uuid"},
headers=_auth("admin-tok"),
)
assert resp.status_code == 422
def test_patch_foreign_daemon_returns_404() -> None:
repo = _repo_with_portfolio_and_daemons(owner=_OTHER_USER_ID)
client = _make_client()
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"managed_by_daemon_id": _DAEMON_UUID},
headers=_auth("admin-tok"),
)
assert resp.status_code == 404
def test_patch_inactive_daemon_returns_409() -> None:
repo = _repo_with_portfolio_and_daemons(owner=_USER_ID, is_active=False)
client = _make_client()
with _with_repo(repo):
resp = client.patch(
f"/v1/portfolios/{_PORTFOLIO_UUID}",
json={"managed_by_daemon_id": _DAEMON_UUID},
headers=_auth("admin-tok"),
)
assert resp.status_code == 409
@@ -0,0 +1,325 @@
"""Real-PostgreSQL persistence and concurrency verification for daemon assignment (Issue #27 / dcy-0004).
Run with PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION=1 against a disposable/local
PostgreSQL endpoint. Normal unit-test environments skip this module.
"""
from __future__ import annotations
import os
import threading
import uuid
from contextlib import ExitStack
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.main import create_app
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
pytestmark = pytest.mark.skipif(
os.getenv("PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION") != "1",
reason="set PORTFOLIOHUB_RUN_POSTGRES_INTEGRATION=1 to run real PostgreSQL tests",
)
def _db_values(dbname: str | None = None) -> dict[str, object]:
return {
"host": os.getenv("TIMESCALEDB_HOST", "127.0.0.1"),
"port": int(os.getenv("TIMESCALEDB_PORT", "5432")),
"dbname": dbname or os.getenv("TIMESCALEDB_NAME", "portfolio_hub_migration_test"),
"user": os.getenv("TIMESCALEDB_USER", "portfolio_hub_user"),
"password": os.getenv("TIMESCALEDB_PASSWORD", "change-me"),
"sslmode": os.getenv("TIMESCALEDB_SSLMODE", "prefer"),
}
def _repo(dbname: str | None = None) -> PostgresPortfolioRepository:
values = _db_values(dbname)
return PostgresPortfolioRepository(
PortfolioDBConfig(
host=str(values["host"]),
port=int(values["port"]),
dbname=str(values["dbname"]),
user=str(values["user"]),
password=str(values["password"]),
sslmode=str(values["sslmode"]),
)
)
def _prepare_daemon_fixture(repo: PostgresPortfolioRepository) -> tuple[str, str, str, str]:
repo.ensure_schema()
strategy = repo.create_strategy_set(f"daemon-{uuid.uuid4().hex}", "{}")
asset_id = str(uuid.uuid4())
owner_user_id = f"daemon-owner-{uuid.uuid4().hex}"
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(owner_user_id, owner_user_id),
)
cur.execute(
"""
INSERT INTO assets (asset_id, symbol, markets, status)
VALUES (%s, %s, ARRAY['KRX'], 'active')
""",
(asset_id, f"DA{uuid.uuid4().hex[:10]}"),
)
daemon = repo.create_daemon(owner_user_id, daemon_slug=f"d-{uuid.uuid4().hex[:10]}")
return strategy.id, asset_id, owner_user_id, daemon.id
def _cleanup(
repo: PostgresPortfolioRepository,
portfolio_ids: list[str],
strategy_id: str,
asset_id: str,
owner_user_id: str,
daemon_id: str,
) -> None:
with repo._connect().cursor() as cur:
for portfolio_id in portfolio_ids:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM auth_tokens WHERE daemon_id = %s", (daemon_id,))
cur.execute("DELETE FROM daemons WHERE id = %s", (daemon_id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
repo.close()
def _make_client() -> TestClient:
get_settings.cache_clear()
settings = Settings(
allowed_origins_raw="http://localhost:3000",
admin_tokens_raw="admin-tok",
trade_write_tokens_raw="user-tok",
)
app = create_app(settings_factory=lambda: settings)
app.dependency_overrides[get_settings] = lambda: settings
return TestClient(app)
def _with_repo(repo: PostgresPortfolioRepository) -> ExitStack:
stack = ExitStack()
stack.enter_context(
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)
)
return stack
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def test_http_post_returns_daemon_and_list_get_preserves_it() -> None:
repo = _repo()
strategy_id, asset_id, owner_user_id, daemon_id = _prepare_daemon_fixture(repo)
client = _make_client()
try:
with _with_repo(repo):
resp = client.post(
"/v1/portfolios",
json={
"asset_id": asset_id,
"broker": "kis",
"strategy_id": strategy_id,
"name": "daemon-assignment-check",
"user_id": owner_user_id,
"managed_by_daemon_id": daemon_id,
},
headers=_auth("admin-tok"),
)
assert resp.status_code == 201
body = resp.json()
assert body["managed_by_daemon_id"] == daemon_id
portfolio_id = body["portfolio_id"]
stored = repo.get(portfolio_id)
assert stored.managed_by_daemon_id == daemon_id
with _with_repo(repo):
list_resp = client.get("/v1/portfolios", headers=_auth("admin-tok"))
assert list_resp.status_code == 200
matched = [p for p in list_resp.json()["portfolios"] if p["portfolio_id"] == portfolio_id]
assert matched and matched[0]["managed_by_daemon_id"] == daemon_id
_cleanup(repo, [portfolio_id], strategy_id, asset_id, owner_user_id, daemon_id)
finally:
pass
def test_validation_failure_leaves_no_partial_portfolio() -> None:
repo = _repo()
strategy_id, asset_id, owner_user_id, daemon_id = _prepare_daemon_fixture(repo)
portfolio_id = str(uuid.uuid4())
inactive = _repo()
try:
inactive.update_daemon(daemon_id, is_active=False)
with pytest.raises(DaemonInactiveError):
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="no-partial-inactive",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=daemon_id,
)
with repo._connect().cursor() as cur:
cur.execute("SELECT COUNT(*) FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
assert cur.fetchone()[0] == 0
foreign_user_id = f"foreign-owner-{uuid.uuid4().hex}"
with repo._connect().cursor() as cur:
cur.execute(
"INSERT INTO users (id, username, permission, is_active) VALUES (%s, %s, 'trade-write', TRUE)",
(foreign_user_id, foreign_user_id),
)
foreign_daemon = repo.create_daemon(foreign_user_id, daemon_slug=f"foreign-{uuid.uuid4().hex[:10]}")
with pytest.raises(DaemonNotFoundError):
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="no-partial-foreign",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=foreign_daemon.id,
)
with repo._connect().cursor() as cur:
cur.execute("SELECT COUNT(*) FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
assert cur.fetchone()[0] == 0
with repo._connect().cursor() as cur:
cur.execute("DELETE FROM auth_tokens WHERE daemon_id = %s", (foreign_daemon.id,))
cur.execute("DELETE FROM daemons WHERE id = %s", (foreign_daemon.id,))
cur.execute("DELETE FROM users WHERE id = %s", (foreign_user_id,))
_cleanup(repo, [], strategy_id, asset_id, owner_user_id, daemon_id)
finally:
inactive.close()
def test_assignment_and_deactivation_race_never_commits_inactive_assignment() -> None:
repo = _repo()
strategy_id, asset_id, owner_user_id, daemon_id = _prepare_daemon_fixture(repo)
portfolio_id = str(uuid.uuid4())
barrier = threading.Barrier(3)
outcomes: list[str] = []
def assign_daemon() -> None:
worker = _repo()
try:
barrier.wait()
worker.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="daemon-race-check",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=daemon_id,
)
outcomes.append("assigned")
except DaemonInactiveError:
outcomes.append("assignment-rejected")
finally:
worker.close()
def deactivate_daemon() -> None:
worker = _repo()
try:
barrier.wait()
worker.update_daemon(daemon_id, is_active=False)
outcomes.append("deactivated")
finally:
worker.close()
threads = [threading.Thread(target=assign_daemon), threading.Thread(target=deactivate_daemon)]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=10)
assert not thread.is_alive()
assert sorted(outcomes) in (
["assigned", "deactivated"],
["assignment-rejected", "deactivated"],
)
with repo._connect().cursor() as cur:
cur.execute("SELECT COUNT(*) FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
portfolio_exists = cur.fetchone()[0] == 1
if portfolio_exists:
assert repo.get(portfolio_id).managed_by_daemon_id == daemon_id
with repo._connect().cursor() as cur:
if portfolio_exists:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM auth_tokens WHERE daemon_id = %s", (daemon_id,))
cur.execute("DELETE FROM daemons WHERE id = %s", (daemon_id,))
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (strategy_id,))
cur.execute("DELETE FROM assets WHERE asset_id = %s", (asset_id,))
cur.execute("DELETE FROM users WHERE id = %s", (owner_user_id,))
repo.close()
def test_patch_reassignment_persists_new_daemon() -> None:
repo = _repo()
strategy_id, asset_id, owner_user_id, daemon_a = _prepare_daemon_fixture(repo)
daemon_b = repo.create_daemon(owner_user_id, daemon_slug=f"d-{uuid.uuid4().hex[:10]}")
portfolio_id = str(uuid.uuid4())
try:
repo.create_portfolio(
portfolio_id,
owner_user_id=owner_user_id,
broker="kis",
status="enabled",
name="reassign-check",
asset_id=asset_id,
cash_balance=0,
asset_quantity=0,
asset_price=0,
profit_buffer=0,
active_strategy_id=strategy_id,
avabile_strategies=[strategy_id],
managed_by_daemon_id=daemon_a,
)
assert repo.get(portfolio_id).managed_by_daemon_id == daemon_a
repo.update_portfolio(portfolio_id, managed_by_daemon_id=daemon_b.id)
assert repo.get(portfolio_id).managed_by_daemon_id == daemon_b.id
with repo._connect().cursor() as cur:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM auth_tokens WHERE daemon_id = %s", (daemon_b.id,))
cur.execute("DELETE FROM daemons WHERE id = %s", (daemon_b.id,))
_cleanup(repo, [], strategy_id, asset_id, owner_user_id, daemon_a)
finally:
pass