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
+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