"""Unit tests for agent variant API endpoints. Tests variant CRUD, clone, activate/deactivate, performance queries, and edge cases (duplicate slug, non-existent resources, validation). Requirements: 1.3, 1.4, 2.1–2.6, 3.1–3.6, 4.1–4.5, 6.3–6.5 """ from __future__ import annotations import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import asyncpg import pytest from httpx import ASGITransport, AsyncClient from services.api.app import app NOW = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) AGENT_ID = str(uuid.uuid4()) VARIANT_ID = str(uuid.uuid4()) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class FakeRecord(dict): """Mimics asyncpg.Record for testing.""" def items(self): return super().items() def _variant_row( *, variant_id: str | None = None, agent_id: str = AGENT_ID, is_active: bool = False, variant_name: str = "test-variant", variant_slug: str = "test-variant", model_name: str = "qwen3:8b", **overrides, ) -> FakeRecord: row = { "id": variant_id or str(uuid.uuid4()), "agent_id": agent_id, "variant_name": variant_name, "variant_slug": variant_slug, "description": "", "model_provider": "ollama", "model_name": model_name, "system_prompt": "You are a test agent.", "user_prompt_template": "Analyze: {text}", "prompt_version": "v1", "temperature": 0.1, "max_tokens": 16384, "context_window": 4096, "input_token_limit": 2048, "token_budget": 10000, "timeout_seconds": 120, "max_retries": 2, "is_active": is_active, "created_at": NOW, "updated_at": NOW, } row.update(overrides) return FakeRecord(row) def _agent_row(agent_id: str = AGENT_ID) -> FakeRecord: return FakeRecord({ "id": agent_id, "model_provider": "ollama", "model_name": "qwen3:8b", "system_prompt": "Base system prompt", "user_prompt_template": "Base template: {text}", "prompt_version": "v1", "temperature": 0.0, "max_tokens": 32768, "timeout_seconds": 120, "max_retries": 2, }) def _perf_row() -> FakeRecord: return FakeRecord({ "total_invocations": 100, "successes": 90, "failures": 10, "avg_duration_ms": 450, "p95_duration_ms": 900, "avg_confidence": 0.82, "avg_retries": 0.3, "total_input_tokens": 50000, "total_output_tokens": 25000, }) def _perf_history_row(hour_offset: int = 0) -> FakeRecord: from datetime import timedelta return FakeRecord({ "hour": NOW - timedelta(hours=hour_offset), "invocations": 10, "successes": 9, "avg_duration_ms": 400, "avg_confidence": 0.85, }) # --------------------------------------------------------------------------- # Task 7.1.1 — CRUD, clone, activate/deactivate, performance # --------------------------------------------------------------------------- class TestCreateVariant: """POST /api/agents/{agent_id}/variants""" @pytest.mark.asyncio async def test_create_variant_returns_201(self): created = _variant_row() mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=created) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants", json={ "variant_name": "test-variant", "model_name": "qwen3:8b", "context_window": 4096, "input_token_limit": 2048, "token_budget": 10000, }, ) assert resp.status_code == 201 data = resp.json() assert data["variant_name"] == "test-variant" assert data["model_name"] == "qwen3:8b" assert data["context_window"] == 4096 assert data["input_token_limit"] == 2048 assert data["token_budget"] == 10000 assert data["is_active"] is False class TestCloneAgentAsVariant: """POST /api/agents/{agent_id}/clone""" @pytest.mark.asyncio async def test_clone_agent_returns_201(self): agent = _agent_row() created = _variant_row(variant_name="cloned-from-agent", variant_slug="cloned-from-agent") mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(side_effect=[agent, created]) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/clone", json={"variant_name": "cloned-from-agent"}, ) assert resp.status_code == 201 data = resp.json() assert data["variant_name"] == "cloned-from-agent" assert data["agent_id"] == AGENT_ID class TestCloneVariant: """POST /api/agents/{agent_id}/variants/{variant_id}/clone""" @pytest.mark.asyncio async def test_clone_variant_returns_201(self): source = _variant_row(variant_id=VARIANT_ID) cloned = _variant_row(variant_name="cloned-v2", variant_slug="cloned-v2") mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(side_effect=[source, cloned]) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}/clone", json={"variant_name": "cloned-v2"}, ) assert resp.status_code == 201 data = resp.json() assert data["variant_name"] == "cloned-v2" class TestListVariants: """GET /api/agents/{agent_id}/variants""" @pytest.mark.asyncio async def test_list_variants_returns_list(self): rows = [ _variant_row(variant_name="v1", variant_slug="v1"), _variant_row(variant_name="v2", variant_slug="v2", is_active=True), ] mock_pool = AsyncMock() mock_pool.fetch = AsyncMock(return_value=rows) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get(f"/api/agents/{AGENT_ID}/variants") assert resp.status_code == 200 data = resp.json() assert len(data) == 2 assert data[0]["variant_name"] == "v1" assert data[1]["is_active"] is True class TestGetVariant: """GET /api/agents/{agent_id}/variants/{variant_id}""" @pytest.mark.asyncio async def test_get_variant_returns_variant(self): row = _variant_row(variant_id=VARIANT_ID) mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=row) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get(f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}") assert resp.status_code == 200 data = resp.json() assert data["id"] == VARIANT_ID class TestUpdateVariant: """PUT /api/agents/{agent_id}/variants/{variant_id}""" @pytest.mark.asyncio async def test_update_variant_returns_updated(self): updated = _variant_row(variant_id=VARIANT_ID, model_name="llama3.1:8b", temperature=0.5) mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=updated) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.put( f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}", json={"model_name": "llama3.1:8b", "temperature": 0.5}, ) assert resp.status_code == 200 data = resp.json() assert data["model_name"] == "llama3.1:8b" assert data["temperature"] == 0.5 class TestDeleteVariant: """DELETE /api/agents/{agent_id}/variants/{variant_id}""" @pytest.mark.asyncio async def test_delete_inactive_variant_succeeds(self): mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({"is_active": False})) mock_pool.execute = AsyncMock() with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.delete(f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}") assert resp.status_code == 200 data = resp.json() assert data["deleted"] is True @pytest.mark.asyncio async def test_delete_active_variant_returns_400(self): mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({"is_active": True})) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.delete(f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}") assert resp.status_code == 400 assert "active" in resp.json()["detail"].lower() class TestActivateDeactivate: """POST .../activate and .../deactivate""" @pytest.mark.asyncio async def test_activate_variant(self): activated = _variant_row(variant_id=VARIANT_ID, is_active=True) mock_conn = AsyncMock() mock_conn.execute = AsyncMock() mock_conn.fetchrow = AsyncMock(return_value=activated) # transaction context manager mock_tx = AsyncMock() mock_tx.__aenter__ = AsyncMock(return_value=None) mock_tx.__aexit__ = AsyncMock(return_value=False) mock_conn.transaction = MagicMock(return_value=mock_tx) mock_pool = AsyncMock() mock_acquire = AsyncMock() mock_acquire.__aenter__ = AsyncMock(return_value=mock_conn) mock_acquire.__aexit__ = AsyncMock(return_value=False) mock_pool.acquire = MagicMock(return_value=mock_acquire) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}/activate" ) assert resp.status_code == 200 data = resp.json() assert data["is_active"] is True @pytest.mark.asyncio async def test_deactivate_variants(self): mock_pool = AsyncMock() mock_pool.execute = AsyncMock() with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post(f"/api/agents/{AGENT_ID}/variants/deactivate") assert resp.status_code == 200 data = resp.json() assert data["deactivated"] is True class TestVariantPerformance: """GET .../performance and .../performance/history""" @pytest.mark.asyncio async def test_get_variant_performance(self): perf = _perf_row() mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=perf) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get( f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}/performance?hours=24" ) assert resp.status_code == 200 data = resp.json() assert data["total_invocations"] == 100 assert data["successes"] == 90 assert data["success_rate"] == 0.9 @pytest.mark.asyncio async def test_get_variant_performance_history(self): rows = [_perf_history_row(0), _perf_history_row(1)] mock_pool = AsyncMock() mock_pool.fetch = AsyncMock(return_value=rows) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get( f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}/performance/history?hours=24" ) assert resp.status_code == 200 data = resp.json() assert len(data) == 2 assert data[0]["invocations"] == 10 # --------------------------------------------------------------------------- # Task 7.1.2 — Edge-case tests # --------------------------------------------------------------------------- class TestEdgeCases: """Edge-case tests: duplicate slug, non-existent resources, validation.""" @pytest.mark.asyncio async def test_duplicate_slug_returns_409(self): """Creating a variant with a duplicate slug returns 409.""" mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock( side_effect=asyncpg.UniqueViolationError("") ) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants", json={"variant_name": "dup", "model_name": "qwen3:8b"}, ) assert resp.status_code == 409 assert "already exists" in resp.json()["detail"] @pytest.mark.asyncio async def test_clone_nonexistent_agent_returns_404(self): """Cloning from a non-existent agent returns 404.""" mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=None) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{str(uuid.uuid4())}/clone", json={"variant_name": "test"}, ) assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() @pytest.mark.asyncio async def test_get_nonexistent_variant_returns_404(self): """Getting a non-existent variant returns 404.""" mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=None) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get( f"/api/agents/{AGENT_ID}/variants/{str(uuid.uuid4())}" ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_delete_nonexistent_variant_returns_404(self): """Deleting a non-existent variant returns 404.""" mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=None) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.delete( f"/api/agents/{AGENT_ID}/variants/{str(uuid.uuid4())}" ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_create_variant_empty_model_name_rejected(self): """Creating a variant with empty model_name is rejected by Pydantic.""" mock_pool = AsyncMock() with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants", json={"variant_name": "test"}, # model_name is required — omitting it should fail ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_update_variant_no_fields_returns_400(self): """Updating a variant with no fields returns 400.""" mock_pool = AsyncMock() with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.put( f"/api/agents/{AGENT_ID}/variants/{VARIANT_ID}", json={}, ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_clone_nonexistent_variant_returns_404(self): """Cloning from a non-existent variant returns 404.""" mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=None) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants/{str(uuid.uuid4())}/clone", json={"variant_name": "test"}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_activate_nonexistent_variant_returns_404(self): """Activating a non-existent variant returns 404.""" mock_conn = AsyncMock() mock_conn.execute = AsyncMock() mock_conn.fetchrow = AsyncMock(return_value=None) mock_tx = AsyncMock() mock_tx.__aenter__ = AsyncMock(return_value=None) mock_tx.__aexit__ = AsyncMock(return_value=False) mock_conn.transaction = MagicMock(return_value=mock_tx) mock_pool = AsyncMock() mock_acquire = AsyncMock() mock_acquire.__aenter__ = AsyncMock(return_value=mock_conn) mock_acquire.__aexit__ = AsyncMock(return_value=False) mock_pool.acquire = MagicMock(return_value=mock_acquire) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/variants/{str(uuid.uuid4())}/activate" ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_duplicate_slug_on_clone_returns_409(self): """Cloning an agent with a duplicate slug returns 409.""" agent = _agent_row() mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock( side_effect=[agent, asyncpg.UniqueViolationError("")] ) with patch("services.api.app.pool", mock_pool): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.post( f"/api/agents/{AGENT_ID}/clone", json={"variant_name": "dup", "variant_slug": "existing-slug"}, ) assert resp.status_code == 409