"""API tests for the admission queue endpoints and changed /api/v1/query admission. Covers docs/QUEUE_SPECIFICATION.md section 11 "API tests": response shapes, status codes, no-store header, cookie issuance on join, credential fields rejected on queue endpoints, implicit join on query while idle, 409 for a non-holder. """ from __future__ import annotations import asyncio from pathlib import Path import httpx import pytest from httpx import ASGITransport from backend.admission import AdmissionController from backend.app import create_app, SESSION_COOKIE_NAME from backend.artifacts import ArtifactStore from backend.containers import FakeContainerManager from backend.dev.fake_peer import ScriptedContainerPeer from backend.model import FakeModelAdapter from backend.settings import Settings from tests.backend.conftest import make_test_confluence_client_factory class FakeClock: def __init__(self, start: float = 0.0): self._now = start def __call__(self) -> float: return self._now def advance(self, seconds: float) -> None: self._now += seconds def build_app( tmp_path: Path, scenario: str = "standard", admission_controller=None, query_timeout_seconds: float = 30.0, cleanup_timeout_seconds: float = 5.0, queue_max_length: int = 20, ): settings = Settings( approved_confluence_origins=["https://approved.example.com"], query_timeout_seconds=query_timeout_seconds, cleanup_timeout_seconds=cleanup_timeout_seconds, queue_max_length=queue_max_length, ) store = ArtifactStore(tmp_path / "artifacts") container_mgr = FakeContainerManager(lambda: ScriptedContainerPeer(scenario=scenario)) model_adapter = FakeModelAdapter() conf_factory = make_test_confluence_client_factory() app = create_app( settings=settings, container_manager=container_mgr, artifact_store=store, model_adapter=model_adapter, confluence_client_factory=conf_factory, admission_controller=admission_controller, ) return app, store, container_mgr QUERY_PAYLOAD = { "prompt": "How do I deploy service X?", "credentials": {"url": "https://approved.example.com", "pat": "valid-pat"}, } ORIGIN = {"Origin": "http://testserver"} @pytest.mark.asyncio async def test_queue_join_response_shape_and_cookie(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert resp.status_code == 200 assert resp.headers.get("Cache-Control") == "no-store" assert SESSION_COOKIE_NAME in resp.cookies data = resp.json() assert set(data.keys()) == { "ticket_id", "status", "position", "ahead", "eta_seconds", "reservation_expires_in_seconds", "runner", } assert data["status"] == "ready" assert data["position"] == 1 assert data["ahead"] == 0 assert data["eta_seconds"] is None assert data["reservation_expires_in_seconds"] == 45 assert data["runner"] == "reserved" @pytest.mark.asyncio async def test_queue_join_idempotent(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: first = (await client.post("/api/v1/queue/join", json={}, headers=ORIGIN)).json() second = (await client.post("/api/v1/queue/join", json={}, headers=ORIGIN)).json() assert first["ticket_id"] == second["ticket_id"] @pytest.mark.asyncio async def test_queue_join_rejects_credential_fields(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.post( "/api/v1/queue/join", json={"credentials": {"url": "https://approved.example.com", "pat": "x"}}, headers=ORIGIN, ) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "invalid_input" resp = await client.post("/api/v1/queue/join", json={"pat": "secret-token"}, headers=ORIGIN) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "invalid_input" assert "secret-token" not in resp.text @pytest.mark.asyncio async def test_queue_join_origin_enforcement(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.post("/api/v1/queue/join", json={}) assert resp.status_code == 403 assert resp.json()["error"]["code"] == "origin_denied" @pytest.mark.asyncio async def test_queue_status_404_without_ticket_then_ready(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: # Bootstrap a session cookie without ever joining. await client.get("/") resp = await client.get("/api/v1/queue/status") assert resp.status_code == 404 assert resp.json()["error"]["code"] == "ticket_not_found" assert resp.headers.get("Cache-Control") == "no-store" join_data = (await client.post("/api/v1/queue/join", json={}, headers=ORIGIN)).json() status_resp = await client.get("/api/v1/queue/status") assert status_resp.status_code == 200 status_data = status_resp.json() assert status_data["ticket_id"] == join_data["ticket_id"] assert status_data["status"] == "ready" @pytest.mark.asyncio async def test_queue_leave_204_always_and_drops_ticket(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: # No ticket yet: still 204. resp = await client.delete("/api/v1/queue/ticket", headers=ORIGIN) assert resp.status_code == 204 await client.post("/api/v1/queue/join", json={}, headers=ORIGIN) resp = await client.delete("/api/v1/queue/ticket", headers=ORIGIN) assert resp.status_code == 204 status_resp = await client.get("/api/v1/queue/status") assert status_resp.status_code == 404 # Idempotent second leave. resp = await client.delete("/api/v1/queue/ticket", headers=ORIGIN) assert resp.status_code == 204 @pytest.mark.asyncio async def test_queue_leave_origin_enforcement(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.delete("/api/v1/queue/ticket") assert resp.status_code == 403 assert resp.json()["error"]["code"] == "origin_denied" @pytest.mark.asyncio async def test_queue_full_503(tmp_path: Path): app, _, _ = build_app(tmp_path, queue_max_length=1) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as reserved_client, \ httpx.AsyncClient(transport=transport, base_url="http://testserver") as queued_client, \ httpx.AsyncClient(transport=transport, base_url="http://testserver") as overflow_client: r1 = await reserved_client.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert r1.json()["status"] == "ready" r2 = await queued_client.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert r2.json()["status"] == "queued" r3 = await overflow_client.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert r3.status_code == 503 assert r3.json()["error"]["code"] == "queue_full" @pytest.mark.asyncio async def test_query_implicit_join_while_idle_then_idle_again_after(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) assert resp.status_code == 200 # Promotion/idle recovery happens after cleanup; a fresh session should # immediately be admitted as "ready" (idle) and see a recorded estimate. async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as probe: probe_resp = await probe.post("/api/v1/queue/join", json={}, headers=ORIGIN) data = probe_resp.json() assert data["status"] == "ready" assert data["eta_seconds"] is not None assert data["eta_seconds"] >= 0 @pytest.mark.asyncio async def test_query_409_busy_for_non_holder_session(tmp_path: Path): app, _, _ = build_app(tmp_path) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as holder, \ httpx.AsyncClient(transport=transport, base_url="http://testserver") as intruder: join_resp = await holder.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert join_resp.json()["status"] == "ready" # A different session, holding no reservation, is refused without # disturbing the queue. busy_resp = await intruder.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) assert busy_resp.status_code == 409 assert busy_resp.json()["error"]["code"] == "busy" status_after = await holder.get("/api/v1/queue/status") assert status_after.json()["status"] == "ready" # The holder itself can now claim and run. ok_resp = await holder.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) assert ok_resp.status_code == 200 @pytest.mark.asyncio async def test_query_busy_while_running_and_promotes_after_cleanup(tmp_path: Path): app, _, _ = build_app( tmp_path, scenario="timeout", query_timeout_seconds=0.2, cleanup_timeout_seconds=2.0 ) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as running_client, \ httpx.AsyncClient(transport=transport, base_url="http://testserver") as other_client, \ httpx.AsyncClient(transport=transport, base_url="http://testserver") as probe_client: task = asyncio.create_task( running_client.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) ) await asyncio.sleep(0.05) busy_resp = await other_client.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) assert busy_resp.status_code == 409 assert busy_resp.json()["error"]["code"] == "busy" # Runner times out, cleanup runs, and the runner returns to idle; # the timed-out run's duration is still recorded (failure counts). timeout_resp = await asyncio.wait_for(task, timeout=10.0) assert timeout_resp.status_code == 504 probe_resp = await probe_client.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert probe_resp.json()["status"] == "ready" @pytest.mark.asyncio async def test_reservation_expiry_query_after_window_is_busy(tmp_path: Path): # The query request itself is what discovers the expiry (spec section 3, # "Claim"): the reserved session sending the query after the window gets # 409, and the ticket is dropped as a side effect of that request. clock = FakeClock(0.0) admission = AdmissionController( reservation_seconds=1.0, heartbeat_seconds=30.0, max_queue_length=5, clock=clock ) app, _, _ = build_app(tmp_path, admission_controller=admission) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as holder: join_resp = await holder.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert join_resp.json()["status"] == "ready" clock.advance(2.0) query_resp = await holder.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) assert query_resp.status_code == 409 assert query_resp.json()["error"]["code"] == "busy" @pytest.mark.asyncio async def test_reservation_expiry_status_poll_is_ticket_not_found(tmp_path: Path): # Spec section 8: "Reservation expires -> ticket dropped, next promoted; # next status is 404; rejoin once." Polling after the window discovers # the expiry itself and reports ticket_not_found rather than "ready". clock = FakeClock(0.0) admission = AdmissionController( reservation_seconds=1.0, heartbeat_seconds=30.0, max_queue_length=5, clock=clock ) app, _, _ = build_app(tmp_path, admission_controller=admission) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as holder: join_resp = await holder.post("/api/v1/queue/join", json={}, headers=ORIGIN) assert join_resp.json()["status"] == "ready" clock.advance(2.0) status_resp = await holder.get("/api/v1/queue/status") assert status_resp.status_code == 404 assert status_resp.json()["error"]["code"] == "ticket_not_found" # Once discovered, the runner is idle again (queue was empty), so a # fresh query from any session -- including this one, now indistinct # from a walk-up -- is admitted by implicit join. query_resp = await holder.post("/api/v1/query", json=QUERY_PAYLOAD, headers=ORIGIN) assert query_resp.status_code == 200