Implement Phase 3: dynamic graph builder, blueprint persistence, and CRUD API
- Add dynamic_graph_builder.py that constructs LangGraph graphs at runtime
from frontend CouncilBlueprint JSON (no more hardcoded graphs in production)
- Add PostgreSQL persistence via SQLAlchemy async with Blueprint model
- Add blueprint CRUD endpoints (POST/GET/PUT/DELETE /api/councils/)
- Add POST /api/councils/{id}/run to execute blueprints dynamically
- Add Alembic migration infrastructure with initial blueprints table
- Add database.py with async engine and SQLite fallback for dev/test
- Fix missing typing-extensions and add aiosqlite dependency
- Add 42 new tests (80/80 total passing) covering dynamic graph building,
blueprint service CRUD, and API integration
https://claude.ai/code/session_014yZUxrPsgZbvkebXbCXR4U
This commit is contained in:
parent
89ba3aacd4
commit
437db4ca68
16 changed files with 1698 additions and 11 deletions
159
backend/tests/test_blueprint_service.py
Normal file
159
backend/tests/test_blueprint_service.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
Tests for the blueprint CRUD service and API endpoints.
|
||||
|
||||
Uses an in-memory SQLite database for isolation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from models.blueprint import Base, Blueprint
|
||||
from services.blueprint_service import (
|
||||
create_blueprint,
|
||||
delete_blueprint,
|
||||
get_blueprint,
|
||||
list_blueprints,
|
||||
update_blueprint,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test database setup (in-memory SQLite)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session():
|
||||
"""Create tables and yield a fresh session for each test."""
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with TestSessionLocal() as sess:
|
||||
yield sess
|
||||
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_NODES = [
|
||||
{
|
||||
"id": "node-1",
|
||||
"label": "Master",
|
||||
"systemPrompt": "You are the master.",
|
||||
"model": "claude-3-5-sonnet",
|
||||
"tools": {"webSearch": False, "pdfReader": False},
|
||||
"position": {"x": 0, "y": 0},
|
||||
},
|
||||
{
|
||||
"id": "node-2",
|
||||
"label": "Critic",
|
||||
"systemPrompt": "You evaluate drafts.",
|
||||
"model": "gpt-4o",
|
||||
"tools": {"webSearch": True, "pdfReader": False},
|
||||
"position": {"x": 300, "y": 0},
|
||||
},
|
||||
]
|
||||
|
||||
SAMPLE_EDGES = [
|
||||
{"id": "edge-1", "source": "node-1", "target": "node-2", "type": "linear"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBlueprintCRUD:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_blueprint(self, session):
|
||||
bp = await create_blueprint(session, "Test Council", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
assert bp.id is not None
|
||||
assert bp.name == "Test Council"
|
||||
assert bp.version == 1
|
||||
assert len(bp.nodes) == 2
|
||||
assert len(bp.edges) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_custom_id(self, session):
|
||||
bp = await create_blueprint(
|
||||
session, "Custom ID", SAMPLE_NODES, SAMPLE_EDGES, blueprint_id="my-custom-id"
|
||||
)
|
||||
assert bp.id == "my-custom-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_blueprint(self, session):
|
||||
bp = await create_blueprint(session, "Get Test", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
fetched = await get_blueprint(session, bp.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "Get Test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_returns_none(self, session):
|
||||
result = await get_blueprint(session, "nonexistent-id")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_blueprints(self, session):
|
||||
await create_blueprint(session, "First", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
await create_blueprint(session, "Second", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
all_bps = await list_blueprints(session)
|
||||
assert len(all_bps) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_blueprint_name(self, session):
|
||||
bp = await create_blueprint(session, "Original", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
updated = await update_blueprint(session, bp.id, name="Renamed")
|
||||
assert updated is not None
|
||||
assert updated.name == "Renamed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_blueprint_nodes(self, session):
|
||||
bp = await create_blueprint(session, "Nodes Test", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
new_nodes = [SAMPLE_NODES[0]] # Remove second node
|
||||
updated = await update_blueprint(session, bp.id, nodes=new_nodes)
|
||||
assert updated is not None
|
||||
assert len(updated.nodes) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_returns_none(self, session):
|
||||
result = await update_blueprint(session, "ghost-id", name="New Name")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_blueprint(self, session):
|
||||
bp = await create_blueprint(session, "To Delete", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
deleted = await delete_blueprint(session, bp.id)
|
||||
assert deleted is True
|
||||
assert await get_blueprint(session, bp.id) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_returns_false(self, session):
|
||||
deleted = await delete_blueprint(session, "ghost-id")
|
||||
assert deleted is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_dict_format(self, session):
|
||||
bp = await create_blueprint(session, "Dict Test", SAMPLE_NODES, SAMPLE_EDGES)
|
||||
d = bp.to_dict()
|
||||
assert d["id"] == bp.id
|
||||
assert d["version"] == 1
|
||||
assert d["name"] == "Dict Test"
|
||||
assert "createdAt" in d
|
||||
assert "updatedAt" in d
|
||||
assert isinstance(d["nodes"], list)
|
||||
assert isinstance(d["edges"], list)
|
||||
Loading…
Add table
Add a link
Reference in a new issue