confluence_web/tests/backend/test_queue_api.py
Artur Mukhamadiev 5aad7a0b45 backend: admission queue with reservations
In-memory AdmissionController (idle/reserved/running, FIFO tickets keyed by
the session cookie, reservation and heartbeat expiry, promotion after
cleanup, EMA wait estimate) behind POST /api/v1/queue/join,
GET /api/v1/queue/status and DELETE /api/v1/queue/ticket. POST /api/v1/query
claims the session's reservation first and joins implicitly when idle.
Settings: CONFLUENCE_WEB_QUEUE_{RESERVATION_SECONDS,HEARTBEAT_SECONDS,MAX_LENGTH}.
2026-09-15 13:38:59 +03:00

330 lines
14 KiB
Python

"""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