FastAPI app, upstream Confluence/model adapters, authoritative history, rootless container lifecycle, artifact storage and downloads, fake peers under backend/dev, tests under tests/backend. Root pytest.ini deselects the live marker by default; requirements gain the backend dependencies.
157 lines
5.3 KiB
Python
157 lines
5.3 KiB
Python
"""Unit tests for QueryRunner orchestration, gate, deadlines, and cleanup."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
import pytest
|
|
import httpx
|
|
|
|
from backend.artifacts import ArtifactStore
|
|
from backend.confluence import ConfluenceClient
|
|
from backend.containers import FakeContainerManager
|
|
from backend.dev.fake_peer import ScriptedContainerPeer
|
|
from backend.errors import BusyError, ExecutionFailedError, QueryTimeoutError
|
|
from backend.model import FakeModelAdapter
|
|
from backend.runner import QueryRunner
|
|
from backend.settings import Settings
|
|
|
|
|
|
from tests.backend.conftest import make_test_confluence_client_factory
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_standard_scenario(tmp_path: Path):
|
|
settings = Settings(
|
|
approved_confluence_origins=["https://approved.example.com"],
|
|
query_timeout_seconds=30.0,
|
|
cleanup_timeout_seconds=5.0,
|
|
)
|
|
artifact_store = ArtifactStore(tmp_path / "artifacts")
|
|
container_mgr = FakeContainerManager(lambda: ScriptedContainerPeer(scenario="standard"))
|
|
model_adapter = FakeModelAdapter()
|
|
conf_factory = make_test_confluence_client_factory()
|
|
|
|
runner = QueryRunner(
|
|
settings=settings,
|
|
container_manager=container_mgr,
|
|
artifact_store=artifact_store,
|
|
model_adapter=model_adapter,
|
|
confluence_client_factory=conf_factory,
|
|
)
|
|
|
|
result = await runner.run(
|
|
prompt="How do I deploy service X?",
|
|
confluence_url="https://approved.example.com",
|
|
confluence_pat="test-pat-12345678901234567890",
|
|
session_id="cw_session_123",
|
|
)
|
|
|
|
# Verify query result matches CONTRACTS section 2 & 7
|
|
assert result["session_id"]
|
|
assert "Deployment Guide" in result["markdown"]
|
|
assert len(result["pages_accessed"]) == 1
|
|
assert result["pages_accessed"][0]["page_id"] == "847291"
|
|
assert result["pages_accessed"][0]["space"] == "OPS"
|
|
assert result["pages_accessed"][0]["title"] == "Deployment Guide"
|
|
|
|
# Authoritative tool history has 2 Confluence calls (search and view)
|
|
assert len(result["tool_history"]) == 2
|
|
assert result["tool_history"][0]["tool"] == "confluence_search"
|
|
assert result["tool_history"][1]["tool"] == "confluence_view"
|
|
|
|
# Artifacts: 1 artifact committed
|
|
assert len(result["artifacts"]) == 1
|
|
art = result["artifacts"][0]
|
|
assert art["name"] == "checklist.md"
|
|
assert art["size_bytes"] == 32
|
|
|
|
# Container removal confirmed before success
|
|
assert len(container_mgr.removed_containers) == 1
|
|
|
|
# Verify download works from committed store
|
|
dl = artifact_store.get_artifact_for_download(art["id"], session_id="cw_session_123")
|
|
assert dl is not None
|
|
fpath, fname, fsize = dl
|
|
assert fsize == 32
|
|
assert fpath.read_bytes() == b"# Checklist\n\n- Deploy service X\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_busy_gate(tmp_path: Path):
|
|
settings = Settings(
|
|
approved_confluence_origins=["https://approved.example.com"],
|
|
query_timeout_seconds=30.0,
|
|
)
|
|
artifact_store = ArtifactStore(tmp_path / "artifacts")
|
|
# Timeout scenario hangs so query is active
|
|
container_mgr = FakeContainerManager(lambda: ScriptedContainerPeer(scenario="timeout"))
|
|
model_adapter = FakeModelAdapter()
|
|
conf_factory = make_test_confluence_client_factory()
|
|
|
|
runner = QueryRunner(
|
|
settings=settings,
|
|
container_manager=container_mgr,
|
|
artifact_store=artifact_store,
|
|
model_adapter=model_adapter,
|
|
confluence_client_factory=conf_factory,
|
|
)
|
|
|
|
# Start first query in background
|
|
task1 = asyncio.create_task(
|
|
runner.run(
|
|
prompt="Query 1",
|
|
confluence_url="https://approved.example.com",
|
|
confluence_pat="test-pat-12345678901234567890",
|
|
session_id="sess_1",
|
|
)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
|
|
# Second query must raise BusyError immediately
|
|
with pytest.raises(BusyError, match="in progress"):
|
|
await runner.run(
|
|
prompt="Query 2",
|
|
confluence_url="https://approved.example.com",
|
|
confluence_pat="test-pat-12345678901234567890",
|
|
session_id="sess_2",
|
|
)
|
|
|
|
# Cancel first task and wait
|
|
task1.cancel()
|
|
try:
|
|
await task1
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_agent_error_cleanup(tmp_path: Path):
|
|
settings = Settings(
|
|
approved_confluence_origins=["https://approved.example.com"],
|
|
query_timeout_seconds=30.0,
|
|
)
|
|
artifact_store = ArtifactStore(tmp_path / "artifacts")
|
|
container_mgr = FakeContainerManager(lambda: ScriptedContainerPeer(scenario="agent_error"))
|
|
model_adapter = FakeModelAdapter()
|
|
conf_factory = make_test_confluence_client_factory()
|
|
|
|
runner = QueryRunner(
|
|
settings=settings,
|
|
container_manager=container_mgr,
|
|
artifact_store=artifact_store,
|
|
model_adapter=model_adapter,
|
|
confluence_client_factory=conf_factory,
|
|
)
|
|
|
|
with pytest.raises(ExecutionFailedError):
|
|
await runner.run(
|
|
prompt="Query error",
|
|
confluence_url="https://approved.example.com",
|
|
confluence_pat="test-pat-12345678901234567890",
|
|
session_id="sess_err",
|
|
)
|
|
|
|
# Cleanup confirmed
|
|
assert len(container_mgr.removed_containers) == 1
|
|
# No retained artifacts
|
|
assert len(artifact_store._committed) == 0
|