fischer-agentkit/tests/unit/test_config_sync.py

498 lines
17 KiB
Python

"""Tests for U7: configuration sync engine.
Covers:
- Server-side config sync API (version, all, skills, agents, workflows)
- Client-side ConfigSync (start, poll, cache, offline)
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from agentkit.client.sync import ConfigSync
from agentkit.server.routes import config_sync
# ── Test fixtures ─────────────────────────────────────────────────────
class _FakeSkillConfig:
"""Minimal skill config mock with to_dict()."""
def __init__(self, name: str, version: str = "1.0.0"):
self.name = name
self.version = version
def to_dict(self) -> dict[str, Any]:
return {"name": self.name, "version": self.version, "description": f"Skill {self.name}"}
class _FakeSkill:
def __init__(self, name: str, version: str = "1.0.0"):
self.config = _FakeSkillConfig(name, version)
class _FakeSkillRegistry:
"""Minimal SkillRegistry mock."""
def __init__(self, skills: dict[str, _FakeSkill] | None = None):
self._skills = skills or {}
def list_skills(self) -> list[str]:
return sorted(self._skills.keys())
def get(self, name: str) -> _FakeSkill | None:
return self._skills.get(name)
class _FakeWorkflow:
"""Minimal workflow definition mock with model_dump()."""
def __init__(self, workflow_id: str, name: str, version: int = 1):
self.workflow_id = workflow_id
self.name = name
self.version = version
self.stages: list = []
self.triggers: list = []
self.created_at = "2026-01-01T00:00:00Z"
self.updated_at = "2026-01-01T00:00:00Z"
def model_dump(self) -> dict[str, Any]:
return {
"workflow_id": self.workflow_id,
"name": self.name,
"version": self.version,
"stages": self.stages,
"triggers": self.triggers,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class _FakeWorkflowStore:
def __init__(self, workflows: list[_FakeWorkflow] | None = None):
self._workflows = workflows or []
def list_workflows(self) -> list[_FakeWorkflow]:
return list(self._workflows)
@pytest.fixture
def config_app_with_data() -> FastAPI:
"""App with mock skill registry + workflow store."""
app = FastAPI()
app.state.skill_registry = _FakeSkillRegistry({
"react_agent": _FakeSkill("react_agent", "1.0.0"),
"code_reviewer": _FakeSkill("code_reviewer", "2.1.0"),
})
app.state.workflow_store = _FakeWorkflowStore([
_FakeWorkflow("wf-1", "CI Pipeline", 1),
_FakeWorkflow("wf-2", "Deploy", 3),
])
app.include_router(config_sync.router, prefix="/api/v1")
# Dev-admin middleware
@app.middleware("http")
async def _set_dev_admin_user(request, call_next):
request.state.current_user = {
"user_id": "dev-admin",
"username": "dev-admin",
"role": "admin",
"dev_mode": True,
}
return await call_next(request)
return app
@pytest.fixture
def config_app_empty() -> FastAPI:
"""App with no skills or workflows."""
app = FastAPI()
app.state.skill_registry = _FakeSkillRegistry()
app.state.workflow_store = _FakeWorkflowStore()
app.include_router(config_sync.router, prefix="/api/v1")
@app.middleware("http")
async def _set_dev_admin_user(request, call_next):
request.state.current_user = {
"user_id": "dev-admin",
"username": "dev-admin",
"role": "admin",
"dev_mode": True,
}
return await call_next(request)
return app
@pytest.fixture
async def config_client(config_app_with_data: FastAPI):
transport = ASGITransport(app=config_app_with_data)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# ── Server API tests ──────────────────────────────────────────────────
class TestConfigVersionEndpoint:
"""Test GET /api/v1/config/version."""
@pytest.mark.asyncio
async def test_version_returns_hash(self, config_client: AsyncClient):
resp = await config_client.get("/api/v1/config/version")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
assert len(data["version"]) == 64 # SHA-256 hex
assert data["skill_count"] == 2
assert data["workflow_count"] == 2
assert "computed_at" in data
@pytest.mark.asyncio
async def test_version_is_stable(self, config_client: AsyncClient):
"""Same configs → same version hash."""
resp1 = await config_client.get("/api/v1/config/version")
resp2 = await config_client.get("/api/v1/config/version")
assert resp1.json()["version"] == resp2.json()["version"]
@pytest.mark.asyncio
async def test_version_empty_app(self, config_app_empty: FastAPI):
transport = ASGITransport(app=config_app_empty)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/v1/config/version")
assert resp.status_code == 200
data = resp.json()
assert data["skill_count"] == 0
assert data["workflow_count"] == 0
assert len(data["version"]) == 64
class TestConfigAllEndpoint:
"""Test GET /api/v1/config/all."""
@pytest.mark.asyncio
async def test_all_returns_skills_and_workflows(self, config_client: AsyncClient):
resp = await config_client.get("/api/v1/config/all")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
assert len(data["version"]) == 64
assert "synced_at" in data
skills = data["skills"]
assert len(skills) == 2
skill_names = {s["name"] for s in skills}
assert skill_names == {"react_agent", "code_reviewer"}
workflows = data["workflows"]
assert len(workflows) == 2
wf_ids = {w["workflow_id"] for w in workflows}
assert wf_ids == {"wf-1", "wf-2"}
@pytest.mark.asyncio
async def test_all_version_matches_version_endpoint(self, config_client: AsyncClient):
all_resp = await config_client.get("/api/v1/config/all")
ver_resp = await config_client.get("/api/v1/config/version")
assert all_resp.json()["version"] == ver_resp.json()["version"]
class TestConfigSkillsEndpoint:
"""Test GET /api/v1/config/skills."""
@pytest.mark.asyncio
async def test_skills_returns_list(self, config_client: AsyncClient):
resp = await config_client.get("/api/v1/config/skills")
assert resp.status_code == 200
data = resp.json()
assert len(data["skills"]) == 2
assert data["count"] == 2
assert "synced_at" in data
class TestConfigAgentsEndpoint:
"""Test GET /api/v1/config/agents."""
@pytest.mark.asyncio
async def test_agents_returns_list(self, config_client: AsyncClient):
resp = await config_client.get("/api/v1/config/agents")
assert resp.status_code == 200
data = resp.json()
assert len(data["agents"]) == 2
assert data["count"] == 2
class TestConfigWorkflowsEndpoint:
"""Test GET /api/v1/config/workflows."""
@pytest.mark.asyncio
async def test_workflows_returns_list(self, config_client: AsyncClient):
resp = await config_client.get("/api/v1/config/workflows")
assert resp.status_code == 200
data = resp.json()
assert len(data["workflows"]) == 2
assert data["count"] == 2
# ── Client-side ConfigSync tests ──────────────────────────────────────
@pytest.fixture
async def sync_server(config_app_with_data: FastAPI):
"""Start the config app as an ASGI server on a random port."""
import uvicorn
config = uvicorn.Config(
config_app_with_data,
host="127.0.0.1",
port=0, # Random port
log_level="warning",
)
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
# Wait for server to start
while not server.started:
await asyncio.sleep(0.01)
# Get the actual port
sockets = list(server.servers[0].sockets)
port = sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
server.should_exit = True
await task
class TestConfigSync:
"""Test the client-side ConfigSync engine."""
@pytest.mark.asyncio
async def test_start_pulls_configs(self, sync_server: str, tmp_path: Path):
"""ConfigSync.start() pulls configs from the server."""
sync = ConfigSync(
server_url=sync_server,
token_provider=None,
cache_db_path=tmp_path / "cache.db",
)
try:
success = await sync.start()
assert success is True
assert sync.get_version() is not None
assert len(sync.get_version()) == 64
skills = sync.get_skills()
assert len(skills) == 2
workflows = sync.get_workflows()
assert len(workflows) == 2
finally:
await sync.stop()
@pytest.mark.asyncio
async def test_get_skill_by_name(self, sync_server: str, tmp_path: Path):
sync = ConfigSync(
server_url=sync_server,
cache_db_path=tmp_path / "cache.db",
)
try:
await sync.start()
skill = sync.get_skill("react_agent")
assert skill is not None
assert skill["name"] == "react_agent"
missing = sync.get_skill("nonexistent")
assert missing is None
finally:
await sync.stop()
@pytest.mark.asyncio
async def test_get_workflow_by_id(self, sync_server: str, tmp_path: Path):
sync = ConfigSync(
server_url=sync_server,
cache_db_path=tmp_path / "cache.db",
)
try:
await sync.start()
wf = sync.get_workflow("wf-1")
assert wf is not None
assert wf["name"] == "CI Pipeline"
missing = sync.get_workflow("nonexistent")
assert missing is None
finally:
await sync.stop()
@pytest.mark.asyncio
async def test_cache_persists_across_instances(self, sync_server: str, tmp_path: Path):
"""Configs cached by one instance are loadable by another."""
cache_path = tmp_path / "cache.db"
# First instance: pull and cache
sync1 = ConfigSync(server_url=sync_server, cache_db_path=cache_path)
await sync1.start()
version1 = sync1.get_version()
assert version1 is not None
await sync1.stop()
# Second instance: no server, load from cache
sync2 = ConfigSync(
server_url="http://127.0.0.1:1", # Unreachable port
cache_db_path=cache_path,
)
try:
success = await sync2.start()
assert success is False # Server unreachable
# But cache should be loaded
assert sync2.get_version() == version1
assert len(sync2.get_skills()) == 2
assert len(sync2.get_workflows()) == 2
finally:
await sync2.stop()
@pytest.mark.asyncio
async def test_poll_detects_version_change(
self, config_app_with_data: FastAPI, tmp_path: Path
):
"""Polling detects when the server's config version changes."""
import uvicorn
config = uvicorn.Config(
config_app_with_data,
host="127.0.0.1",
port=0,
log_level="warning",
)
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
while not server.started:
await asyncio.sleep(0.01)
port = list(server.servers[0].sockets)[0].getsockname()[1]
server_url = f"http://127.0.0.1:{port}"
try:
sync = ConfigSync(
server_url=server_url,
cache_db_path=tmp_path / "cache.db",
poll_interval=1, # 1 second for fast testing
)
await sync.start()
sync.start_polling() # Start background polling
version_before = sync.get_version()
# Change the server's config
config_app_with_data.state.skill_registry._skills["new_skill"] = _FakeSkill("new_skill")
# Wait for the poll to detect the change
await asyncio.sleep(2.5)
version_after = sync.get_version()
assert version_after != version_before
assert len(sync.get_skills()) == 3 # Original 2 + new_skill
await sync.stop()
finally:
server.should_exit = True
await task
@pytest.mark.asyncio
async def test_offline_uses_cache(self, tmp_path: Path):
"""When the server is unreachable, the client uses cached configs."""
cache_path = tmp_path / "cache.db"
# Pre-populate the cache directly
cache_path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(str(cache_path)) as conn:
conn.executescript(
"CREATE TABLE IF NOT EXISTS config_cache "
"(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);"
)
now = datetime.now(timezone.utc).isoformat()
conn.executemany(
"INSERT OR REPLACE INTO config_cache (key, value, updated_at) VALUES (?, ?, ?)",
[
("version", json.dumps("cached-version-hash"), now),
("skills", json.dumps([{"name": "cached_skill", "version": "1.0.0"}]), now),
("workflows", json.dumps([{"workflow_id": "cached-wf", "name": "Cached"}]), now),
("synced_at", json.dumps("2026-01-01T00:00:00Z"), now),
],
)
conn.commit()
# Create a sync instance pointing to an unreachable server
sync = ConfigSync(
server_url="http://127.0.0.1:1", # Unreachable
cache_db_path=cache_path,
)
try:
success = await sync.start()
assert success is False # Server unreachable
# But cache loaded
assert sync.get_version() == "cached-version-hash"
skills = sync.get_skills()
assert len(skills) == 1
assert skills[0]["name"] == "cached_skill"
finally:
await sync.stop()
@pytest.mark.asyncio
async def test_offline_no_cache_returns_empty(self, tmp_path: Path):
"""When the server is unreachable and no cache exists, returns empty."""
sync = ConfigSync(
server_url="http://127.0.0.1:1", # Unreachable
cache_db_path=tmp_path / "nonexistent.db",
)
try:
success = await sync.start()
assert success is False
assert sync.get_version() is None
assert sync.get_skills() == []
assert sync.get_workflows() == []
finally:
await sync.stop()
@pytest.mark.asyncio
async def test_token_provider_attaches_jwt(self, sync_server: str, tmp_path: Path):
"""The token_provider callable is used to attach JWT to requests."""
token_holder = {"token": "test-jwt-token"}
sync = ConfigSync(
server_url=sync_server,
token_provider=lambda: token_holder["token"],
cache_db_path=tmp_path / "cache.db",
)
try:
# Should work even with a fake token (dev mode doesn't check)
success = await sync.start()
assert success is True
finally:
await sync.stop()
@pytest.mark.asyncio
async def test_get_all_returns_combined_dict(self, sync_server: str, tmp_path: Path):
sync = ConfigSync(
server_url=sync_server,
cache_db_path=tmp_path / "cache.db",
)
try:
await sync.start()
all_configs = sync.get_all()
assert "version" in all_configs
assert "skills" in all_configs
assert "workflows" in all_configs
assert "synced_at" in all_configs
assert len(all_configs["skills"]) == 2
assert len(all_configs["workflows"]) == 2
finally:
await sync.stop()