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}.
330 lines
14 KiB
Python
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
|